Mangabz: rewrite, make multisrc, add mirror and new source (#13378)

* Mangabz: rewrite, make multisrc, add mirror and new source

* use HttpUrl.Builder for search query
This commit is contained in:
stevenyomi 2022-09-08 19:52:38 +08:00 committed by GitHub
parent d30e215b97
commit 58b6147c18
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 671 additions and 417 deletions

View File

@ -0,0 +1,11 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="MangabzGenerator" type="JetRunConfigurationType" nameIsGenerated="true">
<module name="tachiyomi-extensions.multisrc.main" />
<option name="MAIN_CLASS_NAME" value="eu.kanade.tachiyomi.multisrc.mangabz.MangabzGenerator" />
<method v="2">
<option name="Make" enabled="true" />
<option name="Gradle.BeforeRunTask" enabled="true" tasks="ktFormat" externalProjectPath="$PROJECT_DIR$/multisrc" vmOptions="" scriptParameters="-Ptheme=mangabz" />
<option name="Gradle.BeforeRunTask" enabled="true" tasks="ktLint" externalProjectPath="$PROJECT_DIR$/multisrc" vmOptions="" scriptParameters="-Ptheme=mangabz" />
</method>
</configuration>
</component>

View File

@ -18,6 +18,10 @@
android:host="mangabz.com"
android:pathPattern="/..*"
android:scheme="https" />
<data
android:host="xmanhua.com"
android:pathPattern="/..*"
android:scheme="https" />
</intent-filter>
</activity>
</application>

View File

@ -0,0 +1,3 @@
dependencies {
implementation 'com.github.stevenyomi:unpacker:2948449d0c' // 1.2
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

View File

@ -0,0 +1,36 @@
package eu.kanade.tachiyomi.extension.zh.mangabz
import android.webkit.CookieManager
import okhttp3.Interceptor
import okhttp3.Response
class CookieInterceptor(
private val domain: String,
private val key: String,
private val value: String
) : Interceptor {
init {
val url = "https://$domain/"
val cookie = "$key=$value; Domain=$domain; Path=/"
CookieManager.getInstance().setCookie(url, cookie)
}
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
if (!request.url.host.endsWith(domain)) return chain.proceed(request)
val cookie = "$key=$value"
val cookieList = request.header("Cookie")?.split("; ") ?: emptyList()
if (cookie in cookieList) return chain.proceed(request)
CookieManager.getInstance().setCookie("https://$domain/", "$cookie; Domain=$domain; Path=/")
val prefix = "$key="
val newCookie = buildList(cookieList.size + 1) {
cookieList.filterNotTo(this) { it.startsWith(prefix) }
add(cookie)
}.joinToString("; ")
val newRequest = request.newBuilder().header("Cookie", newCookie).build()
return chain.proceed(newRequest)
}
}

View File

@ -0,0 +1,38 @@
package eu.kanade.tachiyomi.extension.zh.mangabz
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
fun parseDateInternal(source: String): Long {
// 今天 00:00
if (recentRegex.matches(source)) {
val date = fullDateFormat.format(Date())
val time = timeFormat.parse(date + ' ' + source.substring(3))!!.time
val offset = when (source[0]) {
'今' -> 0L
'昨' -> 86400000L
'前' -> 86400000L * 2
else -> 0L // impossible
}
return time - offset
}
// 01月01号, 01月01號
if (source.length >= 6 && source[2] == '月') {
val year = fullDateFormat.format(Date()).substringBefore('-')
return shortDateFormat.parse("$year $source")!!.time
}
// 2021-01-01
return fullDateFormat.parse(source)!!.time
}
private val recentRegex by lazy { Regex("""[今昨前]天 \d{2}:\d{2}""") }
private val timeFormat by lazy { cstFormat("yyyy-MM-dd hh:mm") }
private val shortDateFormat by lazy { cstFormat("yyyy MM月dd") }
private val fullDateFormat by lazy { cstFormat("yyyy-MM-dd") }
private fun cstFormat(pattern: String) =
SimpleDateFormat(pattern, Locale.ENGLISH).apply { timeZone = TimeZone.getTimeZone("GMT+8") }

View File

@ -0,0 +1,66 @@
package eu.kanade.tachiyomi.extension.zh.mangabz
import eu.kanade.tachiyomi.source.model.Filter
import eu.kanade.tachiyomi.source.model.FilterList
import org.jsoup.nodes.Document
import org.jsoup.select.Evaluator
fun getFilterListInternal(categories: List<CategoryData>): FilterList {
val list: List<Filter<*>> = if (categories.isEmpty()) {
listOf(Filter.Header("点击“重置”刷新分类"))
} else buildList(categories.size + 1) {
add(Filter.Header("分类(搜索文本时无效)"))
categories.mapTo(this, CategoryData::toFilter)
}
return FilterList(list)
}
fun parseFilterList(filters: FilterList): String =
filters.filterIsInstance<CategoryFilter>().joinToString("-") { it.id.toString() }
fun parseCategories(document: Document): List<CategoryData> {
val lines = document.select(Evaluator.Class("class-line")).ifEmpty { return emptyList() }
val defaultIds = IntArray(lines.size)
val idArrays = arrayOfNulls<IntArray>(lines.size)
val result = lines.mapIndexed { filterIndex, line ->
val options = line.select(Evaluator.Tag("a")).mapIndexed { optionIndex, option ->
val optionName = option.ownText()!!
if (optionIndex == 0) {
Pair(optionName, 0) // id is unknown
} else {
val idTuple = option.attr("href")
.removePrefix("/manga-list-").removeSuffix("/").split("-")
for ((indexInTuple, id) in idTuple.withIndex()) {
if (indexInTuple != filterIndex) defaultIds[indexInTuple] = id.toInt()
}
Pair(optionName, idTuple[filterIndex].toInt())
}
}
val name = line.child(0).ownText().removeSuffix("")
val values = Array(options.size) { options[it].first }
val ids = IntArray(options.size) { options[it].second }
idArrays[filterIndex] = ids
CategoryData(name, values, ids)
}
for ((i, idArray) in idArrays.withIndex()) {
idArray!![0] = defaultIds[i]
}
return result
}
class CategoryData(
private val name: String,
private val values: Array<String>,
private val ids: IntArray
) {
fun toFilter() = CategoryFilter(name, values, ids)
}
class CategoryFilter(name: String, values: Array<String>, private val ids: IntArray) :
Filter.Select<String>(name, values) {
val id get() = ids[state]
}

View File

@ -0,0 +1,159 @@
package eu.kanade.tachiyomi.extension.zh.mangabz
import android.app.Application
import androidx.preference.PreferenceScreen
import com.github.stevenyomi.unpacker.ProgressiveParser
import com.github.stevenyomi.unpacker.Unpacker
import eu.kanade.tachiyomi.multisrc.mangabz.MangabzTheme
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.asObservableSuccess
import eu.kanade.tachiyomi.network.interceptor.rateLimit
import eu.kanade.tachiyomi.source.ConfigurableSource
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 okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import okhttp3.Response
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import org.jsoup.select.Elements
import org.jsoup.select.Evaluator
import rx.Observable
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class Mangabz : MangabzTheme("Mangabz", ""), ConfigurableSource {
override val baseUrl: String
override val client: OkHttpClient
private val urlSuffix: String
init {
val preferences = Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
val mirror = preferences.mirror
baseUrl = "https://" + mirror.domain
urlSuffix = mirror.urlSuffix
val cookieInterceptor = CookieInterceptor(mirror.domain, mirror.langCookie, preferences.lang)
client = network.client.newBuilder()
.rateLimit(5)
.addNetworkInterceptor(cookieInterceptor)
.build()
}
private fun SManga.stripMirror() = apply {
val old = url
url = buildString(old.length) {
append(old, 0, old.length - urlSuffix.length).append("bz/")
}
}
private fun String.toMirror() = buildString {
val old = this@toMirror // ...bz/
append(old, 0, old.length - 3).append(urlSuffix)
}
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
if (query.isEmpty()) {
val ids = parseFilterList(filters)
if (ids.isEmpty()) return fetchPopularManga(page)
return client.newCall(GET("$baseUrl/manga-list-$ids-p$page/", headers))
.asObservableSuccess().map(::searchMangaParse)
}
val path = when {
query.startsWith(PREFIX_ID_SEARCH) -> query.removePrefix(PREFIX_ID_SEARCH)
query.startsWith(baseUrl) -> query.removePrefix(baseUrl).trim('/')
else -> return super.fetchSearchManga(page, query, filters)
}
val mirrorPath = "$path/".toMirror()
return client.newCall(GET("$baseUrl/$mirrorPath", headers))
.asObservableSuccess().map { MangasPage(listOf(mangaDetailsParse(it)), false) }
}
override fun searchMangaParse(response: Response) = super.searchMangaParse(response).apply {
for (manga in mangas) manga.stripMirror()
}
override fun mangaDetailsRequest(manga: SManga) = GET(baseUrl + manga.url.toMirror(), headers)
override fun mangaDetailsParse(response: Response) = super.mangaDetailsParse(response).stripMirror()
override fun parseDescription(element: Element, title: String, details: Elements): String {
val text = element.ownText()
val start = if (text.startsWith(title)) title.length + 4 else 0
val collapsed = element.selectFirst(Evaluator.Tag("span"))?.ownText()
?: return text.substring(start)
return buildString { append(text, start, text.length - 1).append(collapsed) }
}
override fun chapterListRequest(manga: SManga) = GET(baseUrl + manga.url.toMirror(), headers)
override fun parseDate(listTitle: String) = parseDateInternal(listTitle.substringAfterLast(", "))
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
val chapterId = chapter.url.removePrefix("/m").removeSuffix("/")
val pageCount = chapter.name.substringAfterLast('').removeSuffix("P").toInt()
val prefix = "$baseUrl${chapter.url}chapterimage.ashx?cid=$chapterId&page="
// 1 request returns 2 pages, or 15 if server cache is ready, so we manually cache them below
val list = List(pageCount) { Page(it, "$prefix${it + 1}#$pageCount") }
return Observable.just(list)
}
override fun pageListParse(response: Response) = throw UnsupportedOperationException()
// key is chapterId, value[0] is URL prefix, value[1..pageCount] are paths
private val imageUrlCache = object : LinkedHashMap<Int, Array<String?>>() {
// limit cache to 10 chapters
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<Int, Array<String?>>?) = size > 10
}
override fun fetchImageUrl(page: Page): Observable<String> {
val url = page.url.toHttpUrl()
var cache: Array<String?>? = null
url.fragment?.run {
val pageCount = toInt()
val chapterId = url.queryParameter("cid")!!.toInt()
val realCache = imageUrlCache.getOrPut(chapterId) { arrayOfNulls(pageCount + 1) }
val path = realCache[page.index + 1]
if (path != null) return Observable.just(realCache[0]!! + path)
cache = realCache
}
return client.newCall(GET(page.url, headers)).asObservableSuccess().map {
val script = Unpacker.unpack(it.body!!.string())
val parser = ProgressiveParser(script)
val prefix = parser.substringBetween("pix=\"", "\"")
// 2 pages, or 15 if server cache is ready
val paths = parser.substringBetween("[\"", "\"]").split("\",\"")
val pageNumber = page.index + 1
cache?.run {
this[0] = prefix
for ((offset, path) in paths.withIndex()) this[pageNumber + offset] = path
}
prefix + paths[0]
}
}
var categories = emptyList<CategoryData>()
override fun parseFilters(document: Document) {
if (categories.isEmpty()) categories = parseCategories(document)
}
override fun getFilterList() = getFilterListInternal(categories)
override fun setupPreferenceScreen(screen: PreferenceScreen) {
getPreferencesInternal(screen.context).forEach(screen::addPreference)
}
companion object {
const val PREFIX_ID_SEARCH = "id:"
}
}

