Refactor HentaiVN (#17391)
* Refactor HentaiVN * better cookie handling
This commit is contained in:
parent
9f542d8234
commit
28850cf51a
|
@ -5,8 +5,12 @@ ext {
|
|||
extName = 'HentaiVN'
|
||||
pkgNameSuffix = 'vi.hentaivn'
|
||||
extClass = '.HentaiVN'
|
||||
extVersionCode = 28
|
||||
extVersionCode = 29
|
||||
isNsfw = true
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":lib-randomua"))
|
||||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|
||||
|
|
|
@ -0,0 +1,46 @@
|
|||
package eu.kanade.tachiyomi.extension.vi.hentaivn
|
||||
|
||||
import android.util.Log
|
||||
import android.webkit.CookieManager
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
|
||||
class CookieInterceptor(
|
||||
private val domain: String,
|
||||
private val key: String,
|
||||
private val value: String,
|
||||
) : Interceptor {
|
||||
|
||||
init {
|
||||
val url = "https://$domain/"
|
||||
val cookie = "$key=$value; Domain=$domain; Path=/"
|
||||
setCookie(url, cookie)
|
||||
}
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
if (!request.url.host.endsWith(domain)) return chain.proceed(request)
|
||||
|
||||
val cookie = "$key=$value"
|
||||
val cookieList = request.header("Cookie")?.split("; ") ?: emptyList()
|
||||
if (cookie in cookieList) return chain.proceed(request)
|
||||
|
||||
setCookie("https://$domain/", "$cookie; Domain=$domain; Path=/")
|
||||
val prefix = "$key="
|
||||
val newCookie = buildList(cookieList.size + 1) {
|
||||
cookieList.filterNotTo(this) { it.startsWith(prefix) }
|
||||
add(cookie)
|
||||
}.joinToString("; ")
|
||||
val newRequest = request.newBuilder().header("Cookie", newCookie).build()
|
||||
return chain.proceed(newRequest)
|
||||
}
|
||||
|
||||
private fun setCookie(url: String, value: String) {
|
||||
try {
|
||||
CookieManager.getInstance().setCookie(url, value)
|
||||
} catch (e: Exception) {
|
||||
// Probably running on Tachidesk
|
||||
Log.e("HentaiVN", "failed to set cookie", e)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,8 +1,19 @@
|
|||
package eu.kanade.tachiyomi.extension.vi.hentaivn
|
||||
|
||||
import android.app.Application
|
||||
import android.content.SharedPreferences
|
||||
import android.widget.Toast
|
||||
import androidx.preference.EditTextPreference
|
||||
import androidx.preference.ListPreference
|
||||
import androidx.preference.PreferenceScreen
|
||||
import eu.kanade.tachiyomi.extension.BuildConfig
|
||||
import eu.kanade.tachiyomi.lib.randomua.getPrefCustomUA
|
||||
import eu.kanade.tachiyomi.lib.randomua.getPrefUAType
|
||||
import eu.kanade.tachiyomi.lib.randomua.setRandomUserAgent
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.asObservableSuccess
|
||||
import eu.kanade.tachiyomi.network.interceptor.rateLimit
|
||||
import eu.kanade.tachiyomi.source.ConfigurableSource
|
||||
import eu.kanade.tachiyomi.source.model.Filter
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
import eu.kanade.tachiyomi.source.model.MangasPage
|
||||
|
@ -11,7 +22,6 @@ import eu.kanade.tachiyomi.source.model.SChapter
|
|||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.source.online.ParsedHttpSource
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import okhttp3.CookieJar
|
||||
import okhttp3.Headers
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
|
@ -21,41 +31,45 @@ import okhttp3.Response
|
|||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import rx.Observable
|
||||
import java.text.ParseException
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
class HentaiVN : ParsedHttpSource() {
|
||||
class HentaiVN : ParsedHttpSource(), ConfigurableSource {
|
||||
|
||||
private val preferences: SharedPreferences by lazy {
|
||||
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
|
||||
}
|
||||
|
||||
override val baseUrl = "https://hentaivn.tv"
|
||||
override val lang = "vi"
|
||||
override val name = "HentaiVN"
|
||||
override val supportsLatest = true
|
||||
|
||||
private val defaultBaseUrl = "https://hentaivn.tv"
|
||||
override val baseUrl = preferences.getString(PREF_KEY_BASE_URL, defaultBaseUrl)!!
|
||||
|
||||
private val domain = baseUrl.toHttpUrl().host
|
||||
private val searchUrl = "$baseUrl/forum/search-plus.php"
|
||||
private val searchByAuthorUrl = "$baseUrl/tim-kiem-tac-gia.html"
|
||||
private val searchAllURL = "$baseUrl/tim-kiem-truyen.html"
|
||||
private val searchClient = network.cloudflareClient
|
||||
|
||||
override val client: OkHttpClient = network.cloudflareClient.newBuilder()
|
||||
.cookieJar(CookieJar.NO_COOKIES)
|
||||
.addInterceptor { chain ->
|
||||
val originalRequest = chain.request()
|
||||
when {
|
||||
originalRequest.url.toString().startsWith(searchUrl) -> {
|
||||
searchClient.newCall(originalRequest).execute()
|
||||
}
|
||||
else -> chain.proceed(originalRequest)
|
||||
}
|
||||
}
|
||||
.rateLimit(1)
|
||||
.build()
|
||||
override val lang = "vi"
|
||||
|
||||
override val supportsLatest = true
|
||||
|
||||
override val client: OkHttpClient by lazy {
|
||||
network.cloudflareClient.newBuilder()
|
||||
.addNetworkInterceptor(CookieInterceptor(domain, "view1", "1"))
|
||||
.addNetworkInterceptor(CookieInterceptor(domain, "view4", "1"))
|
||||
.setRandomUserAgent(
|
||||
preferences.getPrefUAType(),
|
||||
preferences.getPrefCustomUA(),
|
||||
)
|
||||
.rateLimit(1)
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun headersBuilder(): Headers.Builder = super.headersBuilder()
|
||||
.add("Referer", baseUrl)
|
||||
.add("Cookie", "view1=1; view4=1") // bypass "captcha" and get popular manga
|
||||
|
||||
private val dateFormat = SimpleDateFormat("dd/MM/yyyy", Locale.ENGLISH)
|
||||
.add("Referer", "$baseUrl/")
|
||||
|
||||
// latestUpdates
|
||||
override fun latestUpdatesRequest(page: Int): Request {
|
||||
|
@ -63,7 +77,7 @@ class HentaiVN : ParsedHttpSource() {
|
|||
}
|
||||
|
||||
override fun latestUpdatesSelector() = ".main > .block-left > .block-item > ul > li.item"
|
||||
override fun latestUpdatesNextPageSelector() = "ul.pagination > li:contains(Next)"
|
||||
|
||||
override fun latestUpdatesFromElement(element: Element): SManga {
|
||||
val manga = SManga.create()
|
||||
element.select(".box-description a").first()!!.let {
|
||||
|
@ -74,112 +88,20 @@ class HentaiVN : ParsedHttpSource() {
|
|||
return manga
|
||||
}
|
||||
|
||||
override fun latestUpdatesNextPageSelector() = "ul.pagination > li:contains(Next)"
|
||||
|
||||
// Popular
|
||||
override fun popularMangaRequest(page: Int): Request {
|
||||
return GET("$baseUrl/danh-sach.html?page=$page", headers)
|
||||
}
|
||||
|
||||
override fun popularMangaFromElement(element: Element) = latestUpdatesFromElement(element)
|
||||
override fun popularMangaNextPageSelector() = latestUpdatesNextPageSelector()
|
||||
override fun popularMangaSelector() = latestUpdatesSelector()
|
||||
|
||||
// Chapter
|
||||
override fun chapterListSelector() = "table.listing > tbody > tr"
|
||||
override fun chapterFromElement(element: Element): SChapter {
|
||||
if (element.select("a").isEmpty()) throw Exception(element.select("h2").html())
|
||||
val chapter = SChapter.create()
|
||||
element.select("a").first()!!.let {
|
||||
chapter.name = it.select("h2").text()
|
||||
chapter.setUrlWithoutDomain(it.attr("href"))
|
||||
}
|
||||
chapter.date_upload = parseDate(element.select("td:nth-child(2)").text().trim())
|
||||
return chapter
|
||||
}
|
||||
override fun popularMangaFromElement(element: Element) = latestUpdatesFromElement(element)
|
||||
|
||||
override fun chapterListRequest(manga: SManga): Request {
|
||||
val mangaId = manga.url.substringAfterLast("/").substringBefore('-')
|
||||
return GET("$baseUrl/list-showchapter.php?idchapshow=$mangaId", headers)
|
||||
}
|
||||
|
||||
private fun parseDate(dateString: String): Long {
|
||||
return try {
|
||||
dateFormat.parse(dateString)?.time ?: 0L
|
||||
} catch (e: ParseException) {
|
||||
return 0L
|
||||
}
|
||||
}
|
||||
|
||||
override fun imageUrlParse(document: Document) = ""
|
||||
|
||||
// Detail
|
||||
override fun mangaDetailsParse(document: Document): SManga {
|
||||
val infoElement = document.select(".main > .page-left > .left-info > .page-info")
|
||||
val manga = SManga.create()
|
||||
manga.title = document.selectFirst(".breadcrumb2 li:last-child span")!!.text()
|
||||
manga.author = infoElement.select("p:contains(Tác giả:) a").text()
|
||||
manga.description = infoElement.select(":root > p:contains(Nội dung:) + p").text()
|
||||
manga.genre = infoElement.select("p:contains(Thể loại:) a").joinToString { it.text() }
|
||||
manga.thumbnail_url =
|
||||
document.select(".main > .page-right > .right-info > .page-ava > img").attr("src")
|
||||
manga.status =
|
||||
parseStatus(infoElement.select("p:contains(Tình Trạng:) a").firstOrNull()?.text())
|
||||
return manga
|
||||
}
|
||||
|
||||
private fun parseStatus(status: String?) = when {
|
||||
status == null -> SManga.UNKNOWN
|
||||
status.contains("Đang tiến hành") -> SManga.ONGOING
|
||||
status.contains("Đã hoàn thành") -> SManga.COMPLETED
|
||||
status.contains("Tạm ngưng") -> SManga.ON_HIATUS
|
||||
else -> SManga.UNKNOWN
|
||||
}
|
||||
|
||||
// Pages
|
||||
override fun pageListParse(document: Document): List<Page> {
|
||||
return document.select("#image > img").mapIndexed { i, e ->
|
||||
Page(i, imageUrl = e.attr("abs:src"))
|
||||
}
|
||||
}
|
||||
override fun popularMangaNextPageSelector() = latestUpdatesNextPageSelector()
|
||||
|
||||
// Search
|
||||
override fun searchMangaParse(response: Response): MangasPage {
|
||||
val document = response.asJsoup()
|
||||
if (document.select("p").toString()
|
||||
.contains("Bạn chỉ có thể sử dụng chức năng này khi đã đăng ký thành viên")
|
||||
) {
|
||||
throw Exception("Đăng nhập qua WebView để kích hoạt tìm kiếm")
|
||||
}
|
||||
|
||||
val mangas = document.select(searchMangaSelector()).map { element ->
|
||||
searchMangaFromElement(element)
|
||||
}
|
||||
|
||||
val hasNextPage = searchMangaNextPageSelector().let { selector ->
|
||||
document.select(selector).first()
|
||||
} != null
|
||||
|
||||
return MangasPage(mangas, hasNextPage)
|
||||
}
|
||||
|
||||
override fun searchMangaFromElement(element: Element): SManga {
|
||||
val manga = SManga.create()
|
||||
element.select(".search-des > a, .box-description a").first()!!.let {
|
||||
manga.setUrlWithoutDomain(it.attr("href"))
|
||||
manga.title = it.text().trim()
|
||||
}
|
||||
manga.thumbnail_url = element.select("div.search-img img").attr("abs:src")
|
||||
return manga
|
||||
}
|
||||
|
||||
override fun searchMangaNextPageSelector() = "ul.pagination > li:contains(Cuối)"
|
||||
|
||||
private fun searchMangaByIdRequest(id: String) = GET("$searchAllURL?key=$id", headers)
|
||||
private fun searchMangaByIdParse(response: Response, ids: String): MangasPage {
|
||||
val details = mangaDetailsParse(response)
|
||||
details.url = "/$ids-doc-truyen-id.html"
|
||||
return MangasPage(listOf(details), false)
|
||||
}
|
||||
|
||||
override fun fetchSearchManga(
|
||||
page: Int,
|
||||
query: String,
|
||||
|
@ -230,10 +152,6 @@ class HentaiVN : ParsedHttpSource() {
|
|||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val PREFIX_ID_SEARCH = "id:"
|
||||
}
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
val url = "$searchUrl?name=$query&page=$page&dou=&char=&group=0&search=".toHttpUrlOrNull()!!
|
||||
.newBuilder()
|
||||
|
@ -256,9 +174,161 @@ class HentaiVN : ParsedHttpSource() {
|
|||
return GET(url.toString(), headers)
|
||||
}
|
||||
|
||||
override fun searchMangaParse(response: Response): MangasPage {
|
||||
val document = response.asJsoup()
|
||||
if (document.select("p").toString()
|
||||
.contains("Bạn chỉ có thể sử dụng chức năng này khi đã đăng ký thành viên")
|
||||
) {
|
||||
throw Exception("Đăng nhập qua WebView để kích hoạt tìm kiếm")
|
||||
}
|
||||
|
||||
val mangas = document.select(searchMangaSelector()).map { element ->
|
||||
searchMangaFromElement(element)
|
||||
}
|
||||
|
||||
val hasNextPage = searchMangaNextPageSelector().let { selector ->
|
||||
document.select(selector).first()
|
||||
} != null
|
||||
|
||||
return MangasPage(mangas, hasNextPage)
|
||||
}
|
||||
|
||||
override fun searchMangaSelector() =
|
||||
".search-ul .search-li, .main > .block-left > .block-item > ul > li.item"
|
||||
|
||||
override fun searchMangaFromElement(element: Element): SManga {
|
||||
val manga = SManga.create()
|
||||
element.select(".search-des > a, .box-description a").first()!!.let {
|
||||
manga.setUrlWithoutDomain(it.attr("href"))
|
||||
manga.title = it.text().trim()
|
||||
}
|
||||
manga.thumbnail_url = element.select("div.search-img img").attr("abs:src")
|
||||
return manga
|
||||
}
|
||||
|
||||
override fun searchMangaNextPageSelector() = "ul.pagination > li:contains(Cuối)"
|
||||
|
||||
private fun searchMangaByIdRequest(id: String) = GET("$searchAllURL?key=$id", headers)
|
||||
private fun searchMangaByIdParse(response: Response, ids: String): MangasPage {
|
||||
val details = mangaDetailsParse(response)
|
||||
details.url = "/$ids-doc-truyen-id.html"
|
||||
return MangasPage(listOf(details), false)
|
||||
}
|
||||
|
||||
// Detail
|
||||
override fun mangaDetailsParse(document: Document): SManga {
|
||||
val infoElement = document.select(".main > .page-left > .left-info > .page-info")
|
||||
val manga = SManga.create()
|
||||
manga.title = document.selectFirst(".breadcrumb2 li:last-child span")!!.text()
|
||||
manga.author = infoElement.select("p:contains(Tác giả:) a").text()
|
||||
manga.description = infoElement.select(":root > p:contains(Nội dung:) + p").text()
|
||||
manga.genre = infoElement.select("p:contains(Thể loại:) a").joinToString { it.text() }
|
||||
manga.thumbnail_url =
|
||||
document.select(".main > .page-right > .right-info > .page-ava > img").attr("src")
|
||||
manga.status =
|
||||
parseStatus(infoElement.select("p:contains(Tình Trạng:) a").firstOrNull()?.text())
|
||||
return manga
|
||||
}
|
||||
|
||||
// Chapter
|
||||
override fun chapterListRequest(manga: SManga): Request {
|
||||
val mangaId = manga.url.substringAfterLast("/").substringBefore('-')
|
||||
return GET("$baseUrl/list-showchapter.php?idchapshow=$mangaId", headers)
|
||||
}
|
||||
|
||||
override fun chapterListSelector() = "table.listing > tbody > tr"
|
||||
|
||||
override fun chapterFromElement(element: Element): SChapter {
|
||||
if (element.select("a").isEmpty()) throw Exception(element.select("h2").html())
|
||||
val chapter = SChapter.create()
|
||||
element.select("a").first()!!.let {
|
||||
chapter.name = it.select("h2").text()
|
||||
chapter.setUrlWithoutDomain(it.attr("href"))
|
||||
}
|
||||
chapter.date_upload = parseDate(element.select("td:nth-child(2)").text().trim())
|
||||
return chapter
|
||||
}
|
||||
|
||||
// Pages
|
||||
override fun pageListParse(document: Document): List<Page> {
|
||||
return document.select("#image > img").mapIndexed { i, e ->
|
||||
Page(i, imageUrl = imageFromElement(e))
|
||||
}
|
||||
}
|
||||
|
||||
override fun imageUrlParse(document: Document) = ""
|
||||
|
||||
private val dateFormat = SimpleDateFormat("dd/MM/yyyy", Locale.ENGLISH)
|
||||
|
||||
private fun parseDate(dateString: String): Long {
|
||||
return kotlin.runCatching {
|
||||
dateFormat.parse(dateString)?.time
|
||||
}.getOrNull() ?: 0L
|
||||
}
|
||||
|
||||
private fun parseStatus(status: String?) = when {
|
||||
status == null -> SManga.UNKNOWN
|
||||
status.contains("Đang tiến hành") -> SManga.ONGOING
|
||||
status.contains("Đã hoàn thành") -> SManga.COMPLETED
|
||||
status.contains("Tạm ngưng") -> SManga.ON_HIATUS
|
||||
else -> SManga.UNKNOWN
|
||||
}
|
||||
|
||||
private fun imageFromElement(element: Element): String? {
|
||||
return when {
|
||||
element.hasAttr("data-src") -> element.attr("abs:data-src")
|
||||
element.hasAttr("data-lazy-src") -> element.attr("abs:data-lazy-src")
|
||||
element.hasAttr("srcset") -> element.attr("abs:srcset").substringBefore(" ")
|
||||
else -> element.attr("abs:src")
|
||||
}
|
||||
}
|
||||
|
||||
override fun setupPreferenceScreen(screen: PreferenceScreen) {
|
||||
EditTextPreference(screen.context).apply {
|
||||
key = PREF_KEY_BASE_URL
|
||||
title = TITLE_BASE_URL
|
||||
summary = SUMMARY_BASE_URL
|
||||
|
||||
setDefaultValue(defaultBaseUrl)
|
||||
dialogTitle = TITLE_BASE_URL
|
||||
|
||||
setOnPreferenceChangeListener { _, _ ->
|
||||
Toast.makeText(screen.context, RESTART_TACHIYOMI, Toast.LENGTH_LONG).show()
|
||||
true
|
||||
}
|
||||
}.also(screen::addPreference)
|
||||
|
||||
ListPreference(screen.context).apply {
|
||||
key = PREF_KEY_RANDOM_UA
|
||||
title = TITLE_RANDOM_UA
|
||||
entries = ENTRIES_RANDOM_UA
|
||||
entryValues = VALUES_RANDOM_UA
|
||||
summary = "%s"
|
||||
setDefaultValue("off")
|
||||
|
||||
setOnPreferenceChangeListener { _, _ ->
|
||||
Toast.makeText(screen.context, RESTART_TACHIYOMI, Toast.LENGTH_LONG).show()
|
||||
true
|
||||
}
|
||||
}.also(screen::addPreference)
|
||||
|
||||
EditTextPreference(screen.context).apply {
|
||||
key = PREF_KEY_CUSTOM_UA
|
||||
title = TITLE_CUSTOM_UA
|
||||
summary = SUMMARY_CUSTOM_UA
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
try {
|
||||
Headers.Builder().add("User-Agent", newValue as String).build()
|
||||
Toast.makeText(screen.context, RESTART_TACHIYOMI, Toast.LENGTH_LONG).show()
|
||||
true
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Toast.makeText(screen.context, "Chuỗi đại diện người dùng không hợp lệ: ${e.message}", Toast.LENGTH_LONG).show()
|
||||
false
|
||||
}
|
||||
}
|
||||
}.also(screen::addPreference)
|
||||
}
|
||||
|
||||
private class Alls : Filter.Text("Tìm tất cả")
|
||||
private class Author : Filter.Text("Tác giả")
|
||||
private class TextField(name: String, val key: String) : Filter.Text(name)
|
||||
|
@ -280,11 +350,11 @@ class HentaiVN : ParsedHttpSource() {
|
|||
Author(),
|
||||
TextField("Doujinshi", "dou"),
|
||||
TextField("Nhân vật", "char"),
|
||||
GenreList(getGenreList()),
|
||||
GroupList(getGroupList()),
|
||||
GenreList(getGenreList()),
|
||||
)
|
||||
|
||||
// jQuery.makeArray($('#container > div > div > div.box-box.textbox > form > ul:nth-child(7) > li').map((i, e) => `Genre("${e.textContent}", "${e.children[0].value}")`)).join(',\n')
|
||||
// console.log(jQuery.makeArray($('ul.ul-search > li').map((i, e) => `Genre("${e.textContent}", "${e.children[0].value}")`)).join(',\n'))
|
||||
// https://hentaivn.autos/forum/search-plus.php
|
||||
private fun getGenreList() = listOf(
|
||||
Genre("3D Hentai", "3"),
|
||||
|
@ -514,4 +584,23 @@ class HentaiVN : ParsedHttpSource() {
|
|||
TransGroup("Depressed Lolicons Squad - DLS", "52"),
|
||||
TransGroup("Heaven Of The Fuck", "53"),
|
||||
)
|
||||
|
||||
companion object {
|
||||
const val PREFIX_ID_SEARCH = "id:"
|
||||
|
||||
const val RESTART_TACHIYOMI = "Khởi động lại Tachiyomi để áp dụng thay đổi."
|
||||
|
||||
const val PREF_KEY_BASE_URL = "override_base_url_${BuildConfig.VERSION_CODE}"
|
||||
const val TITLE_BASE_URL = "Thay đổi tên miền"
|
||||
const val SUMMARY_BASE_URL = "Thay đổi này là tạm thời và sẽ bị xoá khi cập nhật tiện ích mở rộng."
|
||||
|
||||
const val PREF_KEY_RANDOM_UA = "pref_key_random_ua_"
|
||||
const val TITLE_RANDOM_UA = "Chuỗi đại diện người dùng ngẫu nhiên"
|
||||
val ENTRIES_RANDOM_UA = arrayOf("Tắt", "Máy tính", "Di động")
|
||||
val VALUES_RANDOM_UA = arrayOf("off", "desktop", "mobile")
|
||||
|
||||
const val PREF_KEY_CUSTOM_UA = "pref_key_custom_ua_"
|
||||
const val TITLE_CUSTOM_UA = "Chuỗi đại diện người dùng tuỳ chỉnh"
|
||||
const val SUMMARY_CUSTOM_UA = "Để trống để dùng chuỗi đại diện người dùng mặc định của ứng dụng. Cài đặt này bị vô hiệu nếu chuỗi đại diện người dùng ngẫu nhiên được bật."
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue