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
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 5.3 KiB |
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.8 KiB |
Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 7.1 KiB |
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
@ -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"),
|
||||
)
|
||||
}
|
After Width: | Height: | Size: 2.9 KiB |
After Width: | Height: | Size: 1.7 KiB |
After Width: | Height: | Size: 3.6 KiB |
After Width: | Height: | Size: 6.2 KiB |
After Width: | Height: | Size: 8.2 KiB |
@ -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"),
|
||||
)
|
||||
}
|
@ -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/..*" />
|
@ -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:"
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
@ -1,8 +0,0 @@
|
||||
ext {
|
||||
extName = 'BlogTruyen'
|
||||
extClass = '.BlogTruyen'
|
||||
extVersionCode = 17
|
||||
isNsfw = true
|
||||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|
@ -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),
|
||||
)
|
||||
}
|