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