From c514b4fc04ac4aa85884d1bed1ff437feab20abb Mon Sep 17 00:00:00 2001 From: stevenyomi <95685115+stevenyomi@users.noreply.github.com> Date: Sun, 24 Aug 2025 14:27:30 +0000 Subject: [PATCH] Fix Roumanwu (#10271) --- src/zh/roumanwu/build.gradle | 2 +- .../extension/zh/roumanwu/Roumanwu.kt | 174 ++++++++++-------- .../zh/roumanwu/ScrambledImageInterceptor.kt | 25 ++- 3 files changed, 113 insertions(+), 88 deletions(-) diff --git a/src/zh/roumanwu/build.gradle b/src/zh/roumanwu/build.gradle index a49e1ee3a..1352e9466 100644 --- a/src/zh/roumanwu/build.gradle +++ b/src/zh/roumanwu/build.gradle @@ -1,7 +1,7 @@ ext { extName = 'Roumanwu' extClass = '.Roumanwu' - extVersionCode = 16 + extVersionCode = 17 isNsfw = true } diff --git a/src/zh/roumanwu/src/eu/kanade/tachiyomi/extension/zh/roumanwu/Roumanwu.kt b/src/zh/roumanwu/src/eu/kanade/tachiyomi/extension/zh/roumanwu/Roumanwu.kt index 5efeb702a..bca917e15 100644 --- a/src/zh/roumanwu/src/eu/kanade/tachiyomi/extension/zh/roumanwu/Roumanwu.kt +++ b/src/zh/roumanwu/src/eu/kanade/tachiyomi/extension/zh/roumanwu/Roumanwu.kt @@ -1,6 +1,7 @@ package eu.kanade.tachiyomi.extension.zh.roumanwu import android.content.SharedPreferences +import androidx.preference.ListPreference import androidx.preference.PreferenceScreen import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.source.ConfigurableSource @@ -10,15 +11,19 @@ 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.ParsedHttpSource +import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.util.asJsoup import keiyoushi.utils.getPreferences +import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.Response import org.jsoup.nodes.Document import org.jsoup.nodes.Element +import java.text.ParsePosition +import java.text.SimpleDateFormat +import java.util.Locale import kotlin.math.max -class Roumanwu : ParsedHttpSource(), ConfigurableSource { +class Roumanwu : HttpSource(), ConfigurableSource { override val name = "肉漫屋" override val lang = "zh" override val supportsLatest = true @@ -29,40 +34,42 @@ class Roumanwu : ParsedHttpSource(), ConfigurableSource { max(MIRRORS.size - 1, preferences.getString(MIRROR_PREF, MIRROR_DEFAULT)!!.toInt()), ] - override val client = network.cloudflareClient.newBuilder().addInterceptor(ScrambledImageInterceptor).build() - - private val imageUrlRegex = """\\"imageUrl\\":\\"([^\\]+)""".toRegex() + override val client = network.cloudflareClient.newBuilder().addInterceptor(ScrambledImageInterceptor()).build() override fun popularMangaRequest(page: Int) = GET("$baseUrl/home", headers) - override fun popularMangaNextPageSelector(): String? = null - override fun popularMangaSelector(): String = "div.px-1 > div:matches(正熱門|今日最佳|本週熱門) .grid a[href*=/books/]" - override fun popularMangaFromElement(element: Element): SManga = SManga.create().apply { - title = element.select("div.truncate").text() - url = element.attr("href") - thumbnail_url = element.select("div.bg-cover").attr("style").substringAfter("background-image:url(\"").substringBefore("\")") + + private fun parseEntries(container: Element): List { + return container.select("a[href*=/books/]").map { + SManga.create().apply { + title = it.selectFirst("div.truncate")!!.text() + url = it.attr("href") + thumbnail_url = it.selectFirst("div.bg-cover")!!.attr("style").substringAfter("background-image:url(\"").substringBefore("\")") + } + } } + override fun popularMangaParse(response: Response): MangasPage { val document = response.asJsoup() + return parseHomePage(document, Regex("正熱門|今日最佳|本週熱門")) + } - val mangas = document.select(popularMangaSelector()).map { element -> - popularMangaFromElement(element) - } - val uniqueMangas = mangas.distinctBy { it.url } + private fun parseHomePage(document: Document, sections: Regex): MangasPage { + val entries = document.selectFirst("div.px-1")!!.children().flatMap { section -> + if (section.child(0).text().contains(sections)) { + parseEntries(section) + } else { + emptyList() + } + }.distinctBy { it.url } - val hasNextPage = popularMangaNextPageSelector()?.let { selector -> - document.select(selector).first() - } != null - - return MangasPage(uniqueMangas, hasNextPage) + return MangasPage(entries, false) } override fun latestUpdatesRequest(page: Int) = popularMangaRequest(page) - override fun latestUpdatesNextPageSelector(): String? = null - override fun latestUpdatesSelector(): String = "div.px-1 > div:contains(最近更新) .grid a[href*=/books/]" - override fun latestUpdatesFromElement(element: Element): SManga = SManga.create().apply { - title = element.select("div.truncate").text() - url = element.attr("href") - thumbnail_url = element.select("div.bg-cover").attr("style").substringAfter("background-image:url(\"").substringBefore("\")") + + override fun latestUpdatesParse(response: Response): MangasPage { + val document = response.asJsoup() + return parseHomePage(document, Regex("最近更新")) } override fun searchMangaRequest(page: Int, query: String, filters: FilterList) = @@ -72,67 +79,94 @@ class Roumanwu : ParsedHttpSource(), ConfigurableSource { val parts = filters.filterIsInstance().joinToString("") { it.toUriPart() } GET("$baseUrl/books?page=${page - 1}$parts", headers) } - override fun searchMangaNextPageSelector(): String? = null - override fun searchMangaSelector(): String = "a[href*=/books/]" - override fun searchMangaFromElement(element: Element): SManga = SManga.create().apply { - title = element.select("div.truncate").text() - url = element.attr("href") - thumbnail_url = element.select("div.bg-cover").attr("style").substringAfter("background-image:url(\"").substringBefore("\")") + + override fun searchMangaParse(response: Response): MangasPage { + val document = response.asJsoup() + val entries = parseEntries(document) + val thisPage = response.request.url.queryParameter("page")!! + val nextPage = document.selectFirst("div.justify-end > a:contains(下一頁)")!! + .absUrl("href").toHttpUrl().queryParameter("page")!! + return MangasPage(entries, thisPage != nextPage) } - override fun mangaDetailsParse(document: Document): SManga = SManga.create().apply { - title = document.select("div.basis-3\\/5 > div.text-xl").text() - thumbnail_url = baseUrl + document.select("main > div:first-child img").attr("src") - author = document.select("div.basis-3\\/5 > div:nth-child(3) span").text() - artist = author - status = when (document.select("div.basis-3\\/5 > div:nth-child(4) span").text()) { - "連載中" -> SManga.ONGOING - "已完結" -> SManga.COMPLETED - else -> SManga.UNKNOWN + override fun mangaDetailsParse(response: Response): SManga = SManga.create().apply { + val document = response.asJsoup() + val infobox = parseInfobox(document).iterator() + + title = infobox.next() + thumbnail_url = document.selectFirst("div.basis-2\\/5 img")!!.absUrl("src") + .run { toHttpUrl().queryParameter("url") ?: this } + description = document.selectFirst("p:contains(簡介:)")!!.text().substring(3) + + val genres = ArrayList() + for (text in infobox) { + val value = text.drop(3).trimStart() + if (value.isEmpty()) continue + when (text.take(3)) { + "別名:" -> if (value != title) description = "$text\n\n$description" + "作者:" -> author = value + "狀態:" -> status = when (value) { + "連載中" -> SManga.ONGOING + "已完結" -> SManga.COMPLETED + else -> SManga.UNKNOWN + } + + "地區:" -> genres.add(value) + "標籤:" -> genres.addAll(value.split(",")) + } } - genre = document.select("div.basis-3\\/5 > div:nth-child(6) span").text().replace(",", ", ") - description = document.select("p:contains(簡介:)").text().substring(3) + genre = genres.joinToString() } - override fun chapterListSelector(): String = "a[href~=/books/.*/\\d+]" - override fun chapterFromElement(element: Element): SChapter = SChapter.create().apply { - url = element.attr("href") - name = element.text() + private fun parseInfobox(document: Document): List { + val infobox = document.selectFirst("div.basis-3\\/5")!!.children() + check(infobox.size >= 6 && infobox[0].hasClass("text-xl")) + return infobox.map { it.text() } } + override fun chapterListParse(response: Response): List { - return super.chapterListParse(response).reversed() + val document = response.asJsoup() + val chapters = document.select("a[href~=/books/.*/\\d+]").map { + SChapter.create().apply { + url = it.attr("href") + name = it.text() + } + }.asReversed() + if (chapters.isNotEmpty()) { + val dateFormat = SimpleDateFormat("M/d/yyyy", Locale.US) + for (text in parseInfobox(document).asReversed()) { + val date = dateFormat.parse(text, ParsePosition(0)) ?: continue + chapters[0].date_upload = date.time + break + } + } + return chapters } - override fun pageListParse(document: Document): List { - val images = document.selectFirst("script:containsData(imageUrl)")?.data() - ?.let { content -> - imageUrlRegex + override fun pageListParse(response: Response): List { + val images = response.asJsoup().selectFirst("script:containsData(imageUrl)")!!.data() + .let { content -> + """\\"imageUrl\\":\\"([^\\]+)""".toRegex() .findAll(content).map { it.groups[1]?.value } .toList() - } ?: return emptyList() + } return images.mapIndexed { index, imageUrl -> Page(index, imageUrl = imageUrl) } } - override fun imageUrlParse(document: Document) = throw UnsupportedOperationException() + override fun imageUrlParse(response: Response) = throw UnsupportedOperationException() override fun getFilterList() = FilterList( Filter.Header("提示:搜尋時篩選無效"), - TagFilter(), StatusFilter(), - SortFilter(), ) private abstract class UriPartFilter(name: String, values: Array) : Filter.Select(name, values) { abstract fun toUriPart(): String } - private class TagFilter : UriPartFilter("標籤", TAGS) { - override fun toUriPart() = if (state == 0) "" else "&tag=${values[state]}" - } - private class StatusFilter : UriPartFilter("狀態", arrayOf("全部", "連載中", "已完結")) { override fun toUriPart() = when (state) { @@ -142,17 +176,13 @@ class Roumanwu : ParsedHttpSource(), ConfigurableSource { } } - private class SortFilter : UriPartFilter("排序", arrayOf("更新日期", "評分")) { - override fun toUriPart() = if (state == 0) "" else "&sort=rating" - } - override fun setupPreferenceScreen(screen: PreferenceScreen) { - val mirrorPref = androidx.preference.ListPreference(screen.context).apply { + val mirrorPref = ListPreference(screen.context).apply { key = MIRROR_PREF - title = MIRROR_PREF_TITLE + title = "使用鏡像網址" entries = MIRRORS_DESC - entryValues = MIRRORS.indices.map(Int::toString).toTypedArray() - summary = MIRROR_PREF_SUMMARY + entryValues = Array(MIRRORS.size, Int::toString) + summary = "使用鏡像網址。重啟軟體生效。" setDefaultValue(MIRROR_DEFAULT) } @@ -161,14 +191,10 @@ class Roumanwu : ParsedHttpSource(), ConfigurableSource { companion object { private const val MIRROR_PREF = "MIRROR" - private const val MIRROR_PREF_TITLE = "使用鏡像網址" - private const val MIRROR_PREF_SUMMARY = "使用鏡像網址。重啟軟體生效。" - // 地址: https://rou.pub/dizhi - private val MIRRORS get() = arrayOf("https://rouman5.com", "https://roum20.xyz") + // 地址: https://rou.pub/dizhi or https://rdz1.xyz/dizhi + private val MIRRORS get() = arrayOf("https://rouman5.com", "https://roum22.xyz") private val MIRRORS_DESC get() = arrayOf("主站", "鏡像") private const val MIRROR_DEFAULT = 1.toString() // use mirror - - private val TAGS get() = arrayOf("全部", "\u6B63\u59B9", "\u604B\u7231", "\u51FA\u7248\u6F2B\u753B", "\u8089\u617E", "\u6D6A\u6F2B", "\u5927\u5C3A\u5EA6", "\u5DE8\u4E73", "\u6709\u592B\u4E4B\u5A66", "\u5973\u5927\u751F", "\u72D7\u8840\u5287", "\u540C\u5C45", "\u597D\u53CB", "\u8ABF\u6559", "\u52A8\u4F5C", "\u5F8C\u5BAE", "\u4E0D\u502B") } } diff --git a/src/zh/roumanwu/src/eu/kanade/tachiyomi/extension/zh/roumanwu/ScrambledImageInterceptor.kt b/src/zh/roumanwu/src/eu/kanade/tachiyomi/extension/zh/roumanwu/ScrambledImageInterceptor.kt index bfabcbb69..313b45e35 100644 --- a/src/zh/roumanwu/src/eu/kanade/tachiyomi/extension/zh/roumanwu/ScrambledImageInterceptor.kt +++ b/src/zh/roumanwu/src/eu/kanade/tachiyomi/extension/zh/roumanwu/ScrambledImageInterceptor.kt @@ -8,25 +8,25 @@ import android.util.Base64 import okhttp3.Interceptor import okhttp3.MediaType.Companion.toMediaType import okhttp3.Response -import okhttp3.ResponseBody.Companion.toResponseBody -import java.io.ByteArrayOutputStream +import okhttp3.ResponseBody.Companion.asResponseBody +import okio.Buffer import java.security.MessageDigest -object ScrambledImageInterceptor : Interceptor { +class ScrambledImageInterceptor : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val request = chain.request() val response = chain.proceed(request) - val url = request.url.toString() - if ("sr:1" !in url) return response - val image = BitmapFactory.decodeStream(response.body.byteStream()) + val url = request.url + if ("sr:1" !in url.pathSegments) return response + val image = response.body.use { BitmapFactory.decodeStream(it.byteStream()) } val width = image.width val height = image.height val result = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) val canvas = Canvas(result) - // /_next/static/chunks/pages/books/%5Bbookid%5D/%5Bid%5D-6f60a589e82dc8db.js + // /_next/static/chunks/app/books/[bookId]/[ind]/page-eaacab94dbec1fa4.js // Scrambled images are reversed by blocks. Remainder is included in the bottom (scrambled) block. - val blocks = url.removeSuffix(SCRAMBLED_SUFFIX).substringAfterLast('/').removeSuffix(".jpg") + val blocks = url.pathSegments.last().substringBeforeLast('.') .let { Base64.decode(it, Base64.DEFAULT) } .let { MessageDigest.getInstance("MD5").digest(it) } // thread-safe .let { it.last().toPositiveInt() % 10 + 5 } @@ -42,13 +42,12 @@ object ScrambledImageInterceptor : Interceptor { cy += h } - val output = ByteArrayOutputStream() - result.compress(Bitmap.CompressFormat.JPEG, 90, output) - val responseBody = output.toByteArray().toResponseBody(jpegMediaType) + val responseBody = Buffer().run { + result.compress(Bitmap.CompressFormat.JPEG, 90, outputStream()) + asResponseBody("image/jpeg".toMediaType()) + } return response.newBuilder().body(responseBody).build() } - private val jpegMediaType = "image/jpeg".toMediaType() private fun Byte.toPositiveInt() = toInt() and 0xFF - const val SCRAMBLED_SUFFIX = "#scrambled" }