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
This commit is contained in:
Chopper 2025-07-14 05:47:02 -03:00 committed by Draff
parent b747b55681
commit 03b8b9b4ca
Signed by: Draff
GPG Key ID: E8A89F3211677653
5 changed files with 170 additions and 83 deletions

View File

@ -12,8 +12,8 @@
<category android:name="android.intent.category.BROWSABLE"/>
<data
android:host="truyenhentai18.pro"
android:pathPattern="/..*\\.html"
android:host="truyenhentai18.app"
android:pathPattern="/vi/..*\\.html"
android:scheme="https"/>
</intent-filter>
</activity>

View File

@ -1,7 +1,7 @@
ext {
extName = "Truyen Hentai 18+"
extClass = ".TruyenHentai18"
extVersionCode = 3
extVersionCode = 4
isNsfw = true
}

View File

@ -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<MangasPage> {
// ============================== Search ======================================
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
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<SearchDto>().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<SChapter> {
return response.parseAs<ChapterWrapper>().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<Page> {
val postId = document.findPostId()
val dto = client.newCall(chapterListRequest(postId))
.execute()
.parseAs<ChapterWrapper>()
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()
}
}

View File

@ -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<MangaDto>,
)
@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<ChapterDto>,
) {
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)
}
}

View File

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