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"/> <category android:name="android.intent.category.BROWSABLE"/>
<data <data
android:host="truyenhentai18.pro" android:host="truyenhentai18.app"
android:pathPattern="/..*\\.html" android:pathPattern="/vi/..*\\.html"
android:scheme="https"/> android:scheme="https"/>
</intent-filter> </intent-filter>
</activity> </activity>

View File

@ -1,7 +1,7 @@
ext { ext {
extName = "Truyen Hentai 18+" extName = "Truyen Hentai 18+"
extClass = ".TruyenHentai18" extClass = ".TruyenHentai18"
extVersionCode = 3 extVersionCode = 4
isNsfw = true 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.SChapter
import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.ParsedHttpSource import eu.kanade.tachiyomi.source.online.ParsedHttpSource
import eu.kanade.tachiyomi.util.asJsoup
import keiyoushi.utils.parseAs
import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Request import okhttp3.Request
import okhttp3.Response
import org.jsoup.Jsoup
import org.jsoup.nodes.Document import org.jsoup.nodes.Document
import org.jsoup.nodes.Element import org.jsoup.nodes.Element
import rx.Observable import rx.Observable
import java.util.Calendar
class TruyenHentai18 : ParsedHttpSource() { class TruyenHentai18 : ParsedHttpSource() {
override val name = "Truyện Hentai 18+" 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" override val lang = "vi"
@ -29,24 +34,29 @@ class TruyenHentai18 : ParsedHttpSource() {
override fun headersBuilder() = super.headersBuilder() override fun headersBuilder() = super.headersBuilder()
.add("Referer", "$baseUrl/") .add("Referer", "$baseUrl/")
override fun popularMangaRequest(page: Int) = // ============================== Popular ======================================
GET("$baseUrl/truyen-de-xuat" + if (page > 1) "/page/$page" else "", headers)
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 { override fun popularMangaFromElement(element: Element) = SManga.create().apply {
element.selectFirst("a.item-title")!!.let { element.selectFirst("a[title]")!!.let {
setUrlWithoutDomain(it.attr("href")) setUrlWithoutDomain(it.absUrl("href"))
title = it.text() 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)" override fun popularMangaNextPageSelector() = "ul.pagination li.page-item.active:not(:last-child)"
// ============================== Latest ======================================
override fun latestUpdatesRequest(page: Int) = 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() override fun latestUpdatesSelector() = popularMangaSelector()
@ -54,97 +64,119 @@ class TruyenHentai18 : ParsedHttpSource() {
override fun latestUpdatesNextPageSelector() = popularMangaNextPageSelector() override fun latestUpdatesNextPageSelector() = popularMangaNextPageSelector()
override fun fetchSearchManga( // ============================== Search ======================================
page: Int,
query: String, override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
filters: FilterList,
): Observable<MangasPage> {
return if (query.startsWith(PREFIX_SLUG_SEARCH)) { return if (query.startsWith(PREFIX_SLUG_SEARCH)) {
val slug = query.removePrefix(PREFIX_SLUG_SEARCH) val slug = query.removePrefix(PREFIX_SLUG_SEARCH)
val url = "/$slug" fetchMangaDetails(SManga.create().apply { this.url = "/$lang/$slug" })
.map { MangasPage(listOf(it), false) }
fetchMangaDetails(SManga.create().apply { this.url = url })
.map { MangasPage(listOf(it.apply { this.url = url }), false) }
} else { } else {
super.fetchSearchManga(page, query, filters) super.fetchSearchManga(page, query, filters)
} }
} }
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
val url = baseUrl.toHttpUrl().newBuilder().apply { val url = "$apiUrl/posts".toHttpUrl().newBuilder()
if (page > 1) { .addQueryParameter("language", lang)
addPathSegment("page") .addQueryParameter("order", "latest")
addPathSegment(page.toString()) .addQueryParameter("status", "taxonomyid")
.addQueryParameter("query", query)
.addQueryParameter("limit", "9999")
.addQueryParameter("page", "1")
.build()
return GET(url, headers)
} }
addQueryParameter("s", query) override fun searchMangaParse(response: Response): MangasPage {
}.build() 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) return GET(url, headers)
} }
override fun searchMangaSelector() = "div[data-id] > div.card" override fun chapterListParse(response: Response): List<SChapter> {
return response.parseAs<ChapterWrapper>().toSChapterList()
override fun searchMangaFromElement(element: Element) = popularMangaFromElement(element) .sortedByDescending(SChapter::chapter_number)
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 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 { return CHAPTERS_POST_ID.find(script)?.groups?.get(1)?.value!!
element.selectFirst("a")!!.let {
setUrlWithoutDomain(it.attr("href"))
name = it.selectFirst("b")!!.text()
} }
date_upload = element.selectFirst("div.extra > i.ps-3") override fun chapterListSelector() = throw UnsupportedOperationException()
?.text() override fun chapterFromElement(element: Element) = throw UnsupportedOperationException()
?.let { parseRelativeDate(it) }
?: 0L
}
override fun pageListParse(document: Document) = // ============================== Pages ======================================
document.select("#viewer img").mapIndexed { i, it ->
Page(i, imageUrl = it.absUrl("src")) 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() 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 { companion object {
internal const val PREFIX_SLUG_SEARCH = "slug:" 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 val pathSegments = intent?.data?.pathSegments
if (pathSegments != null && pathSegments.size > 0) { if (pathSegments != null && pathSegments.size > 1) {
val intent = Intent().apply { val intent = Intent().apply {
action = "eu.kanade.tachiyomi.SEARCH" action = "eu.kanade.tachiyomi.SEARCH"
putExtra("query", "${TruyenHentai18.PREFIX_SLUG_SEARCH}${pathSegments[0]}") putExtra("query", "${TruyenHentai18.PREFIX_SLUG_SEARCH}${pathSegments[1]}")
putExtra("filter", packageName) putExtra("filter", packageName)
} }