Add MMLook multisrc (#9624)

* Add MMLook multisrc

* show updated time

* fix updated text

* tweak manga url logic

* Use cloudflareClient
This commit is contained in:
stevenyomi 2025-07-14 08:47:16 +00:00 committed by Draff
parent 03b8b9b4ca
commit 3993e7349b
Signed by: Draff
GPG Key ID: E8A89F3211677653
15 changed files with 327 additions and 433 deletions

View File

@ -0,0 +1,9 @@
plugins {
id("lib-multisrc")
}
baseVersionCode = 1
dependencies {
implementation(project(":lib:unpacker"))
}

View File

@ -0,0 +1,18 @@
package eu.kanade.tachiyomi.multisrc.mmlook
import eu.kanade.tachiyomi.source.model.SChapter
import kotlinx.serialization.Serializable
@Serializable
class ResponseDto(val data: List<ChapterDto>)
@Serializable
class ChapterDto(
private val chapterid: String,
private val chaptername: String,
) {
fun toSChapter(mangaId: String) = SChapter.create().apply {
url = "$mangaId/$chapterid"
name = chaptername
}
}

View File

@ -0,0 +1,44 @@
package eu.kanade.tachiyomi.multisrc.mmlook
import eu.kanade.tachiyomi.source.model.Filter
class Option(val name: String, val value: String)
open class SelectFilter(name: String, val options: Array<Option>) :
Filter.Select<String>(name, Array(options.size) { options[it].name })
class RankingFilter : SelectFilter(
"排行榜",
arrayOf(
Option("不查看", ""),
Option("精品榜", "1"),
Option("人气榜", "2"),
Option("推荐榜", "3"),
Option("黑马榜", "4"),
Option("最近更新", "5"),
Option("新漫画", "6"),
),
)
class CategoryFilter : SelectFilter(
"分类",
arrayOf(
Option("全部", ""),
Option("冒险", "1"),
Option("热血", "2"),
Option("都市", "3"),
Option("玄幻", "4"),
Option("悬疑", "5"),
Option("耽美", "6"),
Option("恋爱", "7"),
Option("生活", "8"),
Option("搞笑", "9"),
Option("穿越", "10"),
Option("修真", "11"),
Option("后宫", "12"),
Option("女主", "13"),
Option("古风", "14"),
Option("连载", "15"),
Option("完结", "16"),
),
)

View File

@ -0,0 +1,200 @@
package eu.kanade.tachiyomi.multisrc.mmlook
import eu.kanade.tachiyomi.lib.unpacker.Unpacker
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.source.model.Filter
import eu.kanade.tachiyomi.source.model.FilterList
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.HttpSource
import eu.kanade.tachiyomi.util.asJsoup
import keiyoushi.utils.parseAs
import okhttp3.FormBody
import okhttp3.Request
import okhttp3.Response
// Rumanhua legacy preference:
// const val APP_CUSTOMIZATION_URL = "APP_CUSTOMIZATION_URL"
/** 漫漫看 */
open class MMLook(
override val name: String,
override val baseUrl: String,
private val desktopUrl: String,
private val useLegacyMangaUrl: Boolean,
) : HttpSource() {
override val lang: String get() = "zh"
override val supportsLatest: Boolean get() = true
override val client = network.cloudflareClient.newBuilder()
.followRedirects(false)
.hostnameVerifier { _, _ -> true }
.build()
private fun String.certificateWorkaround() = replace("https:", "http:")
private fun SManga.formatUrl() = apply { if (useLegacyMangaUrl) url = "/$url/" }
private fun rankingRequest(id: String) = GET("$desktopUrl/rank/$id", headers)
override fun popularMangaRequest(page: Int) = rankingRequest("1")
override fun popularMangaParse(response: Response): MangasPage {
val entries = response.asJsoup().select(".likedata").map { element ->
SManga.create().apply {
url = element.select("a").attr("href").mustRemoveSurrounding("/", "/")
title = element.selectFirst(".le-t")!!.text()
author = element.selectFirst(".likeinfo > p")!!.text()
.mustRemoveSurrounding("作者:", "")
description = element.selectFirst(".le-j")!!.text()
thumbnail_url = element.selectFirst("img")!!.attr("data-src")
}.formatUrl()
}
return MangasPage(entries, false)
}
override fun latestUpdatesRequest(page: Int) = rankingRequest("5")
override fun latestUpdatesParse(response: Response) = popularMangaParse(response)
override fun getFilterList() = FilterList(
RankingFilter(),
Filter.Separator(),
Filter.Header("分类(搜索文本、查看排行榜时无效)"),
CategoryFilter(),
)
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
if (query.isNotBlank()) {
return POST(
"$desktopUrl/s",
headers,
FormBody.Builder().add("k", query.take(12)).build(),
)
}
for (filter in filters) {
when (filter) {
is RankingFilter -> if (filter.state > 0) {
return rankingRequest(filter.options[filter.state].value)
}
is CategoryFilter -> if (filter.state > 0) {
val id = filter.options[filter.state].value
return GET("$desktopUrl/sort/$id", headers)
}
else -> {}
}
}
return popularMangaRequest(page)
}
override fun searchMangaParse(response: Response): MangasPage {
if (response.request.method == "GET") return popularMangaParse(response)
val entries = response.asJsoup().select(".col-auto").map { element ->
SManga.create().apply {
url = element.selectFirst("a")!!.attr("href").mustRemoveSurrounding("/", "/")
title = element.selectFirst(".e-title")!!.text()
author = element.selectFirst(".tip")!!.text()
thumbnail_url = element.selectFirst("img")!!.attr("data-src")
}.formatUrl()
}
return MangasPage(entries, false)
}
override fun getMangaUrl(manga: SManga): String {
val id = manga.url.removeSurrounding("/")
return "$baseUrl/$id/".certificateWorkaround()
}
// Desktop page has consistent template and more initial chapters
override fun mangaDetailsRequest(manga: SManga): Request {
val id = manga.url.removeSurrounding("/")
return GET("$desktopUrl/$id/", headers)
}
override fun mangaDetailsParse(response: Response) = SManga.create().apply {
val comicInfo = response.asJsoup().selectFirst(".comicInfo")!!
thumbnail_url = comicInfo.selectFirst("img")!!.attr("data-src")
val container = comicInfo.selectFirst(".detinfo")!!
title = container.selectFirst("h1")!!.text()
var updated = ""
for (span in container.select("span")) {
val text = span.ownText()
val value = text.substring(4).trimStart()
when (val key = text.substring(0, 4)) {
"作 者:" -> author = value
"更新时间" -> updated = "$text\n\n"
"标 签:" -> genre = value.replace(" ", ", ")
"状 态:" -> status = when (value) {
"连载中" -> SManga.ONGOING
"已完结" -> SManga.COMPLETED
else -> SManga.UNKNOWN
}
else -> throw Exception("Unknown field: $key")
}
}
description = updated + container.selectFirst(".content")!!.text()
}
// Desktop page contains more initial chapters
// "more chapter" request must be sent to the same domain
override fun chapterListRequest(manga: SManga) = mangaDetailsRequest(manga)
override fun chapterListParse(response: Response): List<SChapter> {
val container = response.asJsoup().selectFirst(".chapterlistload")!!
val chapters = container.child(0).children().mapTo(ArrayList()) { element ->
SChapter.create().apply {
url = element.attr("href").mustRemoveSurrounding("/", ".html")
name = element.text()
}
}
if (container.selectFirst(".chaplist-more") != null) {
val mangaId = response.request.url.pathSegments[0]
val request = POST(
"$desktopUrl/morechapter",
headers,
FormBody.Builder().addEncoded("id", mangaId).build(),
)
client.newCall(request).execute().parseAs<ResponseDto>().data
.mapTo(chapters) { it.toSChapter(mangaId) }
}
return chapters
}
private fun SChapter.fullUrl(): String {
val url = this.url
if (url.startsWith('/')) throw Exception("请刷新章节列表")
return "$baseUrl/$url.html"
}
override fun getChapterUrl(chapter: SChapter) = chapter.fullUrl().certificateWorkaround()
override fun pageListRequest(chapter: SChapter): Request = GET(chapter.fullUrl(), headers)
override fun pageListParse(response: Response): List<Page> {
val document = response.asJsoup()
val id = document.selectFirst(".readerContainer")!!.attr("data-id").toInt()
return document.selectFirst("script:containsData(eval)")!!.data()
.let(Unpacker::unpack)
.mustRemoveSurrounding("var __c0rst96=\"", "\"")
.let { decrypt(it, id) }
.parseAs<List<String>>()
.mapIndexed { i, imageUrl -> Page(i, imageUrl = imageUrl) }
}
override fun imageUrlParse(response: Response) = throw UnsupportedOperationException()
}
private fun String.mustRemoveSurrounding(prefix: String, suffix: String): String {
check(startsWith(prefix) && endsWith(suffix)) { "string doesn't match $prefix[...]$suffix" }
return substring(prefix.length, length - suffix.length)
}