View File

@ -0,0 +1,56 @@
package eu.kanade.tachiyomi.extension.zh.mangabz
import android.content.Context
import android.content.SharedPreferences
import androidx.preference.ListPreference
import androidx.preference.SwitchPreferenceCompat
fun getPreferencesInternal(context: Context) = arrayOf(
ListPreference(context).apply {
val mirrors = MIRRORS
val size = mirrors.size
key = MIRROR_PREF
title = "镜像站点"
summary = "%s\n重启生效,不同站点的数据有细微差异"
entries = Array(size) { mirrors[it].domain }
entryValues = Array(size) { it.toString() }
setDefaultValue("0")
},
SwitchPreferenceCompat(context).apply {
key = ZH_HANT_PREF
title = "使用繁体中文"
summary = "重启生效,已添加的漫画需要迁移才能更新标题"
setDefaultValue(false)
},
)
val SharedPreferences.mirror: Mirror
get() {
val mirrors = MIRRORS
val mirrorPref = getString(MIRROR_PREF, "0")!!
val mirrorIndex = mirrorPref.toInt().coerceAtMost(mirrors.size - 1)
return mirrors[mirrorIndex]
}
val SharedPreferences.lang get() = if (getBoolean(ZH_HANT_PREF, false)) "1" else "2"
// Legacy preferences:
// "mainSiteRatelimitPreference" -> 1..10 default "5"
// "imgCDNRatelimitPreference" -> 1..10 default "5"
const val MIRROR_PREF = "mirror"
const val ZH_HANT_PREF = "showZhHantWebsite"
val MIRRORS
get() = arrayOf(
Mirror("mangabz.com", "bz/", "mangabz_lang"),
Mirror("xmanhua.com", "xm/", "xmanhua_lang"),
)
class Mirror(
val domain: String,
val urlSuffix: String,
val langCookie: String,
)

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -0,0 +1,150 @@
package eu.kanade.tachiyomi.extension.zh.vomic
import android.app.Application
import androidx.preference.ListPreference
import androidx.preference.PreferenceScreen
import eu.kanade.tachiyomi.multisrc.mangabz.MangabzTheme
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.source.ConfigurableSource
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.util.asJsoup
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Request
import okhttp3.Response
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import org.jsoup.select.Elements
import org.jsoup.select.Evaluator
import rx.Observable
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.text.SimpleDateFormat
import java.util.Locale
class Vomic : MangabzTheme("vomic", ""), ConfigurableSource {
override val supportsLatest = false
override val baseUrl: String
init {
val mirrors = MIRRORS
val mirrorIndex = Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
.getString(MIRROR_PREF, "0")!!.toInt().coerceAtMost(mirrors.size - 1)
baseUrl = "http://" + mirrors[mirrorIndex]
}
override fun headersBuilder() = super.headersBuilder().removeAll("Referer")
override fun popularMangaRequest(page: Int) = GET(baseUrl, headers)
// original credit: https://github.com/tachiyomiorg/tachiyomi-extensions/pull/5628
override fun popularMangaParse(response: Response): MangasPage {
val document = response.asJsoup()
val link = Evaluator.Tag("a")
val image = Evaluator.Tag("img")
val paragraph = Evaluator.Tag("p")
/* top banner - no thumbnail
document.selectFirst(Evaluator.Class("banner-con")).select(link).mapTo(mangas) { element ->
SManga.create().apply {
title = element.attr("title")
url = element.attr("href")
thumbnail_url = element.selectFirst(image).attr("src")
.takeIf { !it.endsWith("/static/images/bg/banner_info_a.jpg") }
}
} */
val mangas = buildList {
// ranking sidebar
addAll(document.selectFirst(Evaluator.Class("rank-list")).children())
// carousel list
addAll(document.selectFirst(Evaluator.Class("carousel-right-list")).children())
// recommend list
addAll(document.select(Evaluator.Class("index-manga-item")))
}.map { element ->
SManga.create().apply {
title = element.selectFirst(paragraph).text()
url = element.selectFirst(link).attr("href")
thumbnail_url = element.selectFirst(image).attr("src")
}
}
return MangasPage(mangas.distinctBy { it.url }, false)
}
override fun parseDescription(element: Element, title: String, details: Elements): String {
val text = element.ownText()
val collapsed = element.selectFirst(Evaluator.Tag("span"))?.ownText() ?: ""
val source = details[3].text()
return "$source\n\n$text$collapsed"
}
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
val chapterId = manga.url.removePrefix("/").removeSuffix("_c/")
return super.fetchChapterList(manga).doOnNext {
for (chapter in it) chapter.url = chapter.url + "chapterimage.ashx?mid=" + chapterId
}
}
override fun getChapterElements(document: Document): Elements {
val chapterId = document.location().removeSuffix("_c/").substringAfterLast('/')
val response = client.newCall(GET("$baseUrl/chapter-$chapterId-s2/", headers)).execute()
return Jsoup.parseBodyFragment(response.body!!.string()).body().children()
}
override val needPageCount = false
override fun parseDate(listTitle: String): Long {
val date = listTitle.split("|")[2].trim()
return dateFormat.parse(date)!!.time
}
override fun pageListParse(response: Response): List<Page> {
val urls = response.body!!.string().run {
val left = indexOf('[')
val right = lastIndexOf(']')
if (left + 1 == right) return emptyList()
substring(left + 1, right).split(", ")
}
return urls.mapIndexed { index, rawUrl ->
val url = rawUrl.trim('"')
val imageUrl = when {
url.startsWith("http://127.0.0.1") -> url.toHttpUrl().queryParameter("url")
else -> url
}
Page(index, imageUrl = imageUrl)
}
}
override fun imageRequest(page: Page): Request {
val url = page.imageUrl!!
val host = url.toHttpUrl().host
val headers = headersBuilder().set("Referer", "https://$host/").build()
return GET(url, headers)
}
override fun setupPreferenceScreen(screen: PreferenceScreen) {
ListPreference(screen.context).apply {
val mirrors = MIRRORS
key = MIRROR_PREF
title = "镜像网址"
summary = "%s\n重启生效"
entries = mirrors
entryValues = Array(mirrors.size) { it.toString() }
setDefaultValue("0")
}.let(screen::addPreference)
}
companion object {
private const val MIRROR_PREF = "MIRROR"
private val MIRRORS get() = arrayOf("www.vomicmh.com", "www.iewoai.com")
private val dateFormat by lazy { SimpleDateFormat("yyyy-MM-dd hh:mm:ss", Locale.ENGLISH) }
}
}

