[zh-mangabz]Add new source: mangabz (#5628)

This commit is contained in:
Oldwangtouchtouchdoge 2021-02-03 20:18:35 +08:00 committed by GitHub
parent b1d483f293
commit c1da304544
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 552 additions and 0 deletions

View File

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="eu.kanade.tachiyomi.extension">
<application>
<activity
android:name=".zh.mangabz.MangabzUrlActivity"
android:excludeFromRecents="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="mangabz.com"
android:pathPattern="/..*"
android:scheme="https" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@ -0,0 +1,26 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
ext {
extName = 'Mangabz'
pkgNameSuffix = 'zh.mangabz'
extClass = '.Mangabz'
extVersionCode = 1
libVersion = '1.2'
}
dependencies {
implementation project(':lib-ratelimit')
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.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

View File

@ -0,0 +1,461 @@
package eu.kanade.tachiyomi.extension.zh.mangabz
import android.app.Application
import android.content.SharedPreferences
import android.support.v7.preference.CheckBoxPreference
import android.support.v7.preference.ListPreference
import android.support.v7.preference.PreferenceScreen
import com.squareup.duktape.Duktape
import eu.kanade.tachiyomi.lib.ratelimit.SpecificHostRateLimitInterceptor
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.asObservableSuccess
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
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)
}
private val mainSiteRateLimitInterceptor = SpecificHostRateLimitInterceptor(HttpUrl.parse(baseUrl)!!, preferences.getString(MAINSITE_RATELIMIT_PREF, "5")!!.toInt())
private val imageCDNRateLimitInterceptor1 = SpecificHostRateLimitInterceptor(HttpUrl.parse(imageServer[0])!!, preferences.getString(IMAGE_CDN_RATELIMIT_PREF, "5")!!.toInt())
private val imageCDNRateLimitInterceptor2 = SpecificHostRateLimitInterceptor(HttpUrl.parse(imageServer[1])!!, preferences.getString(IMAGE_CDN_RATELIMIT_PREF, "5")!!.toInt())
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()
.addNetworkInterceptor(mainSiteRateLimitInterceptor)
.addNetworkInterceptor(imageCDNRateLimitInterceptor1)
.addNetworkInterceptor(imageCDNRateLimitInterceptor2)
.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 = Duktape.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)
}
override fun setupPreferenceScreen(screen: PreferenceScreen) {
val mainSiteRateLimitPreference = 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 = 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 = 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:"
}
}

View File

@ -0,0 +1,42 @@
package eu.kanade.tachiyomi.extension.zh.mangabz
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Intent
import android.os.Bundle
import android.util.Log
import kotlin.system.exitProcess
/**
* Springboard that accepts https://mangabz.com/XXXXbz intents and redirects them to
* the main tachiyomi process. The idea is to not install the intent filter unless
* you have this extension installed, but still let the main tachiyomi app control
* things.
*/
class MangabzUrlActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val pathSegments = intent?.data?.pathSegments
if (pathSegments != null && pathSegments.size > 0) {
val titleId = pathSegments[0]
val mainIntent = Intent().apply {
action = "eu.kanade.tachiyomi.SEARCH"
putExtra("query", "${Mangabz.PREFIX_ID_SEARCH}$titleId")
putExtra("filter", packageName)
}
try {
startActivity(mainIntent)
} catch (e: ActivityNotFoundException) {
Log.e("MangabzUrlActivity", e.toString())
}
} else {
Log.e("MangabzUrlActivity", "could not parse uri from intent $intent")
}
finish()
exitProcess(0)
}
}