Add BlogTruyen.vn (unoriginal) (#686)

* Add BlogTruyen.vn (unoriginal)

* refactor a thing

* Add final newline

* Epic lint fail

* Move date format out of constructor arguments

* Remove manifest file in override

* Don't display genre list if empty

* Apply rate limit only to real BlogTruyen
This commit is contained in:
beerpsi 2024-01-27 16:25:21 +07:00 committed by Draff
parent 259e12ad25
commit 4eb24b350b
18 changed files with 539 additions and 445 deletions

View File

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

View File

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

View File

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

View File

@ -0,0 +1,67 @@
package eu.kanade.tachiyomi.extension.vi.blogtruyen
import eu.kanade.tachiyomi.multisrc.blogtruyen.BlogTruyen
import eu.kanade.tachiyomi.network.interceptor.rateLimit
class BlogTruyenMoi : BlogTruyen("BlogTruyen", "https://blogtruyenmoi.com", "vi") {
override val client = super.client.newBuilder()
.rateLimit(2)
.build()
override fun getGenreList() = listOf(
Genre("Action", "1"),
Genre("Adventure", "3"),
Genre("Comedy", "5"),
Genre("Comic", "6"),
Genre("Doujinshi", "7"),
Genre("Drama", "49"),
Genre("Ecchi", "48"),
Genre("Event BT", "60"),
Genre("Fantasy", "50"),
Genre("Full màu", "64"),
Genre("Game", "61"),
Genre("Gender Bender", "51"),
Genre("Harem", "12"),
Genre("Historical", "13"),
Genre("Horror", "14"),
Genre("Isekai/Dị giới/Trọng sinh", "63"),
Genre("Josei", "15"),
Genre("Live action", "16"),
Genre("Magic", "46"),
Genre("manga", "55"),
Genre("Manhua", "17"),
Genre("Manhwa", "18"),
Genre("Martial Arts", "19"),
Genre("Mecha", "21"),
Genre("Mystery", "22"),
Genre("Nấu Ăn", "56"),
Genre("Ngôn Tình", "65"),
Genre("NTR", "62"),
Genre("One shot", "23"),
Genre("Psychological", "24"),
Genre("Romance", "25"),
Genre("School Life", "26"),
Genre("Sci-fi", "27"),
Genre("Seinen", "28"),
Genre("Shoujo", "29"),
Genre("Shoujo Ai", "30"),
Genre("Shounen", "31"),
Genre("Shounen Ai", "32"),
Genre("Slice of life", "33"),
Genre("Smut", "34"),
Genre("Soft Yaoi", "35"),
Genre("Soft Yuri", "36"),
Genre("Sports", "37"),
Genre("Supernatural", "38"),
Genre("Tạp chí truyện tranh", "39"),
Genre("Tragedy", "40"),
Genre("Trap (Crossdressing)", "58"),
Genre("Trinh Thám", "57"),
Genre("Truyện scan", "41"),
Genre("Tu chân - tu tiên", "66"),
Genre("Video Clip", "53"),
Genre("VnComic", "42"),
Genre("Webtoon", "52"),
Genre("Yuri", "59"),
)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

View File

@ -0,0 +1,56 @@
package eu.kanade.tachiyomi.extension.vi.blogtruyenvn
import eu.kanade.tachiyomi.multisrc.blogtruyen.BlogTruyen
class BlogTruyenVn : BlogTruyen("BlogTruyen.vn (unoriginal)", "https://blogtruyenvn.com", "vi") {
override fun getGenreList() = listOf(
Genre("Action", "1"),
Genre("Adventure", "3"),
Genre("Comedy", "5"),
Genre("Comic", "6"),
Genre("Doujinshi", "7"),
Genre("Drama", "49"),
Genre("Ecchi", "48"),
Genre("Event BT", "60"),
Genre("Fantasy", "50"),
Genre("Full màu", "64"),
Genre("Game", "61"),
Genre("Harem", "12"),
Genre("Historical", "13"),
Genre("Horror", "14"),
Genre("Isekai/Dị giới/Trọng sinh", "63"),
Genre("Josei", "15"),
Genre("Live action", "16"),
Genre("Magic", "46"),
Genre("manga", "55"),
Genre("Manhua", "17"),
Genre("Manhwa", "18"),
Genre("Martial Arts", "19"),
Genre("Mecha", "21"),
Genre("Mystery", "22"),
Genre("Nấu Ăn", "56"),
Genre("Ngôn Tình", "65"),
Genre("NTR", "62"),
Genre("One shot", "23"),
Genre("Psychological", "24"),
Genre("Romance", "25"),
Genre("School Life", "26"),
Genre("Sci-fi", "27"),
Genre("Seinen", "28"),
Genre("Shoujo", "29"),
Genre("Shounen", "31"),
Genre("Shounen Ai", "32"),
Genre("Slice of life", "33"),
Genre("Smut", "34"),
Genre("Sports", "37"),
Genre("Supernatural", "38"),
Genre("Tạp chí truyện tranh", "39"),
Genre("Tragedy", "40"),
Genre("Trinh Thám", "57"),
Genre("Truyện scan", "41"),
Genre("Tu chân - tu tiên", "66"),
Genre("Video Clip", "53"),
Genre("VnComic", "42"),
Genre("Webtoon", "52"),
)
}

View File

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity android:name=".vi.blogtruyen.BlogTruyenUrlActivity"
<activity android:name="eu.kanade.tachiyomi.multisrc.blogtruyen.BlogTruyenUrlActivity"
android:excludeFromRecents="true"
android:exported="true"
android:theme="@android:style/Theme.NoDisplay">
@ -11,9 +11,9 @@
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:host="blogtruyenmoi.com" />
<data android:host="m.blogtruyenmoi.com" />
<data android:scheme="https" />
<data android:host="${SOURCEHOST}" />
<data android:host="m.${SOURCEHOST}" />
<data android:scheme="${SOURCESCHEME}" />
<data android:pathPattern="/tac-gia/..*" />
<data android:pathPattern="/nhom-dich/..*" />

View File

@ -0,0 +1,386 @@
package eu.kanade.tachiyomi.multisrc.blogtruyen
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.source.model.Filter
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.MangasPage
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 kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.FormBody
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Request
import okhttp3.Response
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import rx.Observable
import rx.Single
import rx.schedulers.Schedulers
import uy.kohesive.injekt.injectLazy
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.TimeZone
abstract class BlogTruyen(
override val name: String,
override val baseUrl: String,
override val lang: String,
) : ParsedHttpSource() {
override val supportsLatest = true
override fun headersBuilder() = super.headersBuilder()
.add("Referer", "$baseUrl/")
private val json: Json by injectLazy()
private val dateFormat: SimpleDateFormat = SimpleDateFormat("dd/MM/yyyy HH:mm", Locale.US).apply {
timeZone = TimeZone.getTimeZone("Asia/Ho_Chi_Minh")
}
override fun popularMangaRequest(page: Int) =
GET("$baseUrl/ajax/Search/AjaxLoadListManga?key=tatca&orderBy=3&p=$page", headers)
override fun popularMangaParse(response: Response): MangasPage {
val document = response.asJsoup()
val manga = document.select(popularMangaSelector()).map {
val tiptip = it.attr("data-tiptip")
popularMangaFromElement(it, document.getElementById(tiptip)!!)
}
val hasNextPage = document.selectFirst(popularMangaNextPageSelector()) != null
return MangasPage(manga, hasNextPage)
}
override fun popularMangaSelector() = ".list .tiptip"
override fun popularMangaFromElement(element: Element) = throw UnsupportedOperationException()
private fun popularMangaFromElement(element: Element, tiptip: Element) = SManga.create().apply {
val anchor = element.selectFirst("a")!!
setUrlWithoutDomain(anchor.attr("href"))
title = anchor.text()
thumbnail_url = tiptip.selectFirst("img")?.absUrl("src")
description = tiptip.selectFirst(".al-j")?.text()
}
override fun popularMangaNextPageSelector() = ".paging:last-child:not(.current_page)"
override fun latestUpdatesRequest(page: Int): Request =
GET(baseUrl + if (page > 1) "/page-$page" else "", headers)
override fun latestUpdatesSelector() = ".storyitem .fl-l"
override fun latestUpdatesFromElement(element: Element): SManga = SManga.create().apply {
val anchor = element.selectFirst("a")!!
setUrlWithoutDomain(anchor.absUrl("href"))
title = anchor.attr("title")
thumbnail_url = element.selectFirst("img")?.absUrl("src")
}
override fun latestUpdatesNextPageSelector() = "select.slcPaging option:last-child:not([selected])"
override fun fetchSearchManga(
page: Int,
query: String,
filters: FilterList,
): Observable<MangasPage> = when {
query.startsWith(PREFIX_ID_SEARCH) -> {
var id = query.removePrefix(PREFIX_ID_SEARCH).trimStart()
// it's a chapter, resolve to manga ID
if (id.startsWith("c")) {
val document = client.newCall(GET("$baseUrl/$id", headers)).execute().asJsoup()
id = document.selectFirst(".breadcrumbs a:last-child")!!.attr("href").removePrefix("/")
}
fetchMangaDetails(
SManga.create().apply {
url = "/$id"
},
)
.map { MangasPage(listOf(it), false) }
}
else -> super.fetchSearchManga(page, query, filters)
}
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
ajaxSearchUrls.keys
.firstOrNull { query.startsWith(it) }
?.let {
val id = extractIdFromQuery(it, query)
val url = "$baseUrl/ajax/${ajaxSearchUrls[it]!!}".toHttpUrl().newBuilder()
.addQueryParameter("id", id)
.addQueryParameter("p", page.toString())
.build()
return GET(url, headers)
}
val url = "$baseUrl/timkiem/nangcao/1".toHttpUrl().newBuilder().apply {
if (query.isNotBlank()) {
addQueryParameter("txt", query)
}
if (page > 1) {
addQueryParameter("p", page.toString())
}
val inclGenres = mutableListOf<String>()
val exclGenres = mutableListOf<String>()
var status = 0
(if (filters.isEmpty()) getFilterList() else filters).forEach { filter ->
when (filter) {
is GenreList -> filter.state.forEach {
when (it.state) {
Filter.TriState.STATE_INCLUDE -> inclGenres.add(it.id)
Filter.TriState.STATE_EXCLUDE -> exclGenres.add(it.id)
else -> {}
}
}
is Author -> {
addQueryParameter("aut", filter.state)
}
is Scanlator -> {
addQueryParameter("gr", filter.state)
}
is Status -> {
status = filter.state
}
else -> {}
}
}
addPathSegment(status.toString())
addPathSegment(inclGenres.joinToString(",").ifEmpty { "-1" })
addPathSegment(exclGenres.joinToString(",").ifEmpty { "-1" })
}.build()
return GET(url, headers)
}
override fun searchMangaParse(response: Response): MangasPage {
val document = response.asJsoup()
val manga = document.select(searchMangaSelector()).map {
val tiptip = it.attr("data-tiptip")
searchMangaFromElement(it, document.getElementById(tiptip)!!)
}
val hasNextPage = document.selectFirst(searchMangaNextPageSelector()) != null
return MangasPage(manga, hasNextPage)
}
override fun searchMangaSelector() = popularMangaSelector()
override fun searchMangaFromElement(element: Element): SManga =
throw UnsupportedOperationException()
private fun searchMangaFromElement(element: Element, tiptip: Element) =
popularMangaFromElement(element, tiptip)
override fun searchMangaNextPageSelector() = ".pagination .glyphicon-step-forward"
override fun mangaDetailsParse(document: Document) = SManga.create().apply {
val anchor = document.selectFirst(".entry-title a")!!
val descriptionBlock = document.selectFirst("div.description")!!
setUrlWithoutDomain(anchor.absUrl("href"))
title = getMangaTitle(document)
thumbnail_url = document.selectFirst(".thumbnail img")?.absUrl("src")
author = descriptionBlock.select("p:contains(Tác giả) a").joinToString { it.text() }
genre = descriptionBlock.select("span.category").joinToString { it.text() }
status = when (descriptionBlock.selectFirst("p:contains(Trạng thái) span.color-red")?.text()) {
"Đang tiến hành" -> SManga.ONGOING
"Đã hoàn thành" -> SManga.COMPLETED
"Tạm ngưng" -> SManga.ON_HIATUS
else -> SManga.UNKNOWN
}
description = buildString {
document.selectFirst(".manga-detail .detail .content")?.let {
// replace the facebook blockquote in synopsis with the link (if there is one)
it.selectFirst(".fb-page, .fb-group")?.let { fb ->
val link = fb.attr("data-href")
val node = document.createElement("p")
node.appendText(link)
fb.replaceWith(node)
}
appendLine(it.textWithNewlines().trim())
appendLine()
}
descriptionBlock.select("p:not(:contains(Thể loại)):not(:contains(Tác giả))")
.forEach { e ->
val text = e.text()
if (text.isBlank()) {
return@forEach
}
// Uploader and status share the same <p>
if (text.contains("Trạng thái")) {
appendLine(text.substringBefore("Trạng thái").trim())
return@forEach
}
// "Source", "Updaters" and "Scanlators" use badges with links
if (text.contains("Nguồn") ||
text.contains("Tham gia update") ||
text.contains("Nhóm dịch")
) {
val key = text.substringBefore(":")
val value = e.select("a").joinToString { el -> el.text() }
appendLine("$key: $value")
return@forEach
}
// Generic paragraphs i.e. view count and follower count for this series
// Basically the same trick as [Element.textWithNewlines], just applied to
// different elements.
e.select("a, span").append("\\n")
appendLine(
e.text()
.replace("\\n", "\n")
.replace("\n ", "\n")
.trim(),
)
}
}.trim()
}
override fun chapterListParse(response: Response): List<SChapter> {
val document = response.asJsoup()
val title = getMangaTitle(document)
return document.select(chapterListSelector()).map { chapterFromElement(it, title) }
}
override fun chapterListSelector() = "div.list-wrap > p"
override fun chapterFromElement(element: Element): SChapter = throw UnsupportedOperationException()
private fun chapterFromElement(element: Element, title: String): SChapter = SChapter.create().apply {
val anchor = element.select("span > a").first()!!
setUrlWithoutDomain(anchor.attr("href"))
name = anchor.text().removePrefix("$title ")
date_upload = runCatching {
dateFormat.parse(
element.selectFirst("span.publishedDate")!!.text(),
)!!.time
}.getOrDefault(0L)
}
override fun pageListParse(document: Document): List<Page> {
val pages = mutableListOf<Page>()
document.select("#content > img").forEachIndexed { i, e ->
pages.add(Page(i, imageUrl = e.absUrl("src")))
}
// Some chapters use js script to render images
document.select("#content > script:containsData(listImageCaption)").lastOrNull()
?.let { script ->
val imagesStr = script.data().substringBefore(";").substringAfterLast("=").trim()
val imageArr = json.parseToJsonElement(imagesStr).jsonArray
imageArr.forEach {
val imageUrl = it.jsonObject["url"]!!.jsonPrimitive.content
pages.add(Page(pages.size, imageUrl = imageUrl))
}
}
runCatching { countView(document) }
return pages
}
override fun imageUrlParse(document: Document) = throw UnsupportedOperationException()
override fun getFilterList(): FilterList {
val filters = mutableListOf<Filter<*>>(
Author(),
Scanlator(),
Status(),
)
val genres = getGenreList()
if (genres.isNotEmpty()) {
filters.add(GenreList(genres))
}
return FilterList(filters)
}
// copy([...document.querySelectorAll(".CategoryFilter li")].map((e) => `Genre("${e.textContent.trim()}", "${e.dataset.id}"),`).join("\n"))
open fun getGenreList(): List<Genre> = emptyList()
private class Status : Filter.Select<String>(
"Status",
arrayOf("Sao cũng được", "Đang tiến hành", "Đã hoàn thành", "Tạm ngưng"),
)
private class Author : Filter.Text("Tác giả")
private class Scanlator : Filter.Text("Nhóm dịch")
class Genre(name: String, val id: String) : Filter.TriState(name)
private class GenreList(genres: List<Genre>) : Filter.Group<Genre>("Thể loại", genres)
private fun getMangaTitle(document: Document) =
document
.selectFirst(".entry-title a")!!
.attr("title")
.removePrefix("truyện tranh ")
private fun Element.textWithNewlines() = run {
select("p, br").prepend("\\n")
text().replace("\\n", "\n").replace("\n ", "\n")
}
private fun extractIdFromQuery(prefix: String, query: String): String =
query.substringAfter(prefix).trimStart().substringAfterLast("-")
private fun countView(document: Document) {
val mangaId = document.getElementById("MangaId")!!.attr("value")
val chapterId = document.getElementById("ChapterId")!!.attr("value")
val request = POST(
"$baseUrl/Chapter/UpdateView",
headers,
FormBody.Builder()
.add("mangaId", mangaId)
.add("chapterId", chapterId)
.build(),
)
Single.fromCallable {
client.newCall(request).execute().close()
}
.subscribeOn(Schedulers.io())
.observeOn(Schedulers.io())
.subscribe()
}
private val ajaxSearchUrls: Map<String, String> = mapOf(
PREFIX_AUTHOR_SEARCH to "Author/AjaxLoadMangaByAuthor?orderBy=3",
PREFIX_TEAM_SEARCH to "TranslateTeam/AjaxLoadMangaByTranslateTeam",
)
companion object {
internal const val PREFIX_ID_SEARCH = "id:"
internal const val PREFIX_AUTHOR_SEARCH = "author:"
internal const val PREFIX_TEAM_SEARCH = "team:"
}
}

View File

@ -0,0 +1,25 @@
package eu.kanade.tachiyomi.multisrc.blogtruyen
import generator.ThemeSourceData.SingleLang
import generator.ThemeSourceGenerator
class BlogTruyenGenerator : ThemeSourceGenerator {
override val themePkg = "blogtruyen"
override val themeClass = "BlogTruyen"
override val baseVersionCode = 1
override val sources = listOf(
SingleLang("BlogTruyen", "https://blogtruyenmoi.com", "vi", className = "BlogTruyenMoi", pkgName = "blogtruyen", overrideVersionCode = 17),
SingleLang("BlogTruyen.vn (unoriginal)", "https://blogtruyenvn.com", "vi", className = "BlogTruyenVn"),
)
companion object {
@JvmStatic
fun main(args: Array<String>) {
BlogTruyenGenerator().createAll()
}
}
}

View File

@ -1,4 +1,4 @@
package eu.kanade.tachiyomi.extension.vi.blogtruyen
package eu.kanade.tachiyomi.multisrc.blogtruyen
import android.app.Activity
import android.content.ActivityNotFoundException

View File

@ -1,8 +0,0 @@
ext {
extName = 'BlogTruyen'
extClass = '.BlogTruyen'
extVersionCode = 17
isNsfw = true
}
apply from: "$rootDir/common.gradle"

View File

@ -1,432 +0,0 @@
package eu.kanade.tachiyomi.extension.vi.blogtruyen
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.interceptor.rateLimit
import eu.kanade.tachiyomi.source.model.Filter
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.MangasPage
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 kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.FormBody
import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import rx.Observable
import uy.kohesive.injekt.injectLazy
import java.text.SimpleDateFormat
import java.util.Locale
class BlogTruyen : ParsedHttpSource() {
override val name = "BlogTruyen"
override val baseUrl = "https://blogtruyenmoi.com"
override val lang = "vi"
override val supportsLatest = true
override val client: OkHttpClient = network.cloudflareClient.newBuilder()
.rateLimit(1)
.build()
private val json: Json by injectLazy()
private val dateFormat: SimpleDateFormat = SimpleDateFormat("dd/MM/yyyy HH:mm", Locale.ENGLISH)
companion object {
const val PREFIX_ID_SEARCH = "id:"
const val PREFIX_AUTHOR_SEARCH = "author:"
const val PREFIX_TEAM_SEARCH = "team:"
}
override fun headersBuilder(): Headers.Builder =
super.headersBuilder().add("Referer", "$baseUrl/")
override fun popularMangaRequest(page: Int): Request =
GET("$baseUrl/ajax/Search/AjaxLoadListManga?key=tatca&orderBy=3&p=$page", headers)
override fun popularMangaParse(response: Response): MangasPage {
val document = response.asJsoup()
val manga = document.select(popularMangaSelector()).map {
val tiptip = it.attr("data-tiptip")
popularMangaFromElement(it, document.getElementById(tiptip)!!)
}
val hasNextPage = document.selectFirst(popularMangaNextPageSelector()) != null
return MangasPage(manga, hasNextPage)
}
override fun popularMangaSelector() = ".list .tiptip"
override fun popularMangaFromElement(element: Element): SManga =
throw UnsupportedOperationException()
private fun popularMangaFromElement(element: Element, tiptip: Element) = SManga.create().apply {
val anchor = element.selectFirst("a")!!
setUrlWithoutDomain(anchor.attr("href"))
title = anchor.attr("title").replace("truyện tranh ", "").trim()
thumbnail_url = tiptip.selectFirst("img")!!.attr("abs:src")
description = tiptip.selectFirst(".al-j")!!.text()
}
override fun popularMangaNextPageSelector() = ".paging:last-child:not(.current_page)"
override fun latestUpdatesRequest(page: Int): Request =
GET(baseUrl + if (page != 1) "/page-$page" else "", headers)
override fun latestUpdatesSelector() = ".storyitem .fl-l"
override fun latestUpdatesFromElement(element: Element): SManga = SManga.create().apply {
setUrlWithoutDomain(element.select("a").attr("href"))
title = element.select("a").attr("title")
thumbnail_url = element.select("img").attr("abs:src")
}
override fun latestUpdatesNextPageSelector() = "select.slcPaging option:last-child:not([selected])"
override fun fetchSearchManga(
page: Int,
query: String,
filters: FilterList,
): Observable<MangasPage> {
return when {
query.startsWith(PREFIX_ID_SEARCH) -> {
var id = query.removePrefix(PREFIX_ID_SEARCH).trim()
// it's a chapter, resolve to manga ID
if (id.startsWith("c")) {
val document = client.newCall(GET("$baseUrl/$id", headers)).execute().asJsoup()
id = document.selectFirst(".breadcrumbs a:last-child")!!.attr("href").removePrefix("/")
}
fetchMangaDetails(
SManga.create().apply {
url = "/$id"
},
)
.map { MangasPage(listOf(it.apply { url = "/$id" }), false) }
}
else -> super.fetchSearchManga(page, query, filters)
}
}
private fun extractIdFromQuery(prefix: String, query: String): String {
val q = query.substringAfter(prefix).trim()
return if (q.contains("-")) {
q.substringAfterLast("-")
} else {
q
}
}
private val ajaxSearchUrls: Map<String, String> = mapOf(
PREFIX_AUTHOR_SEARCH to "Author/AjaxLoadMangaByAuthor?orderBy=3",
PREFIX_TEAM_SEARCH to "TranslateTeam/AjaxLoadMangaByTranslateTeam",
)
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
ajaxSearchUrls.keys.forEach {
if (!query.startsWith(it)) {
return@forEach
}
val id = extractIdFromQuery(it, query)
val url = "$baseUrl/ajax/${ajaxSearchUrls[it]}".toHttpUrl().newBuilder()
.addQueryParameter("id", id)
.addQueryParameter("p", page.toString())
.build()
.toString()
return GET(url, headers)
}
val url = "$baseUrl/timkiem/nangcao/1".toHttpUrl().newBuilder().apply {
addQueryParameter("txt", query)
addQueryParameter("p", page.toString())
val genres = mutableListOf<Int>()
val genresEx = mutableListOf<Int>()
var status = 0
(if (filters.isEmpty()) getFilterList() else filters).forEach { filter ->
when (filter) {
is GenreList -> filter.state.forEach {
when (it.state) {
Filter.TriState.STATE_INCLUDE -> genres.add(it.id)
Filter.TriState.STATE_EXCLUDE -> genresEx.add(it.id)
else -> {}
}
}
is Author -> {
addQueryParameter("aut", filter.state)
}
is Scanlator -> {
addQueryParameter("gr", filter.state)
}
is Status -> {
status = filter.state
}
else -> {}
}
}
addPathSegment(status.toString())
addPathSegment(genres.joinToString(","))
addPathSegment(genresEx.joinToString(","))
}.build().toString()
return GET(url, headers)
}
override fun searchMangaParse(response: Response): MangasPage {
val document = response.asJsoup()
val manga = document.select(searchMangaSelector()).map {
val tiptip = it.attr("data-tiptip")
searchMangaFromElement(it, document.getElementById(tiptip)!!)
}
val hasNextPage = document.selectFirst(searchMangaNextPageSelector()) != null
return MangasPage(manga, hasNextPage)
}
override fun searchMangaSelector() = popularMangaSelector()
override fun searchMangaFromElement(element: Element): SManga =
throw UnsupportedOperationException()
private fun searchMangaFromElement(element: Element, tiptip: Element) =
popularMangaFromElement(element, tiptip)
override fun searchMangaNextPageSelector() = ".pagination .glyphicon-step-forward"
private fun getMangaTitle(document: Document) = document.selectFirst(".entry-title a")!!
.attr("title")
.replaceFirst("truyện tranh", "", false)
.trim()
override fun mangaDetailsParse(document: Document): SManga = SManga.create().apply {
val anchor = document.selectFirst(".entry-title a")!!
setUrlWithoutDomain(anchor.attr("href"))
title = getMangaTitle(document)
thumbnail_url = document.select(".thumbnail img").attr("abs:src")
author = document.select("a[href*=tac-gia]").joinToString { it.text() }
genre = document.select("span.category a").joinToString { it.text() }
status = parseStatus(
document.select("span.color-red:not(.bold)").text(),
)
description = StringBuilder().apply {
// the actual synopsis
val synopsisBlock = document.selectFirst(".manga-detail .detail .content")!!
// replace the facebook blockquote in synopsis with the link (if there is one)
val fbElement = synopsisBlock.selectFirst(".fb-page, .fb-group")
if (fbElement != null) {
val fbLink = fbElement.attr("data-href")
val node = document.createElement("p")
node.appendText(fbLink)
fbElement.replaceWith(node)
}
appendLine(synopsisBlock.textWithNewlines().trim())
appendLine()
// other metadata
document.select(".description p").forEach {
val text = it.text()
if (text.contains("Thể loại") ||
text.contains("Tác giả") ||
text.isBlank()
) {
return@forEach
}
if (text.contains("Trạng thái")) {
appendLine(text.substringBefore("Trạng thái").trim())
return@forEach
}
if (text.contains("Nguồn") ||
text.contains("Tham gia update") ||
text.contains("Nhóm dịch")
) {
val key = text.substringBefore(":")
val value = it.select("a").joinToString { el -> el.text() }
appendLine("$key: $value")
return@forEach
}
it.select("a, span").append("\\n")
appendLine(it.text().replace("\\n", "\n").replace("\n ", "\n").trim())
}
}.toString().trim()
}
private fun Element.textWithNewlines() = run {
select("p").prepend("\\n")
select("br").prepend("\\n")
text().replace("\\n", "\n").replace("\n ", "\n")
}
private fun parseStatus(status: String) = when {
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
}
override fun chapterListParse(response: Response): List<SChapter> {
val document = response.asJsoup()
val title = getMangaTitle(document)
return document.select(chapterListSelector()).map { chapterFromElement(it, title) }
}
override fun chapterListSelector() = "div.list-wrap > p"
override fun chapterFromElement(element: Element): SChapter = throw UnsupportedOperationException()
private fun chapterFromElement(element: Element, title: String): SChapter = SChapter.create().apply {
val anchor = element.select("span > a").first()!!
setUrlWithoutDomain(anchor.attr("href"))
name = anchor.attr("title").replace(title, "", true).trim()
date_upload = runCatching {
dateFormat.parse(
element.selectFirst("span.publishedDate")!!.text(),
)?.time
}.getOrNull() ?: 0L
}
private fun countViewRequest(mangaId: String, chapterId: String): Request = POST(
"$baseUrl/Chapter/UpdateView",
headers,
FormBody.Builder()
.add("mangaId", mangaId)
.add("chapterId", chapterId)
.build(),
)
private fun countView(document: Document) {
val mangaId = document.getElementById("MangaId")!!.attr("value")
val chapterId = document.getElementById("ChapterId")!!.attr("value")
runCatching {
client.newCall(countViewRequest(mangaId, chapterId)).execute().close()
}
}
override fun pageListParse(document: Document): List<Page> {
val pages = mutableListOf<Page>()
document.select("#content > img").forEachIndexed { i, e ->
pages.add(Page(i, imageUrl = e.attr("abs:src")))
}
// Some chapters use js script to render images
document.select("#content > script:containsData(listImageCaption)").lastOrNull()
?.let { script ->
val imagesStr = script.data().substringBefore(";").substringAfterLast("=").trim()
val imageArr = json.parseToJsonElement(imagesStr).jsonArray
imageArr.forEach {
val imageUrl = it.jsonObject["url"]!!.jsonPrimitive.content
pages.add(Page(pages.size, imageUrl = imageUrl))
}
}
countView(document)
return pages
}
override fun imageUrlParse(document: Document) = throw UnsupportedOperationException()
private class Status : Filter.Select<String>(
"Status",
arrayOf("Sao cũng được", "Đang tiến hành", "Đã hoàn thành", "Tạm ngưng"),
)
private class Author : Filter.Text("Tác giả")
private class Scanlator : Filter.Text("Nhóm dịch")
private class Genre(name: String, val id: Int) : Filter.TriState(name)
private class GenreList(genres: List<Genre>) : Filter.Group<Genre>("Thể loại", genres)
override fun getFilterList() = FilterList(
Author(),
Scanlator(),
Status(),
GenreList(getGenreList()),
)
private fun getGenreList() = listOf(
Genre("16+", 54),
Genre("18+", 45),
Genre("Action", 1),
Genre("Adult", 2),
Genre("Adventure", 3),
Genre("Anime", 4),
Genre("Comedy", 5),
Genre("Comic", 6),
Genre("Doujinshi", 7),
Genre("Drama", 49),
Genre("Ecchi", 48),
Genre("Even BT", 60),
Genre("Fantasy", 50),
Genre("Game", 61),
Genre("Gender Bender", 51),
Genre("Harem", 12),
Genre("Historical", 13),
Genre("Horror", 14),
Genre("Isekai/Dị Giới", 63),
Genre("Josei", 15),
Genre("Live Action", 16),
Genre("Magic", 46),
Genre("Manga", 55),
Genre("Manhua", 17),
Genre("Manhwa", 18),
Genre("Martial Arts", 19),
Genre("Mature", 20),
Genre("Mecha", 21),
Genre("Mystery", 22),
Genre("Nấu ăn", 56),
Genre("NTR", 62),
Genre("One shot", 23),
Genre("Psychological", 24),
Genre("Romance", 25),
Genre("School Life", 26),
Genre("Sci-fi", 27),
Genre("Seinen", 28),
Genre("Shoujo", 29),
Genre("Shoujo Ai", 30),
Genre("Shounen", 31),
Genre("Shounen Ai", 32),
Genre("Slice of Life", 33),
Genre("Smut", 34),
Genre("Soft Yaoi", 35),
Genre("Soft Yuri", 36),
Genre("Sports", 37),
Genre("Supernatural", 38),
Genre("Tạp chí truyện tranh", 39),
Genre("Tragedy", 40),
Genre("Trap", 58),
Genre("Trinh thám", 57),
Genre("Truyện scan", 41),
Genre("Video clip", 53),
Genre("VnComic", 42),
Genre("Webtoon", 52),
Genre("Yuri", 59),
)
}