Manwa: Optimize implementation logic (#9103)

* Optimization:
1. Dynamically obtain the list of optional image sources;
2. Dynamically obtain the redirected access URL;
3. Reconstruct some access logic;
4. Support filtering and next page functions;

* Optimization:
1. Dynamically obtain the list of optional image sources;
2. Dynamically obtain the redirected access URL;
3. Reconstruct some access logic;
4. Support filtering and next page functions;

* Optimization:
1. Dynamically obtain the list of optional image sources;
2. Dynamically obtain the redirected access URL;
3. Reconstruct some access logic;
4. Support filtering and next page functions;

* Optimization:
1. Dynamically obtain the list of optional image sources;
2. Dynamically obtain the redirected access URL;
3. Reconstruct some access logic;
4. Support filtering and next page functions;
This commit is contained in:
peakedshout 2025-06-09 10:11:45 +08:00 committed by Draff
parent 31e167dd72
commit 5406227f0f
Signed by: Draff
GPG Key ID: E8A89F3211677653
5 changed files with 468 additions and 57 deletions

View File

@ -1,7 +1,7 @@
ext { ext {
extName = 'Manwa' extName = 'Manwa'
extClass = '.Manwa' extClass = '.Manwa'
extVersionCode = 10 extVersionCode = 11
isNsfw = true isNsfw = true
} }

View File

@ -0,0 +1,83 @@
package eu.kanade.tachiyomi.extension.zh.manwa
import eu.kanade.tachiyomi.source.model.Filter
import okhttp3.HttpUrl.Builder
internal open class UriPartFilter(
displayName: String,
val vals: List<List<String>>,
defaultValue: Int = 0,
) : Filter.Select<String>(displayName, vals.map { it[0] }.toTypedArray(), defaultValue) {
open fun setParamPair(builder: Builder) {
builder.setQueryParameter(vals[state][1], vals[state][2])
}
}
internal class EndFilter : UriPartFilter(
"状态",
listOf(
listOf("全部", "end=", ""),
listOf("连载中", "end", "2"),
listOf("完结", "end", "1"),
),
)
internal class CGenderFilter : UriPartFilter(
"类型",
listOf(
listOf("全部", "gender", "-1"),
listOf("一般向", "gender", "2"),
listOf("BL向", "gender", "0"),
listOf("禁漫", "gender", "1"),
listOf("TL向", "gender", "3"),
),
)
internal class AreaFilter : UriPartFilter(
"地区",
listOf(
listOf("全部", "area", ""),
listOf("韩国", "area", "2"),
listOf("日漫", "area", "3"),
listOf("国漫", "area", "4"),
listOf("台漫", "area", "5"),
listOf("其他", "area", "6"),
listOf("未分类", "area", "1"),
),
)
internal class SortFilter : UriPartFilter(
"排序",
listOf(
listOf("最新", "sort", "-1"),
listOf("最旧", "sort", "0"),
listOf("收藏", "sort", "1"),
listOf("新漫", "sort", "2"),
),
)
internal class TagCheckBoxFilter(name: String, val key: String) : Filter.CheckBox(name) {
override fun toString(): String {
return key
}
}
internal class TagCheckBoxFilterGroup(
name: String,
data: LinkedHashMap<String, String>,
) : Filter.Group<TagCheckBoxFilter>(
name,
data.map { (k, v) ->
TagCheckBoxFilter(k, v)
},
) {
fun setParamPair(builder: Builder) {
if (state[0].state) {
// clear
state.forEach { it.state = false }
builder.setQueryParameter("tag", null)
return
}
builder.setQueryParameter("tag", state.filter { it.state }.joinToString { it.toString() })
}
}

View File

@ -0,0 +1,81 @@
package eu.kanade.tachiyomi.extension.zh.manwa
import android.content.SharedPreferences
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.util.asJsoup
import keiyoushi.utils.toJsonString
import kotlinx.serialization.Serializable
import okhttp3.Headers
import okhttp3.Interceptor
import okhttp3.Response
class ImageSource(
private val baseUrl: String,
private val preferences: SharedPreferences,
) : Interceptor {
@Volatile
private var isUpdated = false
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
if (!request.url.toString().startsWith(baseUrl)) return chain.proceed(request)
if (!isUpdated && updateList(chain)) {
throw java.io.IOException("图源列表已自动更新,请在插件设置中选择合适的图源并重新请求(如果反复提示,可能是服务器故障)")
}
return chain.proceed(request)
}
@Synchronized
private fun updateList(chain: Interceptor.Chain): Boolean {
if (isUpdated) {
return false
}
val request = GET(
url = baseUrl,
headers = Headers.headersOf(
"Accept-Encoding",
"gzip",
"User-Agent",
"okhttp/3.8.1",
),
)
try {
chain.proceed(request).use { response ->
if (!response.isSuccessful) {
throw Exception("Unexpected ${request.url} to update image source")
}
val document = response.asJsoup()
val modalBody = document.selectFirst("#img-host-modal > div.modal-body")
val links = modalBody?.select("a") ?: emptyList()
val infoList = arrayListOf(ImageSourceInfo("None", ""))
for (link in links) {
val href = link.attr("href")
val text = link.text()
infoList.add(ImageSourceInfo(text, href))
}
val newList = infoList.toJsonString()
isUpdated = true
if (newList != preferences.getString(APP_IMAGE_SOURCE_LIST_KEY, "")!!) {
preferences.edit().putString(APP_IMAGE_SOURCE_LIST_KEY, newList).apply()
return true
} else {
return false
}
}
} catch (_: Exception) {
return false
}
}
}
@Serializable
data class ImageSourceInfo(
val name: String,
val param: String,
)

