diff --git a/src/en/reaperscans/build.gradle b/src/en/reaperscans/build.gradle index 657cc92d0..bc16be118 100644 --- a/src/en/reaperscans/build.gradle +++ b/src/en/reaperscans/build.gradle @@ -6,7 +6,7 @@ ext { extName = 'Reaper Scans' pkgNameSuffix = 'en.reaperscans' extClass = '.ReaperScans' - extVersionCode = 36 + extVersionCode = 37 } apply from: "$rootDir/common.gradle" diff --git a/src/en/reaperscans/src/eu/kanade/tachiyomi/extension/en/reaperscans/ReaperScans.kt b/src/en/reaperscans/src/eu/kanade/tachiyomi/extension/en/reaperscans/ReaperScans.kt index 887af0be6..053f6f249 100644 --- a/src/en/reaperscans/src/eu/kanade/tachiyomi/extension/en/reaperscans/ReaperScans.kt +++ b/src/en/reaperscans/src/eu/kanade/tachiyomi/extension/en/reaperscans/ReaperScans.kt @@ -1,6 +1,7 @@ package eu.kanade.tachiyomi.extension.en.reaperscans import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.POST import eu.kanade.tachiyomi.source.model.FilterList import eu.kanade.tachiyomi.source.model.MangasPage import eu.kanade.tachiyomi.source.model.Page @@ -8,17 +9,17 @@ 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 kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.add import kotlinx.serialization.json.addJsonObject import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.put import kotlinx.serialization.json.putJsonArray import kotlinx.serialization.json.putJsonObject -import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.Headers +import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody @@ -75,75 +76,123 @@ class ReaperScans : ParsedHttpSource() { title = it.text().trim() setUrlWithoutDomain(it.attr("href")) } - thumbnail_url = element.select("img").attr("src") + thumbnail_url = element.select("img").attr("abs:src") + } + } + + // Search + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + val response = client.newCall(GET(baseUrl)).execute() + val soup = response.asJsoup() + + val csrfToken = soup.selectFirst("meta[name=csrf-token]")?.attr("content") + + val livewareData = soup.selectFirst("div[wire:initial-data*=frontend.global-search]") + ?.attr("wire:initial-data") + ?.parseJson() + + if (csrfToken == null) error("Couldn't find csrf-token") + if (livewareData == null) error("Couldn't find LiveWireData") + + val payload = buildJsonObject { + put("fingerprint", livewareData.fingerprint) + put("serverMemo", livewareData.serverMemo) + putJsonArray("updates") { + addJsonObject { + put("type", "syncInput") + putJsonObject("payload") { + put("id", "03r6") + put("name", "query") + put("value", query) + } + } + } + }.toString().toRequestBody(JSON_MEDIA_TYPE) + + val headers = Headers.Builder() + .add("x-csrf-token", csrfToken) + .add("x-livewire", "true") + .build() + + return POST("$baseUrl/livewire/message/frontend.global-search", headers, payload) + } + + override fun searchMangaSelector(): String = "a[href*=/comics/]" + + override fun searchMangaParse(response: Response): MangasPage { + val html = response.parseJson().effects.html + val mangas = Jsoup.parse(html, baseUrl).select(searchMangaSelector()).map { element -> + searchMangaFromElement(element) + } + return MangasPage(mangas, false) + } + + override fun searchMangaFromElement(element: Element): SManga { + return SManga.create().apply { + setUrlWithoutDomain(element.attr("href")) + element.select("img").first()?.let { + thumbnail_url = it.attr("abs:src") + } + title = element.select("p").first().text() } } // Details - override fun mangaDetailsParse(document: Document): SManga = SManga.create().apply { - thumbnail_url = document.select("div > img").first().attr("abs:src") - title = document.select("h1").first().text() + override fun mangaDetailsParse(document: Document): SManga { + return SManga.create().apply { + thumbnail_url = document.select("div > img").first().attr("abs:src") + title = document.select("h1").first().text() - status = when (document.select("dt:contains(Source Status)").next().text()) { - "On hold" -> SManga.ON_HIATUS - "Complete" -> SManga.COMPLETED - "Ongoing" -> SManga.ONGOING - "Dropped" -> SManga.CANCELLED - else -> SManga.UNKNOWN + status = when (document.select("dt:contains(Release Status)").next().text()) { + "On hold" -> SManga.ON_HIATUS + "Complete" -> SManga.COMPLETED + "Ongoing" -> SManga.ONGOING + "Dropped" -> SManga.CANCELLED + else -> SManga.UNKNOWN + } + + genre = mutableListOf().apply { + when (document.select("dt:contains(Source Language)").next().text()) { + "Korean" -> "Manhwa" + "Chinese" -> "Manhua" + "Japanese" -> "Manga" + else -> null + }?.let { add(it) } + }.takeIf { it.isNotEmpty() }?.joinToString(",") + + description = document.select("section > div:nth-child(1) > div > p").first().text() } - - val genreList = mutableListOf() - val seriesType = when (document.select("dt:contains(Source Language)").next().text()) { - "Korean" -> "Manhwa" - "Chinese" -> "Manhua" - "Japanese" -> "Manga" - else -> null - } - seriesType?.let { genreList.add(it) } - - genre = genreList.takeIf { genreList.isNotEmpty() }?.joinToString(",") - description = document.select("section > div:nth-child(1) > div > p").first().text() } // Chapters - override fun chapterListSelector() = "ul > li" + private fun chapterListNextPageSelector(): String = "button[wire:click*=nextPage]" - /** - * 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)) - } - } - } + override fun chapterListSelector() = "div[wire:id] > ul[role=list] > li" override fun chapterListParse(response: Response): List { var document = response.asJsoup() val chapters = mutableListOf() - document.select("div.pb-4 > div >" + chapterListSelector()).map { chapters.add(chapterFromElement(it)) } val csrfToken = document.selectFirst("meta[name=csrf-token]")?.attr("content") - val initialProps = document.selectFirst("div[wire:initial-data*=frontend.comic-chapters-list]")?.attr("wire:initial-data")?.let { - json.parseToJsonElement(it) - } + val livewareData = document.selectFirst("div[wire:initial-data*=frontend.comic-chapters-list]") + ?.attr("wire:initial-data") + ?.parseJson() - if (csrfToken != null && initialProps is JsonObject) { - var serverMemo = initialProps["serverMemo"]!!.jsonObject - val fingerprint = initialProps["fingerprint"]!! + if (csrfToken == null) error("Couldn't find csrf-token") + if (livewareData == null) error("Couldn't find LiveWireData") - var nextPage = 2 - while (document.select(popularMangaNextPageSelector()).isNotEmpty()) { + val fingerprint = livewareData.fingerprint + var serverMemo = livewareData.serverMemo + + var pageToQuery = 1 + var hasNextPage = true + + while (hasNextPage) { + if (pageToQuery != 1) { val payload = buildJsonObject { put("fingerprint", fingerprint) put("serverMemo", serverMemo) -// put("updates", json.parseToJsonElement("[{\"type\":\"callMethod\",\"payload\":{\"id\":\"9jhcg\",\"method\":\"gotoPage\",\"params\":[$nextPage,\"page\"]}}]")) putJsonArray("updates") { addJsonObject { put("type", "callMethod") @@ -151,133 +200,105 @@ class ReaperScans : ParsedHttpSource() { put("id", "9jhcg") put("method", "gotoPage") putJsonArray("params") { - add(nextPage) + add(pageToQuery) add("page") } } } } - }.toString().toRequestBody("application/json; charset=utf-8".toMediaTypeOrNull()) + }.toString().toRequestBody(JSON_MEDIA_TYPE) - val request = Request.Builder().url("$baseUrl/livewire/message/frontend.comic-chapters-list").method("POST", payload).addHeader("x-csrf-token", csrfToken).addHeader("x-livewire", "true").build() + val headers = Headers.Builder() + .add("x-csrf-token", csrfToken) + .add("x-livewire", "true") + .build() - val response1 = client.newCall(request).execute() - val responseText = response1.body!!.string() + val request = POST("$baseUrl/livewire/message/frontend.comic-chapters-list", headers, payload) - val responseJson = json.parseToJsonElement(responseText).jsonObject + val responseData = client.newCall(request).execute().parseJson() // response contains state that we need to preserve - serverMemo = mergeLeft(serverMemo, responseJson["serverMemo"]!!.jsonObject) - - document = Jsoup.parse(responseJson["effects"]!!.jsonObject.get("html")?.jsonPrimitive?.content) - - document.select(chapterListSelector()).map { chapters.add(chapterFromElement(it)) } - nextPage++ + serverMemo = serverMemo.mergeLeft(responseData.serverMemo) + document = Jsoup.parse(responseData.effects.html, baseUrl) } + + document.select(chapterListSelector()).forEach { chapters.add(chapterFromElement(it)) } + hasNextPage = document.selectFirst(chapterListNextPageSelector()) != null + pageToQuery++ } + return chapters } override fun chapterFromElement(element: Element): SChapter { - val chapter = SChapter.create() - with(element) { - select("a").first()?.let { urlElement -> - chapter.setUrlWithoutDomain(urlElement.attr("abs:href")) - chapter.name = urlElement.select("p").first().text() - urlElement.select("p").takeIf { it.size > 1 }?.let { - chapter.date_upload = parseRelativeDate(it[1].text()) + return SChapter.create().apply { + element.selectFirst("a")?.let { urlElement -> + setUrlWithoutDomain(urlElement.attr("href")) + urlElement.select("p").let { + name = it.getOrNull(0)?.text() ?: "" + date_upload = it.getOrNull(1)?.text()?.parseRelativeDate() ?: 0 } } } - return chapter - } - - // Search - override fun searchMangaSelector(): String = "a[href*=/comics/]" - - override fun searchMangaFromElement(element: Element) = SManga.create().apply { - setUrlWithoutDomain(element.attr("href")) - element.select("img").first()?.let { - thumbnail_url = it.attr("abs:src") - } - title = element.select("p").first().text() - } - - override fun searchMangaNextPageSelector(): String? = throw UnsupportedOperationException("Not Used") - - override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { - val response = client.newCall(GET(baseUrl)).execute() - val soup = response.asJsoup() - - val csrfToken = soup.selectFirst("meta[name=csrf-token]")?.attr("content") - - val initialProps = soup.selectFirst("div[wire:initial-data*=frontend.global-search]")?.attr("wire:initial-data")?.let { - json.parseToJsonElement(it) - } - - if (csrfToken != null && initialProps is JsonObject) { - val serverMemo = initialProps["serverMemo"]!!.jsonObject - val fingerprint = initialProps["fingerprint"]!! - - val payload = buildJsonObject { - put("fingerprint", fingerprint) - put("serverMemo", serverMemo) -// put("updates", json.parseToJsonElement("[{\"type\":\"syncInput\",\"payload\":{\"id\":\"03r6\",\"name\":\"query\",\"value\":\"$query\"}}]")) - putJsonArray("updates") { - addJsonObject { - put("type", "syncInput") - putJsonObject("payload") { - put("id", "03r6") - put("name", "query") - put("value", query) - } - } - } - }.toString().toRequestBody("application/json; charset=utf-8".toMediaTypeOrNull()) - - return Request.Builder().url("$baseUrl/livewire/message/frontend.global-search").method("POST", payload).addHeader("x-csrf-token", csrfToken).addHeader("x-livewire", "true").build() - } - - throw Exception("search error") - } - - override fun searchMangaParse(response: Response): MangasPage { - val responseText = response.body!!.string() - val responseJson = json.parseToJsonElement(responseText).jsonObject - val document = Jsoup.parse(responseJson["effects"]!!.jsonObject.get("html")?.jsonPrimitive?.content) - val mangas = document.select(searchMangaSelector()).map { element -> - searchMangaFromElement(element) - } - return MangasPage(mangas, false) } // Page - override fun pageListRequest(chapter: SChapter): Request = GET("$baseUrl${chapter.url}") - override fun pageListParse(document: Document): List { return document.select("img.max-w-full").mapIndexed { index, element -> - Page(index, "", element.attr("src")) + Page(index, imageUrl = element.attr("src")) } } - override fun imageUrlParse(document: Document): String = throw UnsupportedOperationException("Not Used") + // Helpers + private inline fun Response.parseJson(): T = use { + it.body?.string().orEmpty().parseJson() + } - // Parses dates in this form: - // 21 horas ago - // Taken from multisrc/madara/Madara.kt - private fun parseRelativeDate(date: String): Long { - val number = Regex("""(\d+)""").find(date)?.value?.toIntOrNull() ?: return 0 + private inline fun String.parseJson(): T = json.decodeFromString(this) + + /** + * 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 JsonObject.mergeLeft(j2: JsonObject): JsonObject = buildJsonObject { + val j1 = this@mergeLeft + j1.entries.forEach { (key, value) -> put(key, value) } + j2.entries.forEach { (key, value) -> + val j1Value = j1[key] + when { + j1Value !is JsonObject -> put(key, value) + value is JsonObject -> put(key, j1Value.mergeLeft(value)) + } + } + } + + /** + * Parses dates in this form: 21 hours ago + * Taken from multisrc/madara/Madara.kt + */ + private fun String.parseRelativeDate(): Long { + val number = Regex("""(\d+)""").find(this)?.value?.toIntOrNull() ?: return 0 val cal = Calendar.getInstance() return when { - date.contains("day") -> cal.apply { add(Calendar.DAY_OF_MONTH, -number) }.timeInMillis - date.contains("hour") -> cal.apply { add(Calendar.HOUR, -number) }.timeInMillis - date.contains("minute") -> cal.apply { add(Calendar.MINUTE, -number) }.timeInMillis - date.contains("second") -> cal.apply { add(Calendar.SECOND, -number) }.timeInMillis - date.contains("week") -> cal.apply { add(Calendar.DAY_OF_MONTH, -number * 7) }.timeInMillis - date.contains("month") -> cal.apply { add(Calendar.MONTH, -number) }.timeInMillis - date.contains("year") -> cal.apply { add(Calendar.YEAR, -number) }.timeInMillis + contains("day") -> cal.apply { add(Calendar.DAY_OF_MONTH, -number) }.timeInMillis + contains("hour") -> cal.apply { add(Calendar.HOUR, -number) }.timeInMillis + contains("minute") -> cal.apply { add(Calendar.MINUTE, -number) }.timeInMillis + contains("second") -> cal.apply { add(Calendar.SECOND, -number) }.timeInMillis + contains("week") -> cal.apply { add(Calendar.DAY_OF_MONTH, -number * 7) }.timeInMillis + contains("month") -> cal.apply { add(Calendar.MONTH, -number) }.timeInMillis + contains("year") -> cal.apply { add(Calendar.YEAR, -number) }.timeInMillis else -> 0 } } + + // Unused + override fun searchMangaNextPageSelector() = throw UnsupportedOperationException("Not Used") + + override fun imageUrlParse(document: Document) = throw UnsupportedOperationException("Not Used") + + companion object { + private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaType() + } } diff --git a/src/en/reaperscans/src/eu/kanade/tachiyomi/extension/en/reaperscans/ReaperScansDto.kt b/src/en/reaperscans/src/eu/kanade/tachiyomi/extension/en/reaperscans/ReaperScansDto.kt new file mode 100644 index 000000000..34fde5173 --- /dev/null +++ b/src/en/reaperscans/src/eu/kanade/tachiyomi/extension/en/reaperscans/ReaperScansDto.kt @@ -0,0 +1,21 @@ +package eu.kanade.tachiyomi.extension.en.reaperscans + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonObject + +@Serializable +data class LiveWireResponseDto( + val effects: LiveWireEffectsDto, + val serverMemo: JsonObject +) + +@Serializable +data class LiveWireEffectsDto( + val html: String +) + +@Serializable +data class LiveWireDataDto( + val fingerprint: JsonObject, + val serverMemo: JsonObject +)