MangaReader.To
parent
332524f1f3
commit
9d4fc1980f
@ -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<SortOrder> = EnumSet.of(
|
||||
SortOrder.POPULARITY,
|
||||
SortOrder.RATING,
|
||||
SortOrder.UPDATED,
|
||||
SortOrder.NEWEST,
|
||||
SortOrder.ALPHABETICAL
|
||||
)
|
||||
|
||||
override val availableStates: Set<MangaState> = 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<MangaTag> {
|
||||
return tags.get().values.toSet()
|
||||
}
|
||||
|
||||
override val isTagsExclusionSupported = false
|
||||
|
||||
override suspend fun getListPage(page: Int, filter: MangaListFilter?): List<Manga> {
|
||||
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<Manga> {
|
||||
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<MangaChapter> {
|
||||
val total = document.select(".chapters-list-ul > ul > li.chapter-item, .volume-list-ul div.lang-volumes > div.item").size
|
||||
val chapters = ArrayList<MangaChapter>(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<MangaPage> {
|
||||
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<Int, IntArray>()
|
||||
|
||||
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<Piece>()
|
||||
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
|
||||
Loading…
Reference in New Issue