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:
beerpsi 2024-02-07 15:46:25 +07:00 committed by GitHub
parent df859e3604
commit eb6d05a54f
17 changed files with 1133 additions and 32 deletions

View File

@ -0,0 +1,12 @@
plugins {
`java-library`
kotlin("jvm")
}
repositories {
mavenCentral()
}
dependencies {
compileOnly(libs.kotlin.stdlib)
}

View File

@ -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
}
}
}
}

View 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>

View 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'))
}

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

View File

@ -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)
}
}

View File

@ -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
}
}

View File

@ -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,
)

View File

@ -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),
)
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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"))
}

View File

@ -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}"