394 lines
14 KiB
Kotlin
394 lines
14 KiB
Kotlin
package eu.kanade.tachiyomi.multisrc.blogtruyen
|
|
|
|
import android.util.Log
|
|
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 val client = network.cloudflareClient
|
|
|
|
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, #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 {
|
|
try {
|
|
client.newCall(request).execute().close()
|
|
} catch (e: Exception) {
|
|
Log.e("BlogTruyen", "Error updating view count", e)
|
|
}
|
|
}
|
|
.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:"
|
|
}
|
|
}
|