View File

@ -1,8 +1,7 @@
package eu.kanade.tachiyomi.extension.zh.manwa package eu.kanade.tachiyomi.extension.zh.manwa
import android.content.SharedPreferences import android.content.SharedPreferences
import android.net.Uri import androidx.preference.EditTextPreference
import androidx.preference.CheckBoxPreference
import androidx.preference.ListPreference import androidx.preference.ListPreference
import androidx.preference.PreferenceScreen import androidx.preference.PreferenceScreen
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
@ -15,12 +14,14 @@ import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.ParsedHttpSource import eu.kanade.tachiyomi.source.online.ParsedHttpSource
import eu.kanade.tachiyomi.util.asJsoup import eu.kanade.tachiyomi.util.asJsoup
import keiyoushi.utils.getPreferences import keiyoushi.utils.getPreferences
import keiyoushi.utils.parseAs
import keiyoushi.utils.toJsonString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.int import kotlinx.serialization.json.int
import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.jsonPrimitive
import okhttp3.Cookie import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MediaType.Companion.toMediaTypeOrNull
@ -42,7 +43,15 @@ class Manwa : ParsedHttpSource(), ConfigurableSource {
override val supportsLatest: Boolean = true override val supportsLatest: Boolean = true
private val json: Json by injectLazy() private val json: Json by injectLazy()
private val preferences: SharedPreferences = getPreferences() private val preferences: SharedPreferences = getPreferences()
override val baseUrl = "https://" + MIRROR_ENTRIES.run { this[preferences.getString(MIRROR_KEY, "0")!!.toInt().coerceAtMost(size)] } override val baseUrl: String = getTargetUrl()
private fun getTargetUrl(): String {
val url = preferences.getString(APP_CUSTOMIZATION_URL_KEY, "")!!
if (url.isNotBlank()) {
return url
}
return preferences.getString(MIRROR_KEY, MIRROR_ENTRIES[0])!!
}
private val rewriteOctetStream: Interceptor = Interceptor { chain -> private val rewriteOctetStream: Interceptor = Interceptor { chain ->
val originalResponse: Response = chain.proceed(chain.request()) val originalResponse: Response = chain.proceed(chain.request())
@ -63,8 +72,15 @@ class Manwa : ParsedHttpSource(), ConfigurableSource {
} }
} }
override val client: OkHttpClient = network.cloudflareClient.newBuilder() private val updateMirror: Interceptor = UpdateMirror(baseUrl, preferences)
private val imageSource: Interceptor = ImageSource(baseUrl, preferences)
override val client: OkHttpClient =
network.cloudflareClient.newBuilder()
.addNetworkInterceptor(rewriteOctetStream) .addNetworkInterceptor(rewriteOctetStream)
.addInterceptor(imageSource)
.addInterceptor(updateMirror)
.build() .build()
private val baseHttpUrl = baseUrl.toHttpUrlOrNull() private val baseHttpUrl = baseUrl.toHttpUrlOrNull()
@ -83,7 +99,11 @@ class Manwa : ParsedHttpSource(), ConfigurableSource {
override fun latestUpdatesRequest(page: Int) = GET("$baseUrl/getUpdate?page=${page * 15 - 15}&date=", headers) override fun latestUpdatesRequest(page: Int) = GET("$baseUrl/getUpdate?page=${page * 15 - 15}&date=", headers)
override fun latestUpdatesParse(response: Response): MangasPage { override fun latestUpdatesParse(response: Response): MangasPage {
// Get image host // Get image host
val resp = client.newCall(GET("$baseUrl/update?img_host=${preferences.getString(IMAGE_HOST_KEY, IMAGE_HOST_ENTRY_VALUES[0])}")).execute() val resp = client.newCall(
GET(
"$baseUrl/update${preferences.getString(IMAGE_HOST_KEY, "")}",
),
).execute()
val document = resp.asJsoup() val document = resp.asJsoup()
val imgHost = document.selectFirst(".manga-list-2-cover-img")!!.attr(":src").drop(1).substringBefore("'") val imgHost = document.selectFirst(".manga-list-2-cover-img")!!.attr(":src").drop(1).substringBefore("'")
@ -109,18 +129,96 @@ class Manwa : ParsedHttpSource(), ConfigurableSource {
// Search // Search
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
val uri = Uri.parse(baseUrl).buildUpon() val url = baseUrl.toHttpUrl().newBuilder().apply {
uri.appendPath("search") if (query != "" && !query.contains("-")) {
.appendQueryParameter("keyword", query) encodedPath("/search")
return GET(uri.toString(), headers) addQueryParameter("keyword", query)
} else {
encodedPath("/booklist")
(if (filters.isEmpty()) getFilterList() else filters).forEach { filter ->
when (filter) {
is UriPartFilter -> {
filter.setParamPair(this)
} }
override fun searchMangaNextPageSelector(): String? = null is TagCheckBoxFilterGroup -> {
override fun searchMangaSelector(): String = "ul.book-list > li" filter.setParamPair(this)
override fun searchMangaFromElement(element: Element): SManga = SManga.create().apply { }
title = element.selectFirst("p.book-list-info-title")!!.text()
setUrlWithoutDomain(element.selectFirst("a")!!.attr("abs:href")) else -> {}
thumbnail_url = element.selectFirst("img")!!.attr("data-original") }
}
}
if (page > 1) {
addQueryParameter("page", page.toString())
}
}.build().toString()
return GET(url, headers)
}
override fun searchMangaParse(response: Response): MangasPage {
val document = response.asJsoup()
val mangas = ArrayList<SManga>()
if (response.request.url.encodedPath == "/booklist") {
if (!isUpdateTag) {
updateTagList(document)
}
val lis = document.select("ul.manga-list-2 > li")
lis.forEach { li ->
mangas.add(
SManga.create().apply {
title = li.selectFirst("p.manga-list-2-title")!!.text()
setUrlWithoutDomain(li.selectFirst("a")!!.absUrl("href"))
thumbnail_url = li.selectFirst("img")?.attr("src")
},
)
}
} else {
val lis = document.select("ul.book-list > li")
lis.forEach { li ->
mangas.add(
SManga.create().apply {
title = li.selectFirst("p.book-list-info-title")!!.text()
setUrlWithoutDomain(li.selectFirst("a")!!.absUrl("href"))
thumbnail_url = li.selectFirst("img")?.attr("data-original")
},
)
}
}
val next = document.select("ul.pagination2 > li").lastOrNull()?.text() == "下一页"
return MangasPage(mangas, next)
}
override fun searchMangaNextPageSelector(): String? = throw UnsupportedOperationException()
override fun searchMangaSelector(): String = throw UnsupportedOperationException()
override fun searchMangaFromElement(element: Element): SManga =
throw UnsupportedOperationException()
@Volatile
private var isUpdateTag = false
@Synchronized
private fun updateTagList(doc: Document) {
if (isUpdateTag) {
return
}
val tags = LinkedHashMap<String, String>()
val lis = doc.select("div.manga-filter-row.tags > a")
lis.forEach { li ->
tags[li.text()] = li.attr("data-val")
}
if (tags.isEmpty()) {
tags["全部"] = ""
}
val tagsJ = tags.toJsonString()
isUpdateTag = true
preferences.edit().putString(APP_TAG_LIST_KEY, tagsJ).apply()
} }
// Details // Details
@ -153,21 +251,15 @@ class Manwa : ParsedHttpSource(), ConfigurableSource {
// Pages // Pages
override fun pageListRequest(chapter: SChapter): Request { override fun pageListRequest(chapter: SChapter): Request {
return GET("$baseUrl${chapter.url}?img_host=${preferences.getString(IMAGE_HOST_KEY, IMAGE_HOST_ENTRY_VALUES[0])}", headers) return GET(
"$baseUrl${chapter.url}${preferences.getString(IMAGE_HOST_KEY, "")}",
headers,
)
} }
override fun pageListParse(document: Document): List<Page> = mutableListOf<Page>().apply { override fun pageListParse(document: Document): List<Page> = mutableListOf<Page>().apply {
val cssQuery = "#cp_img > div.img-content > img[data-r-src]" val cssQuery = "#cp_img > div.img-content > img[data-r-src]"
val elements = document.select(cssQuery) val elements = document.select(cssQuery)
if (elements.size == 3) {
val darkReader = document.selectFirst("#cp_img p")
if (darkReader != null) {
if (preferences.getBoolean(AUTO_CLEAR_COOKIE_KEY, false)) {
clearCookies()
}
throw Exception(darkReader.text())
}
}
elements.forEachIndexed { index, it -> elements.forEachIndexed { index, it ->
add(Page(index, "", it.attr("data-r-src"))) add(Page(index, "", it.attr("data-r-src")))
} }
@ -175,50 +267,100 @@ class Manwa : ParsedHttpSource(), ConfigurableSource {
override fun imageUrlParse(document: Document): String = throw UnsupportedOperationException() override fun imageUrlParse(document: Document): String = throw UnsupportedOperationException()
// Filters
override fun getFilterList() = FilterList(
EndFilter(),
CGenderFilter(),
AreaFilter(),
SortFilter(),
TagCheckBoxFilterGroup(
"标签(懒更新)",
getFilterTags(),
),
)
private fun getFilterTags(): LinkedHashMap<String, String> {
val lhm: LinkedHashMap<String, String> = try {
preferences.getString(APP_TAG_LIST_KEY, "")!!.parseAs<LinkedHashMap<String, String>>()
} catch (_: Exception) {
linkedMapOf(Pair("全部", ""))
}
return lhm
}
override fun setupPreferenceScreen(screen: PreferenceScreen) { override fun setupPreferenceScreen(screen: PreferenceScreen) {
ListPreference(screen.context).apply { ListPreference(screen.context).apply {
key = MIRROR_KEY key = MIRROR_KEY
title = "使用镜像网址" title = "使用镜像网址"
entries = MIRROR_ENTRIES
entryValues = Array(entries.size, Int::toString) val list: Array<String> = try {
setDefaultValue("0") val urlList =
preferences.getString(APP_URL_LIST_PREF_KEY, "")!!.parseAs<ArrayList<String>>()
urlList.add(0, MIRROR_ENTRIES[0])
urlList.toTypedArray()
} catch (e: Exception) {
MIRROR_ENTRIES
}
entries = list
entryValues = list
setDefaultValue(list[0])
}.let { screen.addPreference(it) }
EditTextPreference(screen.context).apply {
key = APP_CUSTOMIZATION_URL_KEY
title = "自定义URL"
summary = "指定访问的目标URL优先级高于选择的镜像URL"
setOnPreferenceChangeListener { _, newValue ->
preferences.edit().putString(APP_CUSTOMIZATION_URL_KEY, newValue as String).commit()
}
}.let { screen.addPreference(it) } }.let { screen.addPreference(it) }
ListPreference(screen.context).apply { ListPreference(screen.context).apply {
key = IMAGE_HOST_KEY key = IMAGE_HOST_KEY
title = "图源" title = "图源"
entries = IMAGE_HOST_ENTRIES summary =
entryValues = IMAGE_HOST_ENTRY_VALUES "切换图源能使一些无法加载的图片进行优化加载,但对于已经缓存了章节图片信息的章节只是修改图源是不会重新加载的,你需要手动在应用设置里<清除章节缓存>"
setDefaultValue(IMAGE_HOST_ENTRY_VALUES[0])
val list: Array<ImageSourceInfo> = try {
preferences.getString(APP_IMAGE_SOURCE_LIST_KEY, "")!!
.parseAs<Array<ImageSourceInfo>>()
} catch (_: Exception) {
arrayOf(ImageSourceInfo("None", ""))
}
entries = list.map { it.name }.toTypedArray()
entryValues = list.map { it.param }.toTypedArray()
setDefaultValue(list[0].param)
}.let { screen.addPreference(it) } }.let { screen.addPreference(it) }
CheckBoxPreference(screen.context).apply { EditTextPreference(screen.context).apply {
key = AUTO_CLEAR_COOKIE_KEY key = APP_REDIRECT_URL_KEY
title = "自动删除 Cookie" title = "重定向URL"
summary = "该URL期望能够获取动态的目标URL列表"
setDefaultValue(false) setOnPreferenceChangeListener { _, newValue ->
preferences.edit().putString(APP_REDIRECT_URL_KEY, newValue as String).commit()
}
}.let { screen.addPreference(it) } }.let { screen.addPreference(it) }
} }
private fun clearCookies() {
if (baseHttpUrl == null) {
return
}
val cookies = client.cookieJar.loadForRequest(baseHttpUrl)
val obsoletedCookies = cookies.map {
val cookie = Cookie.parse(baseHttpUrl, "${it.name}=; Max-Age=-1")!!
cookie
}
client.cookieJar.saveFromResponse(baseHttpUrl, obsoletedCookies)
}
companion object { companion object {
private const val MIRROR_KEY = "MIRROR" private const val MIRROR_KEY = "MIRROR"
private val MIRROR_ENTRIES get() = arrayOf("manwa.fun", "manwa.me", "manwav3.xyz", "manwasa.cc", "manwadf.cc") private val MIRROR_ENTRIES
get() = arrayOf(
"https://manwa.me",
"https://manwass.cc",
"https://manwatg.cc",
"https://manwast.cc",
"https://manwasy.cc",
)
private const val IMAGE_HOST_KEY = "IMG_HOST" private const val IMAGE_HOST_KEY = "IMG_HOST"
private val IMAGE_HOST_ENTRIES = arrayOf("图源1", "图源2", "图源3") }
private val IMAGE_HOST_ENTRY_VALUES = arrayOf("1", "2", "3") }
private const val AUTO_CLEAR_COOKIE_KEY = "CLEAR_COOKIE" const val APP_IMAGE_SOURCE_LIST_KEY = "APP_IMAGE_SOURCE_LIST_KEY"
} const val APP_REDIRECT_URL_KEY = "APP_REDIRECT_URL_KEY"
} const val APP_URL_LIST_PREF_KEY = "APP_URL_LIST_PREF_KEY"
const val APP_CUSTOMIZATION_URL_KEY = "APP_CUSTOMIZATION_URL_KEY"
const val APP_TAG_LIST_KEY = "APP_TAG_LIST_KEY"

View File

@ -0,0 +1,105 @@
package eu.kanade.tachiyomi.extension.zh.manwa
import android.content.SharedPreferences
import android.util.Base64
import eu.kanade.tachiyomi.network.GET
import keiyoushi.utils.parseAs
import keiyoushi.utils.toJsonString
import okhttp3.Headers
import okhttp3.Interceptor
import okhttp3.Response
import org.jsoup.Jsoup
import java.io.IOException
class UpdateMirror(
private val baseUrl: String,
private val preferences: SharedPreferences,
) : Interceptor {
@Volatile
private var isUpdated = false
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
if (!request.url.toString().startsWith(baseUrl)) return chain.proceed(request)
val failedResponse = try {
val response = chain.proceed(request)
if (response.isSuccessful) return response
response.close()
Result.success(response)
} catch (e: Exception) {
if (chain.call().isCanceled() || e.message?.contains("Cloudflare") == true) throw e
Result.failure(e)
}
if (isUpdated || updateUrl(chain)) {
throw IOException("镜像网址已自动更新,请在插件设置中选择合适的镜像网址并重启应用(如果反复提示,可能是服务器故障)")
}
return failedResponse.getOrThrow()
}
@Synchronized
private fun updateUrl(chain: Interceptor.Chain): Boolean {
if (isUpdated) return true
var url = preferences.getString(APP_REDIRECT_URL_KEY, "")!!
if (url.isBlank()) {
url = "https://fuwt.cc/mw666"
}
val request = GET(
url = url,
headers = Headers.headersOf(
"Accept-Encoding",
"gzip",
"User-Agent",
"okhttp/3.8.1",
),
)
try {
chain.proceed(request).use { response ->
if (!response.isSuccessful) {
return false
}
val extractLksBase64 = extractLksBase64(response.body.string()) ?: return false
val extractLks =
String(Base64.decode(extractLksBase64, Base64.DEFAULT)).parseAs<List<String>>()
val extractLksJson = extractLks.map { it.trimEnd('/') }.toJsonString()
if (extractLksJson != preferences.getString(APP_URL_LIST_PREF_KEY, "")!!) {
preferences.edit().putString(APP_URL_LIST_PREF_KEY, extractLksJson).apply()
}
isUpdated = true
return true
}
} catch (_: Exception) {
return false
}
}
private fun extractLksBase64(html: String): String? {
val doc = Jsoup.parse(html)
val scripts = doc.getElementsByTag("script")
val prefix = "var lks = JSON.parse(atob("
val regex = Regex("""atob\(['"]([A-Za-z0-9+/=]+)['"]\)""")
for (script in scripts) {
val lines = script.data().lines()
for (line in lines) {
val trimmedLine = line.trim()
if (trimmedLine.startsWith(prefix)) {
val match = regex.find(trimmedLine)
if (match != null) {
return match.groupValues[1]
}
}
}
}
return null
}
}