without serialization, and added cache

Naga 2 years ago
parent f39a9f191a
commit 1926a73dee

2
.idea/.gitignore vendored

@ -1,3 +1,5 @@
# Default ignored files # Default ignored files
/shelf/ /shelf/
/workspace.xml /workspace.xml
# GitHub Copilot persisted chat sessions
/copilot/chatSessions

@ -5,7 +5,6 @@ plugins {
id 'org.jetbrains.kotlin.jvm' version '1.9.22' id 'org.jetbrains.kotlin.jvm' version '1.9.22'
id 'com.google.devtools.ksp' version '1.9.22-1.0.17' id 'com.google.devtools.ksp' version '1.9.22-1.0.17'
id 'maven-publish' id 'maven-publish'
id 'org.jetbrains.kotlin.plugin.serialization' version '1.9.22'
} }
group = 'org.koitharu' group = 'org.koitharu'
@ -60,7 +59,6 @@ dependencies {
api 'org.jsoup:jsoup:1.17.2' api 'org.jsoup:jsoup:1.17.2'
implementation 'org.json:json:20231013' implementation 'org.json:json:20231013'
implementation 'androidx.collection:collection:1.4.0' implementation 'androidx.collection:collection:1.4.0'
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3"
ksp project(':kotatsu-parsers-ksp') ksp project(':kotatsu-parsers-ksp')

@ -3,10 +3,10 @@ package org.koitharu.kotatsu.parsers.site.en
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable import okhttp3.Headers
import kotlinx.serialization.json.*
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject import org.json.JSONObject
import org.jsoup.Jsoup import org.jsoup.Jsoup
@ -25,7 +25,7 @@ import java.util.EnumSet
import kotlin.random.Random import kotlin.random.Random
private const val TOO_MANY_REQUESTS = 429 private const val TOO_MANY_REQUESTS = 429
private const val MAX_RETRY_COUNT = 3 private const val MAX_RETRY_COUNT = 5
@MangaSourceParser("REAPERCOMICS", "ReaperComics", "en") @MangaSourceParser("REAPERCOMICS", "ReaperComics", "en")
internal class ReaperComics(context: MangaLoaderContext) : internal class ReaperComics(context: MangaLoaderContext) :
@ -48,6 +48,7 @@ internal class ReaperComics(context: MangaLoaderContext) :
private val selectState = "dl.mt-2 div:nth-child(4) > dd" private val selectState = "dl.mt-2 div:nth-child(4) > dd"
private val searchCache = mutableSetOf<Manga>() // Cache search results private val searchCache = mutableSetOf<Manga>() // Cache search results
private val chapterCache = mutableMapOf<String, Manga>() // Cache chapter lists
private val baseUrl = "https://reaperscans.com" private val baseUrl = "https://reaperscans.com"
@ -154,26 +155,21 @@ internal class ReaperComics(context: MangaLoaderContext) :
override suspend fun getAvailableTags(): Set<MangaTag> = emptySet() override suspend fun getAvailableTags(): Set<MangaTag> = emptySet()
private inline fun <reified T> Response.parseJson(): T = use {
it.body!!.string().parseJson()
}
private inline fun <reified T> String.parseJson(): T = json.decodeFromString(this)
companion object { companion object {
private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaType() private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaType()
} }
private fun chapterListNextPageSelector(): String = "button[wire:click*=nextPage]" private fun chapterListNextPageSelector(): String = "button[wire:click*=nextPage]"
private val json = Json {
ignoreUnknownKeys = true
}
private fun chapterListSelector() = "div[wire:id] > div > ul[role=list] > li" private fun chapterListSelector() = "div[wire:id] > div > ul[role=list] > li"
override suspend fun getDetails(manga: Manga): Manga { override suspend fun getDetails(manga: Manga): Manga {
val doc = Jsoup.parse(webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseRaw()) val cachedChapters = chapterCache[manga.url]
if (cachedChapters != null) {
return cachedChapters
}
val doc = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml()
val simpleDateFormat = SimpleDateFormat("dd/MM/yyyy", sourceLocale) val simpleDateFormat = SimpleDateFormat("dd/MM/yyyy", sourceLocale)
var totalChapters = (doc.selectFirst(selectTotalChapter)?.text()?.toIntOrNull() ?: 0) - 1 var totalChapters = (doc.selectFirst(selectTotalChapter)?.text()?.toIntOrNull() ?: 0) - 1
val chapters = mutableSetOf<MangaChapter>() val chapters = mutableSetOf<MangaChapter>()
@ -212,13 +208,13 @@ internal class ReaperComics(context: MangaLoaderContext) :
val csrfToken = doc.selectFirst("meta[name=csrf-token]")?.attr("content") ?: error("Couldn't find csrf-token") val csrfToken = doc.selectFirst("meta[name=csrf-token]")?.attr("content") ?: error("Couldn't find csrf-token")
val livewareData = doc.selectFirst("div[wire:initial-data*=Models\\\\Comic]")?.attr("wire:initial-data") val livewareData = doc.selectFirst("div[wire:initial-data*=Models\\\\Comic]")?.attr("wire:initial-data")
?.parseJson<LiveWireDataDto>() ?: error("Couldn't find LiveWireData") ?.let { JSONObject(it) } ?: error("Couldn't find LiveWireData")
val routeName = val routeName =
livewareData.fingerprint["name"]?.jsonPrimitive?.contentOrNull ?: error("Couldn't find routeName") livewareData.getJSONObject("fingerprint").getStringOrNull("name") ?: error("Couldn't find routeName")
val fingerprint = livewareData.fingerprint val fingerprint = livewareData.getJSONObject("fingerprint")
var serverMemo = livewareData.serverMemo var serverMemo = livewareData.getJSONObject("serverMemo")
var pageToQuery = 2 var pageToQuery = 2
@ -230,30 +226,27 @@ internal class ReaperComics(context: MangaLoaderContext) :
} // Not exactly the same, but results in a 3-5 character string } // Not exactly the same, but results in a 3-5 character string
while (hasNextPage) { while (hasNextPage) {
val payload = buildJsonObject { //need to format the payload to the expected response format since org.json.JSONObject are not ordered, and the server seems to care about the order of the keys
put("fingerprint", fingerprint) val payload = String.format(
put("serverMemo", serverMemo) responseTemplate,
putJsonArray("updates") { fingerprint.getString("id"),
addJsonObject { fingerprint.getString("path"),
put("type", "callMethod") serverMemo.getString("htmlHash"),
putJsonObject("payload") { pageToQuery - 1,
put("id", generateId()) pageToQuery - 1,
put("method", "gotoPage") serverMemo.getJSONObject("dataMeta").getJSONObject("models").getJSONObject("comic").getString("id"),
putJsonArray("params") { serverMemo.getString("checksum"),
add(pageToQuery) generateId(),
add("page") pageToQuery,
} ).toRequestBody(JSON_MEDIA_TYPE)
}
}
}
}.toString().toRequestBody(JSON_MEDIA_TYPE)
val headers = Headers.Builder().add("x-csrf-token", csrfToken).add("x-livewire", "true").build() val headers = Headers.Builder().add("x-csrf-token", csrfToken).add("x-livewire", "true").build()
val responseData = makeRequest("$baseUrl/livewire/message/$routeName", payload, headers) val responseData =
makeRequest("$baseUrl/livewire/message/$routeName", payload, headers)
// response contains state that we need to preserve // response contains state that we need to preserve
serverMemo = serverMemo.mergeLeft(responseData.serverMemo) serverMemo = mergeLeft(serverMemo, responseData.serverMemo)
val chaptersHtml = Jsoup.parse(responseData.effects.html, baseUrl) val chaptersHtml = Jsoup.parse(responseData.effects.html, baseUrl)
chapters.addAll( chapters.addAll(
chaptersHtml.select(chapterListSelector()).mapChapters { _, li -> chaptersHtml.select(chapterListSelector()).mapChapters { _, li ->
@ -278,7 +271,7 @@ internal class ReaperComics(context: MangaLoaderContext) :
pageToQuery++ pageToQuery++
} }
return manga.copy( val copy = manga.copy(
description = doc.selectFirst("div.p-4 p.prose")?.html(), description = doc.selectFirst("div.p-4 p.prose")?.html(),
state = when (doc.selectFirst(selectState)?.text()?.lowercase()) { state = when (doc.selectFirst(selectState)?.text()?.lowercase()) {
"ongoing" -> MangaState.ONGOING "ongoing" -> MangaState.ONGOING
@ -287,21 +280,30 @@ internal class ReaperComics(context: MangaLoaderContext) :
}, },
chapters = chapters.reversed(), chapters = chapters.reversed(),
) )
chapterCache[manga.url] = copy
return copy
} }
private suspend fun makeRequest(url: String, payload: RequestBody, headers: Headers): LiveWireResponseDto { private suspend fun makeRequest(url: String, payload: RequestBody, headers: Headers): LiveWireResponseDto {
var retryCount = 0 var retryCount = 0
var backoffDelay = 2000L // Initial delay (milliseconds) val backoffDelay = 2000L // Initial delay (milliseconds)
val request = Request.Builder().url(url).post(payload).headers(headers).build() val request = Request.Builder().url(url).post(payload).headers(headers).build()
while (true) { while (true) {
try { try {
return context.httpClient.newCall(request).execute().parseJson<LiveWireResponseDto>() val response = context.httpClient.newCall(request).execute().parseJson()
val effectsJson = response.getJSONObject("effects")
val serverMemoJson = response.getJSONObject("serverMemo")
val effects = LiveWireEffectsDto(effectsJson.getString("html"))
return LiveWireResponseDto(effects, serverMemoJson)
} catch (e: Exception) { } catch (e: Exception) {
// Log or handle the exception as needed // Log or handle the exception as needed
if (++retryCount <= MAX_RETRY_COUNT) { if (++retryCount <= MAX_RETRY_COUNT) {
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
delay(backoffDelay) delay(backoffDelay)
backoffDelay += 500L
} }
} else { } else {
throw e throw e
@ -315,16 +317,17 @@ internal class ReaperComics(context: MangaLoaderContext) :
* If j1 and j2 both contain keys whose values aren't both jsonObjects, j2's value overwrites j1's * If j1 and j2 both contain keys whose values aren't both jsonObjects, j2's value overwrites j1's
* *
*/ */
private fun JsonObject.mergeLeft(j2: JsonObject): JsonObject = buildJsonObject { private fun mergeLeft(j1: JSONObject, j2: JSONObject): JSONObject {
val j1 = this@mergeLeft for (key in j2.keys()) {
j1.entries.forEach { (key, value) -> put(key, value) } val j1Value = j1.opt(key)
j2.entries.forEach { (key, value) ->
val j1Value = j1[key] if (j1Value !is JSONObject) {
when { j1.put(key, j2[key])
j1Value !is JsonObject -> put(key, value) } else if (j2[key] is JSONObject) {
value is JsonObject -> put(key, j1Value.mergeLeft(value)) j1.put(key, mergeLeft(j1Value, j2.getJSONObject(key)))
} }
} }
return j1
} }
private fun parseChapterDate(dateFormat: DateFormat, date: String?): Long { private fun parseChapterDate(dateFormat: DateFormat, date: String?): Long {
@ -364,19 +367,14 @@ internal class ReaperComics(context: MangaLoaderContext) :
} }
} }
@Serializable
data class LiveWireResponseDto( data class LiveWireResponseDto(
val effects: LiveWireEffectsDto, val effects: LiveWireEffectsDto,
val serverMemo: JsonObject, val serverMemo: JSONObject,
) )
@Serializable
data class LiveWireEffectsDto( data class LiveWireEffectsDto(
val html: String, val html: String,
) )
@Serializable //!IMPORTANT
data class LiveWireDataDto( private val responseTemplate = """{"fingerprint":{"id":"%s","name":"frontend.comic-chapter-list","locale":"en","path":"%s","method":"GET","v":"acj"},"serverMemo":{"children":[],"errors":[],"htmlHash":"%s","data":{"comic":[],"page":%d,"paginators":{"page":%d}},"dataMeta":{"models":{"comic":{"class":"App\\Models\\Comic","id":"%s","relations":[],"connection":"pgsql","collectionClass":null}}},"checksum":"%s"},"updates":[{"type":"callMethod","payload":{"id":"%s","method":"gotoPage","params":[%d,"page"]}}]}"""
val fingerprint: JsonObject,
val serverMemo: JsonObject,
)

Loading…
Cancel
Save