View File

@ -0,0 +1,27 @@
package eu.kanade.tachiyomi.multisrc.mmlook
import android.util.Base64
import kotlin.experimental.xor
// all2.js?v=2.3
fun decrypt(data: String, index: Int): String {
val key = when (index) {
0 -> "smkhy258"
1 -> "smkd95fv"
2 -> "md496952"
3 -> "cdcsdwq"
4 -> "vbfsa256"
5 -> "cawf151c"
6 -> "cd56cvda"
7 -> "8kihnt9"
8 -> "dso15tlo"
9 -> "5ko6plhy"
else -> throw Exception("Unknown index: $index")
}.encodeToByteArray()
val keyLength = key.size
val bytes = Base64.decode(data, Base64.DEFAULT)
for (i in bytes.indices) {
bytes[i] = bytes[i] xor key[i % keyLength]
}
return String(Base64.decode(bytes, Base64.DEFAULT))
}

View File

@ -0,0 +1,9 @@
ext {
extName = 'Dumanwu'
extClass = '.Dumanwu'
themePkg = 'mmlook'
baseUrl = 'https://m.dumanwu1.com'
overrideVersionCode = 0
}
apply from: "$rootDir/common.gradle"

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -0,0 +1,10 @@
package eu.kanade.tachiyomi.extension.zh.dumanwu
import eu.kanade.tachiyomi.multisrc.mmlook.MMLook
class Dumanwu : MMLook(
"读漫屋",
"https://m.dumanwu1.com",
"https://www.dumanwu1.com",
useLegacyMangaUrl = false,
)

