Add MangaFun + LZString library (#1057)
* Add MangaFun + LZString library * Mark as NSFW * Reverse using :lib:lzstring on Manhuagui * Add ending newline * Replace QuickJS in Manhuagui with LZString + Unpacker * Bump ManhuaGui version * remove unncessary .lets * optimize icons * Apply suggestion
This commit is contained in:
parent
b0b32918e1
commit
23e385128e
|
@ -0,0 +1,12 @@
|
||||||
|
plugins {
|
||||||
|
`java-library`
|
||||||
|
kotlin("jvm")
|
||||||
|
}
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
compileOnly(libs.kotlin.stdlib)
|
||||||
|
}
|
|
@ -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<String>()
|
||||||
|
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<String, Int> = HashMap(),
|
||||||
|
val dictionaryToCreate: MutableMap<String, Boolean> = 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,21 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<application>
|
||||||
|
<activity android:name=".en.mangafun.MangaFunUrlActivity"
|
||||||
|
android:excludeFromRecents="true"
|
||||||
|
android:exported="true"
|
||||||
|
android:theme="@android:style/Theme.NoDisplay">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
|
||||||
|
<data
|
||||||
|
android:scheme="https"
|
||||||
|
android:host="mangafun.me"
|
||||||
|
android:pathPattern="/title/..*" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
|
@ -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'))
|
||||||
|
}
|
Binary file not shown.
After Width: | Height: | Size: 3.4 KiB |
Binary file not shown.
After Width: | Height: | Size: 1.7 KiB |
Binary file not shown.
After Width: | Height: | Size: 4.7 KiB |
Binary file not shown.
After Width: | Height: | Size: 9.3 KiB |
Binary file not shown.
After Width: | Height: | Size: 13 KiB |
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<MinifiedMangaDto>
|
||||||
|
|
||||||
|
override fun fetchPopularManga(page: Int): Observable<MangasPage> {
|
||||||
|
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<List<MinifiedMangaDto>>()
|
||||||
|
.sortedBy { it.rank }
|
||||||
|
return parseDirectory(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun fetchLatestUpdates(page: Int): Observable<MangasPage> {
|
||||||
|
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<List<MinifiedMangaDto>>()
|
||||||
|
.sortedByDescending { MangaFunUtils.convertShortTime(it.updatedAt) }
|
||||||
|
return parseDirectory(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun fetchSearchManga(
|
||||||
|
page: Int,
|
||||||
|
query: String,
|
||||||
|
filters: FilterList,
|
||||||
|
): Observable<MangasPage> {
|
||||||
|
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<List<MinifiedMangaDto>>()
|
||||||
|
.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<Int>()
|
||||||
|
val excluded = mutableListOf<Int>()
|
||||||
|
|
||||||
|
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<Int>()
|
||||||
|
val excluded = mutableListOf<Int>()
|
||||||
|
|
||||||
|
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<Int>()
|
||||||
|
val excluded = mutableListOf<Int>()
|
||||||
|
|
||||||
|
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<NextPagePropsWrapperDto>()
|
||||||
|
.pageProps
|
||||||
|
.dehydratedState
|
||||||
|
.queries
|
||||||
|
.first()
|
||||||
|
.state
|
||||||
|
.data
|
||||||
|
|
||||||
|
return json.decodeFromJsonElement<MangaDto>(data).toSManga()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun chapterListRequest(manga: SManga) = mangaDetailsRequest(manga)
|
||||||
|
|
||||||
|
override fun chapterListParse(response: Response): List<SChapter> {
|
||||||
|
val data = response.parseAs<NextPagePropsWrapperDto>()
|
||||||
|
.pageProps
|
||||||
|
.dehydratedState
|
||||||
|
.queries
|
||||||
|
.first()
|
||||||
|
.state
|
||||||
|
.data
|
||||||
|
|
||||||
|
val mangaData = json.decodeFromJsonElement<MangaDto>(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<Page> {
|
||||||
|
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 <reified T> Response.parseAs(): T =
|
||||||
|
json.decodeFromString(body.string())
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
internal const val PREFIX_ID_SEARCH = "id:"
|
||||||
|
internal const val MANGAFUN_EPOCH = 1693473000
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<String> = emptyList(),
|
||||||
|
@SerialName("g") val genres: List<Int> = emptyList(),
|
||||||
|
@SerialName("au") val author: List<String> = 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<String>,
|
||||||
|
val description: String,
|
||||||
|
val genres: List<GenreDto>,
|
||||||
|
val artist: List<String?>,
|
||||||
|
val author: List<String?>,
|
||||||
|
val chapters: List<ChapterDto>,
|
||||||
|
)
|
||||||
|
|
||||||
|
@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<QueriesDto>,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class QueriesDto(
|
||||||
|
val state: StateDto,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class StateDto(
|
||||||
|
val data: JsonElement,
|
||||||
|
)
|
|
@ -0,0 +1,149 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.en.mangafun
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.source.model.Filter
|
||||||
|
|
||||||
|
class GenreFilter : Filter.Group<Genre>("Genre", genreList)
|
||||||
|
|
||||||
|
class TypeFilter : Filter.Group<Genre>("Type", titleTypeList)
|
||||||
|
|
||||||
|
class StatusFilter : Filter.Group<Genre>(
|
||||||
|
"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),
|
||||||
|
)
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,7 +1,12 @@
|
||||||
ext {
|
ext {
|
||||||
extName = 'ManHuaGui'
|
extName = 'ManHuaGui'
|
||||||
extClass = '.Manhuagui'
|
extClass = '.Manhuagui'
|
||||||
extVersionCode = 19
|
extVersionCode = 20
|
||||||
}
|
}
|
||||||
|
|
||||||
apply from: "$rootDir/common.gradle"
|
apply from: "$rootDir/common.gradle"
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(project(":lib:lzstring"))
|
||||||
|
implementation(project(":lib:unpacker"))
|
||||||
|
}
|
||||||
|
|
|
@ -2,7 +2,8 @@ package eu.kanade.tachiyomi.extension.zh.manhuagui
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.content.SharedPreferences
|
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.GET
|
||||||
import eu.kanade.tachiyomi.network.POST
|
import eu.kanade.tachiyomi.network.POST
|
||||||
import eu.kanade.tachiyomi.network.asObservableSuccess
|
import eu.kanade.tachiyomi.network.asObservableSuccess
|
||||||
|
@ -295,19 +296,13 @@ class Manhuagui(
|
||||||
if (hiddenEncryptedChapterList != null) {
|
if (hiddenEncryptedChapterList != null) {
|
||||||
if (getShowR18()) {
|
if (getShowR18()) {
|
||||||
// Hidden chapter list is LZString encoded
|
// Hidden chapter list is LZString encoded
|
||||||
val decodedHiddenChapterList = QuickJs.create().use {
|
val decodedHiddenChapterList = LZString.decompressFromBase64(hiddenEncryptedChapterList.`val`())
|
||||||
it.evaluate(
|
|
||||||
jsDecodeFunc +
|
|
||||||
"""LZString.decompressFromBase64('${hiddenEncryptedChapterList.`val`()}');""",
|
|
||||||
) as String
|
|
||||||
}
|
|
||||||
val hiddenChapterList = Jsoup.parse(decodedHiddenChapterList, response.request.url.toString())
|
val hiddenChapterList = Jsoup.parse(decodedHiddenChapterList, response.request.url.toString())
|
||||||
if (hiddenChapterList != null) {
|
|
||||||
// Replace R18 warning with actual chapter list
|
// Replace R18 warning with actual chapter list
|
||||||
document.select("#erroraudit_show").first()!!.replaceWith(hiddenChapterList)
|
document.select("#erroraudit_show").first()!!.replaceWith(hiddenChapterList)
|
||||||
// Remove hidden chapter list element
|
// Remove hidden chapter list element
|
||||||
document.select("#__VIEWSTATE").first()!!.remove()
|
document.select("#__VIEWSTATE").first()!!.remove()
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// "You need to enable R18 switch and restart Tachiyomi to read this manga"
|
// "You need to enable R18 switch and restart Tachiyomi to read this manga"
|
||||||
error("您需要打开R18作品显示开关并重启软件才能阅读此作品")
|
error("您需要打开R18作品显示开关并重启软件才能阅读此作品")
|
||||||
|
@ -372,22 +367,18 @@ class Manhuagui(
|
||||||
return manga
|
return manga
|
||||||
}
|
}
|
||||||
|
|
||||||
private val jsDecodeFunc =
|
// 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
|
||||||
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<alphabet.length;i++){baseReverseDic[alphabet][alphabet.charAt(i)]=i}}return baseReverseDic[alphabet][character]}var LZString={decompressFromBase64:function(input){if(input==null)return"";if(input=="")return null;return LZString._0(input.length,32,function(index){return getBaseValue(keyStrBase64,input.charAt(index))})},_0:function(length,resetValue,getNextValue){var dictionary=[],next,enlargeIn=4,dictSize=4,numBits=3,entry="",result=[],i,w,bits,resb,maxpower,power,c,data={val:getNextValue(0),position:resetValue,index:1};for(i=0;i<3;i+=1){dictionary[i]=i}bits=0;maxpower=Math.pow(2,2);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(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)};
|
// version.
|
||||||
"""
|
//
|
||||||
|
|
||||||
// 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.
|
|
||||||
|
|
||||||
// These "\" can't be remove: "\}", more info in pull request 3926.
|
// These "\" can't be remove: "\}", more info in pull request 3926.
|
||||||
@Suppress("RegExpRedundantEscape")
|
@Suppress("RegExpRedundantEscape")
|
||||||
private val re = Regex("""window\[".*?"\](\(.*\)\s*\{[\s\S]+\}\s*\(.*\))""")
|
private val packedRegex = Regex("""window\[".*?"\](\(.*\)\s*\{[\s\S]+\}\s*\(.*\))""")
|
||||||
|
|
||||||
@Suppress("RegExpRedundantEscape")
|
@Suppress("RegExpRedundantEscape")
|
||||||
private val re2 = Regex("""\{.*\}""")
|
private val blockCcArgRegex = Regex("""\{.*\}""")
|
||||||
|
|
||||||
|
private val packedContentRegex = Regex("""['"]([0-9A-Za-z+/=]+)['"]\[['"].*?['"]]\(['"].*?['"]\)""")
|
||||||
|
|
||||||
override fun pageListParse(document: Document): List<Page> {
|
override fun pageListParse(document: Document): List<Page> {
|
||||||
// R18 warning element (#erroraudit_show) is remove by web page javascript, so here the warning element
|
// 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 html = document.html()
|
||||||
val imgCode = re.find(html)?.groups?.get(1)?.value
|
val imgCode = packedRegex.find(html)!!.groupValues[1].let {
|
||||||
val imgDecode = QuickJs.create().use {
|
// Make the packed content normal again so :lib:unpacker can do its job
|
||||||
it.evaluate(jsDecodeFunc + imgCode) as String
|
it.replace(packedContentRegex) { match ->
|
||||||
}
|
val lzs = match.groupValues[1]
|
||||||
|
val decoded = LZString.decompressFromBase64(lzs).replace("'", "\\'")
|
||||||
|
|
||||||
val imgJsonStr = re2.find(imgDecode)?.groups?.get(0)?.value
|
"'$decoded'.split('|')"
|
||||||
val imageJson: Comic = json.decodeFromString(imgJsonStr!!)
|
}
|
||||||
|
}
|
||||||
|
val imgDecode = Unpacker.unpack(imgCode)
|
||||||
|
|
||||||
|
val imgJsonStr = blockCcArgRegex.find(imgDecode)!!.groupValues[0]
|
||||||
|
val imageJson: Comic = json.decodeFromString(imgJsonStr)
|
||||||
|
|
||||||
return imageJson.files!!.mapIndexed { i, imgStr ->
|
return imageJson.files!!.mapIndexed { i, imgStr ->
|
||||||
val imgurl = "${imageServer[0]}${imageJson.path}$imgStr?e=${imageJson.sl?.e}&m=${imageJson.sl?.m}"
|
val imgurl = "${imageServer[0]}${imageJson.path}$imgStr?e=${imageJson.sl?.e}&m=${imageJson.sl?.m}"
|
||||||
|
|
Loading…
Reference in New Issue