Add MangaReader Extension (#11811)

* Add MangaReader extension

* Add quality preference

* Fix text search

* Unscramble images

* Implement requested changes

- Only un-shuffle shuffled images
- Update icons

Co-authored-by: ObserverOfTime <chronobserver@disroot.org>
This commit is contained in:
Skitty 2022-05-11 18:34:15 -05:00 committed by GitHub
parent adb8b29dda
commit 8d76062832
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 657 additions and 0 deletions

View File

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest package="eu.kanade.tachiyomi.extension" />

View File

@ -0,0 +1,12 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
ext {
extName = 'MangaReader'
pkgNameSuffix = 'all.mangareaderto'
extClass = '.MangaReaderFactory'
extVersionCode = 1
isNsfw = true
}
apply from: "$rootDir/common.gradle"

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

View File

@ -0,0 +1,196 @@
package eu.kanade.tachiyomi.extension.all.mangareaderto
import android.app.Application
import android.net.Uri
import androidx.preference.ListPreference
import androidx.preference.PreferenceScreen
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.asObservableSuccess
import eu.kanade.tachiyomi.source.ConfigurableSource
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.Page
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 org.json.JSONObject
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
open class MangaReader(
override val lang: String
) : ConfigurableSource, ParsedHttpSource() {
override val name = "MangaReader"
override val baseUrl = "https://mangareader.to"
override val supportsLatest = true
override val client = network.client.newBuilder()
.addInterceptor(MangaReaderImageInterceptor())
.build()
override fun latestUpdatesRequest(page: Int) =
GET("$baseUrl/filter?sort=latest-updated&language=$lang&page=$page", headers)
override fun latestUpdatesSelector() = searchMangaSelector()
override fun latestUpdatesNextPageSelector() = searchMangaNextPageSelector()
override fun latestUpdatesFromElement(element: Element) =
searchMangaFromElement(element)
override fun popularMangaRequest(page: Int) =
GET("$baseUrl/filter?sort=most-viewed&language=$lang&page=$page", headers)
override fun popularMangaSelector() = searchMangaSelector()
override fun popularMangaNextPageSelector() = searchMangaNextPageSelector()
override fun popularMangaFromElement(element: Element) =
searchMangaFromElement(element)
override fun searchMangaRequest(page: Int, query: String, filters: FilterList) =
if (query.isNotBlank()) {
Uri.parse("$baseUrl/search").buildUpon().run {
appendQueryParameter("keyword", query)
appendQueryParameter("page", page.toString())
GET(toString(), headers)
}
} else {
Uri.parse("$baseUrl/filter").buildUpon().run {
appendQueryParameter("language", lang)
appendQueryParameter("page", page.toString())
filters.ifEmpty(::getFilterList).forEach { filter ->
when (filter) {
is Select -> {
appendQueryParameter(filter.param, filter.selection)
}
is DateFilter -> {
filter.state.forEach {
appendQueryParameter(it.param, it.selection)
}
}
is GenresFilter -> {
appendQueryParameter(filter.param, filter.selection)
}
else -> Unit
}
}
}
GET(toString(), headers)
}
override fun searchMangaSelector() = ".manga_list-sbs .manga-poster"
override fun searchMangaNextPageSelector() = ".page-link[title=Next]"
override fun searchMangaFromElement(element: Element) =
SManga.create().apply {
url = element.attr("href")
element.selectFirst(".manga-poster-img").let {
title = it.attr("alt")
thumbnail_url = it.attr("src")
}
}
private val authorSelector = ".item-head:containsOwn(Authors) ~ a"
private val statusSelector = ".item-head:containsOwn(Status) + .name"
override fun mangaDetailsParse(document: Document) =
SManga.create().apply {
setUrlWithoutDomain(document.location())
document.getElementById("ani_detail").let { el ->
title = el.selectFirst(".manga-name").text().trim()
description = el.selectFirst(".description")?.text()?.trim()
thumbnail_url = el.selectFirst(".manga-poster-img").attr("src")
genre = el.select(".genres > a")?.joinToString { it.text() }
author = el.select(authorSelector)?.joinToString {
it.text().replace(",", "")
}
artist = author // TODO: separate authors and artists
status = when (el.selectFirst(statusSelector)?.text()) {
"Finished" -> SManga.COMPLETED
"Publishing" -> SManga.ONGOING
else -> SManga.UNKNOWN
}
}
}
override fun chapterListSelector() = "#$lang-chapters .item"
override fun chapterFromElement(element: Element) =
SChapter.create().apply {
chapter_number = element.attr("data-number").toFloatOrNull() ?: -1f
element.selectFirst(".item-link").let {
url = it.attr("href")
name = it.attr("title")
}
}
private fun pageListRequest(id: String) =
GET("$baseUrl/ajax/image/list/chap/$id?quality=$quality", headers)
override fun fetchPageList(chapter: SChapter) =
client.newCall(pageListRequest(chapter)).asObservableSuccess().map { res ->
res.asJsoup().getElementById("wrapper").attr("data-reading-id").let {
val call = client.newCall(pageListRequest(it))
val json = JSONObject(call.execute().body!!.string())
pageListParse(Jsoup.parse(json.getString("html")))
}
}!!
override fun pageListParse(document: Document): List<Page> =
document.getElementsByClass("iv-card").mapIndexed { idx, img ->
val url = img.attr("data-url")
if (img.hasClass("shuffled")) {
Page(idx, "", "$url&shuffled=true")
} else {
Page(idx, "", url)
}
}
override fun imageUrlParse(document: Document) =
throw UnsupportedOperationException("Not used")
private val preferences by lazy {
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)!!
}
private val quality by lazy {
preferences.getString("quality", "medium")!!
}
override fun setupPreferenceScreen(screen: PreferenceScreen) {
ListPreference(screen.context).apply {
key = "quality"
title = "Quality"
summary = "%s"
entries = arrayOf("Low", "Medium", "High")
entryValues = arrayOf("low", "medium", "high")
setDefaultValue("medium")
setOnPreferenceChangeListener { _, newValue ->
preferences.edit().putString("quality", newValue as String).commit()
}
}.let(screen::addPreference)
}
override fun getFilterList() =
FilterList(
Note,
TypeFilter(),
StatusFilter(),
RatingFilter(),
ScoreFilter(),
StartDateFilter(),
EndDateFilter(),
SortFilter(),
GenresFilter()
)
}

View File

@ -0,0 +1,8 @@
package eu.kanade.tachiyomi.extension.all.mangareaderto
import eu.kanade.tachiyomi.source.SourceFactory
class MangaReaderFactory : SourceFactory {
override fun createSources() =
listOf(MangaReader("en"), MangaReader("ja"))
}

View File

@ -0,0 +1,247 @@
package eu.kanade.tachiyomi.extension.all.mangareaderto
import eu.kanade.tachiyomi.source.model.Filter
import java.util.Calendar
object Note : Filter.Text("NOTE: Ignored if using text search!")
sealed class Select(
name: String,
val param: String,
values: Array<String>
) : Filter.Select<String>(name, values) {
open val selection: String
get() = if (state == 0) "" else state.toString()
}
class TypeFilter(
values: Array<String> = types
) : Select("Type", "type", values) {
companion object {
private val types: Array<String>
get() = arrayOf(
"All",
"Manga",
"One-Shot",
"Doujinshi",
"Light Novel",
"Manhwa",
"Manhua",
"Comic"
)
}
}
class StatusFilter(
values: Array<String> = statuses
) : Select("Status", "status", values) {
companion object {
private val statuses: Array<String>
get() = arrayOf(
"All",
"Finished",
"Publishing",
"On Hiatus",
"Discontinued",
"Not yet published"
)
}
}
class RatingFilter(
values: Array<String> = ratings
) : Select("Rating Type", "rating_type", values) {
companion object {
private val ratings: Array<String>
get() = arrayOf(
"All",
"G - All Ages",
"PG - Children",
"PG-13 - Teens 13 or older",
"R - 17+ (violence & profanity)",
"R+ - Mild Nudity",
"Rx - Hentai"
)
}
}
class ScoreFilter(
values: Array<String> = scores
) : Select("Score", "score", values) {
companion object {
private val scores: Array<String>
get() = arrayOf(
"All",
"(1) Appalling",
"(2) Horrible",
"(3) Very Bad",
"(4) Bad",
"(5) Average",
"(6) Fine",
"(7) Good",
"(8) Very Good",
"(9) Great",
"(10) Masterpiece"
)
}
}
sealed class DateSelect(
name: String,
param: String,
values: Array<String>
) : Select(name, param, values) {
override val selection: String
get() = if (state == 0) "" else values[state]
}
class YearFilter(
param: String,
values: Array<String> = years
) : DateSelect("Year", param, values) {
companion object {
private val nextYear by lazy {
Calendar.getInstance()[Calendar.YEAR] + 1
}
private val years: Array<String>
get() = Array(nextYear - 1916) {
if (it == 0) "Any" else (nextYear - it).toString()
}
}
}
class MonthFilter(
param: String,
values: Array<String> = months
) : DateSelect("Month", param, values) {
companion object {
private val months: Array<String>
get() = Array(13) {
if (it == 0) "Any" else "%02d".format(it)
}
}
}
class DayFilter(
param: String,
values: Array<String> = days
) : DateSelect("Day", param, values) {
companion object {
private val days: Array<String>
get() = Array(32) {
if (it == 0) "Any" else "%02d".format(it)
}
}
}
sealed class DateFilter(
type: String,
values: List<DateSelect>
) : Filter.Group<DateSelect>("$type Date", values)
class StartDateFilter(
values: List<DateSelect> = parts
) : DateFilter("Start", values) {
companion object {
private val parts: List<DateSelect>
get() = listOf(
YearFilter("sy"),
MonthFilter("sm"),
DayFilter("sd")
)
}
}
class EndDateFilter(
values: List<DateSelect> = parts
) : DateFilter("End", values) {
companion object {
private val parts: List<DateSelect>
get() = listOf(
YearFilter("ey"),
MonthFilter("em"),
DayFilter("ed")
)
}
}
class SortFilter(
values: Array<String> = orders.keys.toTypedArray()
) : Select("Sort", "sort", values) {
override val selection: String
get() = orders[values[state]]!!
companion object {
private val orders = mapOf(
"Default" to "default",
"Latest Updated" to "latest-updated",
"Score" to "score",
"Name A-Z" to "name-az",
"Release Date" to "release-date",
"Most Viewed" to "most-viewed"
)
}
}
class Genre(name: String, val id: String) : Filter.CheckBox(name)
class GenresFilter(
values: List<Genre> = genres
) : Filter.Group<Genre>("Genres", values) {
val param = "genres"
val selection: String
get() = state.filter { it.state }.joinToString(",") { it.id }
companion object {
private val genres: List<Genre>
get() = listOf(
Genre("Action", "1"),
Genre("Adventure", "2"),
Genre("Cars", "3"),
Genre("Comedy", "4"),
Genre("Dementia", "5"),
Genre("Demons", "6"),
Genre("Doujinshi", "7"),
Genre("Drama", "8"),
Genre("Ecchi", "9"),
Genre("Fantasy", "10"),
Genre("Game", "11"),
Genre("Gender Bender", "12"),
Genre("Harem", "13"),
Genre("Hentai", "14"),
Genre("Historical", "15"),
Genre("Horror", "16"),
Genre("Josei", "17"),
Genre("Kids", "18"),
Genre("Magic", "19"),
Genre("Martial Arts", "20"),
Genre("Mecha", "21"),
Genre("Military", "22"),
Genre("Music", "23"),
Genre("Mystery", "24"),
Genre("Parody", "25"),
Genre("Police", "26"),
Genre("Psychological", "27"),
Genre("Romance", "28"),
Genre("Samurai", "29"),
Genre("School", "30"),
Genre("Sci-Fi", "31"),
Genre("Seinen", "32"),
Genre("Shoujo", "33"),
Genre("Shoujo Ai", "34"),
Genre("Shounen", "35"),
Genre("Shounen Ai", "36"),
Genre("Slice of Life", "37"),
Genre("Space", "38"),
Genre("Sports", "39"),
Genre("Super Power", "40"),
Genre("Supernatural", "41"),
Genre("Thriller", "42"),
Genre("Vampire", "43"),
Genre("Yaoi", "44"),
Genre("Yuri", "45"),
)
}
}