View File

@ -1,8 +1,9 @@
ext {
extName = 'Rumanhua'
extClass = '.Rumanhua'
extVersionCode = 2
isNsfw = true
themePkg = 'mmlook'
baseUrl = 'https://m.rumanhua1.com'
overrideVersionCode = 2
}
apply from: "$rootDir/common.gradle"

View File

@ -1,111 +0,0 @@
package eu.kanade.tachiyomi.extension.zh.rumanhua
import android.util.Base64
import org.jsoup.nodes.Document
// all2.js?v=2.3
class PageDecrypt {
private val SCRIPT_PATTERN = "eval(function(p,a,c,k,e,d)"
private val CONTENT_MARKER = "var __c0rst96=\""
fun toDecrypt(document: Document): String {
document.head().select("script").forEach { script ->
val scriptStr = script.data().trim()
if (scriptStr.startsWith(SCRIPT_PATTERN)) {
val obf = obfuscate(extractPackerParams(scriptStr)).substringAfter(CONTENT_MARKER)
.trimEnd('"')
val selectedIndex =
document.selectFirst("div.readerContainer")?.attr("data-id")?.toIntOrNull()
?: throw IllegalArgumentException("Invalid container index")
return decryptToString(obf, selectedIndex)
}
}
throw IllegalArgumentException("No valid script found for decryption")
}
private fun decryptToString(encodedContent: String, selectedIndex: Int): String {
val boundedIndex = selectedIndex.coerceIn(0, 9)
val xorKeyEncoded = getEncodedKeyForIndex(boundedIndex)
val xorKey = base64Decode(xorKeyEncoded)
val encryptedContent = base64Decode(encodedContent)
val keyLength = xorKey.length
val xoredString = StringBuilder(encryptedContent.length)
for (i in encryptedContent.indices) {
val k = i % keyLength
val xoredChar = encryptedContent[i].code xor xorKey[k].code
xoredString.append(xoredChar.toChar())
}
return base64Decode(xoredString.toString())
}
private fun getEncodedKeyForIndex(index: Int): String {
return when (index) {
0 -> "c21raHkyNTg="
1 -> "c21rZDk1ZnY="
2 -> "bWQ0OTY5NTI="
3 -> "Y2Rjc2R3cQ=="
4 -> "dmJmc2EyNTY="
5 -> "Y2F3ZjE1MWM="
6 -> "Y2Q1NmN2ZGE="
7 -> "OGtpaG50OQ=="
8 -> "ZHNvMTV0bG8="
9 -> "NWtvNnBsaHk="
else -> ""
}
}
private fun base64Decode(input: String): String {
if (input.isEmpty()) return input
return String(Base64.decode(input, Base64.DEFAULT))
}
private fun obfuscate(pl: PackerPayload): String {
fun eFunction(c: Int): String {
return if (c < pl.a) {
if (c > 35) {
(c + 29).toChar().toString()
} else {
c.toString(36)
}
} else {
eFunction(c / pl.a) + if (c % pl.a > 35) {
(c % pl.a + 29).toChar().toString()
} else {
(c % pl.a).toString(36)
}
}
}
val d = mutableMapOf<String, String>()
var tempC = pl.c
while (tempC-- > 0) {
d[eFunction(tempC)] = pl.k.getOrElse(tempC) { eFunction(tempC) }
}
var result = pl.p
tempC = pl.c
while (tempC-- > 0) {
val key = eFunction(tempC)
val replacement = d[key] ?: ""
result = result.replace(Regex("\\b${Regex.escape(key)}\\b"), replacement)
}
return result
}
private class PackerPayload(val p: String, val a: Int, val c: Int, val k: List<String>)
private fun extractPackerParams(source: String): PackerPayload {
val args = source.substringAfter("}(").substringBefore(".split('|')").split(",")
return PackerPayload(
args[0].trim('\''),
args[1].toInt(),
args[2].toInt(),
args[3].trim('\'').split("|"),
)
}
}

