[BlogTruyen] rewrite (#14979)

* BlogTruyen rewrite

- feat: Added URL intent filter
- feat: count chapter views
- fix: use the `title` attr to prevent stuff like "One Piece chap 1020"
  in the title
- fix: multi-paragraph descriptions are processed properly

* move to ajax endpoint for latest updates

* script.html() -> script.data()

* tweak fetching images from script
This commit is contained in:
beerpsi 2023-01-17 18:36:41 +07:00 committed by GitHub
parent 326ddc2ad7
commit b6dac7f8eb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 257 additions and 109 deletions

View File

@ -1,2 +1,21 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest package="eu.kanade.tachiyomi.extension" /> <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="eu.kanade.tachiyomi.extension">
<application>
<activity android:name=".vi.blogtruyen.BlogTruyenUrlActivity"
android:excludeFromRecents="true"
android:exported="true"
android:theme="@android:style/Theme.NoDisplay">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:host="blogtruyen.vn" />
<data android:host="*.blogtruyen.vn" />
<data android:pathPattern="/..*"
android:scheme="https" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@ -5,7 +5,8 @@ ext {
extName = 'BlogTruyen' extName = 'BlogTruyen'
pkgNameSuffix = 'vi.blogtruyen' pkgNameSuffix = 'vi.blogtruyen'
extClass = '.BlogTruyen' extClass = '.BlogTruyen'
extVersionCode = 11 extVersionCode = 12
isNsfw = true
} }
apply from: "$rootDir/common.gradle" apply from: "$rootDir/common.gradle"

View File

@ -1,6 +1,7 @@
package eu.kanade.tachiyomi.extension.vi.blogtruyen package eu.kanade.tachiyomi.extension.vi.blogtruyen
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.interceptor.rateLimit import eu.kanade.tachiyomi.network.interceptor.rateLimit
import eu.kanade.tachiyomi.source.model.Filter import eu.kanade.tachiyomi.source.model.Filter
import eu.kanade.tachiyomi.source.model.FilterList import eu.kanade.tachiyomi.source.model.FilterList
@ -14,13 +15,15 @@ import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.jsonPrimitive
import okhttp3.FormBody
import okhttp3.Headers import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import org.jsoup.nodes.Document import org.jsoup.nodes.Document
import org.jsoup.nodes.Element import org.jsoup.nodes.Element
import rx.Observable
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Locale import java.util.Locale
@ -41,172 +44,263 @@ class BlogTruyen : ParsedHttpSource() {
private val json: Json by injectLazy() private val json: Json by injectLazy()
override fun headersBuilder(): Headers.Builder = super.headersBuilder().add("Referer", baseUrl) private val dateFormat: SimpleDateFormat = SimpleDateFormat("dd/MM/yyyy HH:mm", Locale.ENGLISH)
override fun popularMangaSelector() = "div.list span.tiptip.fs-12.ellipsis" companion object {
const val PREFIX_ID_SEARCH = "id:"
override fun latestUpdatesSelector() = "section.list-mainpage.listview > div > div > div > div.fl-l"
override fun popularMangaRequest(page: Int): Request {
return GET("$baseUrl/ajax/Search/AjaxLoadListManga?key=tatca&orderBy=3&p=$page", headers)
} }
override fun latestUpdatesRequest(page: Int): Request { override fun headersBuilder(): Headers.Builder =
return GET("$baseUrl/page-$page", headers) 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 { override fun popularMangaParse(response: Response): MangasPage {
val document = response.asJsoup() val document = response.asJsoup()
val imgURL = document.select("img").map { it.attr("abs:src") } val manga = document.select(popularMangaSelector()).map {
val mangas = document.select(popularMangaSelector()).mapIndexed { index, element -> popularMangaFromElement(element, imgURL[index]) } val tiptip = it.attr("data-tiptip")
popularMangaFromElement(it, document.getElementById(tiptip))
val hasNextPage = popularMangaNextPageSelector().let { selector ->
document.select(selector).first()
} != null
return MangasPage(mangas, hasNextPage)
}
private fun popularMangaFromElement(element: Element, imgURL: String): SManga {
val manga = SManga.create()
element.select("a").first().let {
manga.setUrlWithoutDomain(it.attr("href"))
manga.title = it.text().trim()
manga.thumbnail_url = imgURL
} }
return manga
val hasNextPage = document.selectFirst(popularMangaNextPageSelector()) != null
return MangasPage(manga, hasNextPage)
} }
override fun popularMangaFromElement(element: Element): SManga = throw Exception("Not Used") override fun popularMangaSelector() = ".list .tiptip"
override fun latestUpdatesFromElement(element: Element): SManga { override fun popularMangaFromElement(element: Element): SManga =
val manga = SManga.create() throw UnsupportedOperationException("Not used")
element.select("a").first().let {
manga.setUrlWithoutDomain(it.attr("href")) private fun popularMangaFromElement(element: Element, tiptip: Element) = SManga.create().apply {
manga.title = element.select("img").first().attr("alt").toString().trim() val anchor = element.selectFirst("a")
manga.thumbnail_url = element.select("img").first().attr("abs:src") 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/ajax/Search/AjaxLoadListManga?key=tatca&orderBy=5&p=$page", headers)
override fun latestUpdatesParse(response: Response): MangasPage {
val document = response.asJsoup()
val manga = document.select(latestUpdatesSelector()).map {
val tiptip = it.attr("data-tiptip")
latestUpdatesFromElement(it, document.getElementById(tiptip))
} }
return manga
val hasNextPage = document.selectFirst(latestUpdatesNextPageSelector()) != null
return MangasPage(manga, hasNextPage)
} }
override fun popularMangaNextPageSelector() = "div.paging:last-child:not(.current_page)" override fun latestUpdatesSelector() = popularMangaSelector()
override fun latestUpdatesNextPageSelector() = "ul.pagination.paging.list-unstyled > li:nth-last-child(2) > a" private fun latestUpdatesFromElement(element: Element, tiptip: Element): SManga =
popularMangaFromElement(element, tiptip)
override fun latestUpdatesFromElement(element: Element): SManga =
throw UnsupportedOperationException("Not used")
override fun latestUpdatesNextPageSelector() = popularMangaNextPageSelector()
override fun fetchSearchManga(
page: Int,
query: String,
filters: FilterList
): Observable<MangasPage> {
return when {
query.startsWith(PREFIX_ID_SEARCH) -> {
val id = query.removePrefix(PREFIX_ID_SEARCH).trim()
if (!id.startsWith("/")) {
throw Exception("ID tìm kiếm không hợp lệ")
}
fetchMangaDetails(
SManga.create().apply {
url = id
}
)
.map { MangasPage(listOf(it.apply { url = id }), false) }
}
else -> 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 {
var temp = "$baseUrl/timkiem/nangcao/1/0" val url = "$baseUrl/timkiem/nangcao/1".toHttpUrl().newBuilder().apply {
val genres = mutableListOf<Int>() addQueryParameter("txt", query)
val genresEx = mutableListOf<Int>() addQueryParameter("page", page.toString())
var aut = ""
(if (filters.isEmpty()) getFilterList() else filters).forEach { filter -> val genres = mutableListOf<Int>()
when (filter) { val genresEx = mutableListOf<Int>()
is GenreList -> filter.state.forEach { var status = 0
genre -> (if (filters.isEmpty()) getFilterList() else filters).forEach { filter ->
when (genre.state) { when (filter) {
Filter.TriState.STATE_INCLUDE -> genres.add(genre.id) is GenreList -> filter.state.forEach {
Filter.TriState.STATE_EXCLUDE -> genresEx.add(genre.id) when (it.state) {
Filter.TriState.STATE_INCLUDE -> genres.add(it.id)
Filter.TriState.STATE_EXCLUDE -> genresEx.add(it.id)
else -> {}
}
} }
} is Author -> {
is Author -> { addQueryParameter("aut", filter.state)
if (filter.state.isNotEmpty()) {
aut = filter.state
} }
is Scanlator -> {
addQueryParameter("gr", filter.state)
}
is Status -> {
status = filter.state
}
else -> {}
} }
} }
}
temp = if (genres.isNotEmpty()) temp + "/" + genres.joinToString(",") addPathSegment(status.toString())
else "$temp/-1" addPathSegment(genres.joinToString(","))
temp = if (genresEx.isNotEmpty()) temp + "/" + genresEx.joinToString(",") addPathSegment(genresEx.joinToString(","))
else "$temp/-1" }.build().toString()
val url = temp.toHttpUrlOrNull()!!.newBuilder() return GET(url, headers)
url.addQueryParameter("txt", query)
if (aut.isNotEmpty()) url.addQueryParameter("aut", aut)
url.addQueryParameter("p", page.toString())
return GET(url.toString().replace("m.", ""), headers)
} }
override fun searchMangaSelector() = "div.list > p:has(a)" override fun searchMangaParse(response: Response): MangasPage {
val document = response.asJsoup()
override fun searchMangaFromElement(element: Element): SManga { val manga = document.select(searchMangaSelector()).map {
return SManga.create().apply { val tiptip = it.attr("data-tiptip")
element.select("a").let { searchMangaFromElement(it, document.getElementById(tiptip))
setUrlWithoutDomain(it.attr("href")) }
title = it.text()
val hasNextPage = document.selectFirst(searchMangaNextPageSelector()) != null
return MangasPage(manga, hasNextPage)
}
override fun searchMangaSelector() = popularMangaSelector()
override fun searchMangaFromElement(element: Element): SManga =
throw UnsupportedOperationException("Not used")
private fun searchMangaFromElement(element: Element, tiptip: Element) =
popularMangaFromElement(element, tiptip)
override fun searchMangaNextPageSelector() = ".pagination .glyphicon-step-forward"
override fun mangaDetailsParse(document: Document): SManga = SManga.create().apply {
val anchor = document.selectFirst(".entry-title a")
setUrlWithoutDomain(anchor.attr("href"))
title = anchor.attr("title")
.replace("truyện tranh ", "")
.trim()
thumbnail_url = document.select(".thumbnail img").attr("abs:src")
author = document.select("a.color-green.label.label-info").joinToString { it.text() }
genre = document.select("span.category a").joinToString { it.text() }
status = parseStatus(
document.select("span.color-red:not(.bold)").text()
)
description = document.selectFirst(".manga-detail .detail .content").let {
if (it.select("p").any()) {
it.select("p").joinToString("\n", transform = ::brToNewline)
} else {
brToNewline(it)
} }
thumbnail_url = element.nextElementSibling().select("img").attr("abs:src")
} }
} }
override fun searchMangaNextPageSelector() = "ul.pagination i.glyphicon.glyphicon-step-forward.red" private fun brToNewline(element: Element): String {
return element.run {
override fun mangaDetailsParse(document: Document): SManga { select("br").prepend("\\n")
val infoElement = document.select("div.description").first() text().replace("\\n", "\n").replace("\n ", "\n")
val title = document.select(".entry-title").first() }
val manga = SManga.create()
manga.title = title.select(".entry-title a").first().text()
manga.author = infoElement.select("p:contains(Tác giả) > a").first()?.text()
manga.genre = infoElement.select("span.category a").joinToString { it.text() }
manga.description = document.select("div.detail > div.content").text()
manga.status = infoElement.select("p:contains(Trạng thái) > span.color-red").first()?.text().orEmpty().let { parseStatus(it) }
manga.thumbnail_url = document.select("div.thumbnail > img").first()?.attr("src")
return manga
} }
private fun parseStatus(status: String) = when { private fun parseStatus(status: String) = when {
status.contains("Đang tiến hành") -> SManga.ONGOING status.contains("Đang tiến hành") -> SManga.ONGOING
status.contains("Đã hoàn thành") -> SManga.COMPLETED status.contains("Đã hoàn thành") -> SManga.COMPLETED
status.contains("Tạm ngưng") -> SManga.ON_HIATUS
else -> SManga.UNKNOWN else -> SManga.UNKNOWN
} }
override fun chapterListSelector() = "div.list-wrap > p" override fun chapterListSelector() = "div.list-wrap > p"
override fun chapterFromElement(element: Element): SChapter { override fun chapterFromElement(element: Element): SChapter = SChapter.create().apply {
val urlElement = element.select("span > a").first() val anchor = element.select("span > a").first()
val chapter = SChapter.create() setUrlWithoutDomain(anchor.attr("href"))
chapter.setUrlWithoutDomain(urlElement.attr("href")) name = anchor.attr("title").trim()
chapter.name = urlElement.attr("title").trim() date_upload = kotlin.runCatching {
chapter.date_upload = element.select("span.publishedDate").first()?.text()?.let { dateFormat.parse(
SimpleDateFormat("dd/MM/yyyy HH:mm", Locale.ENGLISH).parse(it)?.time ?: 0 element.selectFirst("span.publishedDate").text()
} ?: 0 )?.time
return chapter }.getOrNull() ?: 0L
} }
override fun pageListRequest(chapter: SChapter) = GET(baseUrl + chapter.url, headers) 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")
kotlin.runCatching {
client.newCall(countViewRequest(mangaId, chapterId)).execute().close()
}
}
override fun pageListParse(document: Document): List<Page> { override fun pageListParse(document: Document): List<Page> {
val pages = mutableListOf<Page>() val pages = mutableListOf<Page>()
val pageUrl = document.select("link[rel=canonical]").attr("href")
document.select("article#content > img").forEachIndexed { i, e -> document.select("#content > img").forEachIndexed { i, e ->
pages.add(Page(i, pageUrl, e.attr("src"))) pages.add(Page(i, imageUrl = e.attr("abs:src")))
} }
// Some chapters use js script to render images // Some chapters use js script to render images
val script = document.select("article#content > script").lastOrNull() document.select("#content > script:containsData(listImageCaption)").lastOrNull()
if (script != null && script.data().contains("listImageCaption")) { ?.let { script ->
val imagesStr = script.data().split(";")[0].split("=").last().trim() val imagesStr = script.data().substringBefore(";").substringAfterLast("=").trim()
val imageArr = json.parseToJsonElement(imagesStr).jsonArray val imageArr = json.parseToJsonElement(imagesStr).jsonArray
for (image in imageArr) { imageArr.forEach {
val imageUrl = image.jsonObject["url"]!!.jsonPrimitive.content val imageUrl = it.jsonObject["url"]!!.jsonPrimitive.content
pages.add(Page(pages.size, pageUrl, imageUrl)) pages.add(Page(pages.size, imageUrl = imageUrl))
}
} }
}
countView(document)
return pages return pages
} }
override fun imageUrlParse(document: Document) = "" override fun imageUrlParse(document: Document) = throw UnsupportedOperationException("Not used")
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 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 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 Genre(name: String, val id: Int) : Filter.TriState(name)
private class GenreList(genres: List<Genre>) : Filter.Group<Genre>("Thể loại", genres) private class GenreList(genres: List<Genre>) : Filter.Group<Genre>("Thể loại", genres)
override fun getFilterList() = FilterList( override fun getFilterList() = FilterList(
Author(),
Scanlator(),
Status(), Status(),
GenreList(getGenreList()), GenreList(getGenreList()),
Author()
) )
private fun getGenreList() = listOf( private fun getGenreList() = listOf(

View File

@ -0,0 +1,34 @@
package eu.kanade.tachiyomi.extension.vi.blogtruyen
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Intent
import android.os.Bundle
import android.util.Log
import kotlin.system.exitProcess
class BlogTruyenUrlActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val pathSegments = intent?.data?.pathSegments
if (pathSegments != null && pathSegments.size > 1) {
val id = "/${pathSegments[0]}/${pathSegments[1]}"
try {
startActivity(
Intent().apply {
action = "eu.kanade.tachiyomi.SEARCH"
putExtra("query", "${BlogTruyen.PREFIX_ID_SEARCH}$id")
putExtra("filter", packageName)
}
)
} catch (e: ActivityNotFoundException) {
Log.e("BlogTruyenUrlActivity", e.toString())
}
} else {
Log.e("BlogTruyenUrlActivity", "Could not parse URI from intent $intent")
}
finish()
exitProcess(0)
}
}