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
					
				
							
								
								
									
										12
									
								
								lib/lzstring/build.gradle.kts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								lib/lzstring/build.gradle.kts
									
									
									
									
									
										Normal file
									
								
							| @ -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 | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										21
									
								
								src/en/mangafun/AndroidManifest.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								src/en/mangafun/AndroidManifest.xml
									
									
									
									
									
										Normal file
									
								
							| @ -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> | ||||
							
								
								
									
										13
									
								
								src/en/mangafun/build.gradle
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								src/en/mangafun/build.gradle
									
									
									
									
									
										Normal file
									
								
							| @ -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')) | ||||
| } | ||||
							
								
								
									
										
											BIN
										
									
								
								src/en/mangafun/res/mipmap-hdpi/ic_launcher.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								src/en/mangafun/res/mipmap-hdpi/ic_launcher.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 3.4 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/en/mangafun/res/mipmap-mdpi/ic_launcher.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								src/en/mangafun/res/mipmap-mdpi/ic_launcher.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 1.7 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/en/mangafun/res/mipmap-xhdpi/ic_launcher.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								src/en/mangafun/res/mipmap-xhdpi/ic_launcher.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 4.7 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/en/mangafun/res/mipmap-xxhdpi/ic_launcher.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								src/en/mangafun/res/mipmap-xxhdpi/ic_launcher.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 9.3 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/en/mangafun/res/mipmap-xxxhdpi/ic_launcher.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								src/en/mangafun/res/mipmap-xxxhdpi/ic_launcher.png
									
									
									
									
									
										Normal file
									
								
							
										
											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 { | ||||
|     extName = 'ManHuaGui' | ||||
|     extClass = '.Manhuagui' | ||||
|     extVersionCode = 19 | ||||
|     extVersionCode = 20 | ||||
| } | ||||
| 
 | ||||
| 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.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<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)}; | ||||
|     """ | ||||
| 
 | ||||
|     // 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<Page> { | ||||
|         // 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}" | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user
	 beerpsi
						beerpsi