Fix Roumanwu (#10271)

This commit is contained in:
stevenyomi 2025-08-24 14:27:30 +00:00 committed by Draff
parent c6bad74c45
commit c514b4fc04
Signed by: Draff
GPG Key ID: E8A89F3211677653
3 changed files with 113 additions and 88 deletions

View File

@ -1,7 +1,7 @@
ext {
extName = 'Roumanwu'
extClass = '.Roumanwu'
extVersionCode = 16
extVersionCode = 17
isNsfw = true
}

View File

@ -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<SManga> {
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<UriPartFilter>().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<String>()
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<String> {
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<SChapter> {
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<Page> {
val images = document.selectFirst("script:containsData(imageUrl)")?.data()
?.let { content ->
imageUrlRegex
override fun pageListParse(response: Response): List<Page> {
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<String>) : Filter.Select<String>(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")
}
}

View File

@ -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"
}