add MangaFire
parent
d218ad5a67
commit
590e7e3ba3
@ -0,0 +1,443 @@
|
|||||||
|
package org.koitharu.kotatsu.parsers.site.all
|
||||||
|
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.Response
|
||||||
|
import org.jsoup.Jsoup
|
||||||
|
import org.jsoup.nodes.Document
|
||||||
|
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||||
|
import org.koitharu.kotatsu.parsers.MangaSourceParser
|
||||||
|
import org.koitharu.kotatsu.parsers.PagedMangaParser
|
||||||
|
import org.koitharu.kotatsu.parsers.bitmap.Rect
|
||||||
|
import org.koitharu.kotatsu.parsers.config.ConfigKey
|
||||||
|
import org.koitharu.kotatsu.parsers.model.*
|
||||||
|
import org.koitharu.kotatsu.parsers.util.*
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.*
|
||||||
|
import kotlin.math.min
|
||||||
|
|
||||||
|
private const val PIECE_SIZE = 200
|
||||||
|
private const val MIN_SPLIT_COUNT = 5
|
||||||
|
|
||||||
|
internal abstract class MangaFireParser(
|
||||||
|
context: MangaLoaderContext,
|
||||||
|
source: MangaSource,
|
||||||
|
private val siteLang: String
|
||||||
|
): PagedMangaParser(context, source, 30), Interceptor {
|
||||||
|
|
||||||
|
override val configKeyDomain: ConfigKey.Domain = ConfigKey.Domain("mangafire.to")
|
||||||
|
|
||||||
|
override val availableSortOrders: Set<SortOrder> = EnumSet.of(
|
||||||
|
SortOrder.UPDATED,
|
||||||
|
SortOrder.POPULARITY,
|
||||||
|
SortOrder.RATING,
|
||||||
|
SortOrder.NEWEST,
|
||||||
|
SortOrder.ALPHABETICAL,
|
||||||
|
)
|
||||||
|
|
||||||
|
override val availableStates: Set<MangaState> = EnumSet.allOf(MangaState::class.java)
|
||||||
|
|
||||||
|
private val tags = SoftSuspendLazy {
|
||||||
|
webClient.httpGet("https://$domain/filter").parseHtml()
|
||||||
|
.select(".genres > li").map {
|
||||||
|
MangaTag(
|
||||||
|
title = it.selectFirst("label")!!.ownText().trim(),
|
||||||
|
key = it.selectFirst("input")!!.attr("value"),
|
||||||
|
source = source
|
||||||
|
)
|
||||||
|
}.associateBy { it.title }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getAvailableTags(): Set<MangaTag> {
|
||||||
|
return tags.get().values.toSet()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getListPage(page: Int, filter: MangaListFilter?): List<Manga> {
|
||||||
|
val url = "https://$domain/filter".toHttpUrl().newBuilder().apply {
|
||||||
|
addQueryParameter("page", page.toString())
|
||||||
|
addQueryParameter("language[]", siteLang)
|
||||||
|
|
||||||
|
when (filter) {
|
||||||
|
is MangaListFilter.Search -> {
|
||||||
|
addQueryParameter("keyword", filter.query)
|
||||||
|
filter.sortOrder?.let {
|
||||||
|
addQueryParameter(
|
||||||
|
name = "sort",
|
||||||
|
value = when (it) {
|
||||||
|
SortOrder.UPDATED -> "recently_updated"
|
||||||
|
SortOrder.POPULARITY -> "most_viewed"
|
||||||
|
SortOrder.RATING -> "scores"
|
||||||
|
SortOrder.NEWEST -> "release_date"
|
||||||
|
SortOrder.ALPHABETICAL -> "title_az"
|
||||||
|
else -> ""
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is MangaListFilter.Advanced -> {
|
||||||
|
filter.tagsExclude.forEach { tag ->
|
||||||
|
addQueryParameter("genre[]", "-${tag.key}")
|
||||||
|
}
|
||||||
|
filter.tags.forEach { tag ->
|
||||||
|
addQueryParameter("genre[]", tag.key)
|
||||||
|
}
|
||||||
|
filter.locale?.let {
|
||||||
|
addQueryParameter("language[]", it.language)
|
||||||
|
}
|
||||||
|
filter.states.forEach { state ->
|
||||||
|
addQueryParameter(
|
||||||
|
name = "status[]",
|
||||||
|
value = when (state) {
|
||||||
|
MangaState.ONGOING -> "releasing"
|
||||||
|
MangaState.FINISHED -> "completed"
|
||||||
|
MangaState.ABANDONED -> "discontinued"
|
||||||
|
MangaState.PAUSED -> "on_hiatus"
|
||||||
|
MangaState.UPCOMING -> "info"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
addQueryParameter(
|
||||||
|
name = "sort",
|
||||||
|
value = when (filter.sortOrder) {
|
||||||
|
SortOrder.UPDATED -> "recently_updated"
|
||||||
|
SortOrder.POPULARITY -> "most_viewed"
|
||||||
|
SortOrder.RATING -> "scores"
|
||||||
|
SortOrder.NEWEST -> "release_date"
|
||||||
|
SortOrder.ALPHABETICAL -> "title_az"
|
||||||
|
else -> ""
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
null -> {}
|
||||||
|
}
|
||||||
|
}.build()
|
||||||
|
|
||||||
|
return webClient.httpGet(url)
|
||||||
|
.parseHtml().parseMangaList()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Document.parseMangaList(): List<Manga> {
|
||||||
|
return select(".original.card-lg .unit .inner").map {
|
||||||
|
val a = it.selectFirst(".info > a")!!
|
||||||
|
val mangaUrl = a.attrAsRelativeUrl("href")
|
||||||
|
Manga(
|
||||||
|
id = generateUid(mangaUrl),
|
||||||
|
url = mangaUrl,
|
||||||
|
publicUrl = mangaUrl.toAbsoluteUrl(domain),
|
||||||
|
title = a.ownText(),
|
||||||
|
coverUrl = it.selectFirst("img")!!.attrAsAbsoluteUrl("src"),
|
||||||
|
source = source,
|
||||||
|
altTitle = null,
|
||||||
|
largeCoverUrl = null,
|
||||||
|
author = null,
|
||||||
|
isNsfw = false,
|
||||||
|
rating = RATING_UNKNOWN,
|
||||||
|
state = null,
|
||||||
|
tags = emptySet(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getDetails(manga: Manga): Manga {
|
||||||
|
val document = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml()
|
||||||
|
val availableTags = tags.get()
|
||||||
|
var isNsfw = false
|
||||||
|
|
||||||
|
return manga.copy(
|
||||||
|
title = document.selectFirst(".info > h1")!!.ownText(),
|
||||||
|
altTitle = document.selectFirst(".info > h6")!!.ownText(),
|
||||||
|
rating = document.selectFirst("div.rating-box")?.attr("data-score")
|
||||||
|
?.toFloatOrNull()?.div(10) ?: RATING_UNKNOWN,
|
||||||
|
coverUrl = document.selectFirst("div.manga-detail div.poster img")!!
|
||||||
|
.attrAsAbsoluteUrl("src"),
|
||||||
|
tags = document.select("div.meta a[href*=/genre/]").mapNotNullToSet {
|
||||||
|
val tag = it.ownText().trim()
|
||||||
|
if (tag == "Hentai" || tag == "Ecchi") {
|
||||||
|
isNsfw = true
|
||||||
|
}
|
||||||
|
availableTags[tag]
|
||||||
|
},
|
||||||
|
isNsfw = isNsfw,
|
||||||
|
state = document.selectFirst(".info > p")?.ownText()?.let {
|
||||||
|
when (it.lowercase().trim()) {
|
||||||
|
"releasing" -> MangaState.ONGOING
|
||||||
|
"completed" -> MangaState.FINISHED
|
||||||
|
"discontinued" -> MangaState.ABANDONED
|
||||||
|
"on_hiatus" -> MangaState.PAUSED
|
||||||
|
"info" -> MangaState.UPCOMING
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
author = document.select("div.meta a[href*=/author/]")
|
||||||
|
.joinToString { it.ownText().trim() },
|
||||||
|
description = document.selectFirst("#synopsis div.modal-content")!!.text(),
|
||||||
|
chapters = getChapters(manga.url, document)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class ChapterBranch(
|
||||||
|
val type: String,
|
||||||
|
val langCode: String,
|
||||||
|
val langTitle: String
|
||||||
|
)
|
||||||
|
|
||||||
|
private suspend fun getChapters(mangaUrl: String, document: Document): List<MangaChapter> {
|
||||||
|
val availableTypes = document.select(".chapvol-tab > a").map {
|
||||||
|
it.attr("data-name")
|
||||||
|
}
|
||||||
|
val langTypePairs = document.select(".m-list div.tab-content").flatMap {
|
||||||
|
val type = it.attr("data-name")
|
||||||
|
|
||||||
|
it.select(".list-menu .dropdown-item").map { item ->
|
||||||
|
ChapterBranch(
|
||||||
|
type = type,
|
||||||
|
langCode = item.attr("data-code").lowercase(),
|
||||||
|
langTitle = item.attr("data-title"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}.filter {
|
||||||
|
it.langCode == siteLang && availableTypes.contains(it.type)
|
||||||
|
}
|
||||||
|
|
||||||
|
val id = mangaUrl.substringAfterLast('.')
|
||||||
|
|
||||||
|
return coroutineScope {
|
||||||
|
langTypePairs.map {
|
||||||
|
async {
|
||||||
|
getChaptersBranch(id, it)
|
||||||
|
}
|
||||||
|
}.awaitAll().flatten()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getChaptersBranch(mangaId: String, branch: ChapterBranch): List<MangaChapter> {
|
||||||
|
val chapterElements = webClient
|
||||||
|
.httpGet("https://$domain/ajax/read/$mangaId/${branch.type}/${branch.langCode}")
|
||||||
|
.parseJson()
|
||||||
|
.getJSONObject("result")
|
||||||
|
.getString("html")
|
||||||
|
.let(Jsoup::parseBodyFragment)
|
||||||
|
.select("ul li a")
|
||||||
|
|
||||||
|
if (branch.type == "chapter") {
|
||||||
|
val doc = webClient
|
||||||
|
.httpGet("https://$domain/ajax/manga/$mangaId/${branch.type}/${branch.langCode}")
|
||||||
|
.parseJson()
|
||||||
|
.getString("result")
|
||||||
|
.let(Jsoup::parseBodyFragment)
|
||||||
|
|
||||||
|
doc.select("ul li a").withIndex().forEach { (i, it) ->
|
||||||
|
val date = it.select("span")[1].ownText()
|
||||||
|
chapterElements[i].attr("upload-date", date)
|
||||||
|
chapterElements[i].attr("other-title", it.attr("title"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return chapterElements.mapChapters(reversed = true) { _, it ->
|
||||||
|
MangaChapter(
|
||||||
|
id = generateUid(it.attr("href")),
|
||||||
|
name = it.attr("title").ifBlank {
|
||||||
|
"${branch.type.toTitleCase()} ${it.attr("data-number")}"
|
||||||
|
},
|
||||||
|
number = it.attr("data-number").toFloat(),
|
||||||
|
volume = it.attr("other-title").let {
|
||||||
|
volumeNumRegex.find(it)?.groupValues?.getOrNull(2)?.toInt() ?: 0
|
||||||
|
},
|
||||||
|
url = "${branch.type}/${it.attr("data-id")}",
|
||||||
|
scanlator = null,
|
||||||
|
uploadDate = dateFormat.tryParse(it.attr("upload-date")),
|
||||||
|
branch = "${branch.langTitle} ${branch.type.toTitleCase()}",
|
||||||
|
source = source
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val dateFormat = SimpleDateFormat("MMM dd, yyyy", Locale.ENGLISH)
|
||||||
|
private val volumeNumRegex = Regex("""vol(ume)?\s*(\d+)""", RegexOption.IGNORE_CASE)
|
||||||
|
|
||||||
|
override suspend fun getRelatedManga(seed: Manga): List<Manga> = coroutineScope {
|
||||||
|
val document = webClient.httpGet(seed.url.toAbsoluteUrl(domain)).parseHtml()
|
||||||
|
val total = document.select(
|
||||||
|
"section.m-related a[href*=/manga/], .side-manga:not(:has(.head:contains(trending))) .unit"
|
||||||
|
).size
|
||||||
|
val mangas = ArrayList<Manga>(total)
|
||||||
|
|
||||||
|
// "Related Manga"
|
||||||
|
document.select("section.m-related a[href*=/manga/]").map {
|
||||||
|
async {
|
||||||
|
val url = it.attrAsRelativeUrl("href")
|
||||||
|
|
||||||
|
val mangaDocument = webClient
|
||||||
|
.httpGet(url.toAbsoluteUrl(domain))
|
||||||
|
.parseHtml()
|
||||||
|
|
||||||
|
val chaptersInManga = mangaDocument.select(".m-list div.tab-content .list-menu .dropdown-item")
|
||||||
|
.map { it.attr("data-code").lowercase() }
|
||||||
|
|
||||||
|
|
||||||
|
if (!chaptersInManga.contains(siteLang)) {
|
||||||
|
return@async null
|
||||||
|
}
|
||||||
|
|
||||||
|
Manga(
|
||||||
|
id = generateUid(url),
|
||||||
|
url = url,
|
||||||
|
publicUrl = url.toAbsoluteUrl(domain),
|
||||||
|
title = it.ownText(),
|
||||||
|
coverUrl = mangaDocument.selectFirst("div.manga-detail div.poster img")!!
|
||||||
|
.attrAsAbsoluteUrl("src"),
|
||||||
|
source = source,
|
||||||
|
altTitle = null,
|
||||||
|
largeCoverUrl = null,
|
||||||
|
author = null,
|
||||||
|
isNsfw = false,
|
||||||
|
rating = RATING_UNKNOWN,
|
||||||
|
state = null,
|
||||||
|
tags = emptySet(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}.awaitAll()
|
||||||
|
.filterNotNull()
|
||||||
|
.also { mangas.addAll(it) }
|
||||||
|
|
||||||
|
// "You may also like"
|
||||||
|
document.select(".side-manga:not(:has(.head:contains(trending))) .unit").forEach {
|
||||||
|
val url = it.attrAsRelativeUrl("href")
|
||||||
|
mangas.add(
|
||||||
|
Manga(
|
||||||
|
id = generateUid(url),
|
||||||
|
url = url,
|
||||||
|
publicUrl = url.toAbsoluteUrl(domain),
|
||||||
|
title = it.selectFirst(".info h6")!!.ownText(),
|
||||||
|
coverUrl = it.selectFirst(".poster img")!!.attrAsAbsoluteUrl("src"),
|
||||||
|
source = source,
|
||||||
|
altTitle = null,
|
||||||
|
largeCoverUrl = null,
|
||||||
|
author = null,
|
||||||
|
isNsfw = false,
|
||||||
|
rating = RATING_UNKNOWN,
|
||||||
|
state = null,
|
||||||
|
tags = emptySet(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
mangas.ifEmpty {
|
||||||
|
// fallback: author's other works
|
||||||
|
document.select("div.meta a[href*=/author/]").map {
|
||||||
|
async {
|
||||||
|
val url = it.attrAsAbsoluteUrl("href")
|
||||||
|
|
||||||
|
webClient.httpGet(url)
|
||||||
|
.parseHtml().parseMangaList()
|
||||||
|
}
|
||||||
|
}.awaitAll().flatten()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
||||||
|
val images = webClient
|
||||||
|
.httpGet("https://$domain/ajax/read/${chapter.url}")
|
||||||
|
.parseJson()
|
||||||
|
.getJSONObject("result")
|
||||||
|
.getJSONArray("images")
|
||||||
|
|
||||||
|
val pages = ArrayList<MangaPage>(images.length())
|
||||||
|
|
||||||
|
for (i in 0 until images.length()) {
|
||||||
|
val img = images.getJSONArray(i)
|
||||||
|
|
||||||
|
val url = img.getString(0)
|
||||||
|
val offset = img.getInt(2)
|
||||||
|
|
||||||
|
pages.add(
|
||||||
|
MangaPage(
|
||||||
|
id = generateUid(url),
|
||||||
|
url = if (offset < 1) {
|
||||||
|
url
|
||||||
|
} else {
|
||||||
|
"$url#scrambled_$offset"
|
||||||
|
},
|
||||||
|
preview = null,
|
||||||
|
source = source
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return pages
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
|
val request = chain.request()
|
||||||
|
val response = chain.proceed(request)
|
||||||
|
|
||||||
|
if (request.url.fragment?.startsWith("scrambled") != true) {
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
val offset = request.url.fragment!!.substringAfter("_").toInt()
|
||||||
|
|
||||||
|
return context.redrawImageResponse(response) { bitmap ->
|
||||||
|
val width = bitmap.width
|
||||||
|
val height = bitmap.height
|
||||||
|
|
||||||
|
val result = context.createBitmap(width, height)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
result.drawBitmap(bitmap, srcRect, dstRect)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return@redrawImageResponse result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Int.ceilDiv(other: Int) = (this + (other - 1)) / other
|
||||||
|
|
||||||
|
@MangaSourceParser("MANGAFIRE_EN", "MangaFire English", "en")
|
||||||
|
class English(context: MangaLoaderContext) : MangaFireParser(context, MangaSource.MANGAFIRE_EN, "en")
|
||||||
|
|
||||||
|
@MangaSourceParser("MANGAFIRE_ES", "MangaFire Spanish", "es")
|
||||||
|
class Spanish(context: MangaLoaderContext) : MangaFireParser(context, MangaSource.MANGAFIRE_ES, "es")
|
||||||
|
|
||||||
|
@MangaSourceParser("MANGAFIRE_ESLA", "MangaFire Spanish (Latim)", "es")
|
||||||
|
class SpanishLatim(context: MangaLoaderContext) : MangaFireParser(context, MangaSource.MANGAFIRE_ESLA, "es-la")
|
||||||
|
|
||||||
|
@MangaSourceParser("MANGAFIRE_FR", "MangaFire French", "fr")
|
||||||
|
class French(context: MangaLoaderContext) : MangaFireParser(context, MangaSource.MANGAFIRE_FR, "fr")
|
||||||
|
|
||||||
|
@MangaSourceParser("MANGAFIRE_JA", "MangaFire Japanese", "ja")
|
||||||
|
class Japanese(context: MangaLoaderContext) : MangaFireParser(context, MangaSource.MANGAFIRE_JA, "ja")
|
||||||
|
|
||||||
|
@MangaSourceParser("MANGAFIRE_PT", "MangaFire Portuguese", "pt")
|
||||||
|
class Portuguese(context: MangaLoaderContext) : MangaFireParser(context, MangaSource.MANGAFIRE_PT, "pt")
|
||||||
|
|
||||||
|
@MangaSourceParser("MANGAFIRE_PTBR", "MangaFire Portuguese (Brazil)", "pt")
|
||||||
|
class PortugueseBR(context: MangaLoaderContext) : MangaFireParser(context, MangaSource.MANGAFIRE_PTBR, "pt-br")
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue