Add MangaFire and create MangaReader multisrc (#16138)

* Add MangaFire.to

* Fixes
Updated 'searchMangaNextPageSelector'
Added throwing 'UnsupportedOperationException' for unused selector

* Fixed naming of filters and fixed parameter's name of minimal chapters' filter

* Fixed language parameter in query

* Clean up MangaFire filters

* Clean up MangaFire descrambler

* Create MangaReader multisrc theme

* Move MangaFire to overrides

* Refactor MangaFire for multisrc

* Update MangaReader changelog

* Remove duplicate filter entry

Co-authored-by: Druzai <70586473+Druzai@users.noreply.github.com>

---------

Co-authored-by: Druzai <g9code@yandex.ru>
Co-authored-by: Druzai <70586473+Druzai@users.noreply.github.com>
This commit is contained in:
stevenyomi 2023-04-24 07:12:26 +08:00 committed by GitHub
parent a6983f0ac1
commit 2ba79a0094
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 653 additions and 127 deletions

View File

@ -0,0 +1,11 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="MangaReaderGenerator" type="JetRunConfigurationType" nameIsGenerated="true">
<module name="tachiyomi-extensions.multisrc.main" />
<option name="MAIN_CLASS_NAME" value="eu.kanade.tachiyomi.multisrc.mangareader.MangaReaderGenerator" />
<method v="2">
<option name="Make" enabled="true" />
<option name="Gradle.BeforeRunTask" enabled="true" tasks="ktFormat" externalProjectPath="$PROJECT_DIR$/multisrc" vmOptions="" scriptParameters="-Ptheme=mangareader" />
<option name="Gradle.BeforeRunTask" enabled="true" tasks="ktLint" externalProjectPath="$PROJECT_DIR$/multisrc" vmOptions="" scriptParameters="-Ptheme=mangareader" />
</method>
</configuration>
</component>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View 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",
)

View File

@ -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
}

View File

@ -0,0 +1,189 @@
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
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.client.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 searchMangaSelector() = ".mangas.items .inner"
override fun searchMangaNextPageSelector() = ".page-item.active + .page-item .page-link"
override fun searchMangaFromElement(element: Element) =
SManga.create().apply {
element.selectFirst("a.color-light")!!.let {
url = it.attr("href")
title = it.attr("title")
}
element.selectFirst(Evaluator.Tag("img"))!!.let {
thumbnail_url = it.attr("src")
}
}
override fun mangaDetailsParse(document: Document) = SManga.create().apply {
val root = document.selectFirst(".detail .top .wrapper")!!
val mangaTitle = root.selectFirst(Evaluator.Class("name"))!!.ownText()
title = mangaTitle
description = document.run {
val description = selectFirst(Evaluator.Class("summary"))!!.ownText()
when (val altTitle = root.selectFirst(Evaluator.Class("al-name"))!!.ownText()) {
"", mangaTitle -> description
else -> "$description\n\nAlternative Title: $altTitle"
}
}
thumbnail_url = root.selectFirst(Evaluator.Tag("img"))!!.attr("src")
status = when (root.selectFirst(Evaluator.Class("status"))!!.ownText()) {
"Completed" -> SManga.COMPLETED
"Releasing" -> SManga.ONGOING
"On_hiatus" -> SManga.ON_HIATUS
"Discontinued" -> SManga.CANCELLED
else -> SManga.UNKNOWN
}
with(root.selectFirst(Evaluator.Class("more-info"))!!) {
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/read/$id/list?viewby=$type", headers)
}
override fun parseChapterElements(response: Response, isVolume: Boolean): List<Element> {
val result = json.decodeFromString<ResponseDto<ChapterListDto>>(response.body.string()).result
val container = result.parseHtml(if (isVolume) volumeType else chapterType)
?.selectFirst(".numberlist[data-lang=$langCode]")
?: return emptyList()
return container.children().map { it.child(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)
}
}
override fun getFilterList() =
FilterList(
Filter.Header("NOTE: Ignored if using text search!"),
Filter.Separator(),
TypeFilter(),
GenresFilter(),
StatusFilter(),
YearFilter(),
ChapterCountFilter(),
SortFilter(),
)
@Serializable
class ChapterListDto(private val html: String, private val link_format: String) {
fun parseHtml(type: String): Document? {
if ("LANG/$type-NUMBER" !in link_format) return null
return Jsoup.parseBodyFragment(html)
}
}
@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)
}

