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 { ext {
extName = 'Roumanwu' extName = 'Roumanwu'
extClass = '.Roumanwu' extClass = '.Roumanwu'
extVersionCode = 16 extVersionCode = 17
isNsfw = true isNsfw = true
} }

View File

@ -1,6 +1,7 @@
package eu.kanade.tachiyomi.extension.zh.roumanwu package eu.kanade.tachiyomi.extension.zh.roumanwu
import android.content.SharedPreferences import android.content.SharedPreferences
import androidx.preference.ListPreference
import androidx.preference.PreferenceScreen import androidx.preference.PreferenceScreen
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.source.ConfigurableSource 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.Page
import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga 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 eu.kanade.tachiyomi.util.asJsoup
import keiyoushi.utils.getPreferences import keiyoushi.utils.getPreferences
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Response import okhttp3.Response
import org.jsoup.nodes.Document import org.jsoup.nodes.Document
import org.jsoup.nodes.Element import org.jsoup.nodes.Element
import java.text.ParsePosition
import java.text.SimpleDateFormat
import java.util.Locale
import kotlin.math.max import kotlin.math.max
class Roumanwu : ParsedHttpSource(), ConfigurableSource { class Roumanwu : HttpSource(), ConfigurableSource {
override val name = "肉漫屋" override val name = "肉漫屋"
override val lang = "zh" override val lang = "zh"
override val supportsLatest = true override val supportsLatest = true
@ -29,40 +34,42 @@ class Roumanwu : ParsedHttpSource(), ConfigurableSource {
max(MIRRORS.size - 1, preferences.getString(MIRROR_PREF, MIRROR_DEFAULT)!!.toInt()), max(MIRRORS.size - 1, preferences.getString(MIRROR_PREF, MIRROR_DEFAULT)!!.toInt()),
] ]
override val client = network.cloudflareClient.newBuilder().addInterceptor(ScrambledImageInterceptor).build() override val client = network.cloudflareClient.newBuilder().addInterceptor(ScrambledImageInterceptor()).build()
private val imageUrlRegex = """\\"imageUrl\\":\\"([^\\]+)""".toRegex()
override fun popularMangaRequest(page: Int) = GET("$baseUrl/home", headers) 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/]" private fun parseEntries(container: Element): List<SManga> {
override fun popularMangaFromElement(element: Element): SManga = SManga.create().apply { return container.select("a[href*=/books/]").map {
title = element.select("div.truncate").text() SManga.create().apply {
url = element.attr("href") title = it.selectFirst("div.truncate")!!.text()
thumbnail_url = element.select("div.bg-cover").attr("style").substringAfter("background-image:url(\"").substringBefore("\")") url = it.attr("href")
thumbnail_url = it.selectFirst("div.bg-cover")!!.attr("style").substringAfter("background-image:url(\"").substringBefore("\")")
}
}
} }
override fun popularMangaParse(response: Response): MangasPage { override fun popularMangaParse(response: Response): MangasPage {
val document = response.asJsoup() val document = response.asJsoup()
return parseHomePage(document, Regex("正熱門|今日最佳|本週熱門"))
}
val mangas = document.select(popularMangaSelector()).map { element -> private fun parseHomePage(document: Document, sections: Regex): MangasPage {
popularMangaFromElement(element) val entries = document.selectFirst("div.px-1")!!.children().flatMap { section ->
} if (section.child(0).text().contains(sections)) {
val uniqueMangas = mangas.distinctBy { it.url } parseEntries(section)
} else {
emptyList()
}
}.distinctBy { it.url }
val hasNextPage = popularMangaNextPageSelector()?.let { selector -> return MangasPage(entries, false)
document.select(selector).first()
} != null
return MangasPage(uniqueMangas, hasNextPage)
} }
override fun latestUpdatesRequest(page: Int) = popularMangaRequest(page) 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 latestUpdatesParse(response: Response): MangasPage {
override fun latestUpdatesFromElement(element: Element): SManga = SManga.create().apply { val document = response.asJsoup()
title = element.select("div.truncate").text() return parseHomePage(document, Regex("最近更新"))
url = element.attr("href")
thumbnail_url = element.select("div.bg-cover").attr("style").substringAfter("background-image:url(\"").substringBefore("\")")
} }
override fun searchMangaRequest(page: Int, query: String, filters: FilterList) = 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() } val parts = filters.filterIsInstance<UriPartFilter>().joinToString("") { it.toUriPart() }
GET("$baseUrl/books?page=${page - 1}$parts", headers) GET("$baseUrl/books?page=${page - 1}$parts", headers)
} }
override fun searchMangaNextPageSelector(): String? = null
override fun searchMangaSelector(): String = "a[href*=/books/]" override fun searchMangaParse(response: Response): MangasPage {
override fun searchMangaFromElement(element: Element): SManga = SManga.create().apply { val document = response.asJsoup()
title = element.select("div.truncate").text() val entries = parseEntries(document)
url = element.attr("href") val thisPage = response.request.url.queryParameter("page")!!
thumbnail_url = element.select("div.bg-cover").attr("style").substringAfter("background-image:url(\"").substringBefore("\")") 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 { override fun mangaDetailsParse(response: Response): SManga = SManga.create().apply {
title = document.select("div.basis-3\\/5 > div.text-xl").text() val document = response.asJsoup()
thumbnail_url = baseUrl + document.select("main > div:first-child img").attr("src") val infobox = parseInfobox(document).iterator()
author = document.select("div.basis-3\\/5 > div:nth-child(3) span").text()
artist = author title = infobox.next()
status = when (document.select("div.basis-3\\/5 > div:nth-child(4) span").text()) { thumbnail_url = document.selectFirst("div.basis-2\\/5 img")!!.absUrl("src")
"連載中" -> SManga.ONGOING .run { toHttpUrl().queryParameter("url") ?: this }
"已完結" -> SManga.COMPLETED description = document.selectFirst("p:contains(簡介:)")!!.text().substring(3)
else -> SManga.UNKNOWN
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(",", ", ") genre = genres.joinToString()
description = document.select("p:contains(簡介:)").text().substring(3)
} }
override fun chapterListSelector(): String = "a[href~=/books/.*/\\d+]" private fun parseInfobox(document: Document): List<String> {
override fun chapterFromElement(element: Element): SChapter = SChapter.create().apply { val infobox = document.selectFirst("div.basis-3\\/5")!!.children()
url = element.attr("href") check(infobox.size >= 6 && infobox[0].hasClass("text-xl"))
name = element.text() return infobox.map { it.text() }
} }
override fun chapterListParse(response: Response): List<SChapter> { 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> { override fun pageListParse(response: Response): List<Page> {
val images = document.selectFirst("script:containsData(imageUrl)")?.data() val images = response.asJsoup().selectFirst("script:containsData(imageUrl)")!!.data()
?.let { content -> .let { content ->
imageUrlRegex """\\"imageUrl\\":\\"([^\\]+)""".toRegex()
.findAll(content).map { it.groups[1]?.value } .findAll(content).map { it.groups[1]?.value }
.toList() .toList()
} ?: return emptyList() }
return images.mapIndexed { index, imageUrl -> return images.mapIndexed { index, imageUrl ->
Page(index, imageUrl = imageUrl) Page(index, imageUrl = imageUrl)
} }
} }
override fun imageUrlParse(document: Document) = throw UnsupportedOperationException() override fun imageUrlParse(response: Response) = throw UnsupportedOperationException()
override fun getFilterList() = FilterList( override fun getFilterList() = FilterList(
Filter.Header("提示:搜尋時篩選無效"), Filter.Header("提示:搜尋時篩選無效"),
TagFilter(),
StatusFilter(), StatusFilter(),
SortFilter(),
) )
private abstract class UriPartFilter(name: String, values: Array<String>) : Filter.Select<String>(name, values) { private abstract class UriPartFilter(name: String, values: Array<String>) : Filter.Select<String>(name, values) {
abstract fun toUriPart(): String abstract fun toUriPart(): String
} }
private class TagFilter : UriPartFilter("標籤", TAGS) {
override fun toUriPart() = if (state == 0) "" else "&tag=${values[state]}"
}
private class StatusFilter : UriPartFilter("狀態", arrayOf("全部", "連載中", "已完結")) { private class StatusFilter : UriPartFilter("狀態", arrayOf("全部", "連載中", "已完結")) {
override fun toUriPart() = override fun toUriPart() =
when (state) { 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) { override fun setupPreferenceScreen(screen: PreferenceScreen) {
val mirrorPref = androidx.preference.ListPreference(screen.context).apply { val mirrorPref = ListPreference(screen.context).apply {
key = MIRROR_PREF key = MIRROR_PREF
title = MIRROR_PREF_TITLE title = "使用鏡像網址"
entries = MIRRORS_DESC entries = MIRRORS_DESC
entryValues = MIRRORS.indices.map(Int::toString).toTypedArray() entryValues = Array(MIRRORS.size, Int::toString)
summary = MIRROR_PREF_SUMMARY summary = "使用鏡像網址。重啟軟體生效。"
setDefaultValue(MIRROR_DEFAULT) setDefaultValue(MIRROR_DEFAULT)
} }
@ -161,14 +191,10 @@ class Roumanwu : ParsedHttpSource(), ConfigurableSource {
companion object { companion object {
private const val MIRROR_PREF = "MIRROR" private const val MIRROR_PREF = "MIRROR"
private const val MIRROR_PREF_TITLE = "使用鏡像網址"
private const val MIRROR_PREF_SUMMARY = "使用鏡像網址。重啟軟體生效。"
// 地址: https://rou.pub/dizhi // 地址: https://rou.pub/dizhi or https://rdz1.xyz/dizhi
private val MIRRORS get() = arrayOf("https://rouman5.com", "https://roum20.xyz") private val MIRRORS get() = arrayOf("https://rouman5.com", "https://roum22.xyz")
private val MIRRORS_DESC get() = arrayOf("主站", "鏡像") private val MIRRORS_DESC get() = arrayOf("主站", "鏡像")
private const val MIRROR_DEFAULT = 1.toString() // use mirror 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.Interceptor
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Response import okhttp3.Response
import okhttp3.ResponseBody.Companion.toResponseBody import okhttp3.ResponseBody.Companion.asResponseBody
import java.io.ByteArrayOutputStream import okio.Buffer
import java.security.MessageDigest import java.security.MessageDigest
object ScrambledImageInterceptor : Interceptor { class ScrambledImageInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response { override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request() val request = chain.request()
val response = chain.proceed(request) val response = chain.proceed(request)
val url = request.url.toString() val url = request.url
if ("sr:1" !in url) return response if ("sr:1" !in url.pathSegments) return response
val image = BitmapFactory.decodeStream(response.body.byteStream()) val image = response.body.use { BitmapFactory.decodeStream(it.byteStream()) }
val width = image.width val width = image.width
val height = image.height val height = image.height
val result = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) val result = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(result) 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. // 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 { Base64.decode(it, Base64.DEFAULT) }
.let { MessageDigest.getInstance("MD5").digest(it) } // thread-safe .let { MessageDigest.getInstance("MD5").digest(it) } // thread-safe
.let { it.last().toPositiveInt() % 10 + 5 } .let { it.last().toPositiveInt() % 10 + 5 }
@ -42,13 +42,12 @@ object ScrambledImageInterceptor : Interceptor {
cy += h cy += h
} }
val output = ByteArrayOutputStream() val responseBody = Buffer().run {
result.compress(Bitmap.CompressFormat.JPEG, 90, output) result.compress(Bitmap.CompressFormat.JPEG, 90, outputStream())
val responseBody = output.toByteArray().toResponseBody(jpegMediaType) asResponseBody("image/jpeg".toMediaType())
}
return response.newBuilder().body(responseBody).build() return response.newBuilder().body(responseBody).build()
} }
private val jpegMediaType = "image/jpeg".toMediaType()
private fun Byte.toPositiveInt() = toInt() and 0xFF private fun Byte.toPositiveInt() = toInt() and 0xFF
const val SCRAMBLED_SUFFIX = "#scrambled"
} }