diff --git a/lib/lzstring/build.gradle.kts b/lib/lzstring/build.gradle.kts new file mode 100644 index 000000000..3aa58a627 --- /dev/null +++ b/lib/lzstring/build.gradle.kts @@ -0,0 +1,12 @@ +plugins { + `java-library` + kotlin("jvm") +} + +repositories { + mavenCentral() +} + +dependencies { + compileOnly(libs.kotlin.stdlib) +} diff --git a/lib/lzstring/src/main/java/eu/kanade/tachiyomi/lib/lzstring/LZString.kt b/lib/lzstring/src/main/java/eu/kanade/tachiyomi/lib/lzstring/LZString.kt new file mode 100644 index 000000000..01474914e --- /dev/null +++ b/lib/lzstring/src/main/java/eu/kanade/tachiyomi/lib/lzstring/LZString.kt @@ -0,0 +1,294 @@ +package eu.kanade.tachiyomi.lib.lzstring + +typealias getCharFromIntFn = (it: Int) -> String +typealias getNextValueFn = (it: Int) -> Int + +/** + * Reimplementation of [lz-string](https://github.com/pieroxy/lz-string) compression/decompression. + */ +object LZString { + private fun compress( + uncompressed: String, + bitsPerChar: Int, + getCharFromInt: getCharFromIntFn, + ): String { + val context = CompressionContext(uncompressed.length, bitsPerChar, getCharFromInt) + + for (ii in uncompressed.indices) { + context.c = uncompressed[ii].toString() + + if (!context.dictionary.containsKey(context.c)) { + context.dictionary[context.c] = context.dictSize++ + context.dictionaryToCreate[context.c] = true + } + + context.wc = context.w + context.c + + if (context.dictionary.containsKey(context.wc)) { + context.w = context.wc + continue + } + + context.outputCodeForW() + + context.decrementEnlargeIn() + context.dictionary[context.wc] = context.dictSize++ + context.w = context.c + } + + if (context.w.isNotEmpty()) { + context.outputCodeForW() + context.decrementEnlargeIn() + } + + // Mark the end of the stream + context.value = 2 + for (i in 0 until context.numBits) { + context.dataVal = (context.dataVal shl 1) or (context.value and 1) + context.appendDataOrAdvancePosition() + context.value = context.value shr 1 + } + + while (true) { + context.dataVal = context.dataVal shl 1 + + if (context.dataPosition == bitsPerChar - 1) { + context.data.append(getCharFromInt(context.dataVal)) + break + } + + context.dataPosition++ + } + + return context.data.toString() + } + + private fun decompress(length: Int, resetValue: Int, getNextValue: getNextValueFn): String { + val dictionary = mutableListOf() + val result = StringBuilder() + val data = DecompressionContext(resetValue, getNextValue) + var enlargeIn = 4 + var numBits = 3 + var entry: String + var c: Char? = null + + for (i in 0 until 3) { + dictionary.add(i.toString()) + } + + data.loopUntilMaxPower() + + when (data.bits) { + 0 -> { + data.bits = 0 + data.maxPower = 1 shl 8 + data.power = 1 + data.loopUntilMaxPower() + c = data.bits.toChar() + } + 1 -> { + data.bits = 0 + data.maxPower = 1 shl 16 + data.power = 1 + data.loopUntilMaxPower() + c = data.bits.toChar() + } + 2 -> throw IllegalArgumentException("Invalid LZString") + } + + if (c == null) { + throw Exception("No character found") + } + + dictionary.add(c.toString()) + var w = c.toString() + result.append(c.toString()) + + while (true) { + if (data.index > length) { + throw IllegalArgumentException("Invalid LZString") + } + + data.bits = 0 + data.maxPower = 1 shl numBits + data.power = 1 + data.loopUntilMaxPower() + + var cc = data.bits + + when (data.bits) { + 0 -> { + data.bits = 0 + data.maxPower = 1 shl 8 + data.power = 1 + data.loopUntilMaxPower() + dictionary.add(data.bits.toChar().toString()) + cc = dictionary.size - 1 + enlargeIn-- + } + 1 -> { + data.bits = 0 + data.maxPower = 1 shl 16 + data.power = 1 + data.loopUntilMaxPower() + dictionary.add(data.bits.toChar().toString()) + cc = dictionary.size - 1 + enlargeIn-- + } + 2 -> return result.toString() + } + + if (enlargeIn == 0) { + enlargeIn = 1 shl numBits + numBits++ + } + + entry = if (cc < dictionary.size) { + dictionary[cc] + } else { + if (cc == dictionary.size) { + w + w[0] + } else { + throw Exception("Invalid LZString") + } + } + result.append(entry) + dictionary.add(w + entry[0]) + enlargeIn-- + w = entry + + if (enlargeIn == 0) { + enlargeIn = 1 shl numBits + numBits++ + } + } + } + + private const val base64KeyStr = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=" + + fun compressToBase64(input: String): String = + compress(input, 6) { base64KeyStr[it].toString() }.let { + return when (it.length % 4) { + 0 -> it + 1 -> "$it===" + 2 -> "$it==" + 3 -> "$it=" + else -> throw IllegalStateException("Modulo of 4 should not exceed 3.") + } + } + + fun decompressFromBase64(input: String): String = + decompress(input.length, 32) { + base64KeyStr.indexOf(input[it]) + } +} + +private data class DecompressionContext( + val resetValue: Int, + val getNextValue: getNextValueFn, + var value: Int = getNextValue(0), + var position: Int = resetValue, + var index: Int = 1, + var bits: Int = 0, + var maxPower: Int = 1 shl 2, + var power: Int = 1, +) { + fun loopUntilMaxPower() { + while (power != maxPower) { + val resb = value and position + + position = position shr 1 + + if (position == 0) { + position = resetValue + value = getNextValue(index++) + } + + bits = bits or ((if (resb > 0) 1 else 0) * power) + power = power shl 1 + } + } +} + +private data class CompressionContext( + val uncompressedLength: Int, + val bitsPerChar: Int, + val getCharFromInt: getCharFromIntFn, + var value: Int = 0, + val dictionary: MutableMap = HashMap(), + val dictionaryToCreate: MutableMap = HashMap(), + var c: String = "", + var wc: String = "", + var w: String = "", + var enlargeIn: Int = 2, // Compensate for the first entry which should not count + var dictSize: Int = 3, + var numBits: Int = 2, + val data: StringBuilder = StringBuilder(uncompressedLength / 3), + var dataVal: Int = 0, + var dataPosition: Int = 0, +) { + fun appendDataOrAdvancePosition() { + if (dataPosition == bitsPerChar - 1) { + dataPosition = 0 + data.append(getCharFromInt(dataVal)) + dataVal = 0 + } else { + dataPosition++ + } + } + + fun decrementEnlargeIn() { + enlargeIn-- + if (enlargeIn == 0) { + enlargeIn = 1 shl numBits + numBits++ + } + } + + // Output the code for W. + fun outputCodeForW() { + if (dictionaryToCreate.containsKey(w)) { + if (w[0].code < 256) { + for (i in 0 until numBits) { + dataVal = dataVal shl 1 + appendDataOrAdvancePosition() + } + + value = w[0].code + + for (i in 0 until 8) { + dataVal = (dataVal shl 1) or (value and 1) + appendDataOrAdvancePosition() + value = value shr 1 + } + } else { + value = 1 + + for (i in 0 until numBits) { + dataVal = (dataVal shl 1) or value + appendDataOrAdvancePosition() + value = 0 + } + + value = w[0].code + + for (i in 0 until 16) { + dataVal = (dataVal shl 1) or (value and 1) + appendDataOrAdvancePosition() + value = value shr 1 + } + } + + decrementEnlargeIn() + dictionaryToCreate.remove(w) + } else { + value = dictionary[w]!! + + for (i in 0 until numBits) { + dataVal = (dataVal shl 1) or (value and 1) + appendDataOrAdvancePosition() + value = value shr 1 + } + } + } +} diff --git a/src/en/mangafun/AndroidManifest.xml b/src/en/mangafun/AndroidManifest.xml new file mode 100644 index 000000000..5085f6751 --- /dev/null +++ b/src/en/mangafun/AndroidManifest.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + diff --git a/src/en/mangafun/build.gradle b/src/en/mangafun/build.gradle new file mode 100644 index 000000000..a2ed1f945 --- /dev/null +++ b/src/en/mangafun/build.gradle @@ -0,0 +1,13 @@ +ext { + extName = "Manga Fun" + extClass = ".MangaFun" + extVersionCode = 1 + isNsfw = true +} + +apply from: "$rootDir/common.gradle" + +dependencies { + implementation("net.pearx.kasechange:kasechange:1.4.1") + implementation(project(':lib:lzstring')) +} diff --git a/src/en/mangafun/res/mipmap-hdpi/ic_launcher.png b/src/en/mangafun/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..db3a21026 Binary files /dev/null and b/src/en/mangafun/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/en/mangafun/res/mipmap-mdpi/ic_launcher.png b/src/en/mangafun/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..5ac08223d Binary files /dev/null and b/src/en/mangafun/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/en/mangafun/res/mipmap-xhdpi/ic_launcher.png b/src/en/mangafun/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..64f1d04b5 Binary files /dev/null and b/src/en/mangafun/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/en/mangafun/res/mipmap-xxhdpi/ic_launcher.png b/src/en/mangafun/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..faa20eef2 Binary files /dev/null and b/src/en/mangafun/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/en/mangafun/res/mipmap-xxxhdpi/ic_launcher.png b/src/en/mangafun/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..ae1476139 Binary files /dev/null and b/src/en/mangafun/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/en/mangafun/src/eu/kanade/tachiyomi/extension/en/mangafun/DecompressJson.kt b/src/en/mangafun/src/eu/kanade/tachiyomi/extension/en/mangafun/DecompressJson.kt new file mode 100644 index 000000000..d1786b193 --- /dev/null +++ b/src/en/mangafun/src/eu/kanade/tachiyomi/extension/en/mangafun/DecompressJson.kt @@ -0,0 +1,134 @@ +package eu.kanade.tachiyomi.extension.en.mangafun + +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonNull +import kotlinx.serialization.json.jsonPrimitive + +/** + * A somewhat direct port of the decoding parts of + * [compress-json](https://github.com/beenotung/compress-json). + */ +object DecompressJson { + fun decompress(c: JsonArray): JsonElement { + val values = c[0].jsonArray + val key = c[1].jsonPrimitive.content + + return decode(values, key) + } + + private fun decode(values: JsonArray, key: String): JsonElement { + if (key.isEmpty() || key == "_") { + return JsonPrimitive(null) + } + + val id = sToInt(key) + val v = values[id] + + try { + v.jsonNull + return v + } catch (_: IllegalArgumentException) { + // v is not null, we continue on. + } + + val vNum = v.jsonPrimitive.intOrNull + + if (vNum != null) { + return v + } + + if (v.jsonPrimitive.isString) { + val content = v.jsonPrimitive.content + + if (content.length < 2) { + return v + } + + return when (content.substring(0..1)) { + "b|" -> decodeBool(content) + "n|" -> decodeNum(content) + "o|" -> decodeObject(values, content) + "a|" -> decodeArray(values, content) + else -> v + } + } + + throw IllegalArgumentException("Unknown data type") + } + + private fun decodeObject(values: JsonArray, s: String): JsonObject { + if (s == "o|") { + return JsonObject(emptyMap()) + } + + val vs = s.split("|") + val keyId = vs[1] + val keys = decode(values, keyId) + val n = vs.size + + val keyArray = try { + keys.jsonArray.map { it.jsonPrimitive.content } + } catch (_: IllegalArgumentException) { + // single-key object using existing value as key + listOf(keys.jsonPrimitive.content) + } + + return buildJsonObject { + for (i in 2 until n) { + val k = keyArray[i - 2] + val v = decode(values, vs[i]) + put(k, v) + } + } + } + + private fun decodeArray(values: JsonArray, s: String): JsonArray { + if (s == "a|") { + return JsonArray(emptyList()) + } + + val vs = s.split("|") + val n = vs.size - 1 + return buildJsonArray { + for (i in 0 until n) { + add(decode(values, vs[i + 1])) + } + } + } + + private fun decodeBool(s: String): JsonPrimitive { + return when (s) { + "b|T" -> JsonPrimitive(true) + "b|F" -> JsonPrimitive(false) + else -> JsonPrimitive(s.isNotEmpty()) + } + } + + private fun decodeNum(s: String): JsonPrimitive = + JsonPrimitive(sToInt(s.substringAfter("n|"))) + + private fun sToInt(s: String): Int { + var acc = 0 + var pow = 1 + + s.reversed().forEach { + acc += stoi[it]!! * pow + pow *= 62 + } + + return acc + } + + private val itos = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + + private val stoi = itos.associate { + it to itos.indexOf(it) + } +} diff --git a/src/en/mangafun/src/eu/kanade/tachiyomi/extension/en/mangafun/MangaFun.kt b/src/en/mangafun/src/eu/kanade/tachiyomi/extension/en/mangafun/MangaFun.kt new file mode 100644 index 000000000..bfe19fe49 --- /dev/null +++ b/src/en/mangafun/src/eu/kanade/tachiyomi/extension/en/mangafun/MangaFun.kt @@ -0,0 +1,296 @@ +package eu.kanade.tachiyomi.extension.en.mangafun + +import android.util.Base64 +import android.util.Log +import eu.kanade.tachiyomi.extension.en.mangafun.MangaFunUtils.toSChapter +import eu.kanade.tachiyomi.extension.en.mangafun.MangaFunUtils.toSManga +import eu.kanade.tachiyomi.lib.lzstring.LZString +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.asObservableSuccess +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 kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import okhttp3.Request +import okhttp3.Response +import rx.Observable +import uy.kohesive.injekt.injectLazy +import kotlin.math.min + +class MangaFun : HttpSource() { + + override val name = "Manga Fun" + + override val baseUrl = "https://mangafun.me" + + private val apiUrl = "https://a.mangafun.me/v0" + + override val lang = "en" + + override val supportsLatest = true + + override val client = network.cloudflareClient + + override fun headersBuilder() = super.headersBuilder() + .add("Referer", "$baseUrl/") + .add("Origin", baseUrl) + + private val json: Json by injectLazy() + + private val nextBuildId by lazy { + val document = client.newCall(GET(baseUrl, headers)).execute().asJsoup() + + json.parseToJsonElement( + document.selectFirst("#__NEXT_DATA__")!!.data(), + ) + .jsonObject["buildId"]!! + .jsonPrimitive + .content + } + + private lateinit var directory: List + + override fun fetchPopularManga(page: Int): Observable { + return if (page == 1) { + client.newCall(popularMangaRequest(page)) + .asObservableSuccess() + .map { popularMangaParse(it) } + } else { + Observable.just(parseDirectory(page)) + } + } + + override fun popularMangaRequest(page: Int) = GET("$apiUrl/title/all", headers) + + override fun popularMangaParse(response: Response): MangasPage { + directory = response.parseAs>() + .sortedBy { it.rank } + return parseDirectory(1) + } + + override fun fetchLatestUpdates(page: Int): Observable { + return if (page == 1) { + client.newCall(latestUpdatesRequest(page)) + .asObservableSuccess() + .map { latestUpdatesParse(it) } + } else { + Observable.just(parseDirectory(page)) + } + } + + override fun latestUpdatesRequest(page: Int) = popularMangaRequest(page) + + override fun latestUpdatesParse(response: Response): MangasPage { + directory = response.parseAs>() + .sortedByDescending { MangaFunUtils.convertShortTime(it.updatedAt) } + return parseDirectory(1) + } + + override fun fetchSearchManga( + page: Int, + query: String, + filters: FilterList, + ): Observable { + return if (query.startsWith(PREFIX_ID_SEARCH)) { + val slug = query.removePrefix(PREFIX_ID_SEARCH) + return fetchMangaDetails(SManga.create().apply { url = "/title/$slug" }) + .map { MangasPage(listOf(it), false) } + } else if (page == 1) { + client.newCall(searchMangaRequest(page, query, filters)) + .asObservableSuccess() + .map { searchMangaParse(it, query, filters) } + } else { + Observable.just(parseDirectory(page)) + } + } + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList) = + popularMangaRequest(page) + + override fun searchMangaParse(response: Response) = throw UnsupportedOperationException() + + private fun searchMangaParse(response: Response, query: String, filters: FilterList): MangasPage { + directory = response.parseAs>() + .filter { + it.name.contains(query, false) || + it.alias.any { a -> a.contains(query, false) } + } + + filters.ifEmpty { getFilterList() }.forEach { filter -> + when (filter) { + is GenreFilter -> { + val included = mutableListOf() + val excluded = mutableListOf() + + filter.state.forEach { g -> + when (g.state) { + Filter.TriState.STATE_INCLUDE -> included.add(g.id) + Filter.TriState.STATE_EXCLUDE -> excluded.add(g.id) + } + } + + if (included.isNotEmpty()) { + directory = directory + .filter { it.genres.any { g -> included.contains(g) } } + } + + if (excluded.isNotEmpty()) { + directory = directory + .filterNot { it.genres.any { g -> excluded.contains(g) } } + } + } + is TypeFilter -> { + val included = mutableListOf() + val excluded = mutableListOf() + + filter.state.forEach { g -> + when (g.state) { + Filter.TriState.STATE_INCLUDE -> included.add(g.id) + Filter.TriState.STATE_EXCLUDE -> excluded.add(g.id) + } + } + + if (included.isNotEmpty()) { + directory = directory + .filter { included.any { t -> it.titleType == t } } + } + + if (excluded.isNotEmpty()) { + directory = directory + .filterNot { excluded.any { t -> it.titleType == t } } + } + } + is StatusFilter -> { + val included = mutableListOf() + val excluded = mutableListOf() + + filter.state.forEach { g -> + when (g.state) { + Filter.TriState.STATE_INCLUDE -> included.add(g.id) + Filter.TriState.STATE_EXCLUDE -> excluded.add(g.id) + } + } + + if (included.isNotEmpty()) { + directory = directory + .filter { included.any { t -> it.publishedStatus == t } } + } + + if (excluded.isNotEmpty()) { + directory = directory + .filterNot { excluded.any { t -> it.publishedStatus == t } } + } + } + is SortFilter -> { + directory = when (filter.state?.index) { + 0 -> directory.sortedBy { it.name } + 1 -> directory.sortedBy { it.rank } + 2 -> directory.sortedBy { MangaFunUtils.convertShortTime(it.createdAt) } + 3 -> directory.sortedBy { MangaFunUtils.convertShortTime(it.updatedAt) } + else -> throw IllegalStateException("Unhandled sort option") + } + + if (filter.state?.ascending != true) { + directory = directory.reversed() + } + } + else -> {} + } + } + + return parseDirectory(1) + } + + override fun getMangaUrl(manga: SManga) = "$baseUrl${manga.url}" + + override fun mangaDetailsRequest(manga: SManga): Request { + val slug = manga.url.substringAfterLast("/") + val nextDataUrl = "$baseUrl/_next/data/$nextBuildId/title/$slug.json" + + return GET(nextDataUrl, headers) + } + + override fun mangaDetailsParse(response: Response): SManga { + val data = response.parseAs() + .pageProps + .dehydratedState + .queries + .first() + .state + .data + + return json.decodeFromJsonElement(data).toSManga() + } + + override fun chapterListRequest(manga: SManga) = mangaDetailsRequest(manga) + + override fun chapterListParse(response: Response): List { + val data = response.parseAs() + .pageProps + .dehydratedState + .queries + .first() + .state + .data + + val mangaData = json.decodeFromJsonElement(data) + return mangaData.chapters.map { it.toSChapter(mangaData.id, mangaData.name) }.reversed() + } + + override fun getChapterUrl(chapter: SChapter) = "$baseUrl${chapter.url}" + + override fun pageListRequest(chapter: SChapter): Request { + val chapterId = chapter.url.substringAfterLast("/").substringBefore("-") + + return GET("$apiUrl/chapter/$chapterId", headers) + } + + override fun pageListParse(response: Response): List { + val encoded = Base64.encode(response.body.bytes(), Base64.DEFAULT or Base64.NO_WRAP).toString(Charsets.UTF_8) + val decoded = LZString.decompressFromBase64(encoded) + val compressedJson = json.parseToJsonElement(decoded).jsonArray + val decompressedJson = DecompressJson.decompress(compressedJson).jsonObject + + Log.d("MangaFun", Json.encodeToString(decompressedJson)) + + return decompressedJson.jsonObject["p"]!!.jsonArray.mapIndexed { i, it -> + Page(i, imageUrl = MangaFunUtils.getImageUrlFromHash(it.jsonArray[0].jsonPrimitive.content)) + } + } + + override fun imageUrlParse(response: Response) = throw UnsupportedOperationException() + + override fun getFilterList() = FilterList( + GenreFilter(), + TypeFilter(), + StatusFilter(), + SortFilter(), + ) + + private fun parseDirectory(page: Int): MangasPage { + val endRange = min((page * 24), directory.size) + val manga = directory.subList(((page - 1) * 24), endRange).map { it.toSManga() } + val hasNextPage = endRange < directory.lastIndex + + return MangasPage(manga, hasNextPage) + } + + private inline fun Response.parseAs(): T = + json.decodeFromString(body.string()) + + companion object { + internal const val PREFIX_ID_SEARCH = "id:" + internal const val MANGAFUN_EPOCH = 1693473000 + } +} diff --git a/src/en/mangafun/src/eu/kanade/tachiyomi/extension/en/mangafun/MangaFunDto.kt b/src/en/mangafun/src/eu/kanade/tachiyomi/extension/en/mangafun/MangaFunDto.kt new file mode 100644 index 000000000..c4bcbc31e --- /dev/null +++ b/src/en/mangafun/src/eu/kanade/tachiyomi/extension/en/mangafun/MangaFunDto.kt @@ -0,0 +1,70 @@ +package eu.kanade.tachiyomi.extension.en.mangafun + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +@Serializable +data class MinifiedMangaDto( + @SerialName("i") val id: Int, + @SerialName("n") val name: String, + @SerialName("t") val thumbnailUrl: String? = null, + @SerialName("s") val publishedStatus: Int = 0, + @SerialName("tt") val titleType: Int = 0, + @SerialName("a") val alias: List = emptyList(), + @SerialName("g") val genres: List = emptyList(), + @SerialName("au") val author: List = emptyList(), + @SerialName("r") val rank: Int = 999999999, + @SerialName("ca") val createdAt: Int = 0, + @SerialName("ua") val updatedAt: Int = 0, +) + +@Serializable +data class MangaDto( + val id: Int, + val name: String, + val thumbnailURL: String? = null, + val publishedStatus: Int = 0, + val titleType: Int = 0, + val alias: List, + val description: String, + val genres: List, + val artist: List, + val author: List, + val chapters: List, +) + +@Serializable +data class ChapterDto( + val id: Int, + val name: String, + val publishedAt: String, +) + +@Serializable +data class GenreDto(val id: Int, val name: String) + +@Serializable +data class NextPagePropsWrapperDto( + val pageProps: NextPagePropsDto, +) + +@Serializable +data class NextPagePropsDto( + val dehydratedState: DehydratedStateDto, +) + +@Serializable +data class DehydratedStateDto( + val queries: List, +) + +@Serializable +data class QueriesDto( + val state: StateDto, +) + +@Serializable +data class StateDto( + val data: JsonElement, +) diff --git a/src/en/mangafun/src/eu/kanade/tachiyomi/extension/en/mangafun/MangaFunFilters.kt b/src/en/mangafun/src/eu/kanade/tachiyomi/extension/en/mangafun/MangaFunFilters.kt new file mode 100644 index 000000000..02f1c5be4 --- /dev/null +++ b/src/en/mangafun/src/eu/kanade/tachiyomi/extension/en/mangafun/MangaFunFilters.kt @@ -0,0 +1,149 @@ +package eu.kanade.tachiyomi.extension.en.mangafun + +import eu.kanade.tachiyomi.source.model.Filter + +class GenreFilter : Filter.Group("Genre", genreList) + +class TypeFilter : Filter.Group("Type", titleTypeList) + +class StatusFilter : Filter.Group( + "Status", + listOf("Ongoing", "Completed", "Hiatus", "Cancelled").mapIndexed { i, it -> Genre(it, i) }, +) + +class SortFilter : Filter.Sort( + "Order by", + arrayOf("Name", "Rank", "Newest", "Update"), + Selection(1, false), +) + +class Genre(name: String, val id: Int) : Filter.TriState(name) + +val genresMap by lazy { + genreList.associate { it.id to it.name } +} + +val titleTypeMap by lazy { + titleTypeList.associate { it.id to it.name } +} + +val titleTypeList by lazy { + listOf( + Genre("Manga", 0), + Genre("Manhwa", 1), + Genre("Manhua", 2), + Genre("Comic", 3), + Genre("Webtoon", 4), + Genre("One Shot", 6), + Genre("Doujinshi", 7), + Genre("Other", 8), + ) +} + +val genreList by lazy { + listOf( + Genre("Supernatural", 1), + Genre("Action", 2), + Genre("Comedy", 3), + Genre("Josei", 4), + Genre("Martial Arts", 5), + Genre("Romance", 6), + Genre("Ecchi", 7), + Genre("Harem", 8), + Genre("School Life", 9), + Genre("Seinen", 10), + Genre("Adventure", 11), + Genre("Fantasy", 12), + Genre("Demons", 13), + Genre("Magic", 14), + Genre("Military", 15), + Genre("Shounen", 16), + Genre("Shoujo", 17), + Genre("Psychological", 18), + Genre("Drama", 19), + Genre("Mystery", 20), + Genre("Sci-Fi", 21), + Genre("Slice of Life", 22), + Genre("Doujinshi", 23), + Genre("Police", 24), + Genre("Mecha", 25), + Genre("Yaoi", 26), + Genre("Horror", 27), + Genre("Historical", 28), + Genre("Thriller", 29), + Genre("Shounen Ai", 30), + Genre("Game", 31), + Genre("Gender Bender", 32), + Genre("Sports", 33), + Genre("Yuri", 34), + Genre("Music", 35), + Genre("Shoujo Ai", 36), + Genre("Vampires", 37), + Genre("Parody", 38), + Genre("Kids", 40), + Genre("Super Power", 41), + Genre("Space", 43), + Genre("Adult", 46), + Genre("Webtoons", 47), + Genre("Mature", 48), + Genre("Smut", 49), + Genre("Tragedy", 51), + Genre("One Shot", 53), + Genre("4-koma", 56), + Genre("Isekai", 58), + Genre("Food", 60), + Genre("Crime", 63), + Genre("Superhero", 67), + Genre("Animals", 69), + Genre("Manhwa", 74), + Genre("Manhua", 75), + Genre("Cooking", 78), + Genre("Medical", 79), + Genre("Magical Girls", 88), + Genre("Monsters", 89), + Genre("Shotacon", 90), + Genre("Philosophical", 91), + Genre("Wuxia", 92), + Genre("Adaptation", 95), + Genre("Full Color", 96), + Genre("Korean", 97), + Genre("Chinese", 98), + Genre("Reincarnation", 100), + Genre("Manga", 102), + Genre("Comic", 104), + Genre("Japanese", 105), + Genre("Time Travel", 108), + Genre("Erotica", 111), + Genre("Survival", 114), + Genre("Gore", 118), + Genre("Monster Girls", 120), + Genre("Dungeons", 123), + Genre("System", 124), + Genre("Cultivation", 125), + Genre("Murim", 128), + Genre("Suggestive", 131), + Genre("Fighting", 134), + Genre("Blood", 140), + Genre("Op-Mc", 142), + Genre("Revenge", 144), + Genre("Overpowered", 146), + Genre("Returner", 150), + Genre("Office", 152), + Genre("Loli", 163), + Genre("Video Games", 173), + Genre("Monster", 199), + Genre("Mafia", 203), + Genre("Anthology", 206), + Genre("Villainess", 207), + Genre("Aliens", 213), + Genre("Zombies", 216), + Genre("Violence", 217), + Genre("Delinquents", 219), + Genre("Post apocalyptic", 255), + Genre("Ghost", 260), + Genre("Virtual Reality", 263), + Genre("Cheat", 324), + Genre("Girls", 374), + Genre("Gender Swap", 384), + ) +} diff --git a/src/en/mangafun/src/eu/kanade/tachiyomi/extension/en/mangafun/MangaFunUrlActivity.kt b/src/en/mangafun/src/eu/kanade/tachiyomi/extension/en/mangafun/MangaFunUrlActivity.kt new file mode 100644 index 000000000..ffbdad214 --- /dev/null +++ b/src/en/mangafun/src/eu/kanade/tachiyomi/extension/en/mangafun/MangaFunUrlActivity.kt @@ -0,0 +1,33 @@ +package eu.kanade.tachiyomi.extension.en.mangafun + +import android.app.Activity +import android.content.ActivityNotFoundException +import android.content.Intent +import android.os.Bundle +import android.util.Log +import kotlin.system.exitProcess + +class MangaFunUrlActivity : Activity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val pathSegments = intent?.data?.pathSegments + if (pathSegments != null && pathSegments.size > 1) { + try { + startActivity( + Intent().apply { + action = "eu.kanade.tachiyomi.SEARCH" + putExtra("query", "${MangaFun.PREFIX_ID_SEARCH}${pathSegments[1]}") + putExtra("filter", packageName) + }, + ) + } catch (e: ActivityNotFoundException) { + Log.e("MangaFunUrlActivity", "Could not start activity", e) + } + } else { + Log.e("MangaFunUrlActivity", "Could not parse URI from intent $intent") + } + + finish() + exitProcess(0) + } +} diff --git a/src/en/mangafun/src/eu/kanade/tachiyomi/extension/en/mangafun/MangaFunUtils.kt b/src/en/mangafun/src/eu/kanade/tachiyomi/extension/en/mangafun/MangaFunUtils.kt new file mode 100644 index 000000000..5ede3d2c6 --- /dev/null +++ b/src/en/mangafun/src/eu/kanade/tachiyomi/extension/en/mangafun/MangaFunUtils.kt @@ -0,0 +1,77 @@ +package eu.kanade.tachiyomi.extension.en.mangafun + +import eu.kanade.tachiyomi.source.model.SChapter +import eu.kanade.tachiyomi.source.model.SManga +import net.pearx.kasechange.toKebabCase +import java.text.SimpleDateFormat +import java.util.Locale + +object MangaFunUtils { + private const val cdnUrl = "https://mimg.bid" + + private val notAlnumRegex = Regex("""[^0-9A-Za-z\s]""") + + private val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.ROOT) + + private fun String.slugify(): String = + this.replace(notAlnumRegex, "").toKebabCase() + + private fun publishedStatusToStatus(ps: Int) = when (ps) { + 0 -> SManga.ONGOING + 1 -> SManga.COMPLETED + 2 -> SManga.ON_HIATUS + 3 -> SManga.CANCELLED + else -> SManga.UNKNOWN + } + + fun convertShortTime(value: Int): Int { + return if (value < MangaFun.MANGAFUN_EPOCH) { + value + MangaFun.MANGAFUN_EPOCH + } else { + value + } + } + + fun getImageUrlFromHash(hash: String?): String? { + if (hash == null) { + return null + } + + return "$cdnUrl/${hash.substring(0, 2)}/${hash.substring(2, 5)}/${hash.substring(5)}.webp" + } + + fun MinifiedMangaDto.toSManga() = SManga.create().apply { + url = "/title/$id-${name.slugify()}" + title = name + author = this@toSManga.author.joinToString() + thumbnail_url = getImageUrlFromHash(thumbnailUrl) + status = publishedStatusToStatus(publishedStatus) + genre = buildList { + titleTypeMap[titleType]?.let { add(it) } + addAll(genres.mapNotNull { genresMap[it] }) + }.joinToString() + } + + fun MangaDto.toSManga() = SManga.create().apply { + url = "/title/$id-${name.slugify()}" + title = name + author = this@toSManga.author.filterNotNull().joinToString() + artist = this@toSManga.artist.filterNotNull().joinToString() + description = this@toSManga.description + genre = genres.mapNotNull { genresMap[it.id] }.joinToString() + status = publishedStatusToStatus(publishedStatus) + thumbnail_url = thumbnailURL + genre = buildList { + titleTypeMap[titleType]?.let { add(it) } + addAll(genres.mapNotNull { genresMap[it.id] }) + }.joinToString() + } + + fun ChapterDto.toSChapter(mangaId: Int, mangaName: String) = SChapter.create().apply { + url = "/title/$mangaId-${mangaName.slugify()}/$id-${this@toSChapter.name.slugify()}" + name = this@toSChapter.name + date_upload = runCatching { + dateFormat.parse(publishedAt)!!.time + }.getOrDefault(0L) + } +} diff --git a/src/zh/manhuagui/build.gradle b/src/zh/manhuagui/build.gradle index 66a65355f..292c910d0 100644 --- a/src/zh/manhuagui/build.gradle +++ b/src/zh/manhuagui/build.gradle @@ -1,7 +1,12 @@ ext { extName = 'ManHuaGui' extClass = '.Manhuagui' - extVersionCode = 19 + extVersionCode = 20 } apply from: "$rootDir/common.gradle" + +dependencies { + implementation(project(":lib:lzstring")) + implementation(project(":lib:unpacker")) +} diff --git a/src/zh/manhuagui/src/eu/kanade/tachiyomi/extension/zh/manhuagui/Manhuagui.kt b/src/zh/manhuagui/src/eu/kanade/tachiyomi/extension/zh/manhuagui/Manhuagui.kt index c9e7f15f4..ef017935b 100644 --- a/src/zh/manhuagui/src/eu/kanade/tachiyomi/extension/zh/manhuagui/Manhuagui.kt +++ b/src/zh/manhuagui/src/eu/kanade/tachiyomi/extension/zh/manhuagui/Manhuagui.kt @@ -2,7 +2,8 @@ package eu.kanade.tachiyomi.extension.zh.manhuagui import android.app.Application import android.content.SharedPreferences -import app.cash.quickjs.QuickJs +import eu.kanade.tachiyomi.lib.lzstring.LZString +import eu.kanade.tachiyomi.lib.unpacker.Unpacker import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.POST import eu.kanade.tachiyomi.network.asObservableSuccess @@ -295,19 +296,13 @@ class Manhuagui( if (hiddenEncryptedChapterList != null) { if (getShowR18()) { // Hidden chapter list is LZString encoded - val decodedHiddenChapterList = QuickJs.create().use { - it.evaluate( - jsDecodeFunc + - """LZString.decompressFromBase64('${hiddenEncryptedChapterList.`val`()}');""", - ) as String - } + val decodedHiddenChapterList = LZString.decompressFromBase64(hiddenEncryptedChapterList.`val`()) val hiddenChapterList = Jsoup.parse(decodedHiddenChapterList, response.request.url.toString()) - if (hiddenChapterList != null) { - // Replace R18 warning with actual chapter list - document.select("#erroraudit_show").first()!!.replaceWith(hiddenChapterList) - // Remove hidden chapter list element - document.select("#__VIEWSTATE").first()!!.remove() - } + + // Replace R18 warning with actual chapter list + document.select("#erroraudit_show").first()!!.replaceWith(hiddenChapterList) + // Remove hidden chapter list element + document.select("#__VIEWSTATE").first()!!.remove() } else { // "You need to enable R18 switch and restart Tachiyomi to read this manga" error("您需要打开R18作品显示开关并重启软件才能阅读此作品") @@ -372,22 +367,18 @@ class Manhuagui( return manga } - private val jsDecodeFunc = - """ - var LZString=(function(){var f=String.fromCharCode;var keyStrBase64="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";var baseReverseDic={};function getBaseValue(alphabet,character){if(!baseReverseDic[alphabet]){baseReverseDic[alphabet]={};for(var i=0;i>=1;if(data.position==0){data.position=resetValue;data.val=getNextValue(data.index++)}bits|=(resb>0?1:0)*power;power<<=1}switch(next=bits){case 0:bits=0;maxpower=Math.pow(2,8);power=1;while(power!=maxpower){resb=data.val&data.position;data.position>>=1;if(data.position==0){data.position=resetValue;data.val=getNextValue(data.index++)}bits|=(resb>0?1:0)*power;power<<=1}c=f(bits);break;case 1:bits=0;maxpower=Math.pow(2,16);power=1;while(power!=maxpower){resb=data.val&data.position;data.position>>=1;if(data.position==0){data.position=resetValue;data.val=getNextValue(data.index++)}bits|=(resb>0?1:0)*power;power<<=1}c=f(bits);break;case 2:return""}dictionary[3]=c;w=c;result.push(c);while(true){if(data.index>length){return""}bits=0;maxpower=Math.pow(2,numBits);power=1;while(power!=maxpower){resb=data.val&data.position;data.position>>=1;if(data.position==0){data.position=resetValue;data.val=getNextValue(data.index++)}bits|=(resb>0?1:0)*power;power<<=1}switch(c=bits){case 0:bits=0;maxpower=Math.pow(2,8);power=1;while(power!=maxpower){resb=data.val&data.position;data.position>>=1;if(data.position==0){data.position=resetValue;data.val=getNextValue(data.index++)}bits|=(resb>0?1:0)*power;power<<=1}dictionary[dictSize++]=f(bits);c=dictSize-1;enlargeIn--;break;case 1:bits=0;maxpower=Math.pow(2,16);power=1;while(power!=maxpower){resb=data.val&data.position;data.position>>=1;if(data.position==0){data.position=resetValue;data.val=getNextValue(data.index++)}bits|=(resb>0?1:0)*power;power<<=1}dictionary[dictSize++]=f(bits);c=dictSize-1;enlargeIn--;break;case 2:return result.join('')}if(enlargeIn==0){enlargeIn=Math.pow(2,numBits);numBits++}if(dictionary[c]){entry=dictionary[c]}else{if(c===dictSize){entry=w+w.charAt(0)}else{return null}}result.push(entry);dictionary[dictSize++]=w+entry.charAt(0);enlargeIn--;w=entry;if(enlargeIn==0){enlargeIn=Math.pow(2,numBits);numBits++}}}};return LZString})();String.prototype.splic=function(f){return LZString.decompressFromBase64(this).split(f)}; - """ - - // Page list is javascript eval encoded and LZString encoded, these website: - // http://www.oicqzone.com/tool/eval/ , https://www.w3xue.com/tools/jseval/ , - // https://www.w3cschool.cn/tools/index?name=evalencode can try to decode javascript eval encoded content, - // jsDecodeFunc's LZString.decompressFromBase64() can decode LZString. - + // Page list is inside [packed](http://dean.edwards.name/packer/) JavaScript with a special twist: + // the normal content array (`'a|b|c'.split('|')`) is replaced with LZString and base64-encoded + // version. + // // These "\" can't be remove: "\}", more info in pull request 3926. @Suppress("RegExpRedundantEscape") - private val re = Regex("""window\[".*?"\](\(.*\)\s*\{[\s\S]+\}\s*\(.*\))""") + private val packedRegex = Regex("""window\[".*?"\](\(.*\)\s*\{[\s\S]+\}\s*\(.*\))""") @Suppress("RegExpRedundantEscape") - private val re2 = Regex("""\{.*\}""") + private val blockCcArgRegex = Regex("""\{.*\}""") + + private val packedContentRegex = Regex("""['"]([0-9A-Za-z+/=]+)['"]\[['"].*?['"]]\(['"].*?['"]\)""") override fun pageListParse(document: Document): List { // R18 warning element (#erroraudit_show) is remove by web page javascript, so here the warning element @@ -398,13 +389,19 @@ class Manhuagui( } val html = document.html() - val imgCode = re.find(html)?.groups?.get(1)?.value - val imgDecode = QuickJs.create().use { - it.evaluate(jsDecodeFunc + imgCode) as String - } + val imgCode = packedRegex.find(html)!!.groupValues[1].let { + // Make the packed content normal again so :lib:unpacker can do its job + it.replace(packedContentRegex) { match -> + val lzs = match.groupValues[1] + val decoded = LZString.decompressFromBase64(lzs).replace("'", "\\'") - val imgJsonStr = re2.find(imgDecode)?.groups?.get(0)?.value - val imageJson: Comic = json.decodeFromString(imgJsonStr!!) + "'$decoded'.split('|')" + } + } + val imgDecode = Unpacker.unpack(imgCode) + + val imgJsonStr = blockCcArgRegex.find(imgDecode)!!.groupValues[0] + val imageJson: Comic = json.decodeFromString(imgJsonStr) return imageJson.files!!.mapIndexed { i, imgStr -> val imgurl = "${imageServer[0]}${imageJson.path}$imgStr?e=${imageJson.sl?.e}&m=${imageJson.sl?.m}"