View File

@ -1,323 +1,10 @@
package eu.kanade.tachiyomi.extension.zh.rumanhua
import android.content.SharedPreferences
import androidx.preference.EditTextPreference
import androidx.preference.PreferenceScreen
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.source.ConfigurableSource
import eu.kanade.tachiyomi.source.model.Filter
import eu.kanade.tachiyomi.source.model.FilterList
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.HttpSource
import eu.kanade.tachiyomi.util.asJsoup
import keiyoushi.utils.getPreferences
import keiyoushi.utils.jsonInstance
import keiyoushi.utils.parseAs
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.decodeFromJsonElement
import okhttp3.FormBody
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okio.IOException
import eu.kanade.tachiyomi.multisrc.mmlook.MMLook
class Rumanhua : HttpSource(), ConfigurableSource {
override val lang: String = "zh"
override val name: String = "如漫画"
override val supportsLatest = true
private val preferences: SharedPreferences = getPreferences()
override val baseUrl: String = getTargetUrl()
override val client: OkHttpClient = network.cloudflareClient
private fun getTargetUrl(): String {
val defaultUrl = "http://www.rumanhua1.com"
val url = preferences.getString(APP_CUSTOMIZATION_URL, defaultUrl)!!
if (url.isNotBlank()) {
return url
}
return defaultUrl
}
// Chapters
override fun chapterListParse(response: Response): List<SChapter> {
val document = response.asJsoup()
val lis = mutableListOf<SChapter>()
document.select("div.forminfo > div.chapterList > div.chapterlistload > ul > a")
.forEach { element ->
lis.add(
SChapter.create().apply {
name = element.text()
setUrlWithoutDomain(element.absUrl("href"))
},
)
}
// get more chapter ...
val bid = response.request.url.pathSegments[0]
val body = FormBody.Builder().add("id", bid).build()
val moreRequest = POST("$baseUrl/morechapter", headers, body)
val moreResponse = client.newCall(moreRequest).execute()
if (!moreResponse.isSuccessful) {
throw IOException("Request failed: ${moreRequest.url}")
}
val moreChapter = moreResponse.parseAs<MoreChapter>()
if (moreChapter.code == "200") {
jsonInstance.decodeFromJsonElement<List<MoreChapterInfo>>(moreChapter.data).forEach {
lis.add(
SChapter.create().apply {
name = it.chaptername
url = "/$bid/${it.chapterid}.html"
},
)
}
}
return lis
}
@Serializable
private class MoreChapter(
val code: String,
val msg: String,
val data: JsonElement,
)
@Serializable
private class MoreChapterInfo(
val chapterid: String,
val chaptername: String,
)
override fun imageUrlParse(response: Response): String = throw UnsupportedOperationException()
// Latest
override fun latestUpdatesParse(response: Response): MangasPage = popularMangaParse(response)
override fun latestUpdatesRequest(page: Int): Request = GET("$baseUrl/rank/5", headers)
// Details
override fun mangaDetailsParse(response: Response): SManga {
val document = response.asJsoup()
val info = document.selectFirst("div.forminfo > div.comicInfo")!!
return SManga.create().apply {
info.select("div.detinfo > p").forEach { element ->
when (element.attr("class")) {
"gray" -> {
element.select("span").forEach { span ->
val spanText = span.text()
val dgenre =
removePrefixAndCheck(spanText, "标 签:")?.replace(" ", ", ")
if (dgenre != null) {
genre = dgenre
} else {
status = when (removePrefixAndCheck(spanText, "状 态:")) {
"连载中" -> SManga.ONGOING
"已完结" -> SManga.COMPLETED
else -> SManga.UNKNOWN
}
}
}
}
"content" -> {
description = element.text()
}
else -> {
element.select("span").forEach { span ->
val dauthor = removePrefixAndCheck(span.text(), "作 者:")
if (dauthor != null) {
author = dauthor
}
}
}
}
}
title = info.selectFirst("div.detinfo > h1")!!.text()
thumbnail_url = document.selectFirst("div.mhcover > div.himg > img")?.absUrl("data-src")
}
}
private fun removePrefixAndCheck(input: String, prefix: String): String? {
if (input.isEmpty() || prefix.isEmpty()) {
return null
}
if (input.startsWith(prefix)) {
return input.substring(prefix.length).trim()
}
return null
}
// Pages
private val pageDecrypt = PageDecrypt()
override fun pageListParse(response: Response): List<Page> {
val document = response.asJsoup()
val lis = mutableListOf<Page>()
pageDecrypt.toDecrypt(document).parseAs<List<String>>().forEachIndexed { index, img ->
lis.add(Page(index, imageUrl = img))
}
return lis
}
// Popular
override fun popularMangaParse(response: Response): MangasPage {
val document = response.asJsoup()
val lis = mutableListOf<SManga>()
document.select("div.float-r.rs-p > div.wholike > div.likedata").forEach { element ->
lis.add(
SManga.create().apply {
title = element.selectFirst("div.likeinfo > a")!!.text()
setUrlWithoutDomain(element.selectFirst("a")!!.absUrl("href"))
thumbnail_url = element.selectFirst("img")?.absUrl("data-src")
},
)
}
return MangasPage(lis, false)
}
override fun popularMangaRequest(page: Int): Request = GET("$baseUrl/rank/1", headers)
// Search
override fun searchMangaParse(response: Response): MangasPage {
if (response.request.url.encodedPath != "/s") {
return popularMangaParse(response)
}
val document = response.asJsoup()
val lis = mutableListOf<SManga>()
document.select("div.item-data.s-data > div.col-auto").forEach { element ->
lis.add(
SManga.create().apply {
title = element.selectFirst("a > p.e-title")!!.text()
setUrlWithoutDomain(element.selectFirst("a")!!.absUrl("href"))
thumbnail_url =
element.selectFirst("a > div.edit-top > img")?.absUrl("data-src")
},
)
}
return MangasPage(lis, false)
}
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
val urlBuilder = baseUrl.toHttpUrl().newBuilder()
if (query != "" && !query.contains("-")) {
val body = FormBody.Builder().add("k", query.take(12)).build()
return POST(
urlBuilder.encodedPath("/s").build().toString(),
headers,
body,
)
} else {
// RankGroup or CategoryGroup take one and reset the other
var url: String? = null
for (filter in filters.filterIsInstance<UriPartFilter>()) {
if (url != null) {
filter.reset()
} else {
val path = filter.toUriPart()
if (path != "") {
url = path
}
}
}
if (url != null) {
return GET(urlBuilder.encodedPath(url).build().toString(), headers)
}
throw IOException("Invalid filter types")
}
}
// Filter
override fun getFilterList() = FilterList(
RankGroup(),
CategoryGroup(),
)
private class RankGroup : UriPartFilter(
"排行榜",
arrayOf(
Pair("None", ""),
Pair("精品榜", "/rank/1"),
Pair("人气榜", "/rank/2"),
Pair("推荐榜", "/rank/3"),
Pair("黑马榜", "/rank/4"),
Pair("最近更新", "/rank/5"),
Pair("新漫画", "/rank/6"),
),
)
private class CategoryGroup : UriPartFilter(
"按类型",
arrayOf(
Pair("None", ""),
Pair("冒险", "/sort/1"),
Pair("热血", "/sort/2"),
Pair("都市", "/sort/3"),
Pair("玄幻", "/sort/4"),
Pair("悬疑", "/sort/5"),
Pair("耽美", "/sort/6"),
Pair("恋爱", "/sort/7"),
Pair("生活", "/sort/8"),
Pair("搞笑", "/sort/9"),
Pair("穿越", "/sort/10"),
Pair("修真", "/sort/11"),
Pair("后宫", "/sort/12"),
Pair("女主", "/sort/13"),
Pair("古风", "/sort/14"),
Pair("连载", "/sort/15"),
Pair("完结", "/sort/16"),
),
)
private open class UriPartFilter(
displayName: String,
val vals: Array<Pair<String, String>>,
defaultValue: Int = 0,
) : Filter.Select<String>(displayName, vals.map { it.first }.toTypedArray(), defaultValue) {
open fun toUriPart() = vals[state].second
open fun reset() {
state = 0
}
}
override fun setupPreferenceScreen(screen: PreferenceScreen) {
EditTextPreference(screen.context).apply {
key = APP_CUSTOMIZATION_URL
title = "自定义url"
summary = "修改后需要重启应用生效"
setOnPreferenceChangeListener { _, newValue ->
preferences.edit().putString(APP_CUSTOMIZATION_URL, newValue as String).commit()
}
}.let(screen::addPreference)
}
}
const val APP_CUSTOMIZATION_URL = "APP_CUSTOMIZATION_URL"
class Rumanhua : MMLook(
"如漫画",
"https://m.rumanhua1.com",
"https://www.rumanhua1.com",
useLegacyMangaUrl = true,
)