View File

@ -0,0 +1,192 @@
package eu.kanade.tachiyomi.extension.all.mangareaderto
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.graphics.Rect
import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.Response
import okhttp3.ResponseBody.Companion.toResponseBody
import java.io.ByteArrayOutputStream
import java.io.InputStream
import kotlin.math.ceil
import kotlin.math.floor
class MangaReaderImageInterceptor : Interceptor {
private var s = IntArray(256)
private var arc4i = 0
private var arc4j = 0
override fun intercept(chain: Interceptor.Chain): Response {
val response = chain.proceed(chain.request())
// shuffled page requests should have shuffled=true query parameter
if (chain.request().url.queryParameter("shuffled") != "true")
return response
val image = unscrambleImage(response.body!!.byteStream())
val body = image.toResponseBody("image/png".toMediaTypeOrNull())
return response.newBuilder()
.body(body)
.build()
}
private fun unscrambleImage(image: InputStream): ByteArray {
// obfuscated code (imgReverser function): https://mangareader.to/js/read.min.js
// essentially, it shuffles arrays of the image slices using the key 'stay'
val bitmap = BitmapFactory.decodeStream(image)
val result = Bitmap.createBitmap(bitmap.width, bitmap.height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(result)
val horizontalParts = ceil(bitmap.width / SLICE_SIZE.toDouble()).toInt()
val totalParts = horizontalParts * ceil(bitmap.height / SLICE_SIZE.toDouble()).toInt()
// calculate slices
val slices: HashMap<Int, MutableList<Rect>> = hashMapOf()
for (i in 0 until totalParts) {
val row = floor(i / horizontalParts.toDouble()).toInt()
val x = (i - row * horizontalParts) * SLICE_SIZE
val y = row * SLICE_SIZE
val width = if (x + SLICE_SIZE <= bitmap.width) SLICE_SIZE else bitmap.width - x
val height = if (y + SLICE_SIZE <= bitmap.height) SLICE_SIZE else bitmap.height - y
val srcRect = Rect(x, y, width, height)
val key = width - height
if (!slices.containsKey(key)) {
slices[key] = mutableListOf()
}
slices[key]?.add(srcRect)
}
// handle groups of slices
for (sliceEntry in slices) {
// reset random number generator for every un-shuffle
resetRng()
val currentSlices = sliceEntry.value
val sliceCount = currentSlices.count()
// un-shuffle slice indices
val orderedSlices = IntArray(sliceCount)
val keys = MutableList(sliceCount) { it }
for (i in currentSlices.indices) {
val r = floor(prng() * keys.count()).toInt()
val g = keys[r]
keys.removeAt(r)
orderedSlices[g] = i
}
// draw slices
val cols = getColumnCount(currentSlices)
val groupX = currentSlices[0].left
val groupY = currentSlices[0].top
for ((i, orderedIndex) in orderedSlices.withIndex()) {
val slice = currentSlices[i]
val row = floor((orderedIndex / cols).toDouble()).toInt()
val col = orderedIndex - row * cols
val width = slice.right
val height = slice.bottom
val x = groupX + col * width
val y = groupY + row * height
val srcRect = Rect(x, y, x + width, y + height)
val dstRect = Rect(
slice.left,
slice.top,
slice.left + width,
slice.top + height
)
canvas.drawBitmap(bitmap, srcRect, dstRect, null)
}
}
val output = ByteArrayOutputStream()
result.compress(Bitmap.CompressFormat.PNG, 100, output)
return output.toByteArray()
}
private fun getColumnCount(slices: List<Rect>): Int {
if (slices.count() == 1) return 1
var t: Int? = null
for (i in slices.indices) {
if (t == null) t = slices[i].top
if (t != slices[i].top) {
return i
}
}
return slices.count()
}
private fun resetRng() {
arc4i = 0
arc4j = 0
initializeS()
arc4(256) // RC4-drop[256]
}
private fun initializeS() {
val t = IntArray(256)
for (i in 0..255) {
s[i] = i
t[i] = KEY[i % KEY.size]
}
var j = 0
var tmp: Int
for (i in 0..255) {
j = (j + s[i] + t[i]) and 0xFF
tmp = s[j]
s[j] = s[i]
s[i] = tmp
}
}
private fun prng(): Double {
var n = arc4(6)
var d = 281474976710656.0 // 256^6 (start with 6 chunks in n)
var x = 0L
while (n < 4503599627370496) { // 2^52 (52 significant digits in a double)
n = (n + x) * 256
d *= 256
x = arc4(1)
if (n < 0) break // overflow
}
return (n + x) / d
}
private fun arc4(count: Int): Long {
var t: Int
var tmp: Int
var r: Long = 0
repeat(count) {
arc4i = (arc4i + 1) and 0xFF
arc4j = (arc4j + s[arc4i]) and 0xFF
tmp = s[arc4j]
s[arc4j] = s[arc4i]
s[arc4i] = tmp
t = (s[arc4i] + s[arc4j]) and 0xFF
r = r * 256 + s[t]
}
return r
}
companion object {
private val KEY = "stay".map { it.toByte().toInt() }
private const val SLICE_SIZE = 200
}
}