Refactor HentaiVN (#17391)

* Refactor HentaiVN

* better cookie handling
This commit is contained in:
beerpsi 2023-08-05 20:31:17 +07:00 committed by GitHub
parent 9f542d8234
commit 28850cf51a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 267 additions and 128 deletions

View File

@ -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"

View File

@ -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)
}
}
}

View File

@ -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."
}
}