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 {
extName = 'Manwa'
extClass = '.Manwa'
extVersionCode = 10
extVersionCode = 11
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
import android.content.SharedPreferences
import android.net.Uri
import androidx.preference.CheckBoxPreference
import androidx.preference.EditTextPreference
import androidx.preference.ListPreference
import androidx.preference.PreferenceScreen
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.util.asJsoup
import keiyoushi.utils.getPreferences
import keiyoushi.utils.parseAs
import keiyoushi.utils.toJsonString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.int
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.Cookie
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaTypeOrNull
@ -42,7 +43,15 @@ class Manwa : ParsedHttpSource(), ConfigurableSource {
override val supportsLatest: Boolean = true
private val json: Json by injectLazy()
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 ->
val originalResponse: Response = chain.proceed(chain.request())
@ -63,9 +72,16 @@ class Manwa : ParsedHttpSource(), ConfigurableSource {
}
}
override val client: OkHttpClient = network.cloudflareClient.newBuilder()
.addNetworkInterceptor(rewriteOctetStream)
.build()
private val updateMirror: Interceptor = UpdateMirror(baseUrl, preferences)
private val imageSource: Interceptor = ImageSource(baseUrl, preferences)
override val client: OkHttpClient =
network.cloudflareClient.newBuilder()
.addNetworkInterceptor(rewriteOctetStream)
.addInterceptor(imageSource)
.addInterceptor(updateMirror)
.build()
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 latestUpdatesParse(response: Response): MangasPage {
// 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 imgHost = document.selectFirst(".manga-list-2-cover-img")!!.attr(":src").drop(1).substringBefore("'")
@ -109,18 +129,96 @@ class Manwa : ParsedHttpSource(), ConfigurableSource {
// Search
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
val uri = Uri.parse(baseUrl).buildUpon()
uri.appendPath("search")
.appendQueryParameter("keyword", query)
return GET(uri.toString(), headers)
val url = baseUrl.toHttpUrl().newBuilder().apply {
if (query != "" && !query.contains("-")) {
encodedPath("/search")
addQueryParameter("keyword", query)
} else {
encodedPath("/booklist")
(if (filters.isEmpty()) getFilterList() else filters).forEach { filter ->
when (filter) {
is UriPartFilter -> {
filter.setParamPair(this)
}
is TagCheckBoxFilterGroup -> {
filter.setParamPair(this)
}
else -> {}
}
}
}
if (page > 1) {
addQueryParameter("page", page.toString())
}
}.build().toString()
return GET(url, headers)
}
override fun searchMangaNextPageSelector(): String? = null
override fun searchMangaSelector(): String = "ul.book-list > li"
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"))
thumbnail_url = element.selectFirst("img")!!.attr("data-original")
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
@ -153,21 +251,15 @@ class Manwa : ParsedHttpSource(), ConfigurableSource {
// Pages
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 {
val cssQuery = "#cp_img > div.img-content > img[data-r-src]"
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 ->
add(Page(index, "", it.attr("data-r-src")))
}
@ -175,50 +267,100 @@ class Manwa : ParsedHttpSource(), ConfigurableSource {
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) {
ListPreference(screen.context).apply {
key = MIRROR_KEY
title = "使用镜像网址"
entries = MIRROR_ENTRIES
entryValues = Array(entries.size, Int::toString)
setDefaultValue("0")
val list: Array<String> = try {
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) }
ListPreference(screen.context).apply {
key = IMAGE_HOST_KEY
title = "图源"
entries = IMAGE_HOST_ENTRIES
entryValues = IMAGE_HOST_ENTRY_VALUES
setDefaultValue(IMAGE_HOST_ENTRY_VALUES[0])
summary =
"切换图源能使一些无法加载的图片进行优化加载,但对于已经缓存了章节图片信息的章节只是修改图源是不会重新加载的,你需要手动在应用设置里<清除章节缓存>"
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) }
CheckBoxPreference(screen.context).apply {
key = AUTO_CLEAR_COOKIE_KEY
title = "自动删除 Cookie"
setDefaultValue(false)
EditTextPreference(screen.context).apply {
key = APP_REDIRECT_URL_KEY
title = "重定向URL"
summary = "该URL期望能够获取动态的目标URL列表"
setOnPreferenceChangeListener { _, newValue ->
preferences.edit().putString(APP_REDIRECT_URL_KEY, newValue as String).commit()
}
}.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 {
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 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
}
}