diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/all/MangaReaderToParser.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/all/MangaReaderToParser.kt new file mode 100644 index 00000000..4b4819dd --- /dev/null +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/all/MangaReaderToParser.kt @@ -0,0 +1,354 @@ +package org.koitharu.kotatsu.parsers.site.all + +import android.graphics.* +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.Interceptor +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import org.jsoup.Jsoup +import org.jsoup.nodes.Document +import org.koitharu.kotatsu.parsers.* +import org.koitharu.kotatsu.parsers.config.ConfigKey +import org.koitharu.kotatsu.parsers.model.* +import org.koitharu.kotatsu.parsers.util.* +import java.io.* +import java.util.* +import javax.crypto.Cipher +import javax.crypto.spec.SecretKeySpec +import kotlin.math.min + +@MangaSourceParser("MANGAREADERTO", "MangaReader.To") +class MangaReaderToParser(context: MangaLoaderContext) : PagedMangaParser(context, MangaSource.MANGAREADERTO, 16), Interceptor { + + override val configKeyDomain = ConfigKey.Domain("mangareader.to") + + override val availableSortOrders: Set = EnumSet.of( + SortOrder.POPULARITY, + SortOrder.RATING, + SortOrder.UPDATED, + SortOrder.NEWEST, + SortOrder.ALPHABETICAL + ) + + override val availableStates: Set = EnumSet.allOf(MangaState::class.java) + + val tags = SoftSuspendLazy { + val document = webClient.httpGet("https://$domain/filter").parseHtml() + + document.select("div.f-genre-item").map { + MangaTag( + title = it.ownText().trim(), + key = it.attr("data-id"), + source = source + ) + }.associateBy { it.title } + } + + override suspend fun getAvailableTags(): Set { + return tags.get().values.toSet() + } + + override val isTagsExclusionSupported = false + + override suspend fun getListPage(page: Int, filter: MangaListFilter?): List { + val url = "https://$domain".toHttpUrl().newBuilder().apply { + when (filter) { + is MangaListFilter.Search -> { + addPathSegment("search") + addQueryParameter("keyword", filter.query) + addQueryParameter("page", page.toString()) + } + is MangaListFilter.Advanced -> { + addPathSegment("filter") + addQueryParameter("page", page.toString()) + addQueryParameter( + name = "sort", + value = when (filter.sortOrder) { + SortOrder.POPULARITY -> "most-viewed" + SortOrder.RATING -> "score" + SortOrder.UPDATED -> "latest-updated" + SortOrder.NEWEST -> "release-date" + SortOrder.ALPHABETICAL -> "name-az" + else -> "default" + } + ) + addQueryParameter("genres", filter.tags.joinToString(",") { it.key }) + addQueryParameter( + name = "status", + value = when (filter.states.oneOrThrowIfMany()) { + MangaState.ONGOING -> "2" + MangaState.FINISHED -> "1" + MangaState.ABANDONED -> "4" + MangaState.PAUSED -> "3" + MangaState.UPCOMING -> "5" + null -> "" + } + ) + } + null -> { + addPathSegment("filter") + addQueryParameter("page", page.toString()) + } + } + }.build() + + val document = webClient.httpGet(url).parseHtml() + + return document.select(".manga_list-sbs .manga-poster").map { + val mangaUrl = it.attrAsRelativeUrl("href") + val thumb = it.select("img") + Manga( + id = generateUid(mangaUrl), + url = mangaUrl, + publicUrl = mangaUrl.toAbsoluteUrl(domain), + title = thumb.attr("alt"), + coverUrl = thumb.attr("src"), + source = source, + altTitle = null, + author = null, + isNsfw = false, + rating = RATING_UNKNOWN, + state = null, + tags = emptySet() + ) + } + } + + override suspend fun getRelatedManga(seed: Manga): List { + val document = webClient.httpGet(seed.url.toAbsoluteUrl(domain)).parseHtml() + return document.select(".block_area_authors-other .manga_list-sbs .manga-poster, .featured-block-ul .manga-poster").map { + val mangaUrl = it.attrAsRelativeUrl("href") + val thumb = it.select("img") + Manga( + id = generateUid(mangaUrl), + url = mangaUrl, + publicUrl = mangaUrl.toAbsoluteUrl(domain), + title = thumb.attr("alt"), + coverUrl = thumb.attr("src"), + source = source, + altTitle = null, + author = null, + isNsfw = false, + rating = RATING_UNKNOWN, + state = null, + tags = emptySet() + ) + } + } + + override suspend fun getDetails(manga: Manga): Manga { + val document = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml() + val availableTags = tags.get() + var isNsfw = false + + return manga.copy( + title = document.selectFirst("h2.manga-name")!!.ownText(), + altTitle = document.selectFirst("div.manga-name-or")?.ownText(), + rating = document.selectFirst("div.anisc-info .item:contains(score:) > .name") + ?.text()?.toFloatOrNull()?.div(10) ?: RATING_UNKNOWN, + coverUrl = document.selectFirst(".manga-poster > img")!!.attr("src"), + tags = document.select("div.genres > a[href*=/genre/]").mapNotNullToSet { + val tag = it.ownText().trim() + if (tag == "Hentai" || tag == "Ecchi") { + isNsfw = true + } + availableTags[tag] + }, + isNsfw = isNsfw, + state = document.selectFirst("div.anisc-info .item:contains(status:) > .name") + ?.text()?.let { + when (it) { + "Publishing" -> MangaState.ONGOING + "Finished" -> MangaState.FINISHED + "On Hiatus" -> MangaState.PAUSED + "Discontinued" -> MangaState.ABANDONED + "Not yet published" -> MangaState.UPCOMING + else -> null + } + }, + author = document.select("div.anisc-info a[href*=/author/]").joinToString { it.ownText().replace(", ", " ") }, + description = document.select("div.description").text(), + chapters = parseChapters(document), + source = source + ) + } + + private fun parseChapters(document: Document): List { + val total = document.select(".chapters-list-ul > ul > li.chapter-item, .volume-list-ul div.lang-volumes > div.item").size + val chapters = ArrayList(total) + + document.select(".chapters-list-ul > ul").forEach { ul -> + ul.select("li.chapter-item").reversed().forEach { li -> + val a = li.selectFirst("a")!! + + chapters.add( + MangaChapter( + id = generateUid(a.attrAsRelativeUrl("href")), + name = a.attr("title"), + number = li.attr("data-number").toFloat(), + volume = 0, + url = a.attrAsRelativeUrl("href"), + scanlator = null, + uploadDate = 0L, + branch = createBranchName(ul.id().substringBefore("-chapters"), "Chapters"), + source = source + ) + ) + } + } + val numRegex = Regex("""(\d+)""") + document.select(".volume-list-ul div.lang-volumes").forEach { div -> + div.select("div.item > div.manga-poster").reversed().forEach { vol -> + val url = vol.selectFirst("a")!!.attrAsRelativeUrl("href") + val name = vol.selectFirst("span")!!.ownText() + chapters.add( + MangaChapter( + id = generateUid(url), + name = name, + number = numRegex.find(name)!!.groupValues[1].toFloat(), + volume = 0, + url = url, + scanlator = null, + uploadDate = 0L, + branch = createBranchName(div.id().substringBefore("-volumes"), "Volumes"), + source = source + ) + ) + } + } + + return chapters + } + + private fun createBranchName(lang: String, type: String): String { + val langCode = lang.substringBefore("-") + + return Locale(langCode).displayLanguage + " " + type + } + + override suspend fun getPages(chapter: MangaChapter): List { + val typeAndId = webClient.httpGet(chapter.url.toAbsoluteUrl(domain)) + .parseHtml() + .selectFirst("#wrapper")!!.run { + "${attr("data-reading-by")}/${attr("data-reading-id")}" + } + val document = webClient.httpGet("https://$domain/ajax/image/list/$typeAndId?quality=high") + .parseJson() + .getString("html") + .let(Jsoup::parse) + + return document.select(".iv-card").map { + val url = it.attr("data-url") + MangaPage( + id = generateUid(url), + url = if (it.hasClass("shuffled")) { + "$url#scrambled" + } else { + url + }, + preview = null, + source = source + ) + } + } + + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + val response = chain.proceed(request) + + if (request.url.fragment != "scrambled") return response + val image = response.body!!.byteStream().use(::descramble) + val body = image.toResponseBody("image/jpeg".toMediaType()) + return response.newBuilder() + .body(body) + .build() + } + + private val memo = hashMapOf() + + private fun descramble(image: InputStream): ByteArray { + val bitmap = BitmapFactory.decodeStream(image) + val width = bitmap.width + val height = bitmap.height + + val result = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + val canvas = Canvas(result) + + val pieces = ArrayList() + for (y in 0 until height step PIECE_SIZE) { + for (x in 0 until width step PIECE_SIZE) { + val w = min(PIECE_SIZE, width - x) + val h = min(PIECE_SIZE, height - y) + pieces.add(Piece(x, y, w, h)) + } + } + + val groups = pieces.groupBy { it.w shl 16 or it.h } + + for (group in groups.values) { + val size = group.size + + val permutation = memo.getOrPut(size) { + val random = SeedRandom("staystay") + + // https://github.com/webcaetano/shuffle-seed + val indices = (0 until size).toMutableList() + IntArray(size) { indices.removeAt((random.nextDouble() * indices.size).toInt()) } + } + + for ((i, original) in permutation.withIndex()) { + val src = group[i] + val dst = group[original] + + val srcRect = Rect(src.x, src.y, src.x + src.w, src.y + src.h) + val dstRect = Rect(dst.x, dst.y, dst.x + dst.w, dst.y + dst.h) + + canvas.drawBitmap(bitmap, srcRect, dstRect, null) + } + } + + val output = ByteArrayOutputStream() + result.compress(Bitmap.CompressFormat.JPEG, 90, output) + return output.toByteArray() + } + + private class Piece(val x: Int, val y: Int, val w: Int, val h: Int) + + // https://github.com/davidbau/seedrandom + private class SeedRandom(key: String) { + private val input = ByteArray(RC4_WIDTH) + private val buffer = ByteArray(RC4_WIDTH) + private var pos = RC4_WIDTH + + private val rc4 = Cipher.getInstance("RC4").apply { + init(Cipher.ENCRYPT_MODE, SecretKeySpec(key.toByteArray(), "RC4")) + update(input, 0, RC4_WIDTH, buffer) // RC4-drop[256] + } + + fun nextDouble(): Double { + var num = nextByte() + var exp = 8 + while (num < 1L shl 52) { + num = num shl 8 or nextByte() + exp += 8 + } + while (num >= 1L shl 53) { + num = num ushr 1 + exp-- + } + return Math.scalb(num.toDouble(), -exp) + } + + private fun nextByte(): Long { + if (pos == RC4_WIDTH) { + rc4.update(input, 0, RC4_WIDTH, buffer) + pos = 0 + } + return buffer[pos++].toLong() and 0xFF + } + } +} + +private const val RC4_WIDTH = 256 +private const val PIECE_SIZE = 200