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