fix(uk/mangainua): Fix chapter list (again) & prevent future trolling (#19504)

* fix: Fix chapter list & prevent future trolling

* refactor: Minor refactoration for fun

* chore: Bump version

* fix: Prevent NPE in manga details

Didn't happen to me in my tests, but better be safe than sorry.
Thumbnails aren't really worth a (rare but possible?) NPE anyway

* fix: Add rate limit to prevent HTTP 429
This commit is contained in:
Claudemirovsky 2023-12-31 12:18:04 -03:00 committed by GitHub
parent e9d0420f81
commit 828c6f7b46
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 110 additions and 87 deletions

View File

@ -5,7 +5,7 @@ ext {
extName = 'MangaInUa' extName = 'MangaInUa'
pkgNameSuffix = 'uk.mangainua' pkgNameSuffix = 'uk.mangainua'
extClass = '.Mangainua' extClass = '.Mangainua'
extVersionCode = 5 extVersionCode = 6
isNsfw = true isNsfw = true
} }

View File

@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.extension.uk.mangainua
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.interceptor.rateLimitHost
import eu.kanade.tachiyomi.source.model.FilterList import eu.kanade.tachiyomi.source.model.FilterList
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
@ -9,11 +10,9 @@ import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.ParsedHttpSource import eu.kanade.tachiyomi.source.online.ParsedHttpSource
import eu.kanade.tachiyomi.util.asJsoup import eu.kanade.tachiyomi.util.asJsoup
import okhttp3.FormBody import okhttp3.FormBody
import okhttp3.Headers import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import org.jsoup.Jsoup
import org.jsoup.nodes.Document import org.jsoup.nodes.Document
import org.jsoup.nodes.Element import org.jsoup.nodes.Element
import org.jsoup.select.Elements import org.jsoup.select.Elements
@ -29,44 +28,42 @@ class Mangainua : ParsedHttpSource() {
override val lang = "uk" override val lang = "uk"
override val supportsLatest = true override val supportsLatest = true
override val client: OkHttpClient = network.cloudflareClient override val client by lazy {
network.cloudflareClient.newBuilder()
.rateLimitHost(baseUrl.toHttpUrl(), 5)
.build()
}
override fun headersBuilder(): Headers.Builder = super.headersBuilder() override fun headersBuilder() = super.headersBuilder()
.add("Referer", baseUrl) .add("Referer", baseUrl)
// Popular // ============================== Popular ===============================
override fun popularMangaRequest(page: Int): Request { override fun popularMangaRequest(page: Int) = GET(baseUrl)
return GET(baseUrl)
}
override fun popularMangaSelector() = "div.owl-carousel div.card--big"
override fun popularMangaFromElement(element: Element): SManga {
return SManga.create().apply {
element.selectFirst("h3.card__title a")!!.let {
setUrlWithoutDomain(it.attr("href"))
title = it.text()
}
thumbnail_url = element.selectFirst("img")?.absUrl("src")
}
}
override fun popularMangaNextPageSelector() = "not used"
// Latest (using for search) override fun popularMangaSelector() = "div.owl-carousel div.card--big"
override fun latestUpdatesRequest(page: Int): Request {
return GET("$baseUrl/page/$page/") override fun popularMangaFromElement(element: Element) = SManga.create().apply {
element.selectFirst("h3.card__title a")!!.run {
setUrlWithoutDomain(attr("href"))
title = text()
} }
thumbnail_url = element.selectFirst("img")?.run {
absUrl("data-src").ifEmpty { absUrl("src") }
}
}
override fun popularMangaNextPageSelector() = null
// =============================== Latest ===============================
override fun latestUpdatesRequest(page: Int) = GET("$baseUrl/page/$page/")
override fun latestUpdatesSelector() = "main.main article.item" override fun latestUpdatesSelector() = "main.main article.item"
override fun latestUpdatesFromElement(element: Element): SManga {
return SManga.create().apply { override fun latestUpdatesFromElement(element: Element) = popularMangaFromElement(element)
element.selectFirst("h3.card__title a")!!.let {
setUrlWithoutDomain(it.attr("href"))
title = it.text()
}
thumbnail_url = element.selectFirst("div.card--big img")?.absUrl("data-src")
}
}
override fun latestUpdatesNextPageSelector() = "a:contains(Наступна)" override fun latestUpdatesNextPageSelector() = "a:contains(Наступна)"
// Search // =============================== Search ===============================
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
return if (query.length > 2) { return if (query.length > 2) {
POST( POST(
@ -85,41 +82,43 @@ class Mangainua : ParsedHttpSource() {
} }
override fun searchMangaSelector() = latestUpdatesSelector() override fun searchMangaSelector() = latestUpdatesSelector()
override fun searchMangaFromElement(element: Element): SManga {
return SManga.create().apply { override fun searchMangaFromElement(element: Element) = popularMangaFromElement(element)
element.selectFirst("h3.card__title a")!!.let {
setUrlWithoutDomain(it.attr("href"))
title = it.text()
}
thumbnail_url = element.selectFirst("div.card--big img")?.absUrl("src")
}
}
override fun searchMangaNextPageSelector() = latestUpdatesNextPageSelector() override fun searchMangaNextPageSelector() = latestUpdatesNextPageSelector()
// Manga Details // =========================== Manga Details ============================
override fun mangaDetailsParse(document: Document): SManga { override fun mangaDetailsParse(document: Document) = SManga.create().apply {
return SManga.create().apply {
title = document.selectFirst("span.UAname")!!.text() title = document.selectFirst("span.UAname")!!.text()
description = document.selectFirst("div.item__full-description")!!.text() description = document.selectFirst("div.item__full-description")!!.text()
thumbnail_url = document.selectFirst("div.item__full-sidebar--poster img")!!.absUrl("src") thumbnail_url = document.selectFirst("div.item__full-sidebar--poster img")?.absUrl("src")
status = when (document.selectFirst("div.item__full-sideba--header:has(div:containsOwn(Статус перекладу:))")?.selectFirst("span.item__full-sidebar--description")?.text()) { status = when (document.getInfoElement("Статус перекладу:")?.text()?.trim()) {
"Триває" -> SManga.ONGOING "Триває" -> SManga.ONGOING
"Покинуто" -> SManga.CANCELLED "Покинуто" -> SManga.CANCELLED
"Закінчений" -> SManga.COMPLETED "Закінчений" -> SManga.COMPLETED
else -> SManga.UNKNOWN else -> SManga.UNKNOWN
} }
val type = when (document.selectFirst("div.item__full-sideba--header:has(div:containsOwn(Тип:))")?.selectFirst("span.item__full-sidebar--description")!!.text()) {
genre = buildList {
// genres
addAll(document.getInfoElement("Жанри:")?.select("a")?.eachText().orEmpty())
// additional
val type = when (document.getInfoElement("Тип:")?.text()) {
"ВЕБМАНХВА" -> "Manhwa" "ВЕБМАНХВА" -> "Manhwa"
"МАНХВА" -> "Manhwa" "МАНХВА" -> "Manhwa"
"МАНЬХВА" -> "Manhua" "МАНЬХВА" -> "Manhua"
"ВЕБМАНЬХВА" -> "Manhua" "ВЕБМАНЬХВА" -> "Manhua"
else -> "Manga" else -> "Manga"
} }
genre = document.selectFirst("div.item__full-sideba--header:has(div:containsOwn(Жанри:))")?.selectFirst("span.item__full-sidebar--description")!!.select("a").joinToString { it.text() } + ", " + type add(type)
} }.joinToString()
} }
// Chapters private fun Document.getInfoElement(text: String): Element? =
selectFirst("div.item__full-sideba--header:has(div:containsOwn($text)) span.item__full-sidebar--description")
// ============================== Chapters ==============================
override fun chapterListSelector() = throw UnsupportedOperationException() override fun chapterListSelector() = throw UnsupportedOperationException()
override fun chapterFromElement(element: Element): SChapter { override fun chapterFromElement(element: Element): SChapter {
@ -128,76 +127,100 @@ class Mangainua : ParsedHttpSource() {
private fun parseChapterElements(elements: Elements): List<SChapter> { private fun parseChapterElements(elements: Elements): List<SChapter> {
var previousChapterName: String? = null var previousChapterName: String? = null
var previousChapterNumber: Float = 0.0f var previousChapterNumber: Float = 0F
val dateFormat = DATE_FORMATTER
return elements.map { element -> return elements.map { element ->
SChapter.create().apply { SChapter.create().apply {
val urlElement = element.selectFirst("a")!! val urlElement = element.selectFirst("a")!!
setUrlWithoutDomain(urlElement.attr("href")) setUrlWithoutDomain(urlElement.attr("href"))
val chapterName = urlElement.text().substringAfter("НОВЕ").trim() val chapterName = urlElement.ownText().trim()
val chapterNumber = urlElement.text().substringAfter("Розділ").substringBefore("-").trim() val chapterNumber = element.attr("manga-chappter")
.ifEmpty { chapterName.substringAfter("Розділ").substringBefore("-").trim() }
.toFloatOrNull() ?: 1F
if (chapterName.contains("Альтернативний переклад")) { if (chapterName.contains("Альтернативний переклад")) {
name = previousChapterName.toString().substringBefore("-").trim() // Alternative translation of the previous chapter
name = previousChapterName.orEmpty().substringBefore("-").trim()
scanlator = urlElement.text().substringAfter("від:").trim() scanlator = urlElement.text().substringAfter("від:").trim()
chapter_number = previousChapterNumber chapter_number = previousChapterNumber
} else { } else {
// Normal chapter
name = chapterName name = chapterName
chapter_number = chapterNumber
scanlator = element.attr("translate").takeUnless(String::isBlank)
previousChapterName = chapterName previousChapterName = chapterName
chapter_number = chapterNumber.toFloat() previousChapterNumber = chapterNumber
previousChapterNumber = chapterNumber.toFloat()
} }
date_upload = dateFormat.parse(element.child(0).ownText())?.time!! date_upload = element.child(0).ownText().toDate()
} }
} }
} }
override fun chapterListParse(response: Response): List<SChapter> { override fun chapterListParse(response: Response): List<SChapter> {
val document = response.asJsoup() val document = response.asJsoup()
val userHash = document.parseUserHash() val userHash = document.parseUserHash()
val endpoint = "engine/ajax/controller.php?mod=load_chapters"
val userHashQuery = document.parseUserHashQuery(endpoint)
val metaElement = document.selectFirst(Evaluator.Id("linkstocomics"))!! val metaElement = document.selectFirst(Evaluator.Id("linkstocomics"))!!
val body = FormBody.Builder() val body = FormBody.Builder()
.addEncoded("action", "show") .addEncoded("action", "show")
.addEncoded("news_id", metaElement.attr("data-news_id")) .addEncoded("news_id", metaElement.attr("data-news_id"))
.addEncoded("news_category", metaElement.attr("data-news_category")) .addEncoded("news_category", metaElement.attr("data-news_category"))
.addEncoded("this_link", metaElement.attr("data-this_link")) .addEncoded("this_link", metaElement.attr("data-this_link"))
.addEncoded("user_hashs", userHash) .addEncoded(userHashQuery, userHash)
.build() .build()
val request = POST("$baseUrl/engine/ajax/controller.php?mod=load_chapters", headers, body) val request = POST("$baseUrl/$endpoint", headers, body)
val chaptersHtml = client.newCall(request).execute().body.string()
val chaptersDocument = Jsoup.parseBodyFragment(chaptersHtml) val chaptersDoc = client.newCall(request).execute().use { it.asJsoup() }
return parseChapterElements(chaptersDocument.body().children()).asReversed() return parseChapterElements(chaptersDoc.body().children()).asReversed()
} }
// Pages // =============================== Pages ================================
override fun pageListParse(document: Document): List<Page> { override fun pageListParse(document: Document): List<Page> {
val userHash = document.parseUserHash() val userHash = document.parseUserHash()
val endpoint = "engine/ajax/controller.php?mod=load_chapters_image"
val userHashQuery = document.parseUserHashQuery(endpoint)
val newsId = document.selectFirst(Evaluator.Id("comics"))!!.attr("data-news_id") val newsId = document.selectFirst(Evaluator.Id("comics"))!!.attr("data-news_id")
val url = "$baseUrl/engine/ajax/controller.php?mod=load_chapters_image&news_id=$newsId&action=show&user_hash=$userHash" val url = "$baseUrl/$endpoint&news_id=$newsId&action=show&$userHashQuery=$userHash"
val pagesHtml = client.newCall(GET(url, headers)).execute().body.string() val pagesDoc = client.newCall(GET(url, headers)).execute()
val pagesDocument = Jsoup.parseBodyFragment(pagesHtml) .use { it.asJsoup() }
return pagesDocument.getElementsByTag("img").mapIndexed { index, img -> return pagesDoc.getElementsByTag("img").mapIndexed { index, img ->
Page(index, imageUrl = img.attr("data-src")) Page(index, imageUrl = img.attr("data-src"))
} }
} }
override fun imageUrlParse(document: Document): String = throw UnsupportedOperationException() override fun imageUrlParse(document: Document): String = throw UnsupportedOperationException()
// ============================= Utilities ==============================
companion object { companion object {
private val DATE_FORMATTER by lazy { private val DATE_FORMATTER by lazy {
SimpleDateFormat("dd.MM.yyyy", Locale.ENGLISH) SimpleDateFormat("dd.MM.yyyy", Locale.ENGLISH)
} }
private fun String.toDate(): Long {
return runCatching { DATE_FORMATTER.parse(trim())?.time }
.getOrNull() ?: 0L
}
private const val SITE_LOGIN_HASH = "site_login_hash"
private fun Document.parseUserHash(): String { private fun Document.parseUserHash(): String {
val start = "site_login_hash = '" val script = selectFirst("script:containsData($SITE_LOGIN_HASH = )")?.data().orEmpty()
for (element in body().children()) { val hash = script.substringAfter("$SITE_LOGIN_HASH = '").substringBefore("'")
if (element.tagName() != "script") continue return hash.ifEmpty { throw Exception("Couldn't find user hash") }
val data = element.data()
val leftIndex = data.indexOf(start)
if (leftIndex == -1) continue
val startIndex = leftIndex + start.length
val endIndex = data.indexOf('\'', startIndex)
return data.substring(startIndex, endIndex)
} }
throw Exception("Couldn't find user hash")
private fun Document.parseUserHashQuery(endpoint: String): String {
val script = selectFirst("script:containsData($endpoint)")?.data()
val queries = script?.run {
substringAfter(endpoint).substringAfter('{').substringBefore('}')
}
val query = queries.orEmpty()
.substringBefore(SITE_LOGIN_HASH, "")
.substringBeforeLast(':')
.trimEnd()
.substringAfterLast(' ')
return query.ifEmpty { throw Exception("Couldn't find user hash query!") }
} }
} }
} }