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