View File

@ -0,0 +1,21 @@
package eu.kanade.tachiyomi.multisrc.mangabz
import generator.ThemeSourceData.SingleLang
import generator.ThemeSourceGenerator
class MangabzGenerator : ThemeSourceGenerator {
override val themeClass = "MangabzTheme"
override val themePkg = "mangabz"
override val baseVersionCode = 1
override val sources = listOf(
SingleLang("Mangabz", "https://mangabz.com", "zh", overrideVersionCode = 5),
SingleLang("vomic", "http://www.vomicmh.com", "zh", className = "Vomic"),
)
companion object {
@JvmStatic
fun main(args: Array<String>) {
MangabzGenerator().createAll()
}
}
}

View File

@ -0,0 +1,127 @@
package eu.kanade.tachiyomi.multisrc.mangabz
import android.util.Log
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.util.asJsoup
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Request
import okhttp3.Response
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import org.jsoup.select.Elements
import org.jsoup.select.Evaluator
abstract class MangabzTheme(
override val name: String,
override val baseUrl: String,
override val lang: String = "zh"
) : HttpSource() {
override val supportsLatest = true
override fun headersBuilder() = super.headersBuilder().add("Referer", baseUrl)
override fun popularMangaRequest(page: Int) = GET("$baseUrl/manga-list-p$page/", headers)
override fun popularMangaParse(response: Response) = searchMangaParse(response)
override fun latestUpdatesRequest(page: Int) = GET("$baseUrl/manga-list-0-0-2-p$page/", headers)
override fun latestUpdatesParse(response: Response) = searchMangaParse(response)
override fun searchMangaRequest(page: Int, query: String, filters: FilterList) =
if (query.isEmpty()) {
popularMangaRequest(page)
} else {
val url = "$baseUrl/search".toHttpUrl().newBuilder()
.addQueryParameter("title", query)
.addQueryParameter("page", page.toString())
Request.Builder().url(url.build()).headers(headers).build()
}
override fun searchMangaParse(response: Response): MangasPage {
val document = response.asJsoup().also(::parseFilters)
val mangas = document.selectFirst(Evaluator.Class("mh-list")).children().map { element ->
SManga.create().apply {
title = element.selectFirst(Evaluator.Tag("h2")).text()
url = element.selectFirst(Evaluator.Tag("a")).attr("href")
thumbnail_url = element.selectFirst(Evaluator.Tag("img")).attr("src")
}
}
val hasNextPage = document.run {
val pagination = selectFirst(Evaluator.Class("page-pagination"))
pagination != null && pagination.select(Evaluator.Tag("a")).last().text() == ">"
}
return MangasPage(mangas, hasNextPage)
}
override fun mangaDetailsParse(response: Response): SManga {
val document = response.asJsoup()
val details = document.selectFirst(Evaluator.Class("detail-info-tip")).children()!!
return SManga.create().apply {
url = document.location().removePrefix(baseUrl)
title = document.selectFirst(Evaluator.Class("detail-info-title")).ownText()
thumbnail_url = document.selectFirst(Evaluator.Class("detail-info-cover")).attr("src")
status = when (details[1].child(0).ownText()) {
"连载中" -> SManga.ONGOING
"已完结" -> SManga.COMPLETED
"連載中" -> SManga.ONGOING
"已完結" -> SManga.COMPLETED
else -> SManga.UNKNOWN
}
author = details[0].children().joinToString { it.ownText() }
genre = details[2].children().joinToString { it.ownText() }
description = parseDescription(document.selectFirst(Evaluator.Class("detail-info-content")), title, details)
initialized = true
}
}
abstract fun parseDescription(element: Element, title: String, details: Elements): String
override fun chapterListParse(response: Response): List<SChapter> {
val document = response.asJsoup()
val needPageCount = needPageCount
val list = getChapterElements(document).map { element ->
val chapterName = element.ownText()
SChapter.create().apply {
url = element.attr("href")
if (needPageCount) {
name = chapterName + element.child(0).ownText()
chapter_number = when (val result = floatRegex.find(chapterName)) {
null -> -2f
else -> result.value.toFloat()
}
} else {
name = chapterName
}
}
}
if (list.isEmpty()) return emptyList()
val listTitle = document.selectFirst(Evaluator.Class("detail-list-form-title")).ownText()
try {
list[0].date_upload = parseDate(listTitle)
} catch (e: Throwable) {
Log.e("Mangabz/$name", "failed to parse date from '$listTitle'", e)
}
return list
}
protected open fun getChapterElements(document: Document): Elements =
document.selectFirst(Evaluator.Id("chapterlistload")).children()
protected open val needPageCount = true
protected abstract fun parseDate(listTitle: String): Long
override fun imageUrlParse(response: Response) = throw UnsupportedOperationException()
protected open fun parseFilters(document: Document) = Unit
private val floatRegex by lazy { Regex("""\d+(?:\.\d+)?""") }
}

