Initial commit
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 17 KiB |
BIN
multisrc/overrides/mangareader/comickiba/res/web_hi_res_512.png
Normal file
|
After Width: | Height: | Size: 106 KiB |
233
multisrc/overrides/mangareader/comickiba/src/Manhuagold.kt
Normal file
@@ -0,0 +1,233 @@
|
||||
package eu.kanade.tachiyomi.extension.en.comickiba
|
||||
|
||||
import eu.kanade.tachiyomi.multisrc.mangareader.MangaReader
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.asObservableSuccess
|
||||
import eu.kanade.tachiyomi.network.interceptor.rateLimit
|
||||
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.util.asJsoup
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.jsoup.Jsoup
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import org.jsoup.nodes.TextNode
|
||||
import org.jsoup.select.Evaluator
|
||||
import rx.Observable
|
||||
|
||||
class Manhuagold : MangaReader() {
|
||||
|
||||
override val name = "Manhuagold"
|
||||
|
||||
override val lang = "en"
|
||||
|
||||
override val baseUrl = "https://manhuagold.com"
|
||||
|
||||
override val client = network.cloudflareClient.newBuilder()
|
||||
.rateLimit(2)
|
||||
.build()
|
||||
|
||||
override fun headersBuilder() = super.headersBuilder()
|
||||
.add("Referer", "$baseUrl/")
|
||||
|
||||
// Popular
|
||||
|
||||
override fun popularMangaRequest(page: Int) =
|
||||
GET("$baseUrl/filter/$page/?sort=views&sex=All&chapter_count=0", headers)
|
||||
|
||||
// Latest
|
||||
|
||||
override fun latestUpdatesRequest(page: Int) =
|
||||
GET("$baseUrl/filter/$page/?sort=latest-updated&sex=All&chapter_count=0", headers)
|
||||
|
||||
// Search
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
val urlBuilder = baseUrl.toHttpUrl().newBuilder()
|
||||
if (query.isNotBlank()) {
|
||||
urlBuilder.addPathSegment("search").apply {
|
||||
addQueryParameter("keyword", query)
|
||||
}
|
||||
} else {
|
||||
urlBuilder.addPathSegment("filter").apply {
|
||||
filters.ifEmpty(::getFilterList).forEach { filter ->
|
||||
when (filter) {
|
||||
is Select -> {
|
||||
addQueryParameter(filter.param, filter.selection)
|
||||
}
|
||||
is GenresFilter -> {
|
||||
addQueryParameter(filter.param, filter.selection)
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
urlBuilder.addPathSegment(page.toString())
|
||||
urlBuilder.addPathSegment("")
|
||||
|
||||
return GET(urlBuilder.build(), headers)
|
||||
}
|
||||
|
||||
override fun searchMangaSelector() = ".manga_list-sbs .manga-poster"
|
||||
|
||||
override fun searchMangaFromElement(element: Element) =
|
||||
SManga.create().apply {
|
||||
setUrlWithoutDomain(element.attr("href"))
|
||||
element.selectFirst(Evaluator.Tag("img"))!!.let {
|
||||
title = it.attr("alt")
|
||||
thumbnail_url = it.imgAttr()
|
||||
}
|
||||
}
|
||||
|
||||
override fun searchMangaNextPageSelector() = "ul.pagination > li.active + li"
|
||||
|
||||
// Filters
|
||||
|
||||
override fun getFilterList() =
|
||||
FilterList(
|
||||
Note,
|
||||
StatusFilter(),
|
||||
SortFilter(),
|
||||
GenresFilter(),
|
||||
)
|
||||
|
||||
// Details
|
||||
|
||||
override fun mangaDetailsParse(document: Document) = SManga.create().apply {
|
||||
val root = document.selectFirst(Evaluator.Id("ani_detail"))!!
|
||||
val mangaTitle = root.selectFirst(Evaluator.Class("manga-name"))!!.ownText()
|
||||
title = mangaTitle
|
||||
description = root.run {
|
||||
val description = selectFirst(Evaluator.Class("description"))!!.ownText()
|
||||
when (val altTitle = selectFirst(Evaluator.Class("manga-name-or"))!!.ownText()) {
|
||||
"", mangaTitle -> description
|
||||
else -> "$description\n\nAlternative Title: $altTitle"
|
||||
}
|
||||
}
|
||||
thumbnail_url = root.selectFirst(Evaluator.Tag("img"))!!.imgAttr()
|
||||
genre = root.selectFirst(Evaluator.Class("genres"))!!.children().joinToString { it.ownText() }
|
||||
for (item in root.selectFirst(Evaluator.Class("anisc-info"))!!.children()) {
|
||||
if (item.hasClass("item").not()) continue
|
||||
when (item.selectFirst(Evaluator.Class("item-head"))!!.ownText()) {
|
||||
"Authors:" -> item.parseAuthorsTo(this)
|
||||
"Status:" -> status = when (item.selectFirst(Evaluator.Class("name"))!!.ownText().lowercase()) {
|
||||
"ongoing" -> SManga.ONGOING
|
||||
"completed" -> SManga.COMPLETED
|
||||
"on-hold" -> SManga.ON_HIATUS
|
||||
"canceled" -> SManga.CANCELLED
|
||||
else -> SManga.UNKNOWN
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Element.parseAuthorsTo(manga: SManga) {
|
||||
val authors = select(Evaluator.Tag("a"))
|
||||
val text = authors.map { it.ownText().replace(",", "") }
|
||||
val count = authors.size
|
||||
when (count) {
|
||||
0 -> return
|
||||
1 -> {
|
||||
manga.author = text[0]
|
||||
return
|
||||
}
|
||||
}
|
||||
val authorList = ArrayList<String>(count)
|
||||
val artistList = ArrayList<String>(count)
|
||||
for ((index, author) in authors.withIndex()) {
|
||||
val textNode = author.nextSibling() as? TextNode
|
||||
val list = if (textNode != null && "(Art)" in textNode.wholeText) artistList else authorList
|
||||
list.add(text[index])
|
||||
}
|
||||
if (authorList.isEmpty().not()) manga.author = authorList.joinToString()
|
||||
if (artistList.isEmpty().not()) manga.artist = artistList.joinToString()
|
||||
}
|
||||
|
||||
// Chapters
|
||||
|
||||
override fun chapterListRequest(mangaUrl: String, type: String): Request =
|
||||
GET(baseUrl + mangaUrl, headers)
|
||||
|
||||
override fun parseChapterElements(response: Response, isVolume: Boolean): List<Element> {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override val chapterType = ""
|
||||
override val volumeType = ""
|
||||
|
||||
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
|
||||
return client.newCall(chapterListRequest(manga))
|
||||
.asObservableSuccess()
|
||||
.map(::parseChapterList)
|
||||
}
|
||||
|
||||
private fun parseChapterList(response: Response): List<SChapter> {
|
||||
val document = response.use { it.asJsoup() }
|
||||
|
||||
return document.select(chapterListSelector())
|
||||
.map(::chapterFromElement)
|
||||
}
|
||||
|
||||
private fun chapterListSelector(): String = "#chapters-list > li"
|
||||
|
||||
private fun chapterFromElement(element: Element): SChapter = SChapter.create().apply {
|
||||
element.selectFirst("a")!!.run {
|
||||
setUrlWithoutDomain(attr("href"))
|
||||
name = selectFirst(".name")?.text() ?: text()
|
||||
}
|
||||
}
|
||||
|
||||
// Images
|
||||
|
||||
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> = Observable.fromCallable {
|
||||
val document = client.newCall(pageListRequest(chapter)).execute().asJsoup()
|
||||
|
||||
val script = document.selectFirst("script:containsData(const CHAPTER_ID)")!!.data()
|
||||
val id = script.substringAfter("const CHAPTER_ID = ").substringBefore(";")
|
||||
|
||||
val ajaxHeaders = super.headersBuilder().apply {
|
||||
add("Accept", "application/json, text/javascript, */*; q=0.01")
|
||||
add("Referer", baseUrl + chapter.url)
|
||||
add("X-Requested-With", "XMLHttpRequest")
|
||||
}.build()
|
||||
|
||||
val ajaxUrl = "$baseUrl/ajax/image/list/chap/$id"
|
||||
client.newCall(GET(ajaxUrl, ajaxHeaders)).execute().let(::pageListParse)
|
||||
}
|
||||
|
||||
override fun pageListParse(response: Response): List<Page> {
|
||||
val document = response.use { it.parseHtmlProperty() }
|
||||
|
||||
val pageList = document.select("div").map {
|
||||
val index = it.attr("data-number").toInt()
|
||||
val imgUrl = it.imgAttr().ifEmpty { it.selectFirst("img")!!.imgAttr() }
|
||||
|
||||
Page(index, "", imgUrl)
|
||||
}
|
||||
|
||||
return pageList
|
||||
}
|
||||
|
||||
// Utilities
|
||||
|
||||
// From mangathemesia
|
||||
private fun Element.imgAttr(): String = when {
|
||||
hasAttr("data-lazy-src") -> attr("abs:data-lazy-src")
|
||||
hasAttr("data-src") -> attr("abs:data-src")
|
||||
else -> attr("abs:src")
|
||||
}
|
||||
|
||||
private fun Response.parseHtmlProperty(): Document {
|
||||
val html = Json.parseToJsonElement(body.string()).jsonObject["html"]!!.jsonPrimitive.content
|
||||
return Jsoup.parseBodyFragment(html)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package eu.kanade.tachiyomi.extension.en.comickiba
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.Filter
|
||||
|
||||
object Note : Filter.Header("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 StatusFilter(
|
||||
values: Array<String> = statuses.keys.toTypedArray(),
|
||||
) : Select("Status", "status", values) {
|
||||
override val selection: String
|
||||
get() = statuses[values[state]]!!
|
||||
|
||||
companion object {
|
||||
private val statuses = mapOf(
|
||||
"All" to "",
|
||||
"Completed" to "completed",
|
||||
"OnGoing" to "on-going",
|
||||
"On-Hold" to "on-hold",
|
||||
"Canceled" to "canceled",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
"Most Viewed" to "views",
|
||||
"Most Viewed Month" to "views_month",
|
||||
"Most Viewed Week" to "views_week",
|
||||
"Most Viewed Day" to "views_day",
|
||||
"Score" to "score",
|
||||
"Name A-Z" to "az",
|
||||
"Name Z-A" to "za",
|
||||
"The highest chapter count" to "chapters",
|
||||
"Newest" to "new",
|
||||
"Oldest" to "old",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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", "37"),
|
||||
Genre("Adaptation", "19"),
|
||||
Genre("Adult", "5310"),
|
||||
Genre("Adventure", "38"),
|
||||
Genre("Aliens", "5436"),
|
||||
Genre("Animals", "1552"),
|
||||
Genre("Award Winning", "39"),
|
||||
Genre("Comedy", "202"),
|
||||
Genre("Comic", "287"),
|
||||
Genre("Cooking", "277"),
|
||||
Genre("Crime", "2723"),
|
||||
Genre("Delinquents", "4438"),
|
||||
Genre("Demons", "379"),
|
||||
Genre("Drama", "3"),
|
||||
Genre("Ecchi", "17"),
|
||||
Genre("Fantasy", "197"),
|
||||
Genre("Full Color", "13"),
|
||||
Genre("Gender Bender", "221"),
|
||||
Genre("Genderswap", "2290"),
|
||||
Genre("Ghosts", "2866"),
|
||||
Genre("Gore", "42"),
|
||||
Genre("Harem", "222"),
|
||||
Genre("Historical", "4"),
|
||||
Genre("Horror", "5"),
|
||||
Genre("Isekai", "259"),
|
||||
Genre("Josei", "292"),
|
||||
Genre("Loli", "5449"),
|
||||
Genre("Long Strip", "7"),
|
||||
Genre("Magic", "272"),
|
||||
Genre("Manhwa", "266"),
|
||||
Genre("Martial Arts", "40"),
|
||||
Genre("Mature", "5311"),
|
||||
Genre("Mecha", "2830"),
|
||||
Genre("Medical", "1598"),
|
||||
Genre("Military", "43"),
|
||||
Genre("Monster Girls", "2307"),
|
||||
Genre("Monsters", "298"),
|
||||
Genre("Music", "3182"),
|
||||
Genre("Mystery", "6"),
|
||||
Genre("Office Workers", "14"),
|
||||
Genre("Official Colored", "1046"),
|
||||
Genre("Philosophical", "2776"),
|
||||
Genre("Post-Apocalyptic", "1059"),
|
||||
Genre("Psychological", "493"),
|
||||
Genre("Reincarnation", "204"),
|
||||
Genre("Reverse", "280"),
|
||||
Genre("Reverse Harem", "199"),
|
||||
Genre("Romance", "186"),
|
||||
Genre("School Life", "601"),
|
||||
Genre("Sci-Fi", "1845"),
|
||||
Genre("Sexual Violence", "731"),
|
||||
Genre("Shoujo", "254"),
|
||||
Genre("Slice of Life", "10"),
|
||||
Genre("Sports", "4066"),
|
||||
Genre("Superhero", "481"),
|
||||
Genre("Supernatural", "198"),
|
||||
Genre("Survival", "44"),
|
||||
Genre("Thriller", "1058"),
|
||||
Genre("Time Travel", "299"),
|
||||
Genre("Tragedy", "41"),
|
||||
Genre("Video Games", "1846"),
|
||||
Genre("Villainess", "278"),
|
||||
Genre("Virtual Reality", "1847"),
|
||||
Genre("Web Comic", "12"),
|
||||
Genre("Webtoon", "279"),
|
||||
Genre("Webtoons", "267"),
|
||||
Genre("Wuxia", "203"),
|
||||
Genre("Yaoi", "18"),
|
||||
Genre("Yuri", "11"),
|
||||
Genre("Zombies", "1060"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
BIN
multisrc/overrides/mangareader/mangafire/res/web_hi_res_512.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
166
multisrc/overrides/mangareader/mangafire/src/Filters.kt
Normal file
@@ -0,0 +1,166 @@
|
||||
package eu.kanade.tachiyomi.extension.all.mangafire
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.Filter
|
||||
|
||||
class Entry(name: String, val id: String) : Filter.CheckBox(name) {
|
||||
constructor(name: String) : this(name, name)
|
||||
}
|
||||
|
||||
sealed class Group(
|
||||
name: String,
|
||||
val param: String,
|
||||
values: List<Entry>,
|
||||
) : Filter.Group<Entry>(name, values)
|
||||
|
||||
sealed class Select(
|
||||
name: String,
|
||||
val param: String,
|
||||
private val valuesMap: Map<String, String>,
|
||||
) : Filter.Select<String>(name, valuesMap.keys.toTypedArray()) {
|
||||
open val selection: String
|
||||
get() = valuesMap[values[state]]!!
|
||||
}
|
||||
|
||||
class TypeFilter : Group("Type", "type[]", types)
|
||||
|
||||
private val types: List<Entry>
|
||||
get() = listOf(
|
||||
Entry("Manga", "manga"),
|
||||
Entry("One-Shot", "one_shot"),
|
||||
Entry("Doujinshi", "doujinshi"),
|
||||
Entry("Light-Novel", "light_novel"),
|
||||
Entry("Novel", "novel"),
|
||||
Entry("Manhwa", "manhwa"),
|
||||
Entry("Manhua", "manhua"),
|
||||
)
|
||||
|
||||
class Genre(name: String, val id: String) : Filter.TriState(name) {
|
||||
val selection: String
|
||||
get() = (if (isExcluded()) "-" else "") + id
|
||||
}
|
||||
|
||||
class GenresFilter : Filter.Group<Genre>("Genre", genres) {
|
||||
val param = "genre[]"
|
||||
|
||||
val combineMode: Boolean
|
||||
get() = state.filter { !it.isIgnored() }.size > 1
|
||||
}
|
||||
|
||||
private val genres: List<Genre>
|
||||
get() = listOf(
|
||||
Genre("Action", "1"),
|
||||
Genre("Adventure", "78"),
|
||||
Genre("Avant Garde", "3"),
|
||||
Genre("Boys Love", "4"),
|
||||
Genre("Comedy", "5"),
|
||||
Genre("Demons", "77"),
|
||||
Genre("Drama", "6"),
|
||||
Genre("Ecchi", "7"),
|
||||
Genre("Fantasy", "79"),
|
||||
Genre("Girls Love", "9"),
|
||||
Genre("Gourmet", "10"),
|
||||
Genre("Harem", "11"),
|
||||
Genre("Horror", "530"),
|
||||
Genre("Isekai", "13"),
|
||||
Genre("Iyashikei", "531"),
|
||||
Genre("Josei", "15"),
|
||||
Genre("Kids", "532"),
|
||||
Genre("Magic", "539"),
|
||||
Genre("Mahou Shoujo", "533"),
|
||||
Genre("Martial Arts", "534"),
|
||||
Genre("Mecha", "19"),
|
||||
Genre("Military", "535"),
|
||||
Genre("Music", "21"),
|
||||
Genre("Mystery", "22"),
|
||||
Genre("Parody", "23"),
|
||||
Genre("Psychological", "536"),
|
||||
Genre("Reverse Harem", "25"),
|
||||
Genre("Romance", "26"),
|
||||
Genre("School", "73"),
|
||||
Genre("Sci-Fi", "28"),
|
||||
Genre("Seinen", "537"),
|
||||
Genre("Shoujo", "30"),
|
||||
Genre("Shounen", "31"),
|
||||
Genre("Slice of Life", "538"),
|
||||
Genre("Space", "33"),
|
||||
Genre("Sports", "34"),
|
||||
Genre("Super Power", "75"),
|
||||
Genre("Supernatural", "76"),
|
||||
Genre("Suspense", "37"),
|
||||
Genre("Thriller", "38"),
|
||||
Genre("Vampire", "39"),
|
||||
)
|
||||
|
||||
class StatusFilter : Group("Status", "status[]", statuses)
|
||||
|
||||
private val statuses: List<Entry>
|
||||
get() = listOf(
|
||||
Entry("Completed", "completed"),
|
||||
Entry("Releasing", "releasing"),
|
||||
Entry("On Hiatus", "on_hiatus"),
|
||||
Entry("Discontinued", "discontinued"),
|
||||
Entry("Not Yet Published", "info"),
|
||||
)
|
||||
|
||||
class YearFilter : Group("Year", "year[]", years)
|
||||
|
||||
private val years: List<Entry>
|
||||
get() = listOf(
|
||||
Entry("2023"),
|
||||
Entry("2022"),
|
||||
Entry("2021"),
|
||||
Entry("2020"),
|
||||
Entry("2019"),
|
||||
Entry("2018"),
|
||||
Entry("2017"),
|
||||
Entry("2016"),
|
||||
Entry("2015"),
|
||||
Entry("2014"),
|
||||
Entry("2013"),
|
||||
Entry("2012"),
|
||||
Entry("2011"),
|
||||
Entry("2010"),
|
||||
Entry("2009"),
|
||||
Entry("2008"),
|
||||
Entry("2007"),
|
||||
Entry("2006"),
|
||||
Entry("2005"),
|
||||
Entry("2004"),
|
||||
Entry("2003"),
|
||||
Entry("2000s"),
|
||||
Entry("1990s"),
|
||||
Entry("1980s"),
|
||||
Entry("1970s"),
|
||||
Entry("1960s"),
|
||||
Entry("1950s"),
|
||||
Entry("1940s"),
|
||||
)
|
||||
|
||||
class ChapterCountFilter : Select("Chapter Count", "minchap", chapterCounts)
|
||||
|
||||
private val chapterCounts
|
||||
get() = mapOf(
|
||||
"Any" to "",
|
||||
"At least 1 chapter" to "1",
|
||||
"At least 3 chapters" to "3",
|
||||
"At least 5 chapters" to "5",
|
||||
"At least 10 chapters" to "10",
|
||||
"At least 20 chapters" to "20",
|
||||
"At least 30 chapters" to "30",
|
||||
"At least 50 chapters" to "50",
|
||||
)
|
||||
|
||||
class SortFilter : Select("Sort", "sort", orders)
|
||||
|
||||
private val orders
|
||||
get() = mapOf(
|
||||
"Trending" to "trending",
|
||||
"Recently updated" to "recently_updated",
|
||||
"Recently added" to "recently_added",
|
||||
"Release date" to "release_date",
|
||||
"Name A-Z" to "title_az",
|
||||
"Score" to "scores",
|
||||
"MAL score" to "mal_scores",
|
||||
"Most viewed" to "most_viewed",
|
||||
"Most favourited" to "most_favourited",
|
||||
)
|
||||
@@ -0,0 +1,79 @@
|
||||
package eu.kanade.tachiyomi.extension.all.mangafire
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Rect
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.InputStream
|
||||
import kotlin.math.min
|
||||
|
||||
object ImageInterceptor : Interceptor {
|
||||
|
||||
const val SCRAMBLED = "scrambled"
|
||||
private const val PIECE_SIZE = 200
|
||||
private const val MIN_SPLIT_COUNT = 5
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
val response = chain.proceed(request)
|
||||
val fragment = request.url.fragment ?: return response
|
||||
if (SCRAMBLED !in fragment) return response
|
||||
val offset = fragment.substringAfterLast('_').toInt()
|
||||
|
||||
val image = response.body.byteStream().use { descramble(it, offset) }
|
||||
val body = image.toResponseBody("image/jpeg".toMediaType())
|
||||
return response.newBuilder().body(body).build()
|
||||
}
|
||||
|
||||
private fun descramble(image: InputStream, offset: Int): ByteArray {
|
||||
// obfuscated code: https://mangafire.to/assets/t1/min/all.js
|
||||
// it shuffles arrays of the image slices
|
||||
|
||||
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 pieceWidth = min(PIECE_SIZE, width.ceilDiv(MIN_SPLIT_COUNT))
|
||||
val pieceHeight = min(PIECE_SIZE, height.ceilDiv(MIN_SPLIT_COUNT))
|
||||
val xMax = width.ceilDiv(pieceWidth) - 1
|
||||
val yMax = height.ceilDiv(pieceHeight) - 1
|
||||
|
||||
for (y in 0..yMax) {
|
||||
for (x in 0..xMax) {
|
||||
val xDst = pieceWidth * x
|
||||
val yDst = pieceHeight * y
|
||||
val w = min(pieceWidth, width - xDst)
|
||||
val h = min(pieceHeight, height - yDst)
|
||||
|
||||
val xSrc = pieceWidth * when (x) {
|
||||
xMax -> x // margin
|
||||
else -> (xMax - x + offset) % xMax
|
||||
}
|
||||
val ySrc = pieceHeight * when (y) {
|
||||
yMax -> y // margin
|
||||
else -> (yMax - y + offset) % yMax
|
||||
}
|
||||
|
||||
val srcRect = Rect(xSrc, ySrc, xSrc + w, ySrc + h)
|
||||
val dstRect = Rect(xDst, yDst, xDst + w, yDst + h)
|
||||
|
||||
canvas.drawBitmap(bitmap, srcRect, dstRect, null)
|
||||
}
|
||||
}
|
||||
|
||||
val output = ByteArrayOutputStream()
|
||||
result.compress(Bitmap.CompressFormat.JPEG, 90, output)
|
||||
return output.toByteArray()
|
||||
}
|
||||
|
||||
@Suppress("NOTHING_TO_INLINE")
|
||||
private inline fun Int.ceilDiv(other: Int) = (this + (other - 1)) / other
|
||||
}
|
||||
231
multisrc/overrides/mangareader/mangafire/src/MangaFire.kt
Normal file
@@ -0,0 +1,231 @@
|
||||
package eu.kanade.tachiyomi.extension.all.mangafire
|
||||
|
||||
import eu.kanade.tachiyomi.multisrc.mangareader.MangaReader
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.source.model.Filter
|
||||
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 kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.int
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.jsoup.Jsoup
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import org.jsoup.select.Evaluator
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
open class MangaFire(
|
||||
override val lang: String,
|
||||
private val langCode: String = lang,
|
||||
) : MangaReader() {
|
||||
override val name = "MangaFire"
|
||||
|
||||
override val baseUrl = "https://mangafire.to"
|
||||
|
||||
private val json: Json by injectLazy()
|
||||
|
||||
override val client = network.cloudflareClient.newBuilder()
|
||||
.addInterceptor(ImageInterceptor)
|
||||
.build()
|
||||
|
||||
override fun latestUpdatesRequest(page: Int) =
|
||||
GET("$baseUrl/filter?sort=recently_updated&language[]=$langCode&page=$page", headers)
|
||||
|
||||
override fun popularMangaRequest(page: Int) =
|
||||
GET("$baseUrl/filter?sort=most_viewed&language[]=$langCode&page=$page", headers)
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
val urlBuilder = baseUrl.toHttpUrl().newBuilder()
|
||||
if (query.isNotBlank()) {
|
||||
urlBuilder.addPathSegment("filter").apply {
|
||||
addQueryParameter("keyword", query)
|
||||
addQueryParameter("page", page.toString())
|
||||
}
|
||||
} else {
|
||||
urlBuilder.addPathSegment("filter").apply {
|
||||
addQueryParameter("language[]", langCode)
|
||||
addQueryParameter("page", page.toString())
|
||||
filters.ifEmpty(::getFilterList).forEach { filter ->
|
||||
when (filter) {
|
||||
is Group -> {
|
||||
filter.state.forEach {
|
||||
if (it.state) {
|
||||
addQueryParameter(filter.param, it.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
is Select -> {
|
||||
addQueryParameter(filter.param, filter.selection)
|
||||
}
|
||||
is GenresFilter -> {
|
||||
filter.state.forEach {
|
||||
if (it.state != 0) {
|
||||
addQueryParameter(filter.param, it.selection)
|
||||
}
|
||||
}
|
||||
if (filter.combineMode) {
|
||||
addQueryParameter("genre_mode", "and")
|
||||
}
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return GET(urlBuilder.build(), headers)
|
||||
}
|
||||
|
||||
override fun searchMangaNextPageSelector() = ".page-item.active + .page-item .page-link"
|
||||
|
||||
override fun searchMangaSelector() = ".original.card-lg .unit .inner"
|
||||
|
||||
override fun searchMangaFromElement(element: Element) =
|
||||
SManga.create().apply {
|
||||
element.selectFirst(".info > a")!!.let {
|
||||
setUrlWithoutDomain(it.attr("href"))
|
||||
title = it.ownText()
|
||||
}
|
||||
element.selectFirst(Evaluator.Tag("img"))!!.let {
|
||||
thumbnail_url = it.attr("src")
|
||||
}
|
||||
}
|
||||
|
||||
override fun mangaDetailsParse(document: Document) = SManga.create().apply {
|
||||
val root = document.selectFirst(".info")!!
|
||||
val mangaTitle = root.child(1).ownText()
|
||||
title = mangaTitle
|
||||
description = document.run {
|
||||
val description = selectFirst(Evaluator.Class("description"))!!.ownText()
|
||||
when (val altTitle = root.child(2).ownText()) {
|
||||
"", mangaTitle -> description
|
||||
else -> "$description\n\nAlternative Title: $altTitle"
|
||||
}
|
||||
}
|
||||
thumbnail_url = document.selectFirst(".poster")!!
|
||||
.selectFirst("img")!!.attr("src")
|
||||
status = when (root.child(0).ownText()) {
|
||||
"Completed" -> SManga.COMPLETED
|
||||
"Releasing" -> SManga.ONGOING
|
||||
"On_hiatus" -> SManga.ON_HIATUS
|
||||
"Discontinued" -> SManga.CANCELLED
|
||||
else -> SManga.UNKNOWN
|
||||
}
|
||||
with(document.selectFirst(Evaluator.Class("meta"))!!) {
|
||||
author = selectFirst("span:contains(Author:) + span")?.text()
|
||||
val type = selectFirst("span:contains(Type:) + span")?.text()
|
||||
val genres = selectFirst("span:contains(Genres:) + span")?.text()
|
||||
genre = listOfNotNull(type, genres).joinToString()
|
||||
}
|
||||
}
|
||||
|
||||
override val chapterType get() = "chapter"
|
||||
override val volumeType get() = "volume"
|
||||
|
||||
override fun chapterListRequest(mangaUrl: String, type: String): Request {
|
||||
val id = mangaUrl.substringAfterLast('.')
|
||||
return GET("$baseUrl/ajax/manga/$id/$type/$langCode", headers)
|
||||
}
|
||||
|
||||
override fun parseChapterElements(response: Response, isVolume: Boolean): List<Element> {
|
||||
val result = json.decodeFromString<ResponseDto<String>>(response.body.string()).result
|
||||
val document = Jsoup.parse(result)
|
||||
|
||||
val elements = document.select("ul li")
|
||||
if (elements.size > 0) {
|
||||
val linkToFirstChapter = elements[0].selectFirst(Evaluator.Tag("a"))!!.attr("href")
|
||||
val mangaId = linkToFirstChapter.toString().substringAfter('.').substringBefore('/')
|
||||
|
||||
val request = GET("$baseUrl/ajax/read/$mangaId/chapter/$langCode", headers)
|
||||
val response = client.newCall(request).execute()
|
||||
val res = json.decodeFromString<ResponseDto<ChapterIdsDto>>(response.body.string()).result.html
|
||||
val chapterInfoDocument = Jsoup.parse(res)
|
||||
val chapters = chapterInfoDocument.select("ul li")
|
||||
for ((i, it) in elements.withIndex()) {
|
||||
it.attr("data-id", chapters[i].select("a").attr("data-id"))
|
||||
}
|
||||
}
|
||||
return elements.toList()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class ChapterIdsDto(
|
||||
val html: String,
|
||||
val title_format: String,
|
||||
)
|
||||
|
||||
override fun updateChapterList(manga: SManga, chapters: List<SChapter>) {
|
||||
val request = chapterListRequest(manga.url, chapterType)
|
||||
val response = client.newCall(request).execute()
|
||||
val result = json.decodeFromString<ResponseDto<String>>(response.body.string()).result
|
||||
val document = Jsoup.parse(result)
|
||||
|
||||
val elements = document.selectFirst(".scroll-sm")!!.children()
|
||||
val chapterCount = chapters.size
|
||||
if (elements.size != chapterCount) throw Exception("Chapter count doesn't match. Try updating again.")
|
||||
val dateFormat = SimpleDateFormat("MMM dd, yyyy", Locale.US)
|
||||
for (i in 0 until chapterCount) {
|
||||
val chapter = chapters[i]
|
||||
val element = elements[i]
|
||||
val number = element.attr("data-number").toFloatOrNull() ?: -1f
|
||||
if (chapter.chapter_number != number) throw Exception("Chapter number doesn't match. Try updating again.")
|
||||
val date = element.select(Evaluator.Tag("span"))[1].ownText()
|
||||
chapter.date_upload = try {
|
||||
dateFormat.parse(date)!!.time
|
||||
} catch (_: Throwable) {
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun pageListRequest(chapter: SChapter): Request {
|
||||
val typeAndId = chapter.url.substringAfterLast('#')
|
||||
return GET("$baseUrl/ajax/read/$typeAndId", headers)
|
||||
}
|
||||
|
||||
override fun pageListParse(response: Response): List<Page> {
|
||||
val result = json.decodeFromString<ResponseDto<PageListDto>>(response.body.string()).result
|
||||
|
||||
return result.pages.mapIndexed { index, image ->
|
||||
val url = image.url
|
||||
val offset = image.offset
|
||||
val imageUrl = if (offset > 0) "$url#${ImageInterceptor.SCRAMBLED}_$offset" else url
|
||||
|
||||
Page(index, imageUrl = imageUrl)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class PageListDto(private val images: List<List<JsonPrimitive>>) {
|
||||
val pages get() = images.map {
|
||||
Image(it[0].content, it[2].int)
|
||||
}
|
||||
}
|
||||
class Image(val url: String, val offset: Int)
|
||||
|
||||
@Serializable
|
||||
class ResponseDto<T>(
|
||||
val result: T,
|
||||
val status: Int,
|
||||
)
|
||||
|
||||
override fun getFilterList() =
|
||||
FilterList(
|
||||
Filter.Header("NOTE: Ignored if using text search!"),
|
||||
Filter.Separator(),
|
||||
TypeFilter(),
|
||||
GenresFilter(),
|
||||
StatusFilter(),
|
||||
YearFilter(),
|
||||
ChapterCountFilter(),
|
||||
SortFilter(),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package eu.kanade.tachiyomi.extension.all.mangafire
|
||||
|
||||
import eu.kanade.tachiyomi.source.SourceFactory
|
||||
|
||||
class MangaFireFactory : SourceFactory {
|
||||
override fun createSources() = listOf(
|
||||
MangaFire("en"),
|
||||
MangaFire("es"),
|
||||
MangaFire("es-419", "es-la"),
|
||||
MangaFire("fr"),
|
||||
MangaFire("ja"),
|
||||
MangaFire("pt"),
|
||||
MangaFire("pt-BR", "pt-br"),
|
||||
)
|
||||
}
|
||||
36
multisrc/overrides/mangareader/mangareaderto/CHANGELOG.md
Normal file
@@ -0,0 +1,36 @@
|
||||
## 1.3.4
|
||||
|
||||
- Refactor and make multisrc
|
||||
- Chapter page list now requires only 1 network request (those fetched in old versions still need 2)
|
||||
|
||||
## 1.3.3
|
||||
|
||||
- Appended `.to` to extension name
|
||||
- Replaced dependencies
|
||||
- `android.net.Uri` → `okhttp3.HttpUrl`
|
||||
- `org.json` → `kotlinx.serialization`
|
||||
- Refactored some code to separate files
|
||||
- Image quality preference: added prompt to summary and made it take effect without restart, fixes [#12504](https://github.com/tachiyomiorg/tachiyomi-extensions/issues/12504)
|
||||
- Added preference to show additional entries in volumes in list results and added code to support volumes, fixes [#12573](https://github.com/tachiyomiorg/tachiyomi-extensions/issues/12573)
|
||||
- Improved parsing
|
||||
- Added code to parse authors and artists
|
||||
- Improved chapter list parsing
|
||||
- Other improvements
|
||||
- Performance boosts in selectors
|
||||
- Added French, Korean and Chinese languages
|
||||
- Corrected filter note type (Text → Header)
|
||||
- Rewrote image descrambler
|
||||
- Used fragment in URL instead of appending error-prone query parameter, hopefully fixes [#12722](https://github.com/tachiyomiorg/tachiyomi-extensions/issues/12722)
|
||||
- Made interceptor singleton to be shared across languages
|
||||
- Simplified code logic to make it a lot more readable, thanks to Vetle in [#9325 (comment)](https://github.com/tachiyomiorg/tachiyomi-extensions/pull/9325#issuecomment-1100950110) for code reference
|
||||
- Used `javax.crypto.Cipher` for ARC4
|
||||
- Memoize permutation result to reduce calculation
|
||||
- Save as compressed JPG instead of PNG to avoid size bloat (original image is already compressed)
|
||||
|
||||
## 1.2.2
|
||||
|
||||
- Fixes filters causing manga list to fail to load.
|
||||
|
||||
## 1.2.1
|
||||
|
||||
- Builds on original PR and unscrambles the images.
|
||||
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 122 KiB |
247
multisrc/overrides/mangareader/mangareaderto/src/Filters.kt
Normal 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.Header("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"),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
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.toMediaType
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.InputStream
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
import kotlin.math.min
|
||||
|
||||
object ImageInterceptor : Interceptor {
|
||||
|
||||
private val memo = hashMapOf<Int, IntArray>()
|
||||
|
||||
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 fun descramble(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 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) {
|
||||
// The key is actually "stay", but it's padded here in case the code is run in
|
||||
// Oracle's JDK, where RC4 key is required to be at least 5 bytes
|
||||
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
|
||||
const val SCRAMBLED = "scrambled"
|
||||
}
|
||||
192
multisrc/overrides/mangareader/mangareaderto/src/MangaReader.kt
Normal file
@@ -0,0 +1,192 @@
|
||||
package eu.kanade.tachiyomi.extension.all.mangareaderto
|
||||
|
||||
import androidx.preference.PreferenceScreen
|
||||
import eu.kanade.tachiyomi.multisrc.mangareader.MangaReader
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
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.util.asJsoup
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.jsoup.Jsoup
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import org.jsoup.nodes.TextNode
|
||||
import org.jsoup.select.Evaluator
|
||||
import rx.Observable
|
||||
|
||||
open class MangaReader(
|
||||
override val lang: String,
|
||||
) : MangaReader() {
|
||||
override val name = "MangaReader"
|
||||
|
||||
override val baseUrl = "https://mangareader.to"
|
||||
|
||||
override val client = network.client.newBuilder()
|
||||
.addInterceptor(ImageInterceptor)
|
||||
.build()
|
||||
|
||||
override fun latestUpdatesRequest(page: Int) =
|
||||
GET("$baseUrl/filter?sort=latest-updated&language=$lang&page=$page", headers)
|
||||
|
||||
override fun popularMangaRequest(page: Int) =
|
||||
GET("$baseUrl/filter?sort=most-viewed&language=$lang&page=$page", headers)
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
val urlBuilder = baseUrl.toHttpUrl().newBuilder()
|
||||
if (query.isNotBlank()) {
|
||||
urlBuilder.addPathSegment("search").apply {
|
||||
addQueryParameter("keyword", query)
|
||||
addQueryParameter("page", page.toString())
|
||||
}
|
||||
} else {
|
||||
urlBuilder.addPathSegment("filter").apply {
|
||||
addQueryParameter("language", lang)
|
||||
addQueryParameter("page", page.toString())
|
||||
filters.ifEmpty(::getFilterList).forEach { filter ->
|
||||
when (filter) {
|
||||
is Select -> {
|
||||
addQueryParameter(filter.param, filter.selection)
|
||||
}
|
||||
is DateFilter -> {
|
||||
filter.state.forEach {
|
||||
addQueryParameter(it.param, it.selection)
|
||||
}
|
||||
}
|
||||
is GenresFilter -> {
|
||||
addQueryParameter(filter.param, filter.selection)
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return GET(urlBuilder.build(), 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(Evaluator.Tag("img"))!!.let {
|
||||
title = it.attr("alt")
|
||||
thumbnail_url = it.attr("src")
|
||||
}
|
||||
}
|
||||
|
||||
private fun Element.parseAuthorsTo(manga: SManga) {
|
||||
val authors = select(Evaluator.Tag("a"))
|
||||
val text = authors.map { it.ownText().replace(",", "") }
|
||||
val count = authors.size
|
||||
when (count) {
|
||||
0 -> return
|
||||
1 -> {
|
||||
manga.author = text[0]
|
||||
return
|
||||
}
|
||||
}
|
||||
val authorList = ArrayList<String>(count)
|
||||
val artistList = ArrayList<String>(count)
|
||||
for ((index, author) in authors.withIndex()) {
|
||||
val textNode = author.nextSibling() as? TextNode
|
||||
val list = if (textNode != null && "(Art)" in textNode.wholeText) artistList else authorList
|
||||
list.add(text[index])
|
||||
}
|
||||
if (authorList.isEmpty().not()) manga.author = authorList.joinToString()
|
||||
if (artistList.isEmpty().not()) manga.artist = artistList.joinToString()
|
||||
}
|
||||
|
||||
override fun mangaDetailsParse(document: Document) = SManga.create().apply {
|
||||
val root = document.selectFirst(Evaluator.Id("ani_detail"))!!
|
||||
val mangaTitle = root.selectFirst(Evaluator.Tag("h2"))!!.ownText()
|
||||
title = mangaTitle
|
||||
description = root.run {
|
||||
val description = selectFirst(Evaluator.Class("description"))!!.ownText()
|
||||
when (val altTitle = selectFirst(Evaluator.Class("manga-name-or"))!!.ownText()) {
|
||||
"", mangaTitle -> description
|
||||
else -> "$description\n\nAlternative Title: $altTitle"
|
||||
}
|
||||
}
|
||||
thumbnail_url = root.selectFirst(Evaluator.Tag("img"))!!.attr("src")
|
||||
genre = root.selectFirst(Evaluator.Class("genres"))!!.children().joinToString { it.ownText() }
|
||||
for (item in root.selectFirst(Evaluator.Class("anisc-info"))!!.children()) {
|
||||
if (item.hasClass("item").not()) continue
|
||||
when (item.selectFirst(Evaluator.Class("item-head"))!!.ownText()) {
|
||||
"Authors:" -> item.parseAuthorsTo(this)
|
||||
"Status:" -> status = when (item.selectFirst(Evaluator.Class("name"))!!.ownText()) {
|
||||
"Finished" -> SManga.COMPLETED
|
||||
"Publishing" -> SManga.ONGOING
|
||||
else -> SManga.UNKNOWN
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override val chapterType get() = "chap"
|
||||
override val volumeType get() = "vol"
|
||||
|
||||
override fun chapterListRequest(mangaUrl: String, type: String): Request {
|
||||
val id = mangaUrl.substringAfterLast('-')
|
||||
return GET("$baseUrl/ajax/manga/reading-list/$id?readingBy=$type", headers)
|
||||
}
|
||||
|
||||
override fun parseChapterElements(response: Response, isVolume: Boolean): List<Element> {
|
||||
val container = response.parseHtmlProperty().run {
|
||||
val type = if (isVolume) "volumes" else "chapters"
|
||||
selectFirst(Evaluator.Id("$lang-$type")) ?: return emptyList()
|
||||
}
|
||||
return container.children()
|
||||
}
|
||||
|
||||
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> = Observable.fromCallable {
|
||||
val typeAndId = chapter.url.substringAfterLast('#', "").ifEmpty {
|
||||
val document = client.newCall(pageListRequest(chapter)).execute().asJsoup()
|
||||
val wrapper = document.selectFirst(Evaluator.Id("wrapper"))!!
|
||||
wrapper.attr("data-reading-by") + '/' + wrapper.attr("data-reading-id")
|
||||
}
|
||||
val ajaxUrl = "$baseUrl/ajax/image/list/$typeAndId?quality=${preferences.quality}"
|
||||
client.newCall(GET(ajaxUrl, headers)).execute().let(::pageListParse)
|
||||
}
|
||||
|
||||
override fun pageListParse(response: Response): List<Page> {
|
||||
val pageDocument = response.parseHtmlProperty()
|
||||
|
||||
return pageDocument.getElementsByClass("iv-card").mapIndexed { index, img ->
|
||||
val url = img.attr("data-url")
|
||||
val imageUrl = if (img.hasClass("shuffled")) "$url#${ImageInterceptor.SCRAMBLED}" else url
|
||||
Page(index, imageUrl = imageUrl)
|
||||
}
|
||||
}
|
||||
|
||||
override fun setupPreferenceScreen(screen: PreferenceScreen) {
|
||||
getPreferences(screen.context).forEach(screen::addPreference)
|
||||
super.setupPreferenceScreen(screen)
|
||||
}
|
||||
|
||||
override fun getFilterList() =
|
||||
FilterList(
|
||||
Note,
|
||||
TypeFilter(),
|
||||
StatusFilter(),
|
||||
RatingFilter(),
|
||||
ScoreFilter(),
|
||||
StartDateFilter(),
|
||||
EndDateFilter(),
|
||||
SortFilter(),
|
||||
GenresFilter(),
|
||||
)
|
||||
|
||||
private fun Response.parseHtmlProperty(): Document {
|
||||
val html = Json.parseToJsonElement(body.string()).jsonObject["html"]!!.jsonPrimitive.content
|
||||
return Jsoup.parseBodyFragment(html)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package eu.kanade.tachiyomi.extension.all.mangareaderto
|
||||
|
||||
import eu.kanade.tachiyomi.source.SourceFactory
|
||||
|
||||
class MangaReaderFactory : SourceFactory {
|
||||
override fun createSources() =
|
||||
arrayOf("en", "fr", "ja", "ko", "zh").map(::MangaReader)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package eu.kanade.tachiyomi.extension.all.mangareaderto
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.preference.ListPreference
|
||||
|
||||
fun getPreferences(context: Context) = arrayOf(
|
||||
|
||||
ListPreference(context).apply {
|
||||
key = QUALITY_PREF
|
||||
title = "Image quality"
|
||||
summary = "%s\n" +
|
||||
"Changes will not be applied to chapters that are already loaded or read " +
|
||||
"until you clear the chapter cache."
|
||||
entries = arrayOf("Low", "Medium", "High")
|
||||
entryValues = arrayOf("low", QUALITY_MEDIUM, "high")
|
||||
setDefaultValue(QUALITY_MEDIUM)
|
||||
},
|
||||
)
|
||||
|
||||
val SharedPreferences.quality
|
||||
get() =
|
||||
getString(QUALITY_PREF, QUALITY_MEDIUM)!!
|
||||
|
||||
private const val QUALITY_PREF = "quality"
|
||||
private const val QUALITY_MEDIUM = "medium"
|
||||