From 28850cf51a3520d350123891b2c93581e8629a24 Mon Sep 17 00:00:00 2001 From: beerpsi <92439990+beerpiss@users.noreply.github.com> Date: Sat, 5 Aug 2023 20:31:17 +0700 Subject: [PATCH] Refactor HentaiVN (#17391) * Refactor HentaiVN * better cookie handling --- src/vi/hentaivn/build.gradle | 6 +- .../vi/hentaivn/CookieInterceptor.kt | 46 +++ .../extension/vi/hentaivn/HentaiVN.kt | 343 +++++++++++------- 3 files changed, 267 insertions(+), 128 deletions(-) create mode 100644 src/vi/hentaivn/src/eu/kanade/tachiyomi/extension/vi/hentaivn/CookieInterceptor.kt diff --git a/src/vi/hentaivn/build.gradle b/src/vi/hentaivn/build.gradle index d0c6f5454..39f6896de 100644 --- a/src/vi/hentaivn/build.gradle +++ b/src/vi/hentaivn/build.gradle @@ -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" diff --git a/src/vi/hentaivn/src/eu/kanade/tachiyomi/extension/vi/hentaivn/CookieInterceptor.kt b/src/vi/hentaivn/src/eu/kanade/tachiyomi/extension/vi/hentaivn/CookieInterceptor.kt new file mode 100644 index 000000000..ce312b321 --- /dev/null +++ b/src/vi/hentaivn/src/eu/kanade/tachiyomi/extension/vi/hentaivn/CookieInterceptor.kt @@ -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) + } + } +} diff --git a/src/vi/hentaivn/src/eu/kanade/tachiyomi/extension/vi/hentaivn/HentaiVN.kt b/src/vi/hentaivn/src/eu/kanade/tachiyomi/extension/vi/hentaivn/HentaiVN.kt index 398707240..940b20101 100644 --- a/src/vi/hentaivn/src/eu/kanade/tachiyomi/extension/vi/hentaivn/HentaiVN.kt +++ b/src/vi/hentaivn/src/eu/kanade/tachiyomi/extension/vi/hentaivn/HentaiVN.kt @@ -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().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 { - 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 { + 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." + } }