View File

@ -1,24 +0,0 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
ext {
extName = 'Mangabz'
pkgNameSuffix = 'zh.mangabz'
extClass = '.Mangabz'
extVersionCode = 5
}
dependencies {
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.1'
}
android {
defaultConfig {
multiDexEnabled true
}
compileOptions {
coreLibraryDesugaringEnabled true
}
}
apply from: "$rootDir/common.gradle"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

View File

@ -1,393 +0,0 @@
package eu.kanade.tachiyomi.extension.zh.mangabz
import android.app.Application
import android.content.SharedPreferences
import app.cash.quickjs.QuickJs
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.asObservableSuccess
import eu.kanade.tachiyomi.network.interceptor.rateLimitHost
import eu.kanade.tachiyomi.source.ConfigurableSource
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.HttpSource
import eu.kanade.tachiyomi.util.asJsoup
import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import org.json.JSONArray
import rx.Observable
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.time.LocalDate
import java.time.ZoneId
import java.time.ZonedDateTime
import java.util.ArrayList
class Mangabz : ConfigurableSource, HttpSource() {
override val lang = "zh"
override val supportsLatest = false
override val name = "Mangabz"
override val baseUrl = "https://mangabz.com"
private val imageServer = arrayOf("https://cover.mangabz.com", "https://image.mangabz.com")
private val preferences: SharedPreferences by lazy {
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
}
override fun headersBuilder(): Headers.Builder = super.headersBuilder()
.set("Referer", "https://mangabz.com")
.set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.104 Safari/537.36")
private val showZhHantWebsite = preferences.getBoolean(SHOW_ZH_HANT_WEBSITE_PREF, false)
override val client: OkHttpClient = network.client.newBuilder()
.rateLimitHost(baseUrl.toHttpUrlOrNull()!!, preferences.getString(MAINSITE_RATELIMIT_PREF, "5")!!.toInt())
.rateLimitHost(imageServer[0].toHttpUrlOrNull()!!, preferences.getString(IMAGE_CDN_RATELIMIT_PREF, "5")!!.toInt())
.rateLimitHost(imageServer[1].toHttpUrlOrNull()!!, preferences.getString(IMAGE_CDN_RATELIMIT_PREF, "5")!!.toInt())
.addNetworkInterceptor { chain ->
val cookies = chain.request().header("Cookie")?.replace(replaceCookiesRegex, "") ?: ""
val newReq = chain
.request()
.newBuilder()
.header("Cookie", if (showZhHantWebsite) cookies else "$cookies; mangabz_lang=2")
.build()
chain.proceed(newReq)
}.build()!!
override fun popularMangaRequest(page: Int): Request = GET(baseUrl, headers)
override fun popularMangaParse(response: Response): MangasPage {
val document = response.asJsoup()
val mangasList = ArrayList<SManga>(0)
// top banner
document.select("div.banner-con a").map { element ->
mangasList.add(
SManga.create().apply {
title = element.attr("title")
url = element.attr("href")
thumbnail_url = element.select("img").first().attr("src")
}
)
}
// ranking sidebar
document.select(".rank-list .list").map { element ->
mangasList.add(
SManga.create().apply {
title = element.select(".rank-item-title").first().text()
url = element.select("a").first().attr("href")
thumbnail_url = element.select("a img").first().attr("src")
}
)
}
// carousel list
document.select(".carousel-right-item").map { element ->
mangasList.add(
SManga.create().apply {
title = element.select(".carousel-right-item-title a").first().text()
url = element.select(".carousel-right-item-title a").first().attr("href")
thumbnail_url = element.select("a img").first().attr("src")
}
)
}
// recommend list
document.select(".index-manga-item").map { element ->
mangasList.add(
SManga.create().apply {
title = element.select(".index-manga-item-title").first().text()
url = element.select(".index-manga-item-title a").first().attr("href")
thumbnail_url = element.select("a img").first().attr("src")
}
)
}
return MangasPage(mangasList.distinctBy { it.url }, false)
}
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
return if (query.startsWith(PREFIX_ID_SEARCH) && query.contains(extractMangaIdRegex)) {
val id = query.removePrefix(PREFIX_ID_SEARCH)
client.newCall(GET("$baseUrl/$id", headers))
.asObservableSuccess()
.map { response ->
val sManga = mangaDetailsParse(response)
sManga.url = "/$id"
return@map MangasPage(listOf(sManga), false)
}
} else if (query.startsWith(baseUrl) && query.contains(extractMangaIdRegex)) {
val id = extractMangaIdRegex.find(query)?.value
client.newCall(GET("$baseUrl/$id", headers))
.asObservableSuccess()
.map { response ->
val sManga = mangaDetailsParse(response)
sManga.url = "/$id"
return@map MangasPage(listOf(sManga), false)
}
} else {
super.fetchSearchManga(page, query, filters)
}
}
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request = GET("$baseUrl/search?title=$query&page=$page")
override fun searchMangaParse(response: Response): MangasPage {
val document = response.asJsoup()
val mangas = document.select(".mh-list .mh-item").map { element ->
SManga.create().apply {
title = element.select(".mh-item-detali h2.title a").first().text()
url = element.select(".mh-item-detali h2.title a").first().attr("href")
thumbnail_url = element.select("a img.mh-cover").first().attr("src")
}
}
val hasNextPage = document.select(".page-pagination li:contains(>)").first() != null
return MangasPage(mangas, hasNextPage)
}
override fun latestUpdatesRequest(page: Int): Request = throw UnsupportedOperationException("Not used.")
override fun latestUpdatesParse(response: Response): MangasPage = throw UnsupportedOperationException("Not used.")
override fun mangaDetailsRequest(manga: SManga): Request {
return GET(baseUrl + manga.url, headers.newBuilder().set("Referer", baseUrl + manga.url).build())
}
override fun mangaDetailsParse(response: Response): SManga {
val document = response.asJsoup()
return SManga.create().apply {
title = document.select(".detail-info-title").first().text()
thumbnail_url = document.select("img.detail-info-cover").first().attr("src")
status = when (document.select("span:contains(状态)>span, span:contains(狀態)>span").first().text()) {
"连载中" -> SManga.ONGOING
"連載中" -> SManga.ONGOING
"已完结" -> SManga.COMPLETED
"已完結" -> SManga.COMPLETED
else -> SManga.UNKNOWN
}
author = document.select("span:contains(作者) a")?.first()?.text() ?: ""
genre = document.select(".item")?.first()?.text() ?: ""
description = document.select(".detail-info-content")?.first()?.text() ?: ""
}
}
override fun chapterListRequest(manga: SManga): Request = mangaDetailsRequest(manga)
override fun chapterListParse(response: Response): List<SChapter> {
val document = response.asJsoup()
val latestChapter = document.select(".s a").first().attr("href")
val chapterInfo = document.select(".detail-list-form-title").first().text()
val latestUploadDate = parseDate(chapterInfo)
return document.select("a.detail-list-form-item").map { element ->
SChapter.create().apply {
url = element.attr("href")
name = element.text()
chapter_number = chapterNumRegex.find(name)?.value?.toFloatOrNull() ?: -1F
if (url == latestChapter) {
date_upload = latestUploadDate
}
}
}
}
private fun parseDate(string: String): Long {
val rightNow = ZonedDateTime.now(ZoneId.of("Asia/Shanghai"))
// today
if (string.contains("今天")) {
return rightNow.toInstant().toEpochMilli()
}
// yesterday
if (string.contains("昨天")) {
return rightNow.minusDays(1).toInstant().toEpochMilli()
}
// the day before yesterday
if (string.contains("前天")) {
return rightNow.minusDays(2).toInstant().toEpochMilli()
}
// 2021-01-01
val result1 = dateRegex1.find(string)?.value
if (result1 != null) {
return LocalDate.parse("$result1").atTime(0, 0).atZone(ZoneId.of("Asia/Shanghai")).toInstant().toEpochMilli()
}
// 1月1号 or 1月1號 -> (1, 1)
val result2 = dateRegex2.find(string)?.groupValues
if (result2 != null && result2.size > 1) {
val d = rightNow.withMonth(result2[1].toInt()).withDayOfMonth(result2[2].toInt())
return d.toInstant().toEpochMilli()
}
return rightNow.toInstant().toEpochMilli()
}
override fun pageListRequest(chapter: SChapter): Request {
return GET(baseUrl + chapter.url, headers.newBuilder().set("Referer", baseUrl + chapter.url).build())
}
// Special thanks to Cimoc project.
// https://github.com/feilongfl/Cimoc/blob/03d378ddb5fe8684ef85cae673624afdb68fcf46/app/src/main/java/com/hiroshi/cimoc/source/MangaBZ.kt#L95
private fun getJSVar(html: String, keyword: String, searchFor: String): String? {
val re = Regex("var\\s+$keyword\\s*=\\s*$searchFor\\s*;")
val match = re.find(html)
return match?.groups?.get(1)?.value
}
override fun pageListParse(response: Response): List<Page> {
val document = response.asJsoup()
val scriptTag = (document.select("head script").filter { it.data().isNotBlank() })[0].data()
val chapterUrl = response.request.url.toString()
val mid = getJSVar(scriptTag, "MANGABZ_MID", "(\\w+)")!!
val cid = getJSVar(scriptTag, "MANGABZ_CID", "(\\w+)")!!
val sign = getJSVar(scriptTag, "MANGABZ_VIEWSIGN", """\"(\w+)\"""")!!
val pageCount = getJSVar(scriptTag, "MANGABZ_IMAGE_COUNT", "(\\d+)")!!.toInt()
val path = getJSVar(scriptTag, "MANGABZ_CURL", "\"/(\\w+)/\"")!!
// Page list return by webpage's API maybe incomplete, so we store API url and
// chapter url in page.url to build header and fetch API when needed.
val pagesList = MutableList(
pageCount,
init = { index ->
Page(
index,
url = "$baseUrl/$path/chapterimage.ashx?cid=$cid&page=${index + 1}&key=&_cid=$cid&_mid=$mid&_sign=$sign&_dt=\n" +
chapterUrl
)
}
) // Fill the list at first.
// Page 1 may return 1~2 image urls.
val apiUrlInPage1 = "$baseUrl/$path/chapterimage.ashx?cid=$cid&page=1&key=&_cid=$cid&_mid=$mid&_sign=$sign&_dt="
val imgUrlList = fetchImageUrlListFromAPI(apiUrlInPage1, response.request.headers)
for (i in 0 until imgUrlList.length()) {
val imgUrl = imgUrlList[i] as String
val pageNum = extractPageNumFromImageUrlRegex.find(imgUrl)!!.groups[1]!!.value.toInt() - 1
pagesList[pageNum] = Page(pageNum, "$apiUrlInPage1\n$chapterUrl", imgUrl)
}
return pagesList
}
private fun fetchImageUrlListFromAPI(apiUrl: String, requestHeaders: Headers = headers): JSONArray {
val jsEvalPayload = client.newCall(GET(apiUrl, requestHeaders)).execute().body!!.string()
val imgUrlDecode = QuickJs.create().use {
it.evaluate("$jsEvalPayload; JSON.stringify(d);") as String
}
return JSONArray(imgUrlDecode)
}
override fun fetchImageUrl(page: Page): Observable<String> {
if (page.imageUrl != null) {
return Observable.just(page.imageUrl)
} else {
val urls = page.url.split("\n")
val imgUrlList = fetchImageUrlListFromAPI(urls[0], headers.newBuilder().set("Referer", urls[1]).build())
for (i in 0 until imgUrlList.length()) {
val imgUrl = imgUrlList[i] as String
val pageNum = extractPageNumFromImageUrlRegex.find(imgUrl)!!.groups[1]!!.value.toInt() - 1
if (page.index == pageNum) {
return Observable.just(imgUrl)
}
}
return Observable.error(Exception("Can't find image urls"))
}
}
override fun imageUrlRequest(page: Page): Request = throw UnsupportedOperationException("Not used.")
override fun imageUrlParse(response: Response): String = throw UnsupportedOperationException("Not used.")
override fun imageRequest(page: Page): Request = GET(page.imageUrl!!, headers)
override fun setupPreferenceScreen(screen: androidx.preference.PreferenceScreen) {
val mainSiteRateLimitPreference = androidx.preference.ListPreference(screen.context).apply {
key = MAINSITE_RATELIMIT_PREF
title = MAINSITE_RATELIMIT_PREF_TITLE
entries = ENTRIES_ARRAY
entryValues = ENTRIES_ARRAY
summary = MAINSITE_RATELIMIT_PREF_SUMMARY
setDefaultValue("5")
setOnPreferenceChangeListener { _, newValue ->
try {
val setting = preferences.edit().putString(MAINSITE_RATELIMIT_PREF, newValue as String).commit()
setting
} catch (e: Exception) {
e.printStackTrace()
false
}
}
}
val imgCDNRateLimitPreference = androidx.preference.ListPreference(screen.context).apply {
key = IMAGE_CDN_RATELIMIT_PREF
title = IMAGE_CDN_RATELIMIT_PREF_TITLE
entries = ENTRIES_ARRAY
entryValues = ENTRIES_ARRAY
summary = IMAGE_CDN_RATELIMIT_PREF_SUMMARY
setDefaultValue("5")
setOnPreferenceChangeListener { _, newValue ->
try {
val setting = preferences.edit().putString(IMAGE_CDN_RATELIMIT_PREF, newValue as String).commit()
setting
} catch (e: Exception) {
e.printStackTrace()
false
}
}
}
val zhHantPreference = androidx.preference.CheckBoxPreference(screen.context).apply {
key = SHOW_ZH_HANT_WEBSITE_PREF
title = SHOW_ZH_HANT_WEBSITE_PREF_TITLE
summary = SHOW_ZH_HANT_WEBSITE_PREF_SUMMARY
setDefaultValue(false)
setOnPreferenceChangeListener { _, newValue ->
try {
val setting = preferences.edit().putBoolean(SHOW_ZH_HANT_WEBSITE_PREF, newValue as Boolean).commit()
setting
} catch (e: Exception) {
e.printStackTrace()
false
}
}
}
screen.addPreference(mainSiteRateLimitPreference)
screen.addPreference(imgCDNRateLimitPreference)
screen.addPreference(zhHantPreference)
}
companion object {
private const val MAINSITE_RATELIMIT_PREF = "mainSiteRatelimitPreference"
private const val MAINSITE_RATELIMIT_PREF_TITLE = "主站每秒连接数限制" // "Ratelimit permits per second for main website"
private const val MAINSITE_RATELIMIT_PREF_SUMMARY = "此值影响向网站发起连接请求的数量。调低此值可能减少发生HTTP 429连接请求过多错误的几率但加载速度也会变慢。需要重启软件以生效。\n当前值:%s" // "This value affects network request amount to main website url. Lower this value may reduce the chance to get HTTP 429 error, but loading speed will be slower too. Tachiyomi restart required. Current value: %s"
private const val IMAGE_CDN_RATELIMIT_PREF = "imgCDNRatelimitPreference"
private const val IMAGE_CDN_RATELIMIT_PREF_TITLE = "图片CDN每秒连接数限制" // "Ratelimit permits per second for image CDN"
private const val IMAGE_CDN_RATELIMIT_PREF_SUMMARY = "此值影响加载图片时发起连接请求的数量。调低此值可能减小IP被屏蔽的几率但加载速度也会变慢。需要重启软件以生效。\n当前值:%s" // "This value affects network request amount for loading image. Lower this value may reduce the chance to get IP Ban, but loading speed will be slower too. Tachiyomi restart required."
private const val SHOW_ZH_HANT_WEBSITE_PREF = "showZhHantWebsite"
private const val SHOW_ZH_HANT_WEBSITE_PREF_TITLE = "使用繁体版网站" // "Use traditional chinese version website"
private const val SHOW_ZH_HANT_WEBSITE_PREF_SUMMARY = "需要重启软件以生效。" // "You need to restart Tachiyomi"
private val replaceCookiesRegex = Regex("""mangabz_lang=\d[;\s]*""")
private val extractMangaIdRegex = Regex("""\d+bz""")
private val chapterNumRegex = Regex("""\d+""")
private val dateRegex1 = Regex("""\d{4}-\d{1,2}-\d{1,2}""")
private val dateRegex2 = Regex("""(\d{1,2})月(\d{1,2})[号號]?""")
private val extractPageNumFromImageUrlRegex = Regex("""/(\d+)_\d+\.""")
private val ENTRIES_ARRAY = (1..10).map { i -> i.toString() }.toTypedArray()
const val PREFIX_ID_SEARCH = "id:"
}
}