From 5406227f0fb54e6854aaabb3c72ef70ff031527b Mon Sep 17 00:00:00 2001 From: peakedshout <93729380+peakedshout@users.noreply.github.com> Date: Mon, 9 Jun 2025 10:11:45 +0800 Subject: [PATCH] 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; --- src/zh/manwa/build.gradle | 2 +- .../tachiyomi/extension/zh/manwa/Filter.kt | 83 ++++++ .../extension/zh/manwa/ImageSource.kt | 81 ++++++ .../tachiyomi/extension/zh/manwa/Manwa.kt | 254 ++++++++++++++---- .../extension/zh/manwa/UpdateMirror.kt | 105 ++++++++ 5 files changed, 468 insertions(+), 57 deletions(-) create mode 100644 src/zh/manwa/src/eu/kanade/tachiyomi/extension/zh/manwa/Filter.kt create mode 100644 src/zh/manwa/src/eu/kanade/tachiyomi/extension/zh/manwa/ImageSource.kt create mode 100644 src/zh/manwa/src/eu/kanade/tachiyomi/extension/zh/manwa/UpdateMirror.kt diff --git a/src/zh/manwa/build.gradle b/src/zh/manwa/build.gradle index 3e26bded5..f6dded95b 100644 --- a/src/zh/manwa/build.gradle +++ b/src/zh/manwa/build.gradle @@ -1,7 +1,7 @@ ext { extName = 'Manwa' extClass = '.Manwa' - extVersionCode = 10 + extVersionCode = 11 isNsfw = true } diff --git a/src/zh/manwa/src/eu/kanade/tachiyomi/extension/zh/manwa/Filter.kt b/src/zh/manwa/src/eu/kanade/tachiyomi/extension/zh/manwa/Filter.kt new file mode 100644 index 000000000..2566e6a66 --- /dev/null +++ b/src/zh/manwa/src/eu/kanade/tachiyomi/extension/zh/manwa/Filter.kt @@ -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>, + defaultValue: Int = 0, +) : Filter.Select(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, +) : Filter.Group( + 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() }) + } +} diff --git a/src/zh/manwa/src/eu/kanade/tachiyomi/extension/zh/manwa/ImageSource.kt b/src/zh/manwa/src/eu/kanade/tachiyomi/extension/zh/manwa/ImageSource.kt new file mode 100644 index 000000000..a7e98ff6d --- /dev/null +++ b/src/zh/manwa/src/eu/kanade/tachiyomi/extension/zh/manwa/ImageSource.kt @@ -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, +) diff --git a/src/zh/manwa/src/eu/kanade/tachiyomi/extension/zh/manwa/Manwa.kt b/src/zh/manwa/src/eu/kanade/tachiyomi/extension/zh/manwa/Manwa.kt index 2e643c2a4..90dd5f009 100644 --- a/src/zh/manwa/src/eu/kanade/tachiyomi/extension/zh/manwa/Manwa.kt +++ b/src/zh/manwa/src/eu/kanade/tachiyomi/extension/zh/manwa/Manwa.kt @@ -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() + 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() + + 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 = mutableListOf().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 { + val lhm: LinkedHashMap = try { + preferences.getString(APP_TAG_LIST_KEY, "")!!.parseAs>() + } 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 = try { + val urlList = + preferences.getString(APP_URL_LIST_PREF_KEY, "")!!.parseAs>() + 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 = try { + preferences.getString(APP_IMAGE_SOURCE_LIST_KEY, "")!! + .parseAs>() + } 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" diff --git a/src/zh/manwa/src/eu/kanade/tachiyomi/extension/zh/manwa/UpdateMirror.kt b/src/zh/manwa/src/eu/kanade/tachiyomi/extension/zh/manwa/UpdateMirror.kt new file mode 100644 index 000000000..9d71543d5 --- /dev/null +++ b/src/zh/manwa/src/eu/kanade/tachiyomi/extension/zh/manwa/UpdateMirror.kt @@ -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>() + 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 + } +}