New: Added support for rumanhua source (#9128)
* New: Added support for rumanhua source * New: Added support for rumanhua source * New: Added support for rumanhua source * New: Added support for rumanhua source
This commit is contained in:
parent
ef9d26cfe8
commit
48f590df0c
8
src/zh/rumanhua/build.gradle
Normal file
8
src/zh/rumanhua/build.gradle
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
ext {
|
||||||
|
extName = 'Rumanhua'
|
||||||
|
extClass = '.Rumanhua'
|
||||||
|
extVersionCode = 1
|
||||||
|
isNsfw = true
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: "$rootDir/common.gradle"
|
BIN
src/zh/rumanhua/res/mipmap-hdpi/ic_launcher.png
Normal file
BIN
src/zh/rumanhua/res/mipmap-hdpi/ic_launcher.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 6.8 KiB |
BIN
src/zh/rumanhua/res/mipmap-mdpi/ic_launcher.png
Normal file
BIN
src/zh/rumanhua/res/mipmap-mdpi/ic_launcher.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.3 KiB |
BIN
src/zh/rumanhua/res/mipmap-xhdpi/ic_launcher.png
Normal file
BIN
src/zh/rumanhua/res/mipmap-xhdpi/ic_launcher.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 11 KiB |
BIN
src/zh/rumanhua/res/mipmap-xxhdpi/ic_launcher.png
Normal file
BIN
src/zh/rumanhua/res/mipmap-xxhdpi/ic_launcher.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 24 KiB |
BIN
src/zh/rumanhua/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
BIN
src/zh/rumanhua/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 40 KiB |
@ -0,0 +1,111 @@
|
|||||||
|
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("|"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,323 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
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).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"
|
Loading…
x
Reference in New Issue
Block a user