[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 hasNextPage = document.selectFirst(popularMangaNextPageSelector()) != null
val manga = SManga.create()
element.select("a").first().let { return MangasPage(manga, hasNextPage)
manga.setUrlWithoutDomain(it.attr("href"))
manga.title = it.text().trim()
manga.thumbnail_url = imgURL
}
return manga
} }
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()
return manga
thumbnail_url = tiptip.selectFirst("img").attr("abs:src")
description = tiptip.selectFirst(".al-j").text()
} }
override fun popularMangaNextPageSelector() = "div.paging:last-child:not(.current_page)" override fun popularMangaNextPageSelector() = ".paging:last-child:not(.current_page)"
override fun latestUpdatesNextPageSelector() = "ul.pagination.paging.list-unstyled > li:nth-last-child(2) > a" 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))
}
val hasNextPage = document.selectFirst(latestUpdatesNextPageSelector()) != null
return MangasPage(manga, hasNextPage)
}
override fun latestUpdatesSelector() = popularMangaSelector()
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 {
addQueryParameter("txt", query)
addQueryParameter("page", page.toString())
val genres = mutableListOf<Int>() val genres = mutableListOf<Int>()
val genresEx = mutableListOf<Int>() val genresEx = mutableListOf<Int>()
var aut = "" var status = 0
(if (filters.isEmpty()) getFilterList() else filters).forEach { filter -> (if (filters.isEmpty()) getFilterList() else filters).forEach { filter ->
when (filter) { when (filter) {
is GenreList -> filter.state.forEach { is GenreList -> filter.state.forEach {
genre -> when (it.state) {
when (genre.state) { Filter.TriState.STATE_INCLUDE -> genres.add(it.id)
Filter.TriState.STATE_INCLUDE -> genres.add(genre.id) Filter.TriState.STATE_EXCLUDE -> genresEx.add(it.id)
Filter.TriState.STATE_EXCLUDE -> genresEx.add(genre.id) else -> {}
} }
} }
is Author -> { is Author -> {
if (filter.state.isNotEmpty()) { addQueryParameter("aut", filter.state)
aut = filter.state
} }
is Scanlator -> {
addQueryParameter("gr", filter.state)
} }
is Status -> {
status = filter.state
} }
} else -> {}
temp = if (genres.isNotEmpty()) temp + "/" + genres.joinToString(",")
else "$temp/-1"
temp = if (genresEx.isNotEmpty()) temp + "/" + genresEx.joinToString(",")
else "$temp/-1"
val url = temp.toHttpUrlOrNull()!!.newBuilder()
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 searchMangaFromElement(element: Element): SManga {
return SManga.create().apply {
element.select("a").let {
setUrlWithoutDomain(it.attr("href"))
title = it.text()
}
thumbnail_url = element.nextElementSibling().select("img").attr("abs:src")
} }
} }
override fun searchMangaNextPageSelector() = "ul.pagination i.glyphicon.glyphicon-step-forward.red" addPathSegment(status.toString())
addPathSegment(genres.joinToString(","))
addPathSegment(genresEx.joinToString(","))
}.build().toString()
return GET(url, headers)
}
override fun mangaDetailsParse(document: Document): SManga { override fun searchMangaParse(response: Response): MangasPage {
val infoElement = document.select("div.description").first() val document = response.asJsoup()
val title = document.select(".entry-title").first()
val manga = SManga.create() val manga = document.select(searchMangaSelector()).map {
manga.title = title.select(".entry-title a").first().text() val tiptip = it.attr("data-tiptip")
manga.author = infoElement.select("p:contains(Tác giả) > a").first()?.text() searchMangaFromElement(it, document.getElementById(tiptip))
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) } val hasNextPage = document.selectFirst(searchMangaNextPageSelector()) != null
manga.thumbnail_url = document.select("div.thumbnail > img").first()?.attr("src")
return manga 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)
}
}
}
private fun brToNewline(element: Element): String {
return element.run {
select("br").prepend("\\n")
text().replace("\\n", "\n").replace("\n ", "\n")
}
} }
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)
}
}