Replace json library with kotlinx.serialization in multiple sources (#7407)

* Catmanga: Replace org.json with kotlinx.serialization + Light Refactor of #7451

* Genkan IO: Replace gson + Make livewire interceptor

* Genkan IO: Tail Call Optimization to avoid blowing stack

* Comick.fun: kotlinx.serialization migration

* Remanga: kotlinx.serialzation migration
This commit is contained in:
h-hyuuga 2021-06-15 09:02:46 -04:00 committed by GitHub
parent 745e57f4e6
commit 1175b0d1c7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 653 additions and 467 deletions

View File

@ -1,11 +1,12 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlinx-serialization'
ext {
extName = 'Comick.fun'
pkgNameSuffix = 'all.comickfun'
extClass = '.ComickFunFactory'
extVersionCode = 1
extVersionCode = 3
libVersion = '1.2'
containsNsfw = true
}

View File

@ -1,13 +1,5 @@
package eu.kanade.tachiyomi.extension.all.comickfun
import android.os.Build
import android.text.Html
import com.github.salomonbrys.kotson.array
import com.github.salomonbrys.kotson.get
import com.github.salomonbrys.kotson.nullString
import com.github.salomonbrys.kotson.obj
import com.google.gson.JsonElement
import com.google.gson.JsonParser
import eu.kanade.tachiyomi.lib.ratelimit.RateLimitInterceptor
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.asObservableSuccess
@ -18,6 +10,13 @@ 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 kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.polymorphic
import okhttp3.CacheControl
import okhttp3.Headers
import okhttp3.HttpUrl
@ -27,10 +26,9 @@ import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import rx.Observable
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.lang.UnsupportedOperationException
import java.text.SimpleDateFormat
import kotlin.math.pow
import kotlin.math.truncate
const val SEARCH_PAGE_LIMIT = 100
@ -40,7 +38,18 @@ abstract class ComickFun(override val lang: String, private val comickFunLang: S
private val apiBase = "$baseUrl/api"
override val supportsLatest = true
private val mangaIdCache = mutableMapOf<String, Int>()
@ExperimentalSerializationApi
private val json: Json by lazy {
Json(from = Injekt.get()) {
serializersModule = SerializersModule {
polymorphic(SManga::class) { default { SMangaDeserializer() } }
polymorphic(SChapter::class) { default { SChapterDeserializer() } }
}
}
}
@ExperimentalSerializationApi
private val mangaIdCache = SMangaDeserializer.mangaIdCache
final override fun headersBuilder() = Headers.Builder().apply {
add("User-Agent", "Tachiyomi " + System.getProperty("http.agent"))
@ -80,92 +89,37 @@ abstract class ComickFun(override val lang: String, private val comickFunLang: S
/** Utils **/
/**
* Parses a json object with information suitable for showing an entry of a manga within a
* catalogue
*
* Attempts to cache the manga's numerical Id
*
* @return SManga - with url, thumbnail_url and title set
*/
private fun parseMangaObj(it: JsonElement) = it.asJsonObject.let { info ->
info["id"]?.asInt?.let { mangaIdCache.getOrPut(info["slug"].asString, { it }) }
val thumbnail = info["coverURL"]?.nullString
?: info["md_covers"]?.asJsonArray?.get(0)?.asJsonObject?.let { cover ->
cover["gpurl"]?.nullString ?: "$baseUrl${cover["url"].asString}"
}
SManga.create().apply {
url = "/comic/${info["slug"].asString}"
thumbnail_url = thumbnail
title = info["title"].asString
}
}
/** Returns an observable which emits a single value -> the manga's id **/
@ExperimentalSerializationApi
private fun chapterId(manga: SManga): Observable<Int> {
val mangaSlug = slug(manga)
return mangaIdCache[mangaSlug]?.let { Observable.just(it) }
?: fetchMangaDetails(manga).map { mangaIdCache[mangaSlug] }
}
private fun parseStatus(status: Int) = when (status) {
1 -> SManga.ONGOING
2 -> SManga.COMPLETED
else -> SManga.UNKNOWN
}
/** Attempts to parse an ISO-8601 compliant Date Time string with offset to epoch.
* @returns epochtime on success, 0 on failure
**/
private fun parseISO8601(s: String): Long {
var fractionalPart_ms: Long = 0
val sNoFraction = Regex("""\.\d+""").replace(s) { match ->
fractionalPart_ms = truncate(
match.value.substringAfter(".").toFloat() * 10.0f.pow(-(match.value.length - 1)) * // seconds
1000 // milliseconds
).toLong()
""
}
val ret = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZZZZZ").parse(sNoFraction)?.let {
fractionalPart_ms + it.time
} ?: 0
return ret
}
/** Returns an identifier referred to as `hid` for chapter **/
private fun hid(chapter: SChapter) = "$baseUrl${chapter.url}".toHttpUrl().pathSegments[2].substringBefore("-")
/** Returns an identifier referred to as a `slug` for manga **/
private fun slug(manga: SManga) = "$baseUrl${manga.url}".toHttpUrl().pathSegments[1]
private fun formatChapterTitle(title: String?, chap: String?, vol: String?): String {
val numNonNull = listOfNotNull(title.takeIf { !it.isNullOrBlank() }, chap, vol).size
if (numNonNull == 0) throw RuntimeException("formatChapterTitle requires at least one non-null argument")
var formattedTitle = StringBuilder()
if (vol != null) formattedTitle.append("${numNonNull.takeIf { it > 1 }?.let { "Vol." } ?: "Volume"} $vol")
if (vol != null && chap != null) formattedTitle.append(", ")
if (chap != null) formattedTitle.append("${numNonNull.takeIf { it > 1 }?.let { "Ch." } ?: "Chapter"} $chap")
if (!title.isNullOrBlank()) formattedTitle.append("${numNonNull.takeIf { it > 1 }?.let { ": " } ?: ""} $title")
return formattedTitle.toString()
}
/** Popular Manga **/
@ExperimentalSerializationApi
override fun fetchPopularManga(page: Int) = fetchSearchManga(page, "", FilterList(emptyList()))
override fun popularMangaRequest(page: Int) = throw UnsupportedOperationException("Not used")
override fun popularMangaParse(response: Response) = throw UnsupportedOperationException("Not used")
/** Latest Manga **/
@ExperimentalSerializationApi
override fun latestUpdatesParse(response: Response): MangasPage {
val noResults = MangasPage(emptyList(), false)
if (response.code == 204)
return noResults
return JsonParser.parseString(response.body!!.string()).obj["data"]?.array?.let { manga ->
MangasPage(manga.map { parseMangaObj(it["md_comics"]) }, true)
} ?: noResults
return json.decodeFromString(
deserializer = deepSelectDeserializer<List<SManga>>("data"),
response.body!!.string()
).let { MangasPage(it, true) }
}
override fun latestUpdatesRequest(page: Int): Request {
@ -175,6 +129,7 @@ abstract class ComickFun(override val lang: String, private val comickFunLang: S
return GET("$url", headers)
}
@ExperimentalSerializationApi
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
if (!query.startsWith(SLUG_SEARCH_PREFIX))
return super.fetchSearchManga(page, query, filters)
@ -205,11 +160,14 @@ abstract class ComickFun(override val lang: String, private val comickFunLang: S
return GET("$url", headers)
}
override fun searchMangaParse(response: Response): MangasPage = JsonParser.parseString(response.body!!.string()).let {
if (it.isJsonObject)
MangasPage(it["comics"].array.map(::parseMangaObj), it["comics"].array.size() == SEARCH_PAGE_LIMIT)
else // search_title isn't paginated
MangasPage(it.array.map(::parseMangaObj), false)
@ExperimentalSerializationApi
override fun searchMangaParse(response: Response): MangasPage = json.parseToJsonElement(response.body!!.string()).let { parsed ->
when (parsed) {
is JsonObject -> json.decodeFromJsonElement<List<SManga>>(parsed["comics"]!!)
.let { MangasPage(it, it.size == SEARCH_PAGE_LIMIT) }
is JsonArray -> MangasPage(json.decodeFromJsonElement(parsed), false)
else -> MangasPage(emptyList(), false)
}
}
/** Manga Details **/
@ -219,6 +177,7 @@ abstract class ComickFun(override val lang: String, private val comickFunLang: S
}
// Shenanigans to allow "open in webview" to show a webpage instead of JSON
@ExperimentalSerializationApi
override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
return client.newCall(apiMangaDetailsRequest(manga))
.asObservableSuccess()
@ -227,36 +186,18 @@ abstract class ComickFun(override val lang: String, private val comickFunLang: S
}
}
override fun mangaDetailsParse(response: Response) = JsonParser.parseString(response.body!!.string())["data"].let { data ->
fun cleanDesc(s: String) = (
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N)
Html.fromHtml(s, Html.FROM_HTML_MODE_LEGACY) else Html.fromHtml(s)
).toString()
fun nameList(e: JsonElement?) = e?.array?.asSequence()?.map { it["name"].asString }
data["comic"]["id"].asInt.let { mangaIdCache.getOrPut(response.request.url.queryParameter("slug")!!, { it }) }
SManga.create().apply {
title = data["comic"]["title"].asString
thumbnail_url = data["coverURL"].asString
description = cleanDesc(data["comic"]["desc"].asString)
status = parseStatus(data["comic"]["status"].asInt)
artist = nameList(data["artists"])?.joinToString(", ")
author = nameList(data["authors"])?.joinToString(", ")
genre = (
(nameList(data["genres"]) ?: sequenceOf()) + sequence {
data["demographic"].nullString?.let { yield(it) }
mapOf("kr" to "Manhwa", "jp" to "Manga", "cn" to "Manhua")[data["comic"]["country"].nullString]
?.let { yield(it) }
}
).joinToString(", ")
}
}
@ExperimentalSerializationApi
override fun mangaDetailsParse(response: Response) = json.decodeFromString(
deserializer = deepSelectDeserializer<SManga>("data", tDeserializer = jsonFlatten(objKey = "comic", "id", "title", "desc", "status", "country", "slug")),
response.body!!.string()
)
/** Chapter List **/
private fun chapterListRequest(page: Int, mangaId: Int) =
GET("$apiBase/get_chapters?comicid=$mangaId&page=$page&limit=$SEARCH_PAGE_LIMIT", headers)
@ExperimentalSerializationApi
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
return if (manga.status != SManga.LICENSED) {
chapterId(manga).concatMap { id ->
@ -281,25 +222,22 @@ abstract class ComickFun(override val lang: String, private val comickFunLang: S
}
}
override fun chapterListParse(response: Response) = JsonParser.parseString(response.body!!.string()).obj["data"]["chapters"].array.map { elem ->
val chapter = elem.asJsonObject
val num = chapter["chap"].nullString ?: "-1"
SChapter.create().apply {
date_upload = parseISO8601(chapter["created_at"].asString)
name = formatChapterTitle(chapter["title"].nullString, chapter["chap"].nullString, chapter["vol"].nullString)
chapter_number = num.toFloat()
url = "/${chapter["hid"].asString}-chapter-${chapter["chap"].nullString}-${chapter["iso639_1"].asString}" // incomplete, is finished in fetchChapterList
scanlator = chapter.get("md_groups")?.array?.get(0)?.obj?.get("title")?.asString
}
}
@ExperimentalSerializationApi
override fun chapterListParse(response: Response) = json.decodeFromString(
deserializer = deepSelectDeserializer<List<SChapter>>("data", "chapters"),
response.body!!.string()
)
/** Page List **/
override fun pageListRequest(chapter: SChapter) = GET("$apiBase/get_chapter?hid=${hid(chapter)}", headers, CacheControl.FORCE_NETWORK)
override fun pageListParse(response: Response) = JsonParser.parseString(response.body!!.string())["data"]["chapter"]["images"].array.mapIndexed { i, url ->
Page(i, imageUrl = url.asString)
}
@ExperimentalSerializationApi
override fun pageListParse(response: Response) =
json.decodeFromString(
deserializer = deepSelectDeserializer<List<String>>("data", "chapter", "images"),
response.body!!.string()
).mapIndexed { i, url -> Page(i, imageUrl = url) }
override fun imageUrlParse(response: Response) = "" // idk what this does, leave me alone kotlin

View File

@ -0,0 +1,261 @@
package eu.kanade.tachiyomi.extension.all.comickfun
import android.os.Build
import android.text.Html
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.KSerializer
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.descriptors.buildClassSerialDescriptor
import kotlinx.serialization.descriptors.element
import kotlinx.serialization.encoding.CompositeDecoder
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.encoding.decodeStructure
import kotlinx.serialization.json.JsonDecoder
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonTransformingSerializer
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.serializer
import java.text.SimpleDateFormat
import kotlin.math.pow
import kotlin.math.truncate
/**
* A serializer of type T which selects the value of type T by traversing down a chain of json objects
*
* e.g
* {
* "user": {
* "name": {
* "first": "John",
* "last": "Smith"
* }
* }
* }
*
* deepSelectDeserializer&lt;String&gt;("user", "name", "first") deserializes the above into "John"
*/
@ExperimentalSerializationApi
inline fun <reified T : Any> deepSelectDeserializer(vararg keys: String, tDeserializer: KSerializer<T> = serializer()): KSerializer<T> {
val descriptors = keys.foldRight(listOf(tDeserializer.descriptor)) { x, acc ->
acc + acc.last().let {
buildClassSerialDescriptor("$x\$${it.serialName}") { element(x, it) }
}
}.asReversed()
var a: ((Int) -> KSerializer<T>)? = null
val b = { depth: Int ->
object : KSerializer<T> {
override val descriptor = descriptors[depth]
override fun deserialize(decoder: Decoder): T {
return if (depth == keys.size) decoder.decodeSerializableValue(tDeserializer)
else decoder.decodeStructureByKnownName(descriptor) { names ->
names.filter { (name, _) -> name == keys[depth] }
.map { (_, index) -> decodeSerializableElement(descriptor, index, a!!(depth + 1)) }
.single()
}
}
override fun serialize(encoder: Encoder, value: T) = throw UnsupportedOperationException("Not supported")
}
}
a = b // this is the hackiest of hacky hacks to get around not being able to define recursive inline functions
return a(0)
}
/**
* Transforms given json element by lifting specified keys in `element[objKey]` up into `element`
* Existing conflicts are overwritten
*
* @param objKey: String - A key identifying an object in JsonElement
* @param keys: vararg String - Keys identifying values to lift from objKey
*/
inline fun <reified T : Any> jsonFlatten(objKey: String, vararg keys: String, tDeserializer: KSerializer<T> = serializer()): JsonTransformingSerializer<T> {
return object : JsonTransformingSerializer<T>(tDeserializer) {
override fun transformDeserialize(element: JsonElement) = buildJsonObject {
require(element is JsonObject)
element.entries.forEach { (key, value) -> put(key, value) }
val fromObj = element[objKey]
require(fromObj is JsonObject)
keys.forEach { put(it, fromObj[it]!!) }
}
}
}
@ExperimentalSerializationApi
inline fun <T> Decoder.decodeStructureByKnownName(descriptor: SerialDescriptor, decodeFn: CompositeDecoder.(Sequence<Pair<String, Int>>) -> T): T {
return decodeStructure(descriptor) {
decodeFn(
generateSequence { decodeElementIndex(descriptor) }
.takeWhile { it != CompositeDecoder.DECODE_DONE }
.filter { it != CompositeDecoder.UNKNOWN_NAME }
.map { descriptor.getElementName(it) to it }
)
}
}
@ExperimentalSerializationApi
class SChapterDeserializer : KSerializer<SChapter> {
override val descriptor = buildClassSerialDescriptor(SChapter::class.qualifiedName!!) {
element<String>("chap")
element<String>("hid")
element<String?>("title")
element<String?>("vol", isOptional = true)
element<String>("created_at")
element<String>("iso639_1")
element<List<String>>("images", isOptional = true)
element<List<JsonObject>>("md_groups", isOptional = true)
}
/** Attempts to parse an ISO-8601 compliant Date Time string with offset to epoch.
* @returns epochtime on success, 0 on failure
**/
private fun parseISO8601(s: String): Long {
var fractionalPart_ms: Long = 0
val sNoFraction = Regex("""\.\d+""").replace(s) { match ->
fractionalPart_ms = truncate(
match.value.substringAfter(".").toFloat() * 10.0f.pow(-(match.value.length - 1)) * // seconds
1000 // milliseconds
).toLong()
""
}
return SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZZZZZ").parse(sNoFraction)?.let {
fractionalPart_ms + it.time
} ?: 0
}
private fun formatChapterTitle(title: String?, chap: String?, vol: String?): String {
val numNonNull = listOfNotNull(title.takeIf { !it.isNullOrBlank() }, chap, vol).size
if (numNonNull == 0) throw RuntimeException("formatChapterTitle requires at least one non-null argument")
val formattedTitle = StringBuilder()
if (vol != null) formattedTitle.append("${numNonNull.takeIf { it > 1 }?.let { "Vol." } ?: "Volume"} $vol")
if (vol != null && chap != null) formattedTitle.append(", ")
if (chap != null) formattedTitle.append("${numNonNull.takeIf { it > 1 }?.let { "Ch." } ?: "Chapter"} $chap")
if (!title.isNullOrBlank()) formattedTitle.append("${numNonNull.takeIf { it > 1 }?.let { ": " } ?: ""} $title")
return formattedTitle.toString()
}
@ExperimentalSerializationApi
override fun deserialize(decoder: Decoder): SChapter {
return SChapter.create().apply {
var chap: String? = null
var vol: String? = null
var title: String? = null
var hid = ""
var iso639_1 = ""
require(decoder is JsonDecoder)
decoder.decodeStructureByKnownName(descriptor) { names ->
for ((name, index) in names) {
when (name) {
"created_at" -> date_upload = parseISO8601(decodeStringElement(descriptor, index))
"title" -> title = decodeNullableSerializableElement(descriptor, index, serializer())
"vol" -> vol = decodeNullableSerializableElement(descriptor, index, serializer())
"chap" -> {
chap = decodeStringElement(descriptor, index)
chapter_number = chap!!.toFloat()
}
"hid" -> hid = decodeStringElement(descriptor, index)
"iso639_1" -> iso639_1 = decodeStringElement(descriptor, index)
"md_groups" -> scanlator = decodeSerializableElement(descriptor, index, ListSerializer(deepSelectDeserializer<String>("title"))).joinToString(", ")
}
}
}
name = formatChapterTitle(title, chap, vol)
url = "/$hid-chapter-$chap-$iso639_1" // incomplete, is finished in fetchChapterList
}
}
override fun serialize(encoder: Encoder, value: SChapter) = throw UnsupportedOperationException("Unsupported")
}
@ExperimentalSerializationApi
class SMangaDeserializer : KSerializer<SManga> {
private fun cleanDesc(s: String) = (
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N)
Html.fromHtml(s, Html.FROM_HTML_MODE_LEGACY) else Html.fromHtml(s)
).toString()
private fun parseStatus(status: Int) = when (status) {
1 -> SManga.ONGOING
2 -> SManga.COMPLETED
else -> SManga.UNKNOWN
}
override val descriptor = buildClassSerialDescriptor(SManga::class.qualifiedName!!) {
element<String>("slug")
element<String>("title")
element<String>("coverURL")
element<String>("id", isOptional = true)
element<List<JsonObject>>("artists", isOptional = true)
element<List<JsonObject>>("authors", isOptional = true)
element<String>("desc", isOptional = true)
element<String>("demographic", isOptional = true)
element<List<JsonObject>>("genres", isOptional = true)
element<Int>("status", isOptional = true)
element<String>("country", isOptional = true)
}
@ExperimentalSerializationApi
override fun deserialize(decoder: Decoder): SManga {
return SManga.create().apply {
var id: Int? = null
var slug: String? = null
val tryTo = (
{
var hasThrown = false;
{ fn: () -> Unit ->
if (!hasThrown) {
try {
fn()
} catch (_: java.lang.Exception) {
hasThrown = true
}
}
}
}
)()
decoder.decodeStructureByKnownName(descriptor) { names ->
for ((name, index) in names) {
val sluggedNameSerializer = ListSerializer(deepSelectDeserializer<String>("name"))
fun nameList() = decodeSerializableElement(descriptor, index, sluggedNameSerializer).joinToString(", ")
when (name) {
"slug" -> {
slug = decodeStringElement(descriptor, index)
url = "/comic/$slug"
}
"title" -> title = decodeStringElement(descriptor, index)
"coverURL" -> thumbnail_url = decodeStringElement(descriptor, index)
"id" -> id = decodeIntElement(descriptor, index)
"artists" -> artist = nameList()
"authors" -> author = nameList()
"desc" -> description = cleanDesc(decodeStringElement(descriptor, index))
// Isn't always a string in every api call
"demographic" -> tryTo { genre = listOfNotNull(genre, decodeStringElement(descriptor, index)).joinToString(", ") }
// Isn't always a list of objects in every api call
"genres" -> tryTo { genre = listOfNotNull(genre, nameList()).joinToString(", ") }
"status" -> status = parseStatus(decodeIntElement(descriptor, index))
"country" -> genre = listOfNotNull(
genre,
mapOf("kr" to "Manhwa", "jp" to "Manga", "cn" to "Manhua")[decodeStringElement(descriptor, index)]
).joinToString(", ")
}
}
}
if (id != null && slug != null) {
mangaIdCache[slug!!] = id!!
}
}
}
override fun serialize(encoder: Encoder, value: SManga) = throw UnsupportedOperationException("Not supported")
companion object {
val mangaIdCache = mutableMapOf<String, Int>()
}
}

View File

@ -1,11 +1,12 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlinx-serialization'
ext {
extName = 'Genkan.io'
pkgNameSuffix = "all.genkanio"
extClass = '.GenkanIO'
extVersionCode = 2
extVersionCode = 3
libVersion = '1.2'
}

View File

@ -1,32 +1,38 @@
package eu.kanade.tachiyomi.extension.all.genkanio
import android.util.Log
import com.github.salomonbrys.kotson.keys
import com.github.salomonbrys.kotson.put
import com.google.gson.JsonArray
import com.google.gson.JsonElement
import com.google.gson.JsonObject
import com.google.gson.JsonParser
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.asObservableSuccess
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.ParsedHttpSource
import eu.kanade.tachiyomi.util.asJsoup
import okhttp3.Headers
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.boolean
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.int
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import okhttp3.ResponseBody.Companion.toResponseBody
import okio.Buffer
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import rx.Observable
import uy.kohesive.injekt.injectLazy
import java.util.Calendar
open class GenkanIO : ParsedHttpSource() {
@ -35,37 +41,141 @@ open class GenkanIO : ParsedHttpSource() {
final override val baseUrl = "https://genkan.io"
final override val supportsLatest = false
data class LiveWireRPC(val csrf: String, val state: JsonObject)
private var livewire: LiveWireRPC? = null
private val json: Json by injectLazy()
/**
* Given a string encoded with html entities and escape sequences, makes an attempt to decode
* and returns decoded string
*
* Warning: This is not all all exhaustive, and probably misses edge cases
*
* @Returns decoded string
/** An interceptor which encapsulates the logic needed to interoperate with Genkan.io's
* livewire server, which uses a form a Remote Procedure call
*/
private fun htmlDecode(html: String): String {
return html.replace(Regex("&([A-Za-z]+);")) { match ->
mapOf(
"raquo" to "»",
"laquo" to "«",
"amp" to "&",
"lt" to "<",
"gt" to ">",
"quot" to "\""
)[match.groups[1]!!.value] ?: match.groups[0]!!.value
}.replace(Regex("\\\\(.)")) { match ->
mapOf(
"t" to "\t",
"n" to "\n",
"r" to "\r",
"b" to "\b"
)[match.groups[1]!!.value] ?: match.groups[1]!!.value
private val livewireInterceptor = object : Interceptor {
private lateinit var fingerprint: JsonElement
lateinit var serverMemo: JsonObject
private lateinit var csrf: String
var initialized = false
val serverUrl = "$baseUrl/livewire/message/manga.list-all-manga"
/**
* Given a string encoded with html entities and escape sequences, makes an attempt to decode
* and returns decoded string
*
* Warning: This is not all all exhaustive, and probably misses edge cases
*
* @Returns decoded string
*/
private fun htmlDecode(html: String): String {
return html.replace(Regex("&([A-Za-z]+);")) { match ->
mapOf(
"raquo" to "»",
"laquo" to "«",
"amp" to "&",
"lt" to "<",
"gt" to ">",
"quot" to "\""
)[match.groups[1]!!.value] ?: match.groups[0]!!.value
}.replace(Regex("\\\\(.)")) { match ->
mapOf(
"t" to "\t",
"n" to "\n",
"r" to "\r",
"b" to "\b"
)[match.groups[1]!!.value] ?: match.groups[1]!!.value
}
}
/**
* Recursively merges j2 onto j1 in place
* If j1 and j2 both contain keys whose values aren't both jsonObjects, j2's value overwrites j1's
*
*/
private fun mergeLeft(j1: JsonObject, j2: JsonObject): JsonObject = buildJsonObject {
j1.keys.forEach { put(it, j1[it]!!) }
j2.keys.forEach { k ->
when {
j1[k] !is JsonObject -> put(k, j2[k]!!)
j1[k] is JsonObject && j2[k] is JsonObject -> put(k, mergeLeft(j1[k]!!.jsonObject, j2[k]!!.jsonObject))
}
}
}
/**
* Initializes lateinit member vars
*/
private fun initLivewire(chain: Interceptor.Chain) {
val response = chain.proceed(GET("$baseUrl/manga", headers))
val soup = response.asJsoup()
response.body?.close()
val csrfToken = soup.selectFirst("meta[name=csrf-token]")?.attr("content")
val initialProps = soup.selectFirst("div[wire:initial-data]")?.attr("wire:initial-data")?.let {
json.parseToJsonElement(htmlDecode(it))
}
if (csrfToken != null && initialProps is JsonObject) {
csrf = csrfToken
serverMemo = initialProps["serverMemo"]!!.jsonObject
fingerprint = initialProps["fingerprint"]!!
initialized = true
} else {
Log.e("GenkanIo", soup.selectFirst("div[wire:initial-data]")?.toString() ?: "null")
}
}
/**
* Builds a request for livewire, augmenting the request with required body fields and headers
*
* @param req: Request - A request with a json encoded body, which represent the updates sent to server
*
*/
private fun livewireRequest(req: Request): Request {
val payload = buildJsonObject {
put("fingerprint", fingerprint)
put("serverMemo", serverMemo)
put("updates", json.parseToJsonElement(Buffer().apply { req.body!!.writeTo(this) }.readUtf8()))
}.toString().toRequestBody("application/json; charset=utf-8".toMediaTypeOrNull())
return req.newBuilder()
.method(req.method, payload)
.addHeader("x-csrf-token", csrf)
.addHeader("x-livewire", "true")
.build()
}
/**
* Transforms json response from livewire server into a response which returns html
*
* @param response: Response - The response of sending a message to genkan's livewire server
*
* @return HTML Response - The html embedded within the provided response
*/
private fun livewireResponse(response: Response): Response {
if (!response.isSuccessful) return response
val body = response.body!!.string()
val responseJson = json.parseToJsonElement(body).jsonObject
// response contains state that we need to preserve
serverMemo = mergeLeft(serverMemo, responseJson["serverMemo"]!!.jsonObject)
// this seems to be an error state, so reset everything
if (responseJson["effects"]?.jsonObject?.get("html") is JsonNull) {
initialized = false
}
// Build html response
return response.newBuilder()
.body(htmlDecode("${responseJson["effects"]?.jsonObject?.get("html")}").toResponseBody("Content-Type: text/html; charset=UTF-8".toMediaTypeOrNull()))
.build()
}
override fun intercept(chain: Interceptor.Chain): Response {
if (chain.request().url.toString() != serverUrl)
return chain.proceed(chain.request())
if (!initialized) initLivewire(chain)
return livewireResponse(chain.proceed(livewireRequest(chain.request())))
}
}
override val client = super.client.newBuilder().addInterceptor(livewireInterceptor).build()
// popular manga
override fun fetchPopularManga(page: Int) = fetchSearchManga(page, "", FilterList(emptyList()))
@ -83,119 +193,33 @@ open class GenkanIO : ParsedHttpSource() {
// search
/**
* initializes `livewire` local variable using data from https://genkan.io/manga
*/
private fun initLiveWire(response: Response) {
val soup = response.asJsoup()
val csrf = soup.selectFirst("meta[name=csrf-token]")?.attr("content")
val initialProps = soup.selectFirst("div[wire:initial-data]")?.attr("wire:initial-data")?.let {
JsonParser.parseString(htmlDecode(it))
}
if (csrf != null && initialProps?.asJsonObject != null) {
livewire = LiveWireRPC(csrf, initialProps.asJsonObject)
} else {
Log.e("GenkanIo", soup.selectFirst("div[wire:initial-data]")?.toString() ?: "null")
}
}
/**
* Prepares a request which'll send a message to livewire server
*
* @param url: String - Message endpoint
* @param updates: JsonElement - JsonElement which describes the actions taken by server
*
* @return Request
*/
private fun livewireRequest(url: String, updates: JsonElement): Request {
// assert(livewire != null)
val payload = JsonObject()
payload.put("fingerprint" to livewire!!.state.get("fingerprint"))
payload.put("serverMemo" to livewire!!.state.get("serverMemo"))
payload.put("updates" to updates)
// not sure why this isn't getting added automatically
val cookie = client.cookieJar.loadForRequest(url.toHttpUrlOrNull()!!).joinToString("; ") { "${it.name}=${it.value}" }
return POST(
url,
Headers.headersOf("x-csrf-token", livewire!!.csrf, "x-livewire", "true", "cookie", cookie, "cache-control", "no-cache, private"),
payload.toString().toRequestBody("application/json; charset=utf-8".toMediaTypeOrNull())
)
}
/**
* Transforms json response from livewire server into a response which returns html
* Also updates `livewire` variable with state returned by livewire server
*
* @param response: Response - The response of sending a message to genkan's livewire server
*
* @return HTML Response - The html embedded within the provided response
*/
private fun livewireResponse(response: Response): Response {
val body = response.body?.string()
val responseJson = JsonParser.parseString(body).asJsonObject
// response contains state that we need to preserve
mergeLeft(livewire!!.state.get("serverMemo").asJsonObject, responseJson.get("serverMemo").asJsonObject)
// this seems to be an error state, so reset everything
if (responseJson.get("effects")?.asJsonObject?.get("html")?.isJsonNull == true) {
livewire = null
}
// Build html response
return response.newBuilder()
.body(htmlDecode("${responseJson.get("effects")?.asJsonObject?.get("html")}").toResponseBody("Content-Type: text/html; charset=UTF-8".toMediaTypeOrNull()))
.build()
}
/**
* Recursively merges j2 onto j1 in place
* If j1 and j2 both contain keys whose values aren't both jsonObjects, j2's value overwrites j1's
*
*/
private fun mergeLeft(j1: JsonObject, j2: JsonObject) {
j2.keys().forEach { k ->
if (j1.get(k)?.isJsonObject != true)
j1.put(k to j2.get(k))
else if (j1.get(k).isJsonObject && j2.get(k).isJsonObject)
mergeLeft(j1.get(k).asJsonObject, j2.get(k).asJsonObject)
}
}
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
fun searchRequest() = client.newCall(searchMangaRequest(page, query, filters)).asObservableSuccess().map(::livewireResponse)
return if (livewire == null) {
client.newCall(GET("$baseUrl/manga", headers))
.asObservableSuccess()
.doOnNext(::initLiveWire)
.concatWith(Observable.defer(::searchRequest))
.reduce { _, x -> x }
} else {
searchRequest()
}.map(::searchMangaParse)
}
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
// assert(livewire != null)
val updates = JsonArray()
val data = livewire!!.state.get("serverMemo")?.asJsonObject?.get("data")?.asJsonObject!!
if (data["readyToLoad"]?.asBoolean == false) {
updates.add(JsonParser.parseString("""{"type":"callMethod","payload":{"method":"loadManga","params":[]}}"""))
}
val isNewQuery = query != data["search"]?.asString
if (isNewQuery) {
updates.add(JsonParser.parseString("""{"type": "syncInput", "payload": {"name": "search", "value": "$query"}}"""))
val data = if (livewireInterceptor.initialized) livewireInterceptor.serverMemo["data"]!!.jsonObject else buildJsonObject {
put("readyToLoad", JsonPrimitive(false))
put("page", JsonPrimitive(1))
put("search", JsonPrimitive(""))
}
val currPage = if (isNewQuery) 1 else data["page"]?.asInt
val updates = buildJsonArray {
if (data["readyToLoad"]?.jsonPrimitive?.boolean == false) {
add(json.parseToJsonElement("""{"type":"callMethod","payload":{"method":"loadManga","params":[]}}"""))
}
val isNewQuery = query != data["search"]?.jsonPrimitive?.content
if (isNewQuery) {
add(json.parseToJsonElement("""{"type": "syncInput", "payload": {"name": "search", "value": "$query"}}"""))
}
for (i in (currPage!! + 1)..page)
updates.add(JsonParser.parseString("""{"type":"callMethod","payload":{"method":"nextPage","params":[]}}"""))
val currPage = if (isNewQuery) 1 else data["page"]!!.jsonPrimitive.int
return livewireRequest("$baseUrl/livewire/message/manga.list-all-manga", updates)
for (i in (currPage + 1)..page)
add(json.parseToJsonElement("""{"type":"callMethod","payload":{"method":"nextPage","params":[]}}"""))
}
return POST(
livewireInterceptor.serverUrl,
headers,
updates.toString().toRequestBody("application/json; charset=utf-8".toMediaTypeOrNull())
)
}
override fun searchMangaFromElement(element: Element): SManga {
@ -220,15 +244,15 @@ open class GenkanIO : ParsedHttpSource() {
return if (manga.status != SManga.LICENSED) {
// Returns an observable which emits the list of chapters found on a page,
// for every page starting from specified page
fun getAllPagesFrom(page: Int): Observable<List<SChapter>> =
fun getAllPagesFrom(page: Int, pred: Observable<List<SChapter>> = Observable.just(emptyList())): Observable<List<SChapter>> =
client.newCall(chapterListRequest(manga, page))
.asObservableSuccess()
.concatMap { response ->
val cp = chapterPageParse(response)
if (cp.hasnext)
Observable.just(cp.chapters).concatWith(getAllPagesFrom(page + 1))
getAllPagesFrom(page + 1, pred = pred.concatWith(Observable.just(cp.chapters))) // tail call to avoid blowing the stack
else
Observable.just(cp.chapters)
pred.concatWith(Observable.just(cp.chapters))
}
getAllPagesFrom(1).reduce(List<SChapter>::plus)
} else {

View File

@ -1,5 +1,6 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlinx-serialization'
ext {
extName = 'CatManga'

View File

@ -10,12 +10,20 @@ 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.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.jsonObject
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Protocol
import okhttp3.Response
import org.json.JSONArray
import org.json.JSONObject
import okhttp3.ResponseBody.Companion.toResponseBody
import org.jsoup.nodes.Document
import rx.Observable
import uy.kohesive.injekt.injectLazy
import java.net.HttpURLConnection
class CatManga : HttpSource() {
@ -25,206 +33,111 @@ class CatManga : HttpSource() {
override val baseUrl = "https://catmanga.org"
override val supportsLatest = true
override val lang = "en"
private val json: Json by injectLazy()
override fun popularMangaRequest(page: Int) = GET(baseUrl)
private lateinit var seriesCache: LinkedHashMap<String, JsonSeries> // LinkedHashMap to preserve insertion order
private lateinit var latestSeries: List<String>
override val client = super.client.newBuilder().addInterceptor { chain ->
// An interceptor which facilitates caching the data retrieved from the homepage
when (chain.request().url) {
doNothingRequest.url -> Response.Builder().body(
"".toResponseBody("text/plain; charset=utf-8".toMediaType())
).code(HttpURLConnection.HTTP_NO_CONTENT).message("").protocol(Protocol.HTTP_1_0).request(chain.request()).build()
homepageRequest.url -> {
/* Homepage embeds a Json Object with information about every single series in the service */
val response = chain.proceed(chain.request())
val responseBody = response.peekBody(Long.MAX_VALUE).string()
val seriesList = response.asJsoup(responseBody).getDataJsonObject()["props"]!!.jsonObject["pageProps"]!!.jsonObject["series"]!!
val latests = response.asJsoup(responseBody).getDataJsonObject()["props"]!!.jsonObject["pageProps"]!!.jsonObject["latests"]!!
seriesCache = linkedMapOf(
*json.decodeFromJsonElement<List<JsonSeries>>(seriesList).map { it.series_id to it }.toTypedArray()
)
latestSeries = json.decodeFromJsonElement<List<List<JsonElement>>>(latests).map { json.decodeFromJsonElement<JsonSeries>(it[0]).series_id }
response
}
else -> chain.proceed(chain.request())
}
}.build()
override fun latestUpdatesRequest(page: Int) = popularMangaRequest(page)
private val homepageRequest = GET(baseUrl)
private val doNothingRequest = GET("https://dev.null")
override fun searchMangaRequest(page: Int, query: String, filters: FilterList) = popularMangaRequest(page)
override fun searchMangaRequest(page: Int, query: String, filters: FilterList) = if (this::seriesCache.isInitialized) doNothingRequest else homepageRequest
override fun popularMangaRequest(page: Int) = if (this::seriesCache.isInitialized) doNothingRequest else homepageRequest
override fun latestUpdatesRequest(page: Int) = if (this::seriesCache.isInitialized) doNothingRequest else homepageRequest
override fun chapterListRequest(manga: SManga) = homepageRequest
private fun idOf(manga: SManga) = manga.url.substringAfterLast("/")
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
return client.newCall(popularMangaRequest(page))
return client.newCall(searchMangaRequest(page, query, filters))
.asObservableSuccess()
.map { response ->
val mangas = if (query.startsWith(SERIES_ID_SEARCH_PREFIX)) {
getFilteredSeriesList(
response.asJsoup().getDataJsonObject(),
idFilter = query.removePrefix(SERIES_ID_SEARCH_PREFIX)
)
} else {
getFilteredSeriesList(
response.asJsoup().getDataJsonObject(),
titleFilter = query
)
}
MangasPage(mangas, false)
.map {
val manga = seriesCache.asSequence().map { it.value }.filter {
if (query.startsWith(SERIES_ID_SEARCH_PREFIX)) {
return@filter it.series_id.contains(query.removePrefix(SERIES_ID_SEARCH_PREFIX), true)
}
sequence { yieldAll(it.alt_titles); yield(it.title) }
.any { title -> title.contains(query, true) }
}.map { it.toSManga() }.toList()
MangasPage(manga, false)
}
}
override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
return client.newCall(popularMangaRequest(0))
.asObservableSuccess()
.map { response ->
manga.also {
getSeriesObject(response.asJsoup().getDataJsonObject(), it)?.let { series ->
it.title = series.getString("title")
it.author = series.getJSONArray("authors").joinToString(", ")
it.description = series.getString("description")
it.genre = series.getJSONArray("genres").joinToString(", ")
it.status = when (series.getString("status")) {
"ongoing" -> SManga.ONGOING
"completed" -> SManga.COMPLETED
else -> SManga.UNKNOWN
}
it.thumbnail_url = series.getJSONObject("cover_art").getString("source")
}
}
}
}
override fun fetchMangaDetails(manga: SManga): Observable<SManga> = client.newCall(homepageRequest)
.asObservableSuccess()
.map { seriesCache[idOf(manga)]?.toSManga() ?: manga }
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
val seriesId = manga.url.substringAfter("/series/")
return client.newCall(popularMangaRequest(0))
return client.newCall(chapterListRequest(manga))
.asObservableSuccess()
.map { response ->
var returnChapter = emptyList<SChapter>()
.map {
val seriesPrefs = application.getSharedPreferences("source_${id}_time_found:$seriesId", 0)
val seriesPrefsEditor = seriesPrefs.edit()
val cl = seriesCache[idOf(manga)]!!.chapters.asReversed().map {
val title = it.title ?: ""
val groups = it.groups.joinToString(", ")
val number = it.number.content
val displayNumber = it.display_number ?: number
SChapter.create().apply {
url = "${manga.url}/$number"
chapter_number = number.toFloat()
name = "Chapter $displayNumber" + if (title.isNotBlank()) " - $title" else ""
scanlator = groups
val series = getSeriesObject(response.asJsoup().getDataJsonObject(), manga)
if (series != null) {
val seriesPrefs = application.getSharedPreferences("source_${id}_time_found:$seriesId", 0)
val seriesPrefsEditor = seriesPrefs.edit()
val chapters = series.getJSONArray("chapters")
returnChapter = (0 until chapters.length()).reversed().map { i ->
val chapter = chapters.getJSONObject(i)
val title = chapter.optString("title")
val groups = chapter.getJSONArray("groups").joinToString()
val number = chapter.getString("number")
val displayNumber = chapter.optString("display_number", number)
SChapter.create().apply {
url = "${manga.url}/$number"
chapter_number = number.toFloat()
name = "Chapter $displayNumber" + if (title.isNotBlank()) " - $title" else ""
scanlator = groups
// Save current time when a chapter is found for the first time, and reuse it on future
// checks to prevent manga entry without any new chapter bumped to the top of
// "Latest chapter" list when the library is updated.
val currentTimeMillis = System.currentTimeMillis()
if (!seriesPrefs.contains(number)) {
seriesPrefsEditor.putLong(number, currentTimeMillis)
}
date_upload = seriesPrefs.getLong(number, currentTimeMillis)
// Save current time when a chapter is found for the first time, and reuse it on future checks to
// prevent manga entry without any new chapter bumped to the top of "Latest chapter" list
// when the library is updated.
val currentTimeMillis = System.currentTimeMillis()
if (!seriesPrefs.contains(number)) {
seriesPrefsEditor.putLong(number, currentTimeMillis)
}
date_upload = seriesPrefs.getLong(number, currentTimeMillis)
}
seriesPrefsEditor.apply()
}
returnChapter
seriesPrefsEditor.apply()
cl
}
}
override fun popularMangaParse(response: Response): MangasPage {
val mangas = getFilteredSeriesList(response.asJsoup().getDataJsonObject())
return MangasPage(mangas, false)
}
override fun popularMangaParse(response: Response) = MangasPage(seriesCache.map { it.value.toSManga() }, false)
override fun latestUpdatesParse(response: Response): MangasPage {
val latests = response.asJsoup().getDataJsonObject()
.getJSONObject("props")
.getJSONObject("pageProps")
.getJSONArray("latests")
val mangas = (0 until latests.length()).map { i ->
val manga = latests.getJSONArray(i).getJSONObject(0)
SManga.create().apply {
url = "/series/${manga.getString("series_id")}"
title = manga.getString("title")
thumbnail_url = manga.getJSONObject("cover_art").getString("source")
}
}
return MangasPage(mangas, false)
}
override fun latestUpdatesParse(response: Response) = MangasPage(
latestSeries.map { seriesCache[it]!!.toSManga() },
false
)
override fun pageListParse(response: Response): List<Page> {
val pages = response.asJsoup().getDataJsonObject()
.getJSONObject("props")
.getJSONObject("pageProps")
.getJSONArray("pages")
return (0 until pages.length()).map { i -> Page(i, "", pages.getString(i)) }
return json.decodeFromJsonElement<List<String>>(response.asJsoup().getDataJsonObject()["props"]!!.jsonObject["pageProps"]!!.jsonObject["pages"]!!).mapIndexed { index, s ->
Page(index, "", s)
}
}
/**
* Returns json object of site data
*/
private fun Document.getDataJsonObject(): JSONObject {
return JSONObject(getElementById("__NEXT_DATA__").html())
}
/**
* Returns JSONObject for [manga] from site data
*/
private fun getSeriesObject(jsonObject: JSONObject, manga: SManga): JSONObject? {
val seriesId = manga.url.substringAfter("/series/")
val seriesArray = jsonObject
.getJSONObject("props")
.getJSONObject("pageProps")
.getJSONArray("series")
val seriesIndex = (0 until seriesArray.length()).firstOrNull { i ->
seriesArray.getJSONObject(i).optString("series_id").takeIf { it.isNotBlank() } == seriesId
}
return if (seriesIndex != null) seriesArray.getJSONObject(seriesIndex) else null
}
/**
* @return filtered series from home page
* @param data json data from [getDataJsonObject]
* @param titleFilter will be used to check against title and alt_titles, null to disable filter
* @param idFilter will be used to check against id, null to disable filter, only used when [titleFilter] is unset
*/
private fun getFilteredSeriesList(
data: JSONObject,
titleFilter: String? = null,
idFilter: String? = null
): List<SManga> {
val series = data.getJSONObject("props").getJSONObject("pageProps").getJSONArray("series")
val mangas = mutableListOf<SManga>()
for (i in 0 until series.length()) {
val manga = series.getJSONObject(i)
val mangaId = manga.getString("series_id")
val mangaTitle = manga.getString("title")
val mangaAltTitles = manga.getJSONArray("alt_titles")
// Filtering
if (titleFilter != null) {
if (!(mangaTitle.contains(titleFilter, true) || mangaAltTitles.contains(titleFilter))) {
continue
}
} else if (idFilter != null) {
if (!mangaId.contains(idFilter, true)) {
continue
}
}
mangas += SManga.create().apply {
url = "/series/$mangaId"
title = mangaTitle
thumbnail_url = manga.getJSONObject("cover_art").getString("source")
}
}
return mangas.toList()
}
private fun JSONArray.joinToString(separator: String = ", "): String {
val stringBuilder = StringBuilder()
for (i in 0 until length()) {
if (i > 0) stringBuilder.append(separator)
val item = getString(i)
stringBuilder.append(item)
}
return stringBuilder.toString()
}
/**
* For string objects
*/
private operator fun JSONArray.contains(other: CharSequence): Boolean {
for (i in 0 until length()) {
if (optString(i, "").contains(other, true)) {
return true
}
}
return false
}
private fun Document.getDataJsonObject() = json.parseToJsonElement(getElementById("__NEXT_DATA__").html()).jsonObject
override fun mangaDetailsParse(response: Response): SManga {
throw UnsupportedOperationException("Not used.")
@ -246,3 +159,28 @@ class CatManga : HttpSource() {
const val SERIES_ID_SEARCH_PREFIX = "series_id:"
}
}
@Serializable
private data class JsonImage(val source: String, val width: Int, val height: Int)
@Serializable
private data class JsonChapter(val title: String? = null, val groups: List<String>, val number: JsonPrimitive, val display_number: String? = null, val volume: Int? = null)
@Serializable
private data class JsonSeries(val alt_titles: List<String>, val authors: List<String>, val genres: List<String>, val chapters: List<JsonChapter>, val title: String, val series_id: String, val description: String, val status: String, val cover_art: JsonImage, val all_covers: List<JsonImage>) {
fun toSManga() = this.let { jsonSeries ->
SManga.create().apply {
url = "/series/${jsonSeries.series_id}"
title = jsonSeries.title
thumbnail_url = jsonSeries.cover_art.source
author = jsonSeries.authors.joinToString(", ")
description = jsonSeries.description
genre = jsonSeries.genres.joinToString(", ")
status = when (jsonSeries.status) {
"ongoing" -> SManga.ONGOING
"completed" -> SManga.COMPLETED
else -> SManga.UNKNOWN
}
}
}
}

View File

@ -1,11 +1,12 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlinx-serialization'
ext {
extName = 'Remanga'
pkgNameSuffix = 'ru.remanga'
extClass = '.Remanga'
extVersionCode = 28
extVersionCode = 29
libVersion = '1.2'
}

View File

@ -17,10 +17,6 @@ import android.content.SharedPreferences
import android.os.Build
import android.text.InputType
import android.widget.Toast
import com.github.salomonbrys.kotson.fromJson
import com.google.gson.Gson
import com.google.gson.JsonObject
import com.google.gson.JsonSyntaxException
import eu.kanade.tachiyomi.lib.dataimage.DataImageInterceptor
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
@ -34,6 +30,13 @@ 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 kotlinx.serialization.SerializationException
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.put
import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.Interceptor
@ -42,17 +45,16 @@ import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import org.json.JSONObject
import org.jsoup.Jsoup
import rx.Observable
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import kotlin.math.absoluteValue
import kotlin.random.Random
class Remanga : ConfigurableSource, HttpSource() {
override val name = "Remanga"
@ -100,15 +102,16 @@ class Remanga : ConfigurableSource, HttpSource() {
private var branches = mutableMapOf<String, List<BranchesDto>>()
private fun login(chain: Interceptor.Chain, username: String, password: String): String {
val jsonObject = JSONObject()
jsonObject.put("user", username)
jsonObject.put("password", password)
val jsonObject = buildJsonObject {
put("user", username)
put("password", password)
}
val body = jsonObject.toString().toRequestBody(MEDIA_TYPE)
val response = chain.proceed(POST("$baseUrl/api/users/login/", headers, body))
if (response.code >= 400) {
throw Exception("Failed to login")
}
val user = gson.fromJson<SeriesWrapperDto<UserDto>>(response.body?.charStream()!!)
val user = json.decodeFromString<SeriesWrapperDto<UserDto>>(response.body!!.string())
return user.content.access_token
}
@ -121,7 +124,7 @@ class Remanga : ConfigurableSource, HttpSource() {
override fun latestUpdatesParse(response: Response): MangasPage = searchMangaParse(response)
override fun searchMangaParse(response: Response): MangasPage {
val page = gson.fromJson<PageWrapperDto<LibraryDto>>(response.body?.charStream()!!)
val page = json.decodeFromString<PageWrapperDto<LibraryDto>>(response.body!!.string())
val mangas = page.content.map {
it.toSManga()
}
@ -159,7 +162,7 @@ class Remanga : ConfigurableSource, HttpSource() {
when (filter) {
is OrderBy -> {
val ord = arrayOf("id", "chapter_date", "rating", "votes", "views", "count_chapters", "random")[filter.state!!.index]
url.addQueryParameter("ordering", if (filter.state!!.ascending) "$ord" else "-$ord")
url.addQueryParameter("ordering", if (filter.state!!.ascending) ord else "-$ord")
}
is CategoryList -> filter.state.forEach { category ->
if (category.state != Filter.TriState.STATE_IGNORE) {
@ -273,7 +276,7 @@ class Remanga : ConfigurableSource, HttpSource() {
return GET(baseUrl.replace("api.", "") + "/manga/" + manga.url.substringAfter("/api/titles/", "/"), headers)
}
override fun mangaDetailsParse(response: Response): SManga {
val series = gson.fromJson<SeriesWrapperDto<MangaDetDto>>(response.body?.charStream()!!)
val series = json.decodeFromString<SeriesWrapperDto<MangaDetDto>>(response.body!!.string())
branches[series.content.en_name] = series.content.branches
return series.content.toSManga()
}
@ -281,10 +284,11 @@ class Remanga : ConfigurableSource, HttpSource() {
private fun mangaBranches(manga: SManga): List<BranchesDto> {
val responseString = client.newCall(GET("$baseUrl/${manga.url}")).execute().body?.string() ?: return emptyList()
// manga requiring login return "content" as a JsonArray instead of the JsonObject we expect
return if (gson.fromJson<JsonObject>(responseString)["content"].isJsonObject) {
val series = gson.fromJson<SeriesWrapperDto<MangaDetDto>>(responseString)
branches[series.content.en_name] = series.content.branches
series.content.branches
val content = json.decodeFromString<JsonObject>(responseString)["content"]
return if (content is JsonObject) {
val series = json.decodeFromJsonElement<MangaDetDto>(content)
branches[series.en_name] = series.branches
series.branches
} else {
emptyList()
}
@ -325,8 +329,8 @@ class Remanga : ConfigurableSource, HttpSource() {
}
override fun chapterListParse(response: Response): List<SChapter> {
val chapters = gson.fromJson<PageWrapperDto<BookDto>>(response.body?.charStream()!!)
return chapters.content.filter { !it.is_paid or it.is_bought }.map { chapter ->
val chapters = json.decodeFromString<SeriesWrapperDto<List<BookDto>>>(response.body!!.string())
return chapters.content.filter { !it.is_paid or (it.is_bought == true) }.map { chapter ->
SChapter.create().apply {
chapter_number = chapter.chapter.split(".").take(2).joinToString(".").toFloat()
name = chapterName(chapter)
@ -343,12 +347,12 @@ class Remanga : ConfigurableSource, HttpSource() {
override fun pageListParse(response: Response): List<Page> {
val body = response.body?.string()!!
return try {
val page = gson.fromJson<SeriesWrapperDto<PageDto>>(body)
val page = json.decodeFromString<SeriesWrapperDto<PageDto>>(body)
page.content.pages.filter { it.height > 1 }.map {
Page(it.page, "", it.link)
}
} catch (e: JsonSyntaxException) {
val page = gson.fromJson<SeriesWrapperDto<PaidPageDto>>(body)
} catch (e: SerializationException) {
val page = json.decodeFromString<SeriesWrapperDto<PaidPageDto>>(body)
val result = mutableListOf<Page>()
page.content.pages.forEach {
it.filter { page -> page.height > 10 }.forEach { page ->
@ -596,7 +600,7 @@ class Remanga : ConfigurableSource, HttpSource() {
dialogTitle = title
if (isPassword) {
if (!value.isNullOrBlank()) { summary = "*****" }
if (value.isNotBlank()) { summary = "*****" }
setOnBindEditTextListener {
it.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
}
@ -617,7 +621,7 @@ class Remanga : ConfigurableSource, HttpSource() {
private fun getPrefUsername(): String = preferences.getString(USERNAME_TITLE, USERNAME_DEFAULT)!!
private fun getPrefPassword(): String = preferences.getString(PASSWORD_TITLE, PASSWORD_DEFAULT)!!
private val gson by lazy { Gson() }
private val json: Json by injectLazy()
private val username by lazy { getPrefUsername() }
private val password by lazy { getPrefPassword() }

View File

@ -1,19 +1,25 @@
import kotlinx.serialization.Serializable
@Serializable
data class GenresDto(
val id: Int,
val name: String
)
@Serializable
data class BranchesDto(
val id: Long,
val count_chapters: Int
)
@Serializable
data class ImgDto(
val high: String,
val mid: String,
val low: String
)
@Serializable
data class LibraryDto(
val id: Long,
val en_name: String,
@ -24,11 +30,13 @@ data class LibraryDto(
val img: ImgDto
)
@Serializable
data class StatusDto(
val id: Int,
val name: String
)
@Serializable
data class MangaDetDto(
val id: Long,
val en_name: String,
@ -47,30 +55,34 @@ data class MangaDetDto(
val age_limit: Int
)
@Serializable
data class PropsDto(
val total_items: Int,
val total_pages: Int,
val page: Int
)
@Serializable
data class PageWrapperDto<T>(
val msg: String,
val content: List<T>,
val props: PropsDto,
val last: Boolean
// val last: Boolean
)
@Serializable
data class SeriesWrapperDto<T>(
val msg: String,
val content: T,
val props: PropsDto
// val props: PropsDto
)
@Serializable
data class PublisherDto(
val name: String,
val dir: String
)
@Serializable
data class BookDto(
val id: Long,
val tome: Int,
@ -78,10 +90,11 @@ data class BookDto(
val name: String,
val upload_date: String,
val is_paid: Boolean,
val is_bought: Boolean,
val is_bought: Boolean?,
val publishers: List<PublisherDto>
)
@Serializable
data class PagesDto(
val id: Int,
val height: Int,
@ -90,14 +103,17 @@ data class PagesDto(
val count_comments: Int
)
@Serializable
data class PageDto(
val pages: List<PagesDto>
)
@Serializable
data class UserDto(
val access_token: String
)
@Serializable
data class PaidPagesDto(
val id: Long,
val link: String,
@ -105,6 +121,7 @@ data class PaidPagesDto(
val page: Int
)
@Serializable
data class PaidPageDto(
val pages: List<List<PaidPagesDto>>
)