View File

@ -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"),
)
}

View File

@ -1,3 +1,8 @@
## 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 ## 1.3.3
- Appended `.to` to extension name - Appended `.to` to extension name

View File

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

View File

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

View File

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

View File

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 122 KiB

View File

@ -14,7 +14,7 @@ import javax.crypto.Cipher
import javax.crypto.spec.SecretKeySpec import javax.crypto.spec.SecretKeySpec
import kotlin.math.min import kotlin.math.min
object MangaReaderImageInterceptor : Interceptor { object ImageInterceptor : Interceptor {
private val memo = hashMapOf<Int, IntArray>() private val memo = hashMapOf<Int, IntArray>()
@ -22,11 +22,9 @@ object MangaReaderImageInterceptor : Interceptor {
val request = chain.request() val request = chain.request()
val response = chain.proceed(request) val response = chain.proceed(request)
val url = request.url if (request.url.fragment != SCRAMBLED) return response
// TODO: remove the query parameter check (legacy) in later versions
if (url.fragment != SCRAMBLED && url.queryParameter("shuffled") == null) return response
val image = descramble(response.body.byteStream()) val image = response.body.byteStream().use(::descramble)
val body = image.toResponseBody("image/jpeg".toMediaType()) val body = image.toResponseBody("image/jpeg".toMediaType())
return response.newBuilder() return response.newBuilder()
.body(body) .body(body)

View File

@ -1,15 +1,13 @@
package eu.kanade.tachiyomi.extension.all.mangareaderto package eu.kanade.tachiyomi.extension.all.mangareaderto
import android.app.Application
import androidx.preference.PreferenceScreen import androidx.preference.PreferenceScreen
import eu.kanade.tachiyomi.multisrc.mangareader.MangaReader
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.source.ConfigurableSource
import eu.kanade.tachiyomi.source.model.FilterList import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.ParsedHttpSource import eu.kanade.tachiyomi.util.asJsoup
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.jsonPrimitive
@ -21,62 +19,25 @@ import org.jsoup.nodes.Document
import org.jsoup.nodes.Element import org.jsoup.nodes.Element
import org.jsoup.nodes.TextNode import org.jsoup.nodes.TextNode
import org.jsoup.select.Evaluator import org.jsoup.select.Evaluator
import uy.kohesive.injekt.Injekt import rx.Observable
import uy.kohesive.injekt.api.get
open class MangaReader( open class MangaReader(
override val lang: String, override val lang: String,
) : ConfigurableSource, ParsedHttpSource() { ) : MangaReader() {
override val name = "MangaReader" override val name = "MangaReader"
override val baseUrl = "https://mangareader.to" override val baseUrl = "https://mangareader.to"
override val supportsLatest = true
override val client = network.client.newBuilder() override val client = network.client.newBuilder()
.addInterceptor(MangaReaderImageInterceptor) .addInterceptor(ImageInterceptor)
.build() .build()
private fun MangasPage.insertVolumeEntries(): MangasPage {
if (preferences.showVolume.not()) return this
val list = mangas.ifEmpty { return this }
val newList = ArrayList<SManga>(list.size * 2)
for (manga in list) {
val volume = SManga.create().apply {
url = manga.url + VOLUME_URL_SUFFIX
title = VOLUME_TITLE_PREFIX + manga.title
thumbnail_url = manga.thumbnail_url
}
newList.add(manga)
newList.add(volume)
}
return MangasPage(newList, hasNextPage)
}
override fun latestUpdatesParse(response: Response) = super.latestUpdatesParse(response).insertVolumeEntries()
override fun popularMangaParse(response: Response) = super.popularMangaParse(response).insertVolumeEntries()
override fun searchMangaParse(response: Response) = super.searchMangaParse(response).insertVolumeEntries()
override fun latestUpdatesRequest(page: Int) = override fun latestUpdatesRequest(page: Int) =
GET("$baseUrl/filter?sort=latest-updated&language=$lang&page=$page", headers) 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) = override fun popularMangaRequest(page: Int) =
GET("$baseUrl/filter?sort=most-viewed&language=$lang&page=$page", headers) 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): Request { override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
val urlBuilder = baseUrl.toHttpUrl().newBuilder() val urlBuilder = baseUrl.toHttpUrl().newBuilder()
if (query.isNotBlank()) { if (query.isNotBlank()) {
@ -106,7 +67,7 @@ open class MangaReader(
} }
} }
} }
return Request.Builder().url(urlBuilder.build()).headers(headers).build() return GET(urlBuilder.build(), headers)
} }
override fun searchMangaSelector() = ".manga_list-sbs .manga-poster" override fun searchMangaSelector() = ".manga_list-sbs .manga-poster"
@ -145,10 +106,9 @@ open class MangaReader(
} }
override fun mangaDetailsParse(document: Document) = SManga.create().apply { override fun mangaDetailsParse(document: Document) = SManga.create().apply {
url = document.location().removePrefix(baseUrl)
val root = document.selectFirst(Evaluator.Id("ani_detail"))!! val root = document.selectFirst(Evaluator.Id("ani_detail"))!!
val mangaTitle = root.selectFirst(Evaluator.Tag("h2"))!!.ownText() val mangaTitle = root.selectFirst(Evaluator.Tag("h2"))!!.ownText()
title = if (url.endsWith(VOLUME_URL_SUFFIX)) VOLUME_TITLE_PREFIX + mangaTitle else mangaTitle title = mangaTitle
description = root.run { description = root.run {
val description = selectFirst(Evaluator.Class("description"))!!.ownText() val description = selectFirst(Evaluator.Class("description"))!!.ownText()
when (val altTitle = selectFirst(Evaluator.Class("manga-name-or"))!!.ownText()) { when (val altTitle = selectFirst(Evaluator.Class("manga-name-or"))!!.ownText()) {
@ -171,70 +131,45 @@ open class MangaReader(
} }
} }
override fun chapterListRequest(manga: SManga): Request { override val chapterType get() = "chap"
val url = manga.url override val volumeType get() = "vol"
val id = url.removeSuffix(VOLUME_URL_SUFFIX).substringAfterLast('-')
val type = if (url.endsWith(VOLUME_URL_SUFFIX)) "vol" else "chap" override fun chapterListRequest(mangaUrl: String, type: String): Request {
val id = mangaUrl.substringAfterLast('-')
return GET("$baseUrl/ajax/manga/reading-list/$id?readingBy=$type", headers) return GET("$baseUrl/ajax/manga/reading-list/$id?readingBy=$type", headers)
} }
override fun chapterListSelector() = "#$lang-chapters .item" override fun parseChapterElements(response: Response, isVolume: Boolean): List<Element> {
override fun chapterListParse(response: Response): List<SChapter> {
val isVolume = response.request.url.queryParameter("readingBy") == "vol"
val container = response.parseHtmlProperty().run { val container = response.parseHtmlProperty().run {
val type = if (isVolume) "volumes" else "chapters" val type = if (isVolume) "volumes" else "chapters"
selectFirst(Evaluator.Id("$lang-$type")) ?: return emptyList() selectFirst(Evaluator.Id("$lang-$type")) ?: return emptyList()
} }
val abbrPrefix = if (isVolume) "Vol" else "Chap" return container.children()
val fullPrefix = if (isVolume) "Volume" else "Chapter"
return container.children().map { chapterFromElement(it, abbrPrefix, fullPrefix) }
} }
override fun chapterFromElement(element: Element) = override fun fetchPageList(chapter: SChapter): Observable<List<Page>> = Observable.fromCallable {
throw UnsupportedOperationException("Not used.") val typeAndId = chapter.url.substringAfterLast('#', "").ifEmpty {
val document = client.newCall(pageListRequest(chapter)).execute().asJsoup()
private fun chapterFromElement(element: Element, abbrPrefix: String, fullPrefix: String) = val wrapper = document.selectFirst(Evaluator.Id("wrapper"))!!
SChapter.create().apply { wrapper.attr("data-reading-by") + '/' + wrapper.attr("data-reading-id")
val number = element.attr("data-number")
chapter_number = number.toFloatOrNull() ?: -1f
element.selectFirst(Evaluator.Tag("a"))!!.let {
url = it.attr("href")
name = run {
val name = it.attr("title")
val prefix = "$abbrPrefix $number: "
if (name.startsWith(prefix).not()) return@run name
val realName = name.removePrefix(prefix)
if (realName.contains(number)) realName else "$fullPrefix $number: $realName"
}
}
} }
val ajaxUrl = "$baseUrl/ajax/image/list/$typeAndId?quality=${preferences.quality}"
client.newCall(GET(ajaxUrl, headers)).execute().let(::pageListParse)
}
override fun pageListParse(document: Document): List<Page> { override fun pageListParse(response: Response): List<Page> {
val ajaxUrl = document.selectFirst(Evaluator.Id("wrapper"))!!.run { val pageDocument = response.parseHtmlProperty()
val readingBy = attr("data-reading-by")
val readingId = attr("data-reading-id")
"$baseUrl/ajax/image/list/$readingBy/$readingId?quality=${preferences.quality}"
}
val pageDocument = client.newCall(GET(ajaxUrl, headers)).execute().parseHtmlProperty()
return pageDocument.getElementsByClass("iv-card").mapIndexed { index, img -> return pageDocument.getElementsByClass("iv-card").mapIndexed { index, img ->
val url = img.attr("data-url") val url = img.attr("data-url")
val imageUrl = if (img.hasClass("shuffled")) "$url#${MangaReaderImageInterceptor.SCRAMBLED}" else url val imageUrl = if (img.hasClass("shuffled")) "$url#${ImageInterceptor.SCRAMBLED}" else url
Page(index, imageUrl = imageUrl) Page(index, imageUrl = imageUrl)
} }
} }
override fun imageUrlParse(document: Document) =
throw UnsupportedOperationException("Not used")
private val preferences by lazy {
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)!!
}
override fun setupPreferenceScreen(screen: PreferenceScreen) { override fun setupPreferenceScreen(screen: PreferenceScreen) {
getPreferences(screen.context).forEach(screen::addPreference) getPreferences(screen.context).forEach(screen::addPreference)
super.setupPreferenceScreen(screen)
} }
override fun getFilterList() = override fun getFilterList() =

View File

@ -3,39 +3,24 @@ package eu.kanade.tachiyomi.extension.all.mangareaderto
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import androidx.preference.ListPreference import androidx.preference.ListPreference
import androidx.preference.SwitchPreferenceCompat
fun getPreferences(context: Context) = arrayOf( fun getPreferences(context: Context) = arrayOf(
ListPreference(context).apply { ListPreference(context).apply {
key = QUALITY_PREF key = QUALITY_PREF
title = "Image quality" title = "Image quality"
summary = "Selected: %s\n" + summary = "%s\n" +
"Changes will not be applied to chapters that are already loaded or read " + "Changes will not be applied to chapters that are already loaded or read " +
"until you clear the chapter cache." "until you clear the chapter cache."
entries = arrayOf("Low", "Medium", "High") entries = arrayOf("Low", "Medium", "High")
entryValues = arrayOf("low", QUALITY_MEDIUM, "high") entryValues = arrayOf("low", QUALITY_MEDIUM, "high")
setDefaultValue(QUALITY_MEDIUM) setDefaultValue(QUALITY_MEDIUM)
}, },
SwitchPreferenceCompat(context).apply {
key = SHOW_VOLUME_PREF
title = "Show manga in volumes in search result"
setDefaultValue(false)
},
) )
val SharedPreferences.quality val SharedPreferences.quality
get() = get() =
getString(QUALITY_PREF, QUALITY_MEDIUM)!! getString(QUALITY_PREF, QUALITY_MEDIUM)!!
val SharedPreferences.showVolume
get() =
getBoolean(SHOW_VOLUME_PREF, false)
private const val QUALITY_PREF = "quality" private const val QUALITY_PREF = "quality"
private const val QUALITY_MEDIUM = "medium" private const val QUALITY_MEDIUM = "medium"
private const val SHOW_VOLUME_PREF = "show_volume"
const val VOLUME_URL_SUFFIX = "#vol"
const val VOLUME_TITLE_PREFIX = "[VOL] "

View File

@ -0,0 +1,125 @@
package eu.kanade.tachiyomi.multisrc.mangareader
import android.app.Application
import androidx.preference.PreferenceScreen
import androidx.preference.SwitchPreferenceCompat
import eu.kanade.tachiyomi.source.ConfigurableSource
import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.util.asJsoup
import okhttp3.Request
import okhttp3.Response
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import org.jsoup.select.Evaluator
import rx.Observable
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
abstract class MangaReader : HttpSource(), ConfigurableSource {
override val supportsLatest = true
final override fun latestUpdatesParse(response: Response) = searchMangaParse(response)
final override fun popularMangaParse(response: Response) = searchMangaParse(response)
final override fun searchMangaParse(response: Response): MangasPage {
val document = response.asJsoup()
var entries = document.select(searchMangaSelector()).map(::searchMangaFromElement)
if (preferences.getBoolean(SHOW_VOLUME_PREF, false)) {
entries = entries.flatMapTo(ArrayList(entries.size * 2)) { manga ->
val volume = SManga.create().apply {
url = manga.url + VOLUME_URL_SUFFIX
title = VOLUME_TITLE_PREFIX + manga.title
thumbnail_url = manga.thumbnail_url
}
listOf(manga, volume)
}
}
val hasNextPage = document.selectFirst(searchMangaNextPageSelector()) != null
return MangasPage(entries, hasNextPage)
}
final override fun getMangaUrl(manga: SManga) = baseUrl + manga.url.removeSuffix(VOLUME_URL_SUFFIX)
abstract fun searchMangaSelector(): String
abstract fun searchMangaNextPageSelector(): String
abstract fun searchMangaFromElement(element: Element): SManga
abstract fun mangaDetailsParse(document: Document): SManga
final override fun mangaDetailsParse(response: Response): SManga {
val document = response.asJsoup()
val manga = mangaDetailsParse(document)
if (response.request.url.fragment == VOLUME_URL_FRAGMENT) {
manga.title = VOLUME_TITLE_PREFIX + manga.title
}
return manga
}
abstract val chapterType: String
abstract val volumeType: String
abstract fun chapterListRequest(mangaUrl: String, type: String): Request
abstract fun parseChapterElements(response: Response, isVolume: Boolean): List<Element>
override fun chapterListParse(response: Response) = throw UnsupportedOperationException()
final override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> = Observable.fromCallable {
val path = manga.url
val isVolume = path.endsWith(VOLUME_URL_SUFFIX)
val type = if (isVolume) volumeType else chapterType
val request = chapterListRequest(path.removeSuffix(VOLUME_URL_SUFFIX), type)
val response = client.newCall(request).execute()
val abbrPrefix = if (isVolume) "Vol" else "Chap"
val fullPrefix = if (isVolume) "Volume" else "Chapter"
val linkSelector = Evaluator.Tag("a")
parseChapterElements(response, isVolume).map { element ->
SChapter.create().apply {
val number = element.attr("data-number")
chapter_number = number.toFloatOrNull() ?: -1f
val link = element.selectFirst(linkSelector)!!
name = run {
val name = link.text()
val prefix = "$abbrPrefix $number: "
if (!name.startsWith(prefix)) return@run name
val realName = name.removePrefix(prefix)
if (realName.contains(number)) realName else "$fullPrefix $number: $realName"
}
setUrlWithoutDomain(link.attr("href") + '#' + type + '/' + element.attr("data-id"))
}
}
}
final override fun getChapterUrl(chapter: SChapter) = baseUrl + chapter.url.substringBeforeLast('#')
override fun imageUrlParse(response: Response) = throw UnsupportedOperationException()
val preferences by lazy {
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)!!
}
override fun setupPreferenceScreen(screen: PreferenceScreen) {
SwitchPreferenceCompat(screen.context).apply {
key = SHOW_VOLUME_PREF
title = "Show volume entries in search result"
setDefaultValue(false)
}.let(screen::addPreference)
}
companion object {
private const val SHOW_VOLUME_PREF = "show_volume"
private const val VOLUME_URL_FRAGMENT = "vol"
private const val VOLUME_URL_SUFFIX = "#" + VOLUME_URL_FRAGMENT
private const val VOLUME_TITLE_PREFIX = "[VOL] "
}
}

View File

@ -0,0 +1,33 @@
package eu.kanade.tachiyomi.multisrc.mangareader
import generator.ThemeSourceData.MultiLang
import generator.ThemeSourceGenerator
class MangaReaderGenerator : ThemeSourceGenerator {
override val themeClass = "MangaReader"
override val themePkg = "mangareader"
override val baseVersionCode = 1
override val sources = listOf(
MultiLang(
name = "MangaReader",
baseUrl = "https://mangareader.to",
langs = listOf("en", "fr", "ja", "ko", "zh"),
isNsfw = true,
pkgName = "mangareaderto",
overrideVersionCode = 3,
),
MultiLang(
name = "MangaFire",
baseUrl = "https://mangafire.to",
langs = listOf("en", "es", "es-419", "fr", "ja", "pt", "pt-BR"),
isNsfw = true,
),
)
companion object {
@JvmStatic
fun main(args: Array<String>) {
MangaReaderGenerator().createAll()
}
}
}

View File

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

View File

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