From 03b8b9b4caa94f547b619a1c463f09199de74d93 Mon Sep 17 00:00:00 2001 From: Chopper <156493704+choppeh@users.noreply.github.com> Date: Mon, 14 Jul 2025 05:47:02 -0300 Subject: [PATCH] TruyenHentai18: Update domain and fix loading content (#9586) * Update domains * Add private statement in DTO * Add setUrlWithoutDomain in mangaDetailsParse * Save slug without lang prefix * Apply changes --- src/vi/truyenhentai18/AndroidManifest.xml | 4 +- src/vi/truyenhentai18/build.gradle | 2 +- .../vi/truyenhentai18/TruyenHentai18.kt | 188 ++++++++++-------- .../vi/truyenhentai18/TruyenHentai18Dto.kt | 55 +++++ .../TruyenHentai18UrlActivity.kt | 4 +- 5 files changed, 170 insertions(+), 83 deletions(-) create mode 100644 src/vi/truyenhentai18/src/eu/kanade/tachiyomi/extension/vi/truyenhentai18/TruyenHentai18Dto.kt diff --git a/src/vi/truyenhentai18/AndroidManifest.xml b/src/vi/truyenhentai18/AndroidManifest.xml index 5410ccc86..58b847150 100644 --- a/src/vi/truyenhentai18/AndroidManifest.xml +++ b/src/vi/truyenhentai18/AndroidManifest.xml @@ -12,8 +12,8 @@ diff --git a/src/vi/truyenhentai18/build.gradle b/src/vi/truyenhentai18/build.gradle index 03e8df4d9..9229f80f6 100644 --- a/src/vi/truyenhentai18/build.gradle +++ b/src/vi/truyenhentai18/build.gradle @@ -1,7 +1,7 @@ ext { extName = "Truyen Hentai 18+" extClass = ".TruyenHentai18" - extVersionCode = 3 + extVersionCode = 4 isNsfw = true } diff --git a/src/vi/truyenhentai18/src/eu/kanade/tachiyomi/extension/vi/truyenhentai18/TruyenHentai18.kt b/src/vi/truyenhentai18/src/eu/kanade/tachiyomi/extension/vi/truyenhentai18/TruyenHentai18.kt index c1d27a0c1..52f0fdbde 100644 --- a/src/vi/truyenhentai18/src/eu/kanade/tachiyomi/extension/vi/truyenhentai18/TruyenHentai18.kt +++ b/src/vi/truyenhentai18/src/eu/kanade/tachiyomi/extension/vi/truyenhentai18/TruyenHentai18.kt @@ -7,18 +7,23 @@ import eu.kanade.tachiyomi.source.model.Page 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 keiyoushi.utils.parseAs import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.Request +import okhttp3.Response +import org.jsoup.Jsoup import org.jsoup.nodes.Document import org.jsoup.nodes.Element import rx.Observable -import java.util.Calendar class TruyenHentai18 : ParsedHttpSource() { override val name = "Truyện Hentai 18+" - override val baseUrl = "https://truyenhentai18.pro" + override val baseUrl = "https://truyenhentai18.app" + + private val apiUrl = "https://api.th18.app" override val lang = "vi" @@ -29,24 +34,29 @@ class TruyenHentai18 : ParsedHttpSource() { override fun headersBuilder() = super.headersBuilder() .add("Referer", "$baseUrl/") - override fun popularMangaRequest(page: Int) = - GET("$baseUrl/truyen-de-xuat" + if (page > 1) "/page/$page" else "", headers) + // ============================== Popular ====================================== - override fun popularMangaSelector() = "div.row > div[class^=item-] > div.card" + override fun popularMangaRequest(page: Int) = + GET("$baseUrl/$lang/truyen-de-xuat" + if (page > 1) "/page/$page" else "", headers) + + override fun popularMangaSelector() = ".container .p-2 .shadow-sm.overflow-hidden" override fun popularMangaFromElement(element: Element) = SManga.create().apply { - element.selectFirst("a.item-title")!!.let { - setUrlWithoutDomain(it.attr("href")) - title = it.text() + element.selectFirst("a[title]")!!.let { + setUrlWithoutDomain(it.absUrl("href")) + url = url.removePrefix("/$lang") + title = it.attr("title") } - thumbnail_url = element.selectFirst("a.item-cover img")?.absUrl("data-src") + thumbnail_url = element.selectFirst("img")?.absUrl("src") } override fun popularMangaNextPageSelector() = "ul.pagination li.page-item.active:not(:last-child)" + // ============================== Latest ====================================== + override fun latestUpdatesRequest(page: Int) = - GET("$baseUrl/truyen-moi" + if (page > 1) "/page/$page" else "", headers) + GET("$baseUrl/$lang/truyen-moi" + if (page > 1) "/page/$page" else "", headers) override fun latestUpdatesSelector() = popularMangaSelector() @@ -54,97 +64,119 @@ class TruyenHentai18 : ParsedHttpSource() { override fun latestUpdatesNextPageSelector() = popularMangaNextPageSelector() - override fun fetchSearchManga( - page: Int, - query: String, - filters: FilterList, - ): Observable { + // ============================== Search ====================================== + + override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable { return if (query.startsWith(PREFIX_SLUG_SEARCH)) { val slug = query.removePrefix(PREFIX_SLUG_SEARCH) - val url = "/$slug" - - fetchMangaDetails(SManga.create().apply { this.url = url }) - .map { MangasPage(listOf(it.apply { this.url = url }), false) } + fetchMangaDetails(SManga.create().apply { this.url = "/$lang/$slug" }) + .map { MangasPage(listOf(it), false) } } else { super.fetchSearchManga(page, query, filters) } } override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { - val url = baseUrl.toHttpUrl().newBuilder().apply { - if (page > 1) { - addPathSegment("page") - addPathSegment(page.toString()) - } + val url = "$apiUrl/posts".toHttpUrl().newBuilder() + .addQueryParameter("language", lang) + .addQueryParameter("order", "latest") + .addQueryParameter("status", "taxonomyid") + .addQueryParameter("query", query) + .addQueryParameter("limit", "9999") + .addQueryParameter("page", "1") + .build() + return GET(url, headers) + } - addQueryParameter("s", query) - }.build() + override fun searchMangaParse(response: Response): MangasPage { + val mangas = response.parseAs().data.map { it.toSManga() } + return MangasPage(mangas, hasNextPage = false) + } + + override fun searchMangaSelector() = throw UnsupportedOperationException() + override fun searchMangaFromElement(element: Element) = throw UnsupportedOperationException() + override fun searchMangaNextPageSelector() = throw UnsupportedOperationException() + + // ============================== Details ====================================== + + override fun getMangaUrl(manga: SManga) = "$baseUrl/$lang${manga.url}" + + override fun mangaDetailsParse(document: Document) = SManga.create().apply { + title = document.selectFirst("h1")!!.text() + genre = document.select("a[href*=the-loai]").joinToString { it.attr("title") } + thumbnail_url = document.selectFirst("img.bg-background")?.absUrl("src") + document.selectFirst("h5")?.text()?.lowercase()?.let { + status = when { + it.equals("Đã hoàn thành", ignoreCase = true) -> SManga.COMPLETED + it.equals("Đang tiến hành", ignoreCase = true) -> SManga.ONGOING + else -> SManga.UNKNOWN + } + } + setUrlWithoutDomain(document.location()) + url = url.removePrefix("/$lang") + } + + // ============================== Chapters ====================================== + + override fun getChapterUrl(chapter: SChapter) = "$baseUrl/$lang${chapter.url}" + + override fun chapterListRequest(manga: SManga): Request { + val document = client.newCall(super.chapterListRequest(manga)) + .execute().asJsoup() + val postId = document.findPostId() + return chapterListRequest(postId) + } + + private fun chapterListRequest(postId: String): Request { + val url = "$apiUrl/posts/$postId/chapters".toHttpUrl().newBuilder() + .addQueryParameter("language", lang) + .addQueryParameter("limit", "9999") + .addQueryParameter("page", "1") + .build() return GET(url, headers) } - override fun searchMangaSelector() = "div[data-id] > div.card" - - override fun searchMangaFromElement(element: Element) = popularMangaFromElement(element) - - override fun searchMangaNextPageSelector() = popularMangaNextPageSelector() - - override fun mangaDetailsParse(document: Document) = SManga.create().apply { - val statusClassName = document.selectFirst("em.eflag.item-flag")!!.className() - - title = document.selectFirst("span[itemprop=name]")!!.text() - author = document.select("div.attr-item b:contains(Tác giả) ~ span a, span[itemprop=author]").joinToString { it.text() } - description = document.selectFirst("div[itemprop=about]")?.text() - genre = document.select("ul.post-categories li a").joinToString { it.text() } - thumbnail_url = document.selectFirst("div.attr-cover img")?.absUrl("src") - status = when { - statusClassName.contains("flag-completed") -> SManga.COMPLETED - statusClassName.contains("flag-ongoing") -> SManga.ONGOING - else -> SManga.UNKNOWN - } + override fun chapterListParse(response: Response): List { + return response.parseAs().toSChapterList() + .sortedByDescending(SChapter::chapter_number) } - override fun chapterListSelector() = "#chaptersbox > div" + private fun Document.findPostId(): String { + val script = select("script").map(Element::data) + .first(CHAPTERS_POST_ID::containsMatchIn) - override fun chapterFromElement(element: Element) = SChapter.create().apply { - element.selectFirst("a")!!.let { - setUrlWithoutDomain(it.attr("href")) - name = it.selectFirst("b")!!.text() - } - - date_upload = element.selectFirst("div.extra > i.ps-3") - ?.text() - ?.let { parseRelativeDate(it) } - ?: 0L + return CHAPTERS_POST_ID.find(script)?.groups?.get(1)?.value!! } - override fun pageListParse(document: Document) = - document.select("#viewer img").mapIndexed { i, it -> - Page(i, imageUrl = it.absUrl("src")) + override fun chapterListSelector() = throw UnsupportedOperationException() + override fun chapterFromElement(element: Element) = throw UnsupportedOperationException() + + // ============================== Pages ====================================== + + override fun pageListRequest(chapter: SChapter) = GET(getChapterUrl(chapter), headers) + + override fun pageListParse(document: Document): List { + val postId = document.findPostId() + val dto = client.newCall(chapterListRequest(postId)) + .execute() + .parseAs() + + val pathSegment = document.location() + .substringAfterLast("/") + .substringBeforeLast(".") + + val page = dto.data.first { pathSegment.equals(it.slug, ignoreCase = true) } + + return Jsoup.parseBodyFragment(page.content).select("img").mapIndexed { index, element -> + Page(index, imageUrl = element.absUrl("src")) } + } override fun imageUrlParse(document: Document) = throw UnsupportedOperationException() - private fun parseRelativeDate(date: String): Long { - val (valueString, unit) = date.substringBefore(" trước").split(" ") - val value = valueString.toInt() - - val calendar = Calendar.getInstance().apply { - when (unit) { - "giây" -> add(Calendar.SECOND, -value) - "phút" -> add(Calendar.MINUTE, -value) - "giờ" -> add(Calendar.HOUR_OF_DAY, -value) - "ngày" -> add(Calendar.DAY_OF_MONTH, -value) - "tuần" -> add(Calendar.WEEK_OF_MONTH, -value) - "tháng" -> add(Calendar.MONTH, -value) - "năm" -> add(Calendar.YEAR, -value) - } - } - - return calendar.timeInMillis - } - companion object { internal const val PREFIX_SLUG_SEARCH = "slug:" + private val CHAPTERS_POST_ID = """(?:(?:postId|post_id).{3})(\d+)""".toRegex() } } diff --git a/src/vi/truyenhentai18/src/eu/kanade/tachiyomi/extension/vi/truyenhentai18/TruyenHentai18Dto.kt b/src/vi/truyenhentai18/src/eu/kanade/tachiyomi/extension/vi/truyenhentai18/TruyenHentai18Dto.kt new file mode 100644 index 000000000..8fd325fe0 --- /dev/null +++ b/src/vi/truyenhentai18/src/eu/kanade/tachiyomi/extension/vi/truyenhentai18/TruyenHentai18Dto.kt @@ -0,0 +1,55 @@ +package eu.kanade.tachiyomi.extension.vi.truyenhentai18 + +import eu.kanade.tachiyomi.source.model.SChapter +import eu.kanade.tachiyomi.source.model.SManga +import keiyoushi.utils.tryParse +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import java.text.SimpleDateFormat +import java.util.Locale + +@Serializable +class SearchDto( + val data: List, +) + +@Serializable +class MangaDto( + private val title: String, + private val slug: String, +) { + fun toSManga() = SManga.create().apply { + title = this@MangaDto.title + url = "/$slug.html" + } +} + +@Serializable +class ChapterWrapper( + @SerialName("post_slug") + private val postSlug: String, + val data: List, +) { + fun toSChapterList() = data.map { it.toSChapter(postSlug) } +} + +@Serializable +class ChapterDto( + val slug: String, + @SerialName("chapter_number") + private val chapterNumber: Float, + @SerialName("created_at") + private val createdAt: String, + val content: String, +) { + fun toSChapter(postSlug: String) = SChapter.create().apply { + name = chapterNumber.toString() + chapter_number = chapterNumber + url = "/$postSlug/$slug.html" + date_upload = dateFormat.tryParse(createdAt) + } + + companion object { + private val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.ROOT) + } +} diff --git a/src/vi/truyenhentai18/src/eu/kanade/tachiyomi/extension/vi/truyenhentai18/TruyenHentai18UrlActivity.kt b/src/vi/truyenhentai18/src/eu/kanade/tachiyomi/extension/vi/truyenhentai18/TruyenHentai18UrlActivity.kt index 91a3fe261..7c6bd2327 100644 --- a/src/vi/truyenhentai18/src/eu/kanade/tachiyomi/extension/vi/truyenhentai18/TruyenHentai18UrlActivity.kt +++ b/src/vi/truyenhentai18/src/eu/kanade/tachiyomi/extension/vi/truyenhentai18/TruyenHentai18UrlActivity.kt @@ -13,10 +13,10 @@ class TruyenHentai18UrlActivity : Activity() { val pathSegments = intent?.data?.pathSegments - if (pathSegments != null && pathSegments.size > 0) { + if (pathSegments != null && pathSegments.size > 1) { val intent = Intent().apply { action = "eu.kanade.tachiyomi.SEARCH" - putExtra("query", "${TruyenHentai18.PREFIX_SLUG_SEARCH}${pathSegments[0]}") + putExtra("query", "${TruyenHentai18.PREFIX_SLUG_SEARCH}${pathSegments[1]}") putExtra("filter", packageName) }