diff --git a/src/zh/rumanhua/build.gradle b/src/zh/rumanhua/build.gradle new file mode 100644 index 000000000..06aab7590 --- /dev/null +++ b/src/zh/rumanhua/build.gradle @@ -0,0 +1,8 @@ +ext { + extName = 'Rumanhua' + extClass = '.Rumanhua' + extVersionCode = 1 + isNsfw = true +} + +apply from: "$rootDir/common.gradle" diff --git a/src/zh/rumanhua/res/mipmap-hdpi/ic_launcher.png b/src/zh/rumanhua/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..e48d1a42c Binary files /dev/null and b/src/zh/rumanhua/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/zh/rumanhua/res/mipmap-mdpi/ic_launcher.png b/src/zh/rumanhua/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..1dc85beac Binary files /dev/null and b/src/zh/rumanhua/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/zh/rumanhua/res/mipmap-xhdpi/ic_launcher.png b/src/zh/rumanhua/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..ee90280d4 Binary files /dev/null and b/src/zh/rumanhua/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/zh/rumanhua/res/mipmap-xxhdpi/ic_launcher.png b/src/zh/rumanhua/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..e82c7ea1f Binary files /dev/null and b/src/zh/rumanhua/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/zh/rumanhua/res/mipmap-xxxhdpi/ic_launcher.png b/src/zh/rumanhua/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..45d518be6 Binary files /dev/null and b/src/zh/rumanhua/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/zh/rumanhua/src/eu/kanade/tachiyomi/extension/zh/rumanhua/PageDecrypt.kt b/src/zh/rumanhua/src/eu/kanade/tachiyomi/extension/zh/rumanhua/PageDecrypt.kt new file mode 100644 index 000000000..cabfb829b --- /dev/null +++ b/src/zh/rumanhua/src/eu/kanade/tachiyomi/extension/zh/rumanhua/PageDecrypt.kt @@ -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() + 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) + + 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("|"), + ) + } +} diff --git a/src/zh/rumanhua/src/eu/kanade/tachiyomi/extension/zh/rumanhua/Rumanhua.kt b/src/zh/rumanhua/src/eu/kanade/tachiyomi/extension/zh/rumanhua/Rumanhua.kt new file mode 100644 index 000000000..e85fc7ad0 --- /dev/null +++ b/src/zh/rumanhua/src/eu/kanade/tachiyomi/extension/zh/rumanhua/Rumanhua.kt @@ -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 { + val document = response.asJsoup() + + val lis = mutableListOf() + 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() + if (moreChapter.code == "200") { + jsonInstance.decodeFromJsonElement>(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 { + val document = response.asJsoup() + + val lis = mutableListOf() + pageDecrypt.toDecrypt(document).parseAs>().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() + 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() + 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()) { + 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>, + defaultValue: Int = 0, + ) : Filter.Select(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"