Remove referrer field from page model

pull/168/head
Koitharu 3 years ago
parent fc53b19915
commit 93f5f70d79
No known key found for this signature in database
GPG Key ID: 8E861F8CE6E7CE27

@ -20,32 +20,32 @@ import java.util.*
@MangaSourceParser("ANIBEL", "Anibel", "be") @MangaSourceParser("ANIBEL", "Anibel", "be")
internal class AnibelParser(context: MangaLoaderContext) : MangaParser(context, MangaSource.ANIBEL) { internal class AnibelParser(context: MangaLoaderContext) : MangaParser(context, MangaSource.ANIBEL) {
override val configKeyDomain = ConfigKey.Domain("anibel.net", null) override val configKeyDomain = ConfigKey.Domain("anibel.net", null)
override val sortOrders: Set<SortOrder> = EnumSet.of( override val sortOrders: Set<SortOrder> = EnumSet.of(
SortOrder.NEWEST, SortOrder.NEWEST,
) )
override suspend fun getList( override suspend fun getList(
offset: Int, offset: Int,
query: String?, query: String?,
tags: Set<MangaTag>?, tags: Set<MangaTag>?,
sortOrder: SortOrder, sortOrder: SortOrder,
): List<Manga> { ): List<Manga> {
if (!query.isNullOrEmpty()) { if (!query.isNullOrEmpty()) {
return if (offset == 0) { return if (offset == 0) {
search(query) search(query)
} else { } else {
emptyList() emptyList()
} }
} }
val filters = tags?.takeUnless { it.isEmpty() }?.joinToString( val filters = tags?.takeUnless { it.isEmpty() }?.joinToString(
separator = ",", separator = ",",
prefix = "genres: [", prefix = "genres: [",
postfix = "]", postfix = "]",
) { "\"${it.key}\"" }.orEmpty() ) { "\"${it.key}\"" }.orEmpty()
val array = apiCall( val array = apiCall(
""" """
getMediaList(offset: $offset, limit: 20, mediaType: manga, filters: {$filters}) { getMediaList(offset: $offset, limit: 20, mediaType: manga, filters: {$filters}) {
docs { docs {
mediaId mediaId
@ -62,37 +62,37 @@ internal class AnibelParser(context: MangaLoaderContext) : MangaParser(context,
} }
} }
""".trimIndent(), """.trimIndent(),
).getJSONObject("getMediaList").getJSONArray("docs") ).getJSONObject("getMediaList").getJSONArray("docs")
return array.mapJSON { jo -> return array.mapJSON { jo ->
val mediaId = jo.getString("mediaId") val mediaId = jo.getString("mediaId")
val title = jo.getJSONObject("title") val title = jo.getJSONObject("title")
val href = "${jo.getString("mediaType")}/${jo.getString("slug")}" val href = "${jo.getString("mediaType")}/${jo.getString("slug")}"
Manga( Manga(
id = generateUid(mediaId), id = generateUid(mediaId),
title = title.getString("be"), title = title.getString("be"),
coverUrl = jo.getString("poster").removePrefix("/cdn") coverUrl = jo.getString("poster").removePrefix("/cdn")
.toAbsoluteUrl(getDomain("cdn")) + "?width=200&height=280", .toAbsoluteUrl(getDomain("cdn")) + "?width=200&height=280",
altTitle = title.getString("alt").takeUnless(String::isEmpty), altTitle = title.getString("alt").takeUnless(String::isEmpty),
author = null, author = null,
isNsfw = false, isNsfw = false,
rating = jo.getDouble("rating").toFloat() / 10f, rating = jo.getDouble("rating").toFloat() / 10f,
url = href, url = href,
publicUrl = "https://${domain}/$href", publicUrl = "https://${domain}/$href",
tags = jo.getJSONArray("genres").mapToTags(), tags = jo.getJSONArray("genres").mapToTags(),
state = when (jo.getString("status")) { state = when (jo.getString("status")) {
"ongoing" -> MangaState.ONGOING "ongoing" -> MangaState.ONGOING
"finished" -> MangaState.FINISHED "finished" -> MangaState.FINISHED
else -> null else -> null
}, },
source = source, source = source,
) )
} }
} }
override suspend fun getDetails(manga: Manga): Manga { override suspend fun getDetails(manga: Manga): Manga {
val (type, slug) = manga.url.split('/') val (type, slug) = manga.url.split('/')
val details = apiCall( val details = apiCall(
""" """
media(mediaType: $type, slug: "$slug") { media(mediaType: $type, slug: "$slug") {
mediaId mediaId
title { title {
@ -108,52 +108,52 @@ internal class AnibelParser(context: MangaLoaderContext) : MangaParser(context,
genres genres
} }
""".trimIndent(), """.trimIndent(),
).getJSONObject("media") ).getJSONObject("media")
val title = details.getJSONObject("title") val title = details.getJSONObject("title")
val poster = details.getString("poster").removePrefix("/cdn") val poster = details.getString("poster").removePrefix("/cdn")
.toAbsoluteUrl(getDomain("cdn")) .toAbsoluteUrl(getDomain("cdn"))
val chapters = apiCall( val chapters = apiCall(
""" """
chapters(mediaId: "${details.getString("mediaId")}") { chapters(mediaId: "${details.getString("mediaId")}") {
id id
chapter chapter
released released
} }
""".trimIndent(), """.trimIndent(),
).getJSONArray("chapters") ).getJSONArray("chapters")
return manga.copy( return manga.copy(
title = title.getString("be"), title = title.getString("be"),
altTitle = title.getString("alt"), altTitle = title.getString("alt"),
coverUrl = "$poster?width=200&height=280", coverUrl = "$poster?width=200&height=280",
largeCoverUrl = poster, largeCoverUrl = poster,
description = details.getJSONObject("description").getString("be"), description = details.getJSONObject("description").getString("be"),
rating = details.getDouble("rating").toFloat() / 10f, rating = details.getDouble("rating").toFloat() / 10f,
tags = details.getJSONArray("genres").mapToTags(), tags = details.getJSONArray("genres").mapToTags(),
state = when (details.getString("status")) { state = when (details.getString("status")) {
"ongoing" -> MangaState.ONGOING "ongoing" -> MangaState.ONGOING
"finished" -> MangaState.FINISHED "finished" -> MangaState.FINISHED
else -> null else -> null
}, },
chapters = chapters.mapJSON { jo -> chapters = chapters.mapJSON { jo ->
val number = jo.getInt("chapter") val number = jo.getInt("chapter")
MangaChapter( MangaChapter(
id = generateUid(jo.getString("id")), id = generateUid(jo.getString("id")),
name = "Глава $number", name = "Глава $number",
number = number, number = number,
url = "${manga.url}/read/$number", url = "${manga.url}/read/$number",
scanlator = null, scanlator = null,
uploadDate = jo.getLong("released"), uploadDate = jo.getLong("released"),
branch = null, branch = null,
source = source, source = source,
) )
}, },
) )
} }
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> { override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val (_, slug, _, number) = chapter.url.split('/') val (_, slug, _, number) = chapter.url.split('/')
val chapterJson = apiCall( val chapterJson = apiCall(
""" """
chapter(slug: "$slug", chapter: $number) { chapter(slug: "$slug", chapter: $number) {
id id
images { images {
@ -162,35 +162,34 @@ internal class AnibelParser(context: MangaLoaderContext) : MangaParser(context,
} }
} }
""".trimIndent(), """.trimIndent(),
).getJSONObject("chapter") ).getJSONObject("chapter")
val pages = chapterJson.getJSONArray("images") val pages = chapterJson.getJSONArray("images")
val chapterUrl = "https://${domain}/${chapter.url}" val chapterUrl = "https://${domain}/${chapter.url}"
return pages.mapJSONIndexed { i, jo -> return pages.mapJSONIndexed { i, jo ->
MangaPage( MangaPage(
id = generateUid("${chapter.url}/$i"), id = generateUid("${chapter.url}/$i"),
url = jo.getString("large"), url = jo.getString("large"),
referer = chapterUrl, preview = jo.getString("thumbnail"),
preview = jo.getString("thumbnail"), source = source,
source = source, )
) }
} }
}
override suspend fun getTags(): Set<MangaTag> { override suspend fun getTags(): Set<MangaTag> {
val json = apiCall( val json = apiCall(
""" """
getFilters(mediaType: manga) { getFilters(mediaType: manga) {
genres genres
} }
""".trimIndent(), """.trimIndent(),
) )
val array = json.getJSONObject("getFilters").getJSONArray("genres") val array = json.getJSONObject("getFilters").getJSONArray("genres")
return array.mapToTags() return array.mapToTags()
} }
private suspend fun search(query: String): List<Manga> { private suspend fun search(query: String): List<Manga> {
val json = apiCall( val json = apiCall(
""" """
search(query: "$query", limit: 40) { search(query: "$query", limit: 40) {
id id
title { title {
@ -202,65 +201,65 @@ internal class AnibelParser(context: MangaLoaderContext) : MangaParser(context,
type type
} }
""".trimIndent(), """.trimIndent(),
) )
val array = json.getJSONArray("search") val array = json.getJSONArray("search")
return array.mapJSON { jo -> return array.mapJSON { jo ->
val mediaId = jo.getString("id") val mediaId = jo.getString("id")
val title = jo.getJSONObject("title") val title = jo.getJSONObject("title")
val href = "${jo.getString("type").lowercase()}/${jo.getString("url")}" val href = "${jo.getString("type").lowercase()}/${jo.getString("url")}"
Manga( Manga(
id = generateUid(mediaId), id = generateUid(mediaId),
title = title.getString("be"), title = title.getString("be"),
coverUrl = jo.getString("poster").removePrefix("/cdn") coverUrl = jo.getString("poster").removePrefix("/cdn")
.toAbsoluteUrl(getDomain("cdn")) + "?width=200&height=280", .toAbsoluteUrl(getDomain("cdn")) + "?width=200&height=280",
altTitle = title.getString("en").takeUnless(String::isEmpty), altTitle = title.getString("en").takeUnless(String::isEmpty),
author = null, author = null,
isNsfw = false, isNsfw = false,
rating = RATING_UNKNOWN, rating = RATING_UNKNOWN,
url = href, url = href,
publicUrl = "https://${domain}/$href", publicUrl = "https://${domain}/$href",
tags = emptySet(), tags = emptySet(),
state = null, state = null,
source = source, source = source,
) )
} }
} }
private suspend fun apiCall(request: String): JSONObject { private suspend fun apiCall(request: String): JSONObject {
return webClient.graphQLQuery("https://api.${domain}/graphql", request) return webClient.graphQLQuery("https://api.${domain}/graphql", request)
.getJSONObject("data") .getJSONObject("data")
} }
private fun JSONArray.mapToTags(): Set<MangaTag> { private fun JSONArray.mapToTags(): Set<MangaTag> {
fun toTitle(slug: String): String { fun toTitle(slug: String): String {
val builder = StringBuilder(slug) val builder = StringBuilder(slug)
var capitalize = true var capitalize = true
for ((i, c) in builder.withIndex()) { for ((i, c) in builder.withIndex()) {
when { when {
c == '-' -> { c == '-' -> {
builder.setCharAt(i, ' ') builder.setCharAt(i, ' ')
} }
capitalize -> { capitalize -> {
builder.setCharAt(i, c.uppercaseChar()) builder.setCharAt(i, c.uppercaseChar())
capitalize = false capitalize = false
} }
} }
} }
return builder.toString() return builder.toString()
} }
val result = ArraySet<MangaTag>(length()) val result = ArraySet<MangaTag>(length())
stringIterator().forEach { stringIterator().forEach {
result.add( result.add(
MangaTag( MangaTag(
title = toTitle(it), title = toTitle(it),
key = it, key = it,
source = source, source = source,
), ),
) )
} }
return result return result
} }
} }

@ -20,290 +20,298 @@ import javax.crypto.spec.SecretKeySpec
@MangaSourceParser("BATOTO", "Bato.To") @MangaSourceParser("BATOTO", "Bato.To")
internal class BatoToParser(context: MangaLoaderContext) : PagedMangaParser( internal class BatoToParser(context: MangaLoaderContext) : PagedMangaParser(
context = context, context = context,
source = MangaSource.BATOTO, source = MangaSource.BATOTO,
pageSize = 60, pageSize = 60,
searchPageSize = 20, searchPageSize = 20,
) { ) {
override val sortOrders: Set<SortOrder> = EnumSet.of( override val sortOrders: Set<SortOrder> = EnumSet.of(
SortOrder.NEWEST, SortOrder.NEWEST,
SortOrder.UPDATED, SortOrder.UPDATED,
SortOrder.POPULARITY, SortOrder.POPULARITY,
SortOrder.ALPHABETICAL, SortOrder.ALPHABETICAL,
) )
override val configKeyDomain = ConfigKey.Domain( override val configKeyDomain = ConfigKey.Domain(
"bato.to", "bato.to",
arrayOf("bato.to", "mto.to", "hto.to", "mangatoto.com", "battwo.com", "batotwo.com", "comiko.net", "batotoo.com"), arrayOf(
) "bato.to",
"mto.to",
"hto.to",
"mangatoto.com",
"battwo.com",
"batotwo.com",
"comiko.net",
"batotoo.com",
),
)
override suspend fun getListPage( override suspend fun getListPage(
page: Int, page: Int,
query: String?, query: String?,
tags: Set<MangaTag>?, tags: Set<MangaTag>?,
sortOrder: SortOrder, sortOrder: SortOrder,
): List<Manga> { ): List<Manga> {
if (!query.isNullOrEmpty()) { if (!query.isNullOrEmpty()) {
return search(page, query) return search(page, query)
} }
@Suppress("NON_EXHAUSTIVE_WHEN_STATEMENT") @Suppress("NON_EXHAUSTIVE_WHEN_STATEMENT")
val url = buildString { val url = buildString {
append("https://") append("https://")
append(domain) append(domain)
append("/browse?sort=") append("/browse?sort=")
when (sortOrder) { when (sortOrder) {
SortOrder.UPDATED, SortOrder.UPDATED,
-> append("update.za") -> append("update.za")
SortOrder.POPULARITY -> append("views_a.za") SortOrder.POPULARITY -> append("views_a.za")
SortOrder.NEWEST -> append("create.za") SortOrder.NEWEST -> append("create.za")
SortOrder.ALPHABETICAL -> append("title.az") SortOrder.ALPHABETICAL -> append("title.az")
} }
if (!tags.isNullOrEmpty()) { if (!tags.isNullOrEmpty()) {
append("&genres=") append("&genres=")
appendAll(tags, ",") { it.key } appendAll(tags, ",") { it.key }
} }
append("&page=") append("&page=")
append(page) append(page)
} }
return parseList(url, page) return parseList(url, page)
} }
override suspend fun getDetails(manga: Manga): Manga { override suspend fun getDetails(manga: Manga): Manga {
val root = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml() val root = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml()
.requireElementById("mainer") .requireElementById("mainer")
val details = root.selectFirstOrThrow(".detail-set") val details = root.selectFirstOrThrow(".detail-set")
val attrs = details.selectFirst(".attr-main")?.select(".attr-item")?.associate { val attrs = details.selectFirst(".attr-main")?.select(".attr-item")?.associate {
it.child(0).text().trim() to it.child(1) it.child(0).text().trim() to it.child(1)
}.orEmpty() }.orEmpty()
return manga.copy( return manga.copy(
title = root.selectFirst("h3.item-title")?.text() ?: manga.title, title = root.selectFirst("h3.item-title")?.text() ?: manga.title,
isNsfw = !root.selectFirst("alert")?.getElementsContainingOwnText("NSFW").isNullOrEmpty(), isNsfw = !root.selectFirst("alert")?.getElementsContainingOwnText("NSFW").isNullOrEmpty(),
largeCoverUrl = details.selectFirst("img[src]")?.absUrl("src"), largeCoverUrl = details.selectFirst("img[src]")?.absUrl("src"),
description = details.getElementById("limit-height-body-summary") description = details.getElementById("limit-height-body-summary")
?.selectFirst(".limit-html") ?.selectFirst(".limit-html")
?.html(), ?.html(),
tags = manga.tags + attrs["Genres:"]?.parseTags().orEmpty(), tags = manga.tags + attrs["Genres:"]?.parseTags().orEmpty(),
state = when (attrs["Release status:"]?.text()) { state = when (attrs["Release status:"]?.text()) {
"Ongoing" -> MangaState.ONGOING "Ongoing" -> MangaState.ONGOING
"Completed" -> MangaState.FINISHED "Completed" -> MangaState.FINISHED
else -> manga.state else -> manga.state
}, },
author = attrs["Authors:"]?.text()?.trim() ?: manga.author, author = attrs["Authors:"]?.text()?.trim() ?: manga.author,
chapters = root.selectFirst(".episode-list") chapters = root.selectFirst(".episode-list")
?.selectFirst(".main") ?.selectFirst(".main")
?.children() ?.children()
?.reversed() ?.reversed()
?.mapChapters { i, div -> ?.mapChapters { i, div ->
div.parseChapter(i) div.parseChapter(i)
}.orEmpty(), }.orEmpty(),
) )
} }
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> { override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val fullUrl = chapter.url.toAbsoluteUrl(domain) val fullUrl = chapter.url.toAbsoluteUrl(domain)
val scripts = webClient.httpGet(fullUrl).parseHtml().select("script") val scripts = webClient.httpGet(fullUrl).parseHtml().select("script")
for (script in scripts) { for (script in scripts) {
val scriptSrc = script.html() val scriptSrc = script.html()
val p = scriptSrc.indexOf("const imgHttpLis =") val p = scriptSrc.indexOf("const imgHttpLis =")
if (p == -1) continue if (p == -1) continue
val start = scriptSrc.indexOf('[', p) val start = scriptSrc.indexOf('[', p)
val end = scriptSrc.indexOf(';', start) val end = scriptSrc.indexOf(';', start)
if (start == -1 || end == -1) { if (start == -1 || end == -1) {
continue continue
} }
val images = JSONArray(scriptSrc.substring(start, end)) val images = JSONArray(scriptSrc.substring(start, end))
val batoPass = scriptSrc.substringBetweenFirst("batoPass =", ";")?.trim(' ', '"', '\n') val batoPass = scriptSrc.substringBetweenFirst("batoPass =", ";")?.trim(' ', '"', '\n')
?: script.parseFailed("Cannot find batoPass") ?: script.parseFailed("Cannot find batoPass")
val batoWord = scriptSrc.substringBetweenFirst("batoWord =", ";")?.trim(' ', '"', '\n') val batoWord = scriptSrc.substringBetweenFirst("batoWord =", ";")?.trim(' ', '"', '\n')
?: script.parseFailed("Cannot find batoWord") ?: script.parseFailed("Cannot find batoWord")
val password = context.evaluateJs(batoPass)?.removeSurrounding('"') val password = context.evaluateJs(batoPass)?.removeSurrounding('"')
?: script.parseFailed("Cannot evaluate batoPass") ?: script.parseFailed("Cannot evaluate batoPass")
val args = JSONArray(decryptAES(batoWord, password)) val args = JSONArray(decryptAES(batoWord, password))
val result = ArrayList<MangaPage>(images.length()) val result = ArrayList<MangaPage>(images.length())
repeat(images.length()) { i -> repeat(images.length()) { i ->
val url = images.getString(i) val url = images.getString(i)
result += MangaPage( result += MangaPage(
id = generateUid(url), id = generateUid(url),
url = url + "?" + args.getString(i), url = url + "?" + args.getString(i),
referer = fullUrl, preview = null,
preview = null, source = source,
source = source, )
) }
} return result
return result }
} throw ParseException("Cannot find images list", fullUrl)
throw ParseException("Cannot find images list", fullUrl) }
}
override suspend fun getTags(): Set<MangaTag> { override suspend fun getTags(): Set<MangaTag> {
val scripts = webClient.httpGet( val scripts = webClient.httpGet(
"https://${domain}/browse", "https://${domain}/browse",
).parseHtml().selectOrThrow("script") ).parseHtml().selectOrThrow("script")
for (script in scripts) { for (script in scripts) {
val genres = script.html().substringBetweenFirst("const _genres =", ";") ?: continue val genres = script.html().substringBetweenFirst("const _genres =", ";") ?: continue
val jo = JSONObject(genres) val jo = JSONObject(genres)
val result = ArraySet<MangaTag>(jo.length()) val result = ArraySet<MangaTag>(jo.length())
jo.keys().forEach { key -> jo.keys().forEach { key ->
val item = jo.getJSONObject(key) val item = jo.getJSONObject(key)
result += MangaTag( result += MangaTag(
title = item.getString("text").toTitleCase(), title = item.getString("text").toTitleCase(),
key = item.getString("file"), key = item.getString("file"),
source = source, source = source,
) )
} }
return result return result
} }
throw ParseException("Cannot find gernes list", scripts[0].baseUri()) throw ParseException("Cannot find gernes list", scripts[0].baseUri())
} }
private suspend fun search(page: Int, query: String): List<Manga> { private suspend fun search(page: Int, query: String): List<Manga> {
val url = buildString { val url = buildString {
append("https://") append("https://")
append(domain) append(domain)
append("/search?word=") append("/search?word=")
append(query.replace(' ', '+')) append(query.replace(' ', '+'))
append("&page=") append("&page=")
append(page) append(page)
} }
return parseList(url, page) return parseList(url, page)
} }
private fun getActivePage(body: Element): Int = body.select("nav ul.pagination > li.page-item.active") private fun getActivePage(body: Element): Int = body.select("nav ul.pagination > li.page-item.active")
.lastOrNull() .lastOrNull()
?.text() ?.text()
?.toIntOrNull() ?: body.parseFailed("Cannot determine current page") ?.toIntOrNull() ?: body.parseFailed("Cannot determine current page")
private suspend fun parseList(url: String, page: Int): List<Manga> { private suspend fun parseList(url: String, page: Int): List<Manga> {
val body = webClient.httpGet(url).parseHtml().body() val body = webClient.httpGet(url).parseHtml().body()
if (body.selectFirst(".browse-no-matches") != null) { if (body.selectFirst(".browse-no-matches") != null) {
return emptyList() return emptyList()
} }
val activePage = getActivePage(body) val activePage = getActivePage(body)
if (activePage != page) { if (activePage != page) {
return emptyList() return emptyList()
} }
val root = body.requireElementById("series-list") val root = body.requireElementById("series-list")
return root.children().map { div -> return root.children().map { div ->
val a = div.selectFirstOrThrow("a") val a = div.selectFirstOrThrow("a")
val href = a.attrAsRelativeUrl("href") val href = a.attrAsRelativeUrl("href")
val title = div.selectFirstOrThrow(".item-title").text() val title = div.selectFirstOrThrow(".item-title").text()
Manga( Manga(
id = generateUid(href), id = generateUid(href),
title = title, title = title,
altTitle = div.selectFirst(".item-alias")?.text()?.takeUnless { it == title }, altTitle = div.selectFirst(".item-alias")?.text()?.takeUnless { it == title },
url = href, url = href,
publicUrl = a.absUrl("href"), publicUrl = a.absUrl("href"),
rating = RATING_UNKNOWN, rating = RATING_UNKNOWN,
isNsfw = false, isNsfw = false,
coverUrl = div.selectFirst("img[src]")?.absUrl("src").orEmpty(), coverUrl = div.selectFirst("img[src]")?.absUrl("src").orEmpty(),
largeCoverUrl = null, largeCoverUrl = null,
description = null, description = null,
tags = div.selectFirst(".item-genre")?.parseTags().orEmpty(), tags = div.selectFirst(".item-genre")?.parseTags().orEmpty(),
state = null, state = null,
author = null, author = null,
source = source, source = source,
) )
} }
} }
private fun Element.parseTags() = children().mapToSet { span -> private fun Element.parseTags() = children().mapToSet { span ->
val text = span.ownText() val text = span.ownText()
MangaTag( MangaTag(
title = text.toTitleCase(), title = text.toTitleCase(),
key = text.lowercase(Locale.ENGLISH).replace(' ', '_'), key = text.lowercase(Locale.ENGLISH).replace(' ', '_'),
source = source, source = source,
) )
} }
private fun Element.parseChapter(index: Int): MangaChapter? { private fun Element.parseChapter(index: Int): MangaChapter? {
val a = selectFirst("a.chapt") ?: return null val a = selectFirst("a.chapt") ?: return null
val extra = selectFirst(".extra") val extra = selectFirst(".extra")
val href = a.attrAsRelativeUrl("href") val href = a.attrAsRelativeUrl("href")
return MangaChapter( return MangaChapter(
id = generateUid(href), id = generateUid(href),
name = a.text(), name = a.text(),
number = index + 1, number = index + 1,
url = href, url = href,
scanlator = extra?.getElementsByAttributeValueContaining("href", "/group/")?.text(), scanlator = extra?.getElementsByAttributeValueContaining("href", "/group/")?.text(),
uploadDate = runCatching { uploadDate = runCatching {
parseChapterDate(extra?.select("i")?.lastOrNull()?.ownText()) parseChapterDate(extra?.select("i")?.lastOrNull()?.ownText())
}.getOrDefault(0), }.getOrDefault(0),
branch = null, branch = null,
source = source, source = source,
) )
} }
private fun parseChapterDate(date: String?): Long { private fun parseChapterDate(date: String?): Long {
if (date.isNullOrEmpty()) { if (date.isNullOrEmpty()) {
return 0 return 0
} }
val value = date.substringBefore(' ').toInt() val value = date.substringBefore(' ').toInt()
val field = when { val field = when {
"sec" in date -> Calendar.SECOND "sec" in date -> Calendar.SECOND
"min" in date -> Calendar.MINUTE "min" in date -> Calendar.MINUTE
"hour" in date -> Calendar.HOUR "hour" in date -> Calendar.HOUR
"day" in date -> Calendar.DAY_OF_MONTH "day" in date -> Calendar.DAY_OF_MONTH
"week" in date -> Calendar.WEEK_OF_YEAR "week" in date -> Calendar.WEEK_OF_YEAR
"month" in date -> Calendar.MONTH "month" in date -> Calendar.MONTH
"year" in date -> Calendar.YEAR "year" in date -> Calendar.YEAR
else -> return 0 else -> return 0
} }
val calendar = Calendar.getInstance() val calendar = Calendar.getInstance()
calendar.add(field, -value) calendar.add(field, -value)
return calendar.timeInMillis return calendar.timeInMillis
} }
private fun decryptAES(encrypted: String, password: String): String { private fun decryptAES(encrypted: String, password: String): String {
val cipherData = context.decodeBase64(encrypted) val cipherData = context.decodeBase64(encrypted)
val saltData = cipherData.copyOfRange(8, 16) val saltData = cipherData.copyOfRange(8, 16)
val (key, iv) = generateKeyAndIV( val (key, iv) = generateKeyAndIV(
keyLength = 32, keyLength = 32,
ivLength = 16, ivLength = 16,
iterations = 1, iterations = 1,
salt = saltData, salt = saltData,
password = password.toByteArray(StandardCharsets.UTF_8), password = password.toByteArray(StandardCharsets.UTF_8),
md = MessageDigest.getInstance("MD5"), md = MessageDigest.getInstance("MD5"),
) )
val encryptedData = cipherData.copyOfRange(16, cipherData.size) val encryptedData = cipherData.copyOfRange(16, cipherData.size)
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(Cipher.DECRYPT_MODE, key, iv) cipher.init(Cipher.DECRYPT_MODE, key, iv)
return cipher.doFinal(encryptedData).toString(Charsets.UTF_8) return cipher.doFinal(encryptedData).toString(Charsets.UTF_8)
} }
@Suppress("SameParameterValue") @Suppress("SameParameterValue")
private fun generateKeyAndIV( private fun generateKeyAndIV(
keyLength: Int, keyLength: Int,
ivLength: Int, ivLength: Int,
iterations: Int, iterations: Int,
salt: ByteArray, salt: ByteArray,
password: ByteArray, password: ByteArray,
md: MessageDigest, md: MessageDigest,
): Pair<SecretKeySpec, IvParameterSpec> { ): Pair<SecretKeySpec, IvParameterSpec> {
val digestLength = md.digestLength val digestLength = md.digestLength
val requiredLength = (keyLength + ivLength + digestLength - 1) / digestLength * digestLength val requiredLength = (keyLength + ivLength + digestLength - 1) / digestLength * digestLength
val generatedData = ByteArray(requiredLength) val generatedData = ByteArray(requiredLength)
var generatedLength = 0 var generatedLength = 0
md.reset() md.reset()
while (generatedLength < keyLength + ivLength) { while (generatedLength < keyLength + ivLength) {
if (generatedLength > 0) { if (generatedLength > 0) {
md.update(generatedData, generatedLength - digestLength, digestLength) md.update(generatedData, generatedLength - digestLength, digestLength)
} }
md.update(password) md.update(password)
md.update(salt, 0, 8) md.update(salt, 0, 8)
md.digest(generatedData, generatedLength, digestLength) md.digest(generatedData, generatedLength, digestLength)
repeat(iterations - 1) { repeat(iterations - 1) {
md.update(generatedData, generatedLength, digestLength) md.update(generatedData, generatedLength, digestLength)
md.digest(generatedData, generatedLength, digestLength) md.digest(generatedData, generatedLength, digestLength)
} }
generatedLength += digestLength generatedLength += digestLength
} }
return SecretKeySpec(generatedData.copyOfRange(0, keyLength), "AES") to IvParameterSpec( return SecretKeySpec(generatedData.copyOfRange(0, keyLength), "AES") to IvParameterSpec(
if (ivLength > 0) { if (ivLength > 0) {
generatedData.copyOfRange(keyLength, keyLength + ivLength) generatedData.copyOfRange(keyLength, keyLength + ivLength)
} else byteArrayOf(), } else byteArrayOf(),
) )
} }
} }

@ -17,238 +17,236 @@ import java.util.*
@MangaSourceParser("BLOGTRUYEN", "BlogTruyen", "vi") @MangaSourceParser("BLOGTRUYEN", "BlogTruyen", "vi")
class BlogTruyenParser(context: MangaLoaderContext) : class BlogTruyenParser(context: MangaLoaderContext) :
PagedMangaParser(context, MangaSource.BLOGTRUYEN, pageSize = 20) { PagedMangaParser(context, MangaSource.BLOGTRUYEN, pageSize = 20) {
override val configKeyDomain: ConfigKey.Domain override val configKeyDomain: ConfigKey.Domain
get() = ConfigKey.Domain("blogtruyen.vn", null) get() = ConfigKey.Domain("blogtruyen.vn", null)
override val sortOrders: Set<SortOrder> override val sortOrders: Set<SortOrder>
get() = EnumSet.of(SortOrder.UPDATED) get() = EnumSet.of(SortOrder.UPDATED)
private val mutex = Mutex() private val mutex = Mutex()
private val dateFormat = SimpleDateFormat("dd/MM/yyyy HH:mm", Locale.US) private val dateFormat = SimpleDateFormat("dd/MM/yyyy HH:mm", Locale.US)
private var cacheTags: ArrayMap<String, MangaTag>? = null private var cacheTags: ArrayMap<String, MangaTag>? = null
override suspend fun getDetails(manga: Manga): Manga { override suspend fun getDetails(manga: Manga): Manga {
val doc = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml() val doc = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml()
val descriptionElement = doc.selectFirstOrThrow("div.description") val descriptionElement = doc.selectFirstOrThrow("div.description")
val statusText = descriptionElement val statusText = descriptionElement
.selectFirst("p:contains(Trạng thái) > span.color-red") .selectFirst("p:contains(Trạng thái) > span.color-red")
?.text() ?.text()
val state = when (statusText) { val state = when (statusText) {
"Đang tiến hành" -> MangaState.ONGOING "Đang tiến hành" -> MangaState.ONGOING
"Đã hoàn thành" -> MangaState.FINISHED "Đã hoàn thành" -> MangaState.FINISHED
else -> null else -> null
} }
val rating = doc.selectFirst("span.total-vote")?.attr("ng-init")?.let { text -> val rating = doc.selectFirst("span.total-vote")?.attr("ng-init")?.let { text ->
val like = text.substringAfter("TotalLike=") val like = text.substringAfter("TotalLike=")
.substringBefore(';') .substringBefore(';')
.toIntOrNull() ?: return@let RATING_UNKNOWN .toIntOrNull() ?: return@let RATING_UNKNOWN
val dislike = text.substringAfter("TotalDisLike=") val dislike = text.substringAfter("TotalDisLike=")
.toIntOrNull() ?: return@let RATING_UNKNOWN .toIntOrNull() ?: return@let RATING_UNKNOWN
when { when {
like == 0 && dislike == 0 -> RATING_UNKNOWN like == 0 && dislike == 0 -> RATING_UNKNOWN
else -> like.toFloat() / (like + dislike) else -> like.toFloat() / (like + dislike)
} }
} }
val tagMap = getOrCreateTagMap() val tagMap = getOrCreateTagMap()
val tags = descriptionElement.select("p > span.category").mapNotNullToSet { val tags = descriptionElement.select("p > span.category").mapNotNullToSet {
val tagName = it.selectFirst("a")?.text()?.trim() ?: return@mapNotNullToSet null val tagName = it.selectFirst("a")?.text()?.trim() ?: return@mapNotNullToSet null
tagMap[tagName] tagMap[tagName]
} }
return manga.copy( return manga.copy(
tags = tags, tags = tags,
author = descriptionElement.selectFirst("p:contains(Tác giả) > a")?.text(), author = descriptionElement.selectFirst("p:contains(Tác giả) > a")?.text(),
description = doc.selectFirst(".detail .content")?.html(), description = doc.selectFirst(".detail .content")?.html(),
chapters = parseChapterList(doc), chapters = parseChapterList(doc),
largeCoverUrl = doc.selectLast("div.thumbnail > img")?.imageUrl(), largeCoverUrl = doc.selectLast("div.thumbnail > img")?.imageUrl(),
state = state, state = state,
rating = rating ?: RATING_UNKNOWN, rating = rating ?: RATING_UNKNOWN,
isNsfw = doc.getElementById("warningCategory") != null, isNsfw = doc.getElementById("warningCategory") != null,
) )
} }
private fun parseChapterList(doc: Document): List<MangaChapter> { private fun parseChapterList(doc: Document): List<MangaChapter> {
val chapterList = doc.select("#list-chapters > p") val chapterList = doc.select("#list-chapters > p")
return chapterList.asReversed().mapChapters { index, element -> return chapterList.asReversed().mapChapters { index, element ->
val titleElement = element.selectFirst("span.title > a") ?: return@mapChapters null val titleElement = element.selectFirst("span.title > a") ?: return@mapChapters null
val name = titleElement.text() val name = titleElement.text()
val relativeUrl = titleElement.attrAsRelativeUrl("href") val relativeUrl = titleElement.attrAsRelativeUrl("href")
val id = relativeUrl.substringAfter('/').substringBefore('/') val id = relativeUrl.substringAfter('/').substringBefore('/')
val uploadDate = dateFormat.tryParse(element.select("span.publishedDate").text()) val uploadDate = dateFormat.tryParse(element.select("span.publishedDate").text())
MangaChapter( MangaChapter(
id = generateUid(id), id = generateUid(id),
name = name, name = name,
number = index + 1, number = index + 1,
url = relativeUrl, url = relativeUrl,
scanlator = null, scanlator = null,
uploadDate = uploadDate, uploadDate = uploadDate,
branch = null, branch = null,
source = source, source = source,
) )
} }
} }
override suspend fun getListPage( override suspend fun getListPage(
page: Int, page: Int,
query: String?, query: String?,
tags: Set<MangaTag>?, tags: Set<MangaTag>?,
sortOrder: SortOrder, sortOrder: SortOrder,
): List<Manga> { ): List<Manga> {
return when { return when {
!query.isNullOrEmpty() -> { !query.isNullOrEmpty() -> {
val searchUrl = "https://${domain}/timkiem/nangcao/1/0/-1/-1?txt=${query.urlEncoded()}&p=$page" val searchUrl = "https://${domain}/timkiem/nangcao/1/0/-1/-1?txt=${query.urlEncoded()}&p=$page"
val searchContent = webClient.httpGet(searchUrl).parseHtml() val searchContent = webClient.httpGet(searchUrl).parseHtml()
.selectFirst("section.list-manga-bycate > div.list") .selectFirst("section.list-manga-bycate > div.list")
parseMangaList(searchContent) parseMangaList(searchContent)
} }
!tags.isNullOrEmpty() -> { !tags.isNullOrEmpty() -> {
val tag = tags.oneOrThrowIfMany()!! val tag = tags.oneOrThrowIfMany()!!
val categoryAjax = val categoryAjax =
"https://${domain}/ajax/Category/AjaxLoadMangaByCategory?id=${tag.key}&orderBy=5&p=$page" "https://${domain}/ajax/Category/AjaxLoadMangaByCategory?id=${tag.key}&orderBy=5&p=$page"
val listContent = webClient.httpGet(categoryAjax).parseHtml().selectFirst("div.list") val listContent = webClient.httpGet(categoryAjax).parseHtml().selectFirst("div.list")
parseMangaList(listContent) parseMangaList(listContent)
} }
else -> getNormalList(page) else -> getNormalList(page)
} }
} }
private suspend fun getNormalList(page: Int): List<Manga> { private suspend fun getNormalList(page: Int): List<Manga> {
val pageLink = "https://${domain}/page-$page" val pageLink = "https://${domain}/page-$page"
val doc = webClient.httpGet(pageLink).parseHtml() val doc = webClient.httpGet(pageLink).parseHtml()
val listElements = doc.selectFirstOrThrow("section.list-mainpage.listview") val listElements = doc.selectFirstOrThrow("section.list-mainpage.listview")
.select("div.bg-white.storyitem") .select("div.bg-white.storyitem")
return listElements.mapNotNull { return listElements.mapNotNull {
val linkTag = it.selectFirst("div.fl-l > a") ?: return@mapNotNull null val linkTag = it.selectFirst("div.fl-l > a") ?: return@mapNotNull null
val relativeUrl = linkTag.attrAsRelativeUrl("href") val relativeUrl = linkTag.attrAsRelativeUrl("href")
val tagMap = getOrCreateTagMap() val tagMap = getOrCreateTagMap()
val tags = it.select("footer > div.category > a").mapNotNullToSet { a -> val tags = it.select("footer > div.category > a").mapNotNullToSet { a ->
tagMap[a.text()] tagMap[a.text()]
} }
Manga( Manga(
id = generateUid(relativeUrl), id = generateUid(relativeUrl),
title = linkTag.attr("title"), title = linkTag.attr("title"),
altTitle = null, altTitle = null,
description = it.selectFirst("p.al-j.break.line-height-15")?.text(), description = it.selectFirst("p.al-j.break.line-height-15")?.text(),
url = relativeUrl, url = relativeUrl,
publicUrl = relativeUrl.toAbsoluteUrl(domain), publicUrl = relativeUrl.toAbsoluteUrl(domain),
coverUrl = linkTag.selectLast("img")?.imageUrl().orEmpty(), coverUrl = linkTag.selectLast("img")?.imageUrl().orEmpty(),
source = source, source = source,
tags = tags, tags = tags,
isNsfw = false, isNsfw = false,
rating = RATING_UNKNOWN, rating = RATING_UNKNOWN,
author = null, author = null,
state = null, state = null,
) )
} }
} }
private fun parseMangaList(listElement: Element?): List<Manga> { private fun parseMangaList(listElement: Element?): List<Manga> {
listElement ?: return emptyList() listElement ?: return emptyList()
return listElement.select("span.tiptip[data-tiptip]").mapNotNull { return listElement.select("span.tiptip[data-tiptip]").mapNotNull {
val mangaInfo = listElement.getElementById(it.attr("data-tiptip")) ?: return@mapNotNull null val mangaInfo = listElement.getElementById(it.attr("data-tiptip")) ?: return@mapNotNull null
val a = it.selectFirst("a") ?: return@mapNotNull null val a = it.selectFirst("a") ?: return@mapNotNull null
val relativeUrl = a.attrAsRelativeUrl("href") val relativeUrl = a.attrAsRelativeUrl("href")
Manga( Manga(
id = generateUid(relativeUrl), id = generateUid(relativeUrl),
title = a.text(), title = a.text(),
altTitle = null, altTitle = null,
description = mangaInfo.select("div.al-j.fs-12").text(), description = mangaInfo.select("div.al-j.fs-12").text(),
url = relativeUrl, url = relativeUrl,
publicUrl = relativeUrl.toAbsoluteUrl(domain), publicUrl = relativeUrl.toAbsoluteUrl(domain),
coverUrl = mangaInfo.selectFirst("div > img.img")?.imageUrl().orEmpty(), coverUrl = mangaInfo.selectFirst("div > img.img")?.imageUrl().orEmpty(),
isNsfw = false, isNsfw = false,
rating = RATING_UNKNOWN, rating = RATING_UNKNOWN,
tags = emptySet(), tags = emptySet(),
author = null, author = null,
state = null, state = null,
source = source, source = source,
) )
} }
} }
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> { override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
fun generateImageId(index: Int) = generateUid("${chapter.url}/$index") fun generateImageId(index: Int) = generateUid("${chapter.url}/$index")
val doc = webClient.httpGet(chapter.url.toAbsoluteUrl(domain)).parseHtml() val doc = webClient.httpGet(chapter.url.toAbsoluteUrl(domain)).parseHtml()
val pages = ArrayList<MangaPage>() val pages = ArrayList<MangaPage>()
val referer = chapter.url.toAbsoluteUrl(domain) val referer = chapter.url.toAbsoluteUrl(domain)
doc.select("#content > img").forEach { img -> doc.select("#content > img").forEach { img ->
pages.add( pages.add(
MangaPage( MangaPage(
id = generateImageId(pages.size), id = generateImageId(pages.size),
url = img.imageUrl(), url = img.imageUrl(),
referer = referer, preview = null,
preview = null, source = source,
source = source, ),
), )
) }
}
// Some chapters use js script to render images
// Some chapters use js script to render images val script = doc.selectLast("#content > script")
val script = doc.selectLast("#content > script") if (script != null && script.data().contains("listImageCaption")) {
if (script != null && script.data().contains("listImageCaption")) { val imagesStr = script.data().substringBefore(';').substringAfterLast('=').trim()
val imagesStr = script.data().substringBefore(';').substringAfterLast('=').trim() val imageArr = JSONArray(imagesStr)
val imageArr = JSONArray(imagesStr) for (i in 0 until imageArr.length()) {
for (i in 0 until imageArr.length()) { val imageUrl = imageArr.getJSONObject(i).getString("url")
val imageUrl = imageArr.getJSONObject(i).getString("url") pages.add(
pages.add( MangaPage(
MangaPage( id = generateImageId(pages.size),
id = generateImageId(pages.size), url = imageUrl,
url = imageUrl, preview = null,
referer = referer, source = source,
preview = null, ),
source = source, )
), }
) }
}
} return pages
}
return pages
} override suspend fun getTags(): Set<MangaTag> {
val map = getOrCreateTagMap()
override suspend fun getTags(): Set<MangaTag> { val tags = HashSet<MangaTag>(map.size)
val map = getOrCreateTagMap() for (entry in map) {
val tags = HashSet<MangaTag>(map.size) tags.add(entry.value)
for (entry in map) { }
tags.add(entry.value)
} return tags
}
return tags
}
private suspend fun getOrCreateTagMap(): ArrayMap<String, MangaTag> = mutex.withLock {
cacheTags?.let { return@withLock it }
private suspend fun getOrCreateTagMap(): ArrayMap<String, MangaTag> = mutex.withLock { val doc = webClient.httpGet("/timkiem/nangcao".toAbsoluteUrl(domain)).parseHtml()
cacheTags?.let { return@withLock it } val tagItems = doc.select("li[data-id]")
val doc = webClient.httpGet("/timkiem/nangcao".toAbsoluteUrl(domain)).parseHtml() val tagMap = ArrayMap<String, MangaTag>(tagItems.size)
val tagItems = doc.select("li[data-id]") for (tag in tagItems) {
val tagMap = ArrayMap<String, MangaTag>(tagItems.size) val title = tag.text().trim()
for (tag in tagItems) { tagMap[tag.text().trim()] = MangaTag(
val title = tag.text().trim() title = title,
tagMap[tag.text().trim()] = MangaTag( key = tag.attr("data-id"),
title = title, source = source,
key = tag.attr("data-id"), )
source = source, }
)
} cacheTags = tagMap
tagMap
cacheTags = tagMap }
tagMap
} private fun Element.imageUrl(): String {
return attrAsAbsoluteUrlOrNull("src")
private fun Element.imageUrl(): String { ?: attrAsAbsoluteUrlOrNull("data-cfsrc")
return attrAsAbsoluteUrlOrNull("src") ?: ""
?: attrAsAbsoluteUrlOrNull("data-cfsrc") }
?: ""
}
} }

@ -90,7 +90,6 @@ internal class CloneMangaParser(context: MangaLoaderContext) : PagedMangaParser(
MangaPage( MangaPage(
id = generateUid(imgUrl), id = generateUid(imgUrl),
url = imgUrl, url = imgUrl,
referer = imgUrl,
preview = null, preview = null,
source = source, source = source,
), ),

@ -24,193 +24,192 @@ private const val CHAPTERS_LIMIT = 99999
@MangaSourceParser("COMICK_FUN", "ComicK") @MangaSourceParser("COMICK_FUN", "ComicK")
internal class ComickFunParser(context: MangaLoaderContext) : MangaParser(context, MangaSource.COMICK_FUN) { internal class ComickFunParser(context: MangaLoaderContext) : MangaParser(context, MangaSource.COMICK_FUN) {
override val configKeyDomain = ConfigKey.Domain("comick.app", null) override val configKeyDomain = ConfigKey.Domain("comick.app", null)
override val sortOrders: Set<SortOrder> = EnumSet.of( override val sortOrders: Set<SortOrder> = EnumSet.of(
SortOrder.POPULARITY, SortOrder.POPULARITY,
SortOrder.UPDATED, SortOrder.UPDATED,
SortOrder.RATING, SortOrder.RATING,
) )
@Volatile @Volatile
private var cachedTags: SparseArrayCompat<MangaTag>? = null private var cachedTags: SparseArrayCompat<MangaTag>? = null
override suspend fun getList( override suspend fun getList(
offset: Int, offset: Int,
query: String?, query: String?,
tags: Set<MangaTag>?, tags: Set<MangaTag>?,
sortOrder: SortOrder, sortOrder: SortOrder,
): List<Manga> { ): List<Manga> {
val domain = domain val domain = domain
val url = buildString { val url = buildString {
append("https://api.") append("https://api.")
append(domain) append(domain)
append("/search?tachiyomi=true") append("/search?tachiyomi=true")
if (!query.isNullOrEmpty()) { if (!query.isNullOrEmpty()) {
if (offset > 0) { if (offset > 0) {
return emptyList() return emptyList()
} }
append("&q=") append("&q=")
append(query.urlEncoded()) append(query.urlEncoded())
} else { } else {
append("&limit=") append("&limit=")
append(PAGE_SIZE) append(PAGE_SIZE)
append("&page=") append("&page=")
append((offset / PAGE_SIZE) + 1) append((offset / PAGE_SIZE) + 1)
if (!tags.isNullOrEmpty()) { if (!tags.isNullOrEmpty()) {
append("&genres=") append("&genres=")
appendAll(tags, "&genres=", MangaTag::key) appendAll(tags, "&genres=", MangaTag::key)
} }
append("&sort=") // view, uploaded, rating, follow, user_follow_count append("&sort=") // view, uploaded, rating, follow, user_follow_count
append( append(
when (sortOrder) { when (sortOrder) {
SortOrder.POPULARITY -> "view" SortOrder.POPULARITY -> "view"
SortOrder.RATING -> "rating" SortOrder.RATING -> "rating"
else -> "uploaded" else -> "uploaded"
}, },
) )
} }
} }
val ja = webClient.httpGet(url).parseJsonArray() val ja = webClient.httpGet(url).parseJsonArray()
val tagsMap = cachedTags ?: loadTags() val tagsMap = cachedTags ?: loadTags()
return ja.mapJSON { jo -> return ja.mapJSON { jo ->
val slug = jo.getString("slug") val slug = jo.getString("slug")
Manga( Manga(
id = generateUid(slug), id = generateUid(slug),
title = jo.getString("title"), title = jo.getString("title"),
altTitle = null, altTitle = null,
url = slug, url = slug,
publicUrl = "https://$domain/comic/$slug", publicUrl = "https://$domain/comic/$slug",
rating = jo.getDoubleOrDefault("rating", -10.0).toFloat() / 10f, rating = jo.getDoubleOrDefault("rating", -10.0).toFloat() / 10f,
isNsfw = false, isNsfw = false,
coverUrl = jo.getString("cover_url"), coverUrl = jo.getString("cover_url"),
largeCoverUrl = null, largeCoverUrl = null,
description = jo.getStringOrNull("desc"), description = jo.getStringOrNull("desc"),
tags = jo.selectGenres("genres", tagsMap), tags = jo.selectGenres("genres", tagsMap),
state = runCatching { state = runCatching {
if (jo.getBoolean("translation_completed")) { if (jo.getBoolean("translation_completed")) {
MangaState.FINISHED MangaState.FINISHED
} else { } else {
MangaState.ONGOING MangaState.ONGOING
} }
}.getOrNull(), }.getOrNull(),
author = null, author = null,
source = source, source = source,
) )
} }
} }
override suspend fun getDetails(manga: Manga): Manga { override suspend fun getDetails(manga: Manga): Manga {
val domain = domain val domain = domain
val url = "https://api.$domain/comic/${manga.url}?tachiyomi=true" val url = "https://api.$domain/comic/${manga.url}?tachiyomi=true"
val jo = webClient.httpGet(url).parseJson() val jo = webClient.httpGet(url).parseJson()
val comic = jo.getJSONObject("comic") val comic = jo.getJSONObject("comic")
return manga.copy( return manga.copy(
title = comic.getString("title"), title = comic.getString("title"),
altTitle = null, // TODO altTitle = null, // TODO
isNsfw = jo.getBoolean("matureContent") || comic.getBoolean("hentai"), isNsfw = jo.getBoolean("matureContent") || comic.getBoolean("hentai"),
description = comic.getStringOrNull("parsed") ?: comic.getString("desc"), description = comic.getStringOrNull("parsed") ?: comic.getString("desc"),
tags = manga.tags + jo.getJSONArray("genres").mapJSONToSet { tags = manga.tags + jo.getJSONArray("genres").mapJSONToSet {
MangaTag( MangaTag(
title = it.getString("name"), title = it.getString("name"),
key = it.getString("slug"), key = it.getString("slug"),
source = source, source = source,
) )
}, },
author = jo.getJSONArray("artists").optJSONObject(0)?.getString("name"), author = jo.getJSONArray("artists").optJSONObject(0)?.getString("name"),
chapters = getChapters(comic.getLong("id")), chapters = getChapters(comic.getLong("id")),
) )
} }
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> { override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val jo = webClient.httpGet( val jo = webClient.httpGet(
"https://api.${domain}/chapter/${chapter.url}?tachiyomi=true", "https://api.${domain}/chapter/${chapter.url}?tachiyomi=true",
).parseJson().getJSONObject("chapter") ).parseJson().getJSONObject("chapter")
val referer = "https://${domain}/" val referer = "https://${domain}/"
return jo.getJSONArray("images").mapJSON { return jo.getJSONArray("images").mapJSON {
val url = it.getString("url") val url = it.getString("url")
MangaPage( MangaPage(
id = generateUid(url), id = generateUid(url),
url = url, url = url,
referer = referer, preview = null,
preview = null, source = source,
source = source, )
) }
} }
}
override suspend fun getTags(): Set<MangaTag> { override suspend fun getTags(): Set<MangaTag> {
val sparseArray = cachedTags ?: loadTags() val sparseArray = cachedTags ?: loadTags()
val set = ArraySet<MangaTag>(sparseArray.size()) val set = ArraySet<MangaTag>(sparseArray.size())
for (i in 0 until sparseArray.size()) { for (i in 0 until sparseArray.size()) {
set.add(sparseArray.valueAt(i)) set.add(sparseArray.valueAt(i))
} }
return set return set
} }
private suspend fun loadTags(): SparseArrayCompat<MangaTag> { private suspend fun loadTags(): SparseArrayCompat<MangaTag> {
val ja = webClient.httpGet("https://api.${domain}/genre").parseJsonArray() val ja = webClient.httpGet("https://api.${domain}/genre").parseJsonArray()
val tags = SparseArrayCompat<MangaTag>(ja.length()) val tags = SparseArrayCompat<MangaTag>(ja.length())
for (jo in ja.JSONIterator()) { for (jo in ja.JSONIterator()) {
tags.append( tags.append(
jo.getInt("id"), jo.getInt("id"),
MangaTag( MangaTag(
title = jo.getString("name"), title = jo.getString("name"),
key = jo.getString("slug"), key = jo.getString("slug"),
source = source, source = source,
), ),
) )
} }
cachedTags = tags cachedTags = tags
return tags return tags
} }
private suspend fun getChapters(id: Long): List<MangaChapter> { private suspend fun getChapters(id: Long): List<MangaChapter> {
val ja = webClient.httpGet( val ja = webClient.httpGet(
url = "https://api.${domain}/comic/$id/chapter?tachiyomi=true&limit=$CHAPTERS_LIMIT", url = "https://api.${domain}/comic/$id/chapter?tachiyomi=true&limit=$CHAPTERS_LIMIT",
).parseJson().getJSONArray("chapters") ).parseJson().getJSONArray("chapters")
val dateFormat = SimpleDateFormat("yyyy-MM-dd") val dateFormat = SimpleDateFormat("yyyy-MM-dd")
val counters = HashMap<Locale, Int>() val counters = HashMap<Locale, Int>()
return ja.mapReversed { jo -> return ja.mapReversed { jo ->
val locale = Locale.forLanguageTag(jo.getString("lang")) val locale = Locale.forLanguageTag(jo.getString("lang"))
var number = counters[locale] ?: 0 var number = counters[locale] ?: 0
number++ number++
counters[locale] = number counters[locale] = number
MangaChapter( MangaChapter(
id = generateUid(jo.getLong("id")), id = generateUid(jo.getLong("id")),
name = buildString { name = buildString {
jo.getStringOrNull("vol")?.let { append("Vol ").append(it).append(' ') } jo.getStringOrNull("vol")?.let { append("Vol ").append(it).append(' ') }
jo.getStringOrNull("chap")?.let { append("Chap ").append(it) } jo.getStringOrNull("chap")?.let { append("Chap ").append(it) }
jo.getStringOrNull("title")?.let { append(": ").append(it) } jo.getStringOrNull("title")?.let { append(": ").append(it) }
}, },
number = number, number = number,
url = jo.getString("hid"), url = jo.getString("hid"),
scanlator = jo.optJSONArray("group_name")?.optString(0), scanlator = jo.optJSONArray("group_name")?.optString(0),
uploadDate = dateFormat.tryParse(jo.getString("created_at").substringBefore('T')), uploadDate = dateFormat.tryParse(jo.getString("created_at").substringBefore('T')),
branch = locale.getDisplayName(locale).toTitleCase(locale), branch = locale.getDisplayName(locale).toTitleCase(locale),
source = source, source = source,
) )
} }
} }
private inline fun <R> JSONArray.mapReversed(block: (JSONObject) -> R): List<R> { private inline fun <R> JSONArray.mapReversed(block: (JSONObject) -> R): List<R> {
val len = length() val len = length()
val destination = ArrayList<R>(len) val destination = ArrayList<R>(len)
for (i in (0 until len).reversed()) { for (i in (0 until len).reversed()) {
val jo = getJSONObject(i) val jo = getJSONObject(i)
destination.add(block(jo)) destination.add(block(jo))
} }
return destination return destination
} }
private fun JSONObject.selectGenres(name: String, tags: SparseArrayCompat<MangaTag>): Set<MangaTag> { private fun JSONObject.selectGenres(name: String, tags: SparseArrayCompat<MangaTag>): Set<MangaTag> {
val array = optJSONArray(name) ?: return emptySet() val array = optJSONArray(name) ?: return emptySet()
val res = ArraySet<MangaTag>(array.length()) val res = ArraySet<MangaTag>(array.length())
for (i in 0 until array.length()) { for (i in 0 until array.length()) {
val id = array.getInt(i) val id = array.getInt(i)
val tag = tags.get(id) ?: continue val tag = tags.get(id) ?: continue
res.add(tag) res.add(tag)
} }
return res return res
} }
} }

@ -135,7 +135,6 @@ internal class DesuMeParser(context: MangaLoaderContext) : PagedMangaParser(cont
return json.getJSONObject("pages").getJSONArray("list").mapJSON { jo -> return json.getJSONObject("pages").getJSONArray("list").mapJSON { jo ->
MangaPage( MangaPage(
id = generateUid(jo.getLong("id")), id = generateUid(jo.getLong("id")),
referer = fullUrl,
preview = null, preview = null,
source = chapter.source, source = chapter.source,
url = jo.getString("img"), url = jo.getString("img"),

@ -20,289 +20,288 @@ private const val DOMAIN_AUTHORIZED = "exhentai.org"
@MangaSourceParser("EXHENTAI", "ExHentai") @MangaSourceParser("EXHENTAI", "ExHentai")
internal class ExHentaiParser( internal class ExHentaiParser(
context: MangaLoaderContext, context: MangaLoaderContext,
) : PagedMangaParser(context, MangaSource.EXHENTAI, pageSize = 25), MangaParserAuthProvider { ) : PagedMangaParser(context, MangaSource.EXHENTAI, pageSize = 25), MangaParserAuthProvider {
override val sortOrders: Set<SortOrder> = Collections.singleton( override val sortOrders: Set<SortOrder> = Collections.singleton(
SortOrder.NEWEST, SortOrder.NEWEST,
) )
override val configKeyDomain: ConfigKey.Domain override val configKeyDomain: ConfigKey.Domain
get() = ConfigKey.Domain(if (isAuthorized) DOMAIN_AUTHORIZED else DOMAIN_UNAUTHORIZED, null) get() = ConfigKey.Domain(if (isAuthorized) DOMAIN_AUTHORIZED else DOMAIN_UNAUTHORIZED, null)
override val authUrl: String override val authUrl: String
get() = "https://${domain}/bounce_login.php" get() = "https://${domain}/bounce_login.php"
private val ratingPattern = Regex("-?[0-9]+px") private val ratingPattern = Regex("-?[0-9]+px")
private val authCookies = arrayOf("ipb_member_id", "ipb_pass_hash") private val authCookies = arrayOf("ipb_member_id", "ipb_pass_hash")
private var updateDm = false private var updateDm = false
private val nextPages = SparseArrayCompat<Long>() private val nextPages = SparseArrayCompat<Long>()
private val suspiciousContentKey = ConfigKey.ShowSuspiciousContent(true) private val suspiciousContentKey = ConfigKey.ShowSuspiciousContent(true)
override val isAuthorized: Boolean override val isAuthorized: Boolean
get() { get() {
val authorized = isAuthorized(DOMAIN_UNAUTHORIZED) val authorized = isAuthorized(DOMAIN_UNAUTHORIZED)
if (authorized) { if (authorized) {
if (!isAuthorized(DOMAIN_AUTHORIZED)) { if (!isAuthorized(DOMAIN_AUTHORIZED)) {
context.cookieJar.copyCookies( context.cookieJar.copyCookies(
DOMAIN_UNAUTHORIZED, DOMAIN_UNAUTHORIZED,
DOMAIN_AUTHORIZED, DOMAIN_AUTHORIZED,
authCookies, authCookies,
) )
context.cookieJar.insertCookies(DOMAIN_AUTHORIZED, "yay=louder") context.cookieJar.insertCookies(DOMAIN_AUTHORIZED, "yay=louder")
} }
return true return true
} }
return false return false
} }
init { init {
context.cookieJar.insertCookies(DOMAIN_AUTHORIZED, "nw=1", "sl=dm_2") context.cookieJar.insertCookies(DOMAIN_AUTHORIZED, "nw=1", "sl=dm_2")
context.cookieJar.insertCookies(DOMAIN_UNAUTHORIZED, "nw=1", "sl=dm_2") context.cookieJar.insertCookies(DOMAIN_UNAUTHORIZED, "nw=1", "sl=dm_2")
paginator.firstPage = 0 paginator.firstPage = 0
} }
override suspend fun getListPage( override suspend fun getListPage(
page: Int, page: Int,
query: String?, query: String?,
tags: Set<MangaTag>?, tags: Set<MangaTag>?,
sortOrder: SortOrder, sortOrder: SortOrder,
): List<Manga> { ): List<Manga> {
var search = query?.urlEncoded().orEmpty() var search = query?.urlEncoded().orEmpty()
val next = nextPages.get(page, 0L) val next = nextPages.get(page, 0L)
if (page > 0 && next == 0L) { if (page > 0 && next == 0L) {
assert(false) { "Page timestamp not found" } assert(false) { "Page timestamp not found" }
return emptyList() return emptyList()
} }
val url = buildString { val url = buildString {
append("https://") append("https://")
append(domain) append(domain)
append("/?next=") append("/?next=")
append(next) append(next)
if (!tags.isNullOrEmpty()) { if (!tags.isNullOrEmpty()) {
var fCats = 0 var fCats = 0
for (tag in tags) { for (tag in tags) {
tag.key.toIntOrNull()?.let { fCats = fCats or it } ?: run { tag.key.toIntOrNull()?.let { fCats = fCats or it } ?: run {
search += tag.key + " " search += tag.key + " "
} }
} }
if (fCats != 0) { if (fCats != 0) {
append("&f_cats=") append("&f_cats=")
append(1023 - fCats) append(1023 - fCats)
} }
} }
if (search.isNotEmpty()) { if (search.isNotEmpty()) {
append("&f_search=") append("&f_search=")
append(search.trim().replace(' ', '+')) append(search.trim().replace(' ', '+'))
} }
// by unknown reason cookie "sl=dm_2" is ignored, so, we should request it again // by unknown reason cookie "sl=dm_2" is ignored, so, we should request it again
if (updateDm) { if (updateDm) {
append("&inline_set=dm_e") append("&inline_set=dm_e")
} }
append("&advsearch=1") append("&advsearch=1")
if (config[suspiciousContentKey]) { if (config[suspiciousContentKey]) {
append("&f_sh=on") append("&f_sh=on")
} }
} }
val body = webClient.httpGet(url).parseHtml().body() val body = webClient.httpGet(url).parseHtml().body()
val root = body.selectFirst("table.itg") val root = body.selectFirst("table.itg")
?.selectFirst("tbody") ?.selectFirst("tbody")
?: if (updateDm) { ?: if (updateDm) {
body.parseFailed("Cannot find root") body.parseFailed("Cannot find root")
} else { } else {
updateDm = true updateDm = true
return getListPage(page, query, tags, sortOrder) return getListPage(page, query, tags, sortOrder)
} }
updateDm = false updateDm = false
nextPages[page + 1] = getNextTimestamp(body) nextPages[page + 1] = getNextTimestamp(body)
return root.children().mapNotNull { tr -> return root.children().mapNotNull { tr ->
if (tr.childrenSize() != 2) return@mapNotNull null if (tr.childrenSize() != 2) return@mapNotNull null
val (td1, td2) = tr.children() val (td1, td2) = tr.children()
val glink = td2.selectFirstOrThrow("div.glink") val glink = td2.selectFirstOrThrow("div.glink")
val a = glink.parents().select("a").first() ?: glink.parseFailed("link not found") val a = glink.parents().select("a").first() ?: glink.parseFailed("link not found")
val href = a.attrAsRelativeUrl("href") val href = a.attrAsRelativeUrl("href")
val tagsDiv = glink.nextElementSibling() ?: glink.parseFailed("tags div not found") val tagsDiv = glink.nextElementSibling() ?: glink.parseFailed("tags div not found")
val mainTag = td2.selectFirst("div.cn")?.let { div -> val mainTag = td2.selectFirst("div.cn")?.let { div ->
MangaTag( MangaTag(
title = div.text().toTitleCase(), title = div.text().toTitleCase(),
key = tagIdByClass(div.classNames()) ?: return@let null, key = tagIdByClass(div.classNames()) ?: return@let null,
source = source, source = source,
) )
} }
Manga( Manga(
id = generateUid(href), id = generateUid(href),
title = glink.text().cleanupTitle(), title = glink.text().cleanupTitle(),
altTitle = null, altTitle = null,
url = href, url = href,
publicUrl = a.absUrl("href"), publicUrl = a.absUrl("href"),
rating = td2.selectFirst("div.ir")?.parseRating() ?: RATING_UNKNOWN, rating = td2.selectFirst("div.ir")?.parseRating() ?: RATING_UNKNOWN,
isNsfw = true, isNsfw = true,
coverUrl = td1.selectFirst("img")?.absUrl("src").orEmpty(), coverUrl = td1.selectFirst("img")?.absUrl("src").orEmpty(),
tags = setOfNotNull(mainTag), tags = setOfNotNull(mainTag),
state = null, state = null,
author = tagsDiv.getElementsContainingOwnText("artist:").first() author = tagsDiv.getElementsContainingOwnText("artist:").first()
?.nextElementSibling()?.text(), ?.nextElementSibling()?.text(),
source = source, source = source,
) )
} }
} }
override suspend fun getDetails(manga: Manga): Manga { override suspend fun getDetails(manga: Manga): Manga {
val doc = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml() val doc = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml()
val root = doc.body().selectFirstOrThrow("div.gm") val root = doc.body().selectFirstOrThrow("div.gm")
val cover = root.getElementById("gd1")?.children()?.first() val cover = root.getElementById("gd1")?.children()?.first()
val title = root.getElementById("gd2") val title = root.getElementById("gd2")
val taglist = root.getElementById("taglist") val taglist = root.getElementById("taglist")
val tabs = doc.body().selectFirst("table.ptt")?.selectFirst("tr") val tabs = doc.body().selectFirst("table.ptt")?.selectFirst("tr")
return manga.copy( return manga.copy(
title = title?.getElementById("gn")?.text()?.cleanupTitle() ?: manga.title, title = title?.getElementById("gn")?.text()?.cleanupTitle() ?: manga.title,
altTitle = title?.getElementById("gj")?.text()?.cleanupTitle() ?: manga.altTitle, altTitle = title?.getElementById("gj")?.text()?.cleanupTitle() ?: manga.altTitle,
publicUrl = doc.baseUri().ifEmpty { manga.publicUrl }, publicUrl = doc.baseUri().ifEmpty { manga.publicUrl },
rating = root.getElementById("rating_label")?.text() rating = root.getElementById("rating_label")?.text()
?.substringAfterLast(' ') ?.substringAfterLast(' ')
?.toFloatOrNull() ?.toFloatOrNull()
?.div(5f) ?: manga.rating, ?.div(5f) ?: manga.rating,
largeCoverUrl = cover?.styleValueOrNull("background")?.cssUrl(), largeCoverUrl = cover?.styleValueOrNull("background")?.cssUrl(),
description = taglist?.select("tr")?.joinToString("<br>") { tr -> description = taglist?.select("tr")?.joinToString("<br>") { tr ->
val (tc, td) = tr.children() val (tc, td) = tr.children()
val subtags = td.select("a").joinToString { it.html() } val subtags = td.select("a").joinToString { it.html() }
"<b>${tc.html()}</b> $subtags" "<b>${tc.html()}</b> $subtags"
}, },
chapters = tabs?.select("a")?.findLast { a -> chapters = tabs?.select("a")?.findLast { a ->
a.text().toIntOrNull() != null a.text().toIntOrNull() != null
}?.let { a -> }?.let { a ->
val count = a.text().toInt() val count = a.text().toInt()
val chapters = ChaptersListBuilder(count) val chapters = ChaptersListBuilder(count)
for (i in 1..count) { for (i in 1..count) {
val url = "${manga.url}?p=${i - 1}" val url = "${manga.url}?p=${i - 1}"
chapters += MangaChapter( chapters += MangaChapter(
id = generateUid(url), id = generateUid(url),
name = "${manga.title} #$i", name = "${manga.title} #$i",
number = i, number = i,
url = url, url = url,
uploadDate = 0L, uploadDate = 0L,
source = source, source = source,
scanlator = null, scanlator = null,
branch = null, branch = null,
) )
} }
chapters.toList() chapters.toList()
}, },
) )
} }
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> { override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val doc = webClient.httpGet(chapter.url.toAbsoluteUrl(domain)).parseHtml() val doc = webClient.httpGet(chapter.url.toAbsoluteUrl(domain)).parseHtml()
val root = doc.body().requireElementById("gdt") val root = doc.body().requireElementById("gdt")
return root.select("a").map { a -> return root.select("a").map { a ->
val url = a.attrAsRelativeUrl("href") val url = a.attrAsRelativeUrl("href")
MangaPage( MangaPage(
id = generateUid(url), id = generateUid(url),
url = url, url = url,
referer = a.absUrl("href"), preview = null,
preview = null, source = source,
source = source, )
) }
} }
}
override suspend fun getPageUrl(page: MangaPage): String { override suspend fun getPageUrl(page: MangaPage): String {
val doc = webClient.httpGet(page.url.toAbsoluteUrl(domain)).parseHtml() val doc = webClient.httpGet(page.url.toAbsoluteUrl(domain)).parseHtml()
return doc.body().requireElementById("img").attrAsAbsoluteUrl("src") return doc.body().requireElementById("img").attrAsAbsoluteUrl("src")
} }
override suspend fun getTags(): Set<MangaTag> { override suspend fun getTags(): Set<MangaTag> {
val doc = webClient.httpGet("https://${domain}").parseHtml() val doc = webClient.httpGet("https://${domain}").parseHtml()
val root = doc.body().requireElementById("searchbox").selectFirstOrThrow("table") val root = doc.body().requireElementById("searchbox").selectFirstOrThrow("table")
return root.select("div.cs").mapNotNullToSet { div -> return root.select("div.cs").mapNotNullToSet { div ->
val id = div.id().substringAfterLast('_').toIntOrNull() val id = div.id().substringAfterLast('_').toIntOrNull()
?: return@mapNotNullToSet null ?: return@mapNotNullToSet null
MangaTag( MangaTag(
title = div.text().toTitleCase(), title = div.text().toTitleCase(),
key = id.toString(), key = id.toString(),
source = source, source = source,
) )
} }
} }
override suspend fun getUsername(): String { override suspend fun getUsername(): String {
val doc = webClient.httpGet("https://forums.$DOMAIN_UNAUTHORIZED/").parseHtml().body() val doc = webClient.httpGet("https://forums.$DOMAIN_UNAUTHORIZED/").parseHtml().body()
val username = doc.getElementById("userlinks") val username = doc.getElementById("userlinks")
?.getElementsByAttributeValueContaining("href", "showuser=") ?.getElementsByAttributeValueContaining("href", "showuser=")
?.firstOrNull() ?.firstOrNull()
?.ownText() ?.ownText()
?: if (doc.getElementById("userlinksguest") != null) { ?: if (doc.getElementById("userlinksguest") != null) {
throw AuthRequiredException(source) throw AuthRequiredException(source)
} else { } else {
doc.parseFailed() doc.parseFailed()
} }
return username return username
} }
override fun onCreateConfig(keys: MutableCollection<ConfigKey<*>>) { override fun onCreateConfig(keys: MutableCollection<ConfigKey<*>>) {
super.onCreateConfig(keys) super.onCreateConfig(keys)
keys.add(suspiciousContentKey) keys.add(suspiciousContentKey)
} }
private fun isAuthorized(domain: String): Boolean { private fun isAuthorized(domain: String): Boolean {
val cookies = context.cookieJar.getCookies(domain).mapToSet { x -> x.name } val cookies = context.cookieJar.getCookies(domain).mapToSet { x -> x.name }
return authCookies.all { it in cookies } return authCookies.all { it in cookies }
} }
private fun Element.parseRating(): Float { private fun Element.parseRating(): Float {
return runCatching { return runCatching {
val style = requireNotNull(attr("style")) val style = requireNotNull(attr("style"))
val (v1, v2) = ratingPattern.find(style)!!.destructured val (v1, v2) = ratingPattern.find(style)!!.destructured
var p1 = v1.dropLast(2).toInt() var p1 = v1.dropLast(2).toInt()
val p2 = v2.dropLast(2).toInt() val p2 = v2.dropLast(2).toInt()
if (p2 != -1) { if (p2 != -1) {
p1 += 8 p1 += 8
} }
(80 - p1) / 80f (80 - p1) / 80f
}.getOrDefault(RATING_UNKNOWN) }.getOrDefault(RATING_UNKNOWN)
} }
private fun String.cleanupTitle(): String { private fun String.cleanupTitle(): String {
val result = StringBuilder(length) val result = StringBuilder(length)
var skip = false var skip = false
for (c in this) { for (c in this) {
when { when {
c == '[' -> skip = true c == '[' -> skip = true
c == ']' -> skip = false c == ']' -> skip = false
c.isWhitespace() && result.isEmpty() -> continue c.isWhitespace() && result.isEmpty() -> continue
!skip -> result.append(c) !skip -> result.append(c)
} }
} }
while (result.lastOrNull()?.isWhitespace() == true) { while (result.lastOrNull()?.isWhitespace() == true) {
result.deleteCharAt(result.lastIndex) result.deleteCharAt(result.lastIndex)
} }
return result.toString() return result.toString()
} }
private fun String.cssUrl(): String? { private fun String.cssUrl(): String? {
val fromIndex = indexOf("url(") val fromIndex = indexOf("url(")
if (fromIndex == -1) { if (fromIndex == -1) {
return null return null
} }
val toIndex = indexOf(')', startIndex = fromIndex) val toIndex = indexOf(')', startIndex = fromIndex)
return if (toIndex == -1) { return if (toIndex == -1) {
null null
} else { } else {
substring(fromIndex + 4, toIndex).trim() substring(fromIndex + 4, toIndex).trim()
} }
} }
private fun tagIdByClass(classNames: Collection<String>): String? { private fun tagIdByClass(classNames: Collection<String>): String? {
val className = classNames.find { x -> x.startsWith("ct") } ?: return null val className = classNames.find { x -> x.startsWith("ct") } ?: return null
val num = className.drop(2).toIntOrNull(16) ?: return null val num = className.drop(2).toIntOrNull(16) ?: return null
return 2.0.pow(num).toInt().toString() return 2.0.pow(num).toInt().toString()
} }
private fun getNextTimestamp(root: Element): Long { private fun getNextTimestamp(root: Element): Long {
return root.getElementById("unext") return root.getElementById("unext")
?.attrAsAbsoluteUrlOrNull("href") ?.attrAsAbsoluteUrlOrNull("href")
?.toHttpUrlOrNull() ?.toHttpUrlOrNull()
?.queryParameter("next") ?.queryParameter("next")
?.toLongOrNull() ?: 1 ?.toLongOrNull() ?: 1
} }
} }

@ -22,254 +22,252 @@ private const val CHAPTERS_MAX_PAGE_SIZE = 500
private const val CHAPTERS_PARALLELISM = 3 private const val CHAPTERS_PARALLELISM = 3
private const val CHAPTERS_MAX_COUNT = 10_000 // strange api behavior, looks like a bug private const val CHAPTERS_MAX_COUNT = 10_000 // strange api behavior, looks like a bug
private const val CONTENT_RATING = private const val CONTENT_RATING =
"contentRating[]=safe&contentRating[]=suggestive&contentRating[]=erotica&contentRating[]=pornographic" "contentRating[]=safe&contentRating[]=suggestive&contentRating[]=erotica&contentRating[]=pornographic"
private const val LOCALE_FALLBACK = "en" private const val LOCALE_FALLBACK = "en"
@MangaSourceParser("MANGADEX", "MangaDex") @MangaSourceParser("MANGADEX", "MangaDex")
internal class MangaDexParser(context: MangaLoaderContext) : MangaParser(context, MangaSource.MANGADEX) { internal class MangaDexParser(context: MangaLoaderContext) : MangaParser(context, MangaSource.MANGADEX) {
override val configKeyDomain = ConfigKey.Domain("mangadex.org", null) override val configKeyDomain = ConfigKey.Domain("mangadex.org", null)
override val sortOrders: EnumSet<SortOrder> = EnumSet.of( override val sortOrders: EnumSet<SortOrder> = EnumSet.of(
SortOrder.UPDATED, SortOrder.UPDATED,
SortOrder.ALPHABETICAL, SortOrder.ALPHABETICAL,
SortOrder.NEWEST, SortOrder.NEWEST,
SortOrder.POPULARITY, SortOrder.POPULARITY,
) )
override suspend fun getList( override suspend fun getList(
offset: Int, offset: Int,
query: String?, query: String?,
tags: Set<MangaTag>?, tags: Set<MangaTag>?,
sortOrder: SortOrder, sortOrder: SortOrder,
): List<Manga> { ): List<Manga> {
val domain = domain val domain = domain
val url = buildString { val url = buildString {
append("https://api.") append("https://api.")
append(domain) append(domain)
append("/manga?limit=") append("/manga?limit=")
append(PAGE_SIZE) append(PAGE_SIZE)
append("&offset=") append("&offset=")
append(offset) append(offset)
append("&includes[]=cover_art&includes[]=author&includes[]=artist&") append("&includes[]=cover_art&includes[]=author&includes[]=artist&")
tags?.forEach { tag -> tags?.forEach { tag ->
append("includedTags[]=") append("includedTags[]=")
append(tag.key) append(tag.key)
append('&') append('&')
} }
if (!query.isNullOrEmpty()) { if (!query.isNullOrEmpty()) {
append("title=") append("title=")
append(query.urlEncoded()) append(query.urlEncoded())
append('&') append('&')
} }
append(CONTENT_RATING) append(CONTENT_RATING)
append("&order") append("&order")
append( append(
when (sortOrder) { when (sortOrder) {
SortOrder.UPDATED, SortOrder.UPDATED,
-> "[latestUploadedChapter]=desc" -> "[latestUploadedChapter]=desc"
SortOrder.ALPHABETICAL -> "[title]=asc" SortOrder.ALPHABETICAL -> "[title]=asc"
SortOrder.NEWEST -> "[createdAt]=desc" SortOrder.NEWEST -> "[createdAt]=desc"
SortOrder.POPULARITY -> "[followedCount]=desc" SortOrder.POPULARITY -> "[followedCount]=desc"
else -> "[followedCount]=desc" else -> "[followedCount]=desc"
}, },
) )
} }
val json = webClient.httpGet(url).parseJson().getJSONArray("data") val json = webClient.httpGet(url).parseJson().getJSONArray("data")
return json.mapJSON { jo -> return json.mapJSON { jo ->
val id = jo.getString("id") val id = jo.getString("id")
val attrs = jo.getJSONObject("attributes") val attrs = jo.getJSONObject("attributes")
val relations = jo.getJSONArray("relationships").associateByKey("type") val relations = jo.getJSONArray("relationships").associateByKey("type")
val cover = relations["cover_art"] val cover = relations["cover_art"]
?.getJSONObject("attributes") ?.getJSONObject("attributes")
?.getString("fileName") ?.getString("fileName")
?.let { ?.let {
"https://uploads.$domain/covers/$id/$it" "https://uploads.$domain/covers/$id/$it"
} }
Manga( Manga(
id = generateUid(id), id = generateUid(id),
title = requireNotNull(attrs.getJSONObject("title").selectByLocale()) { title = requireNotNull(attrs.getJSONObject("title").selectByLocale()) {
"Title should not be null" "Title should not be null"
}, },
altTitle = attrs.optJSONObject("altTitles")?.selectByLocale(), altTitle = attrs.optJSONObject("altTitles")?.selectByLocale(),
url = id, url = id,
publicUrl = "https://$domain/title/$id", publicUrl = "https://$domain/title/$id",
rating = RATING_UNKNOWN, rating = RATING_UNKNOWN,
isNsfw = attrs.getStringOrNull("contentRating") == "erotica", isNsfw = attrs.getStringOrNull("contentRating") == "erotica",
coverUrl = cover?.plus(".256.jpg").orEmpty(), coverUrl = cover?.plus(".256.jpg").orEmpty(),
largeCoverUrl = cover, largeCoverUrl = cover,
description = attrs.optJSONObject("description")?.selectByLocale(), description = attrs.optJSONObject("description")?.selectByLocale(),
tags = attrs.getJSONArray("tags").mapJSONToSet { tag -> tags = attrs.getJSONArray("tags").mapJSONToSet { tag ->
MangaTag( MangaTag(
title = tag.getJSONObject("attributes") title = tag.getJSONObject("attributes")
.getJSONObject("name") .getJSONObject("name")
.firstStringValue() .firstStringValue()
.toTitleCase(), .toTitleCase(),
key = tag.getString("id"), key = tag.getString("id"),
source = source, source = source,
) )
}, },
state = when (jo.getStringOrNull("status")) { state = when (jo.getStringOrNull("status")) {
"ongoing" -> MangaState.ONGOING "ongoing" -> MangaState.ONGOING
"completed" -> MangaState.FINISHED "completed" -> MangaState.FINISHED
else -> null else -> null
}, },
author = (relations["author"] ?: relations["artist"]) author = (relations["author"] ?: relations["artist"])
?.getJSONObject("attributes") ?.getJSONObject("attributes")
?.getStringOrNull("name"), ?.getStringOrNull("name"),
source = source, source = source,
) )
} }
} }
override suspend fun getDetails(manga: Manga): Manga = coroutineScope { override suspend fun getDetails(manga: Manga): Manga = coroutineScope {
val domain = domain val domain = domain
val mangaId = manga.url.removePrefix("/") val mangaId = manga.url.removePrefix("/")
val attrsDeferred = async { val attrsDeferred = async {
webClient.httpGet( webClient.httpGet(
"https://api.$domain/manga/${mangaId}?includes[]=artist&includes[]=author&includes[]=cover_art", "https://api.$domain/manga/${mangaId}?includes[]=artist&includes[]=author&includes[]=cover_art",
).parseJson().getJSONObject("data").getJSONObject("attributes") ).parseJson().getJSONObject("data").getJSONObject("attributes")
} }
val feedDeferred = async { loadChapters(mangaId) } val feedDeferred = async { loadChapters(mangaId) }
val mangaAttrs = attrsDeferred.await() val mangaAttrs = attrsDeferred.await()
val feed = feedDeferred.await() val feed = feedDeferred.await()
// 2022-01-02T00:27:11+00:00 // 2022-01-02T00:27:11+00:00
val dateFormat = SimpleDateFormat( val dateFormat = SimpleDateFormat(
"yyyy-MM-dd'T'HH:mm:ss'+00:00'", "yyyy-MM-dd'T'HH:mm:ss'+00:00'",
Locale.ROOT, Locale.ROOT,
) )
manga.copy( manga.copy(
description = mangaAttrs.optJSONObject("description")?.selectByLocale() description = mangaAttrs.optJSONObject("description")?.selectByLocale()
?: manga.description, ?: manga.description,
chapters = feed.mapChapters { _, jo -> chapters = feed.mapChapters { _, jo ->
val id = jo.getString("id") val id = jo.getString("id")
val attrs = jo.getJSONObject("attributes") val attrs = jo.getJSONObject("attributes")
if (!attrs.isNull("externalUrl")) { if (!attrs.isNull("externalUrl")) {
return@mapChapters null return@mapChapters null
} }
val locale = attrs.getStringOrNull("translatedLanguage")?.let { Locale.forLanguageTag(it) } val locale = attrs.getStringOrNull("translatedLanguage")?.let { Locale.forLanguageTag(it) }
val relations = jo.getJSONArray("relationships").associateByKey("type") val relations = jo.getJSONArray("relationships").associateByKey("type")
val number = attrs.getIntOrDefault("chapter", 0) val number = attrs.getIntOrDefault("chapter", 0)
MangaChapter( MangaChapter(
id = generateUid(id), id = generateUid(id),
name = attrs.getStringOrNull("title")?.takeUnless(String::isEmpty) name = attrs.getStringOrNull("title")?.takeUnless(String::isEmpty)
?: "Chapter #$number", ?: "Chapter #$number",
number = number, number = number,
url = id, url = id,
scanlator = relations["scanlation_group"]?.getStringOrNull("name"), scanlator = relations["scanlation_group"]?.getStringOrNull("name"),
uploadDate = dateFormat.tryParse(attrs.getString("publishAt")), uploadDate = dateFormat.tryParse(attrs.getString("publishAt")),
branch = locale?.getDisplayName(locale)?.toTitleCase(locale), branch = locale?.getDisplayName(locale)?.toTitleCase(locale),
source = source, source = source,
) )
}, },
) )
} }
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> { override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val domain = domain val domain = domain
val chapterJson = webClient.httpGet("https://api.$domain/at-home/server/${chapter.url}?forcePort443=false") val chapterJson = webClient.httpGet("https://api.$domain/at-home/server/${chapter.url}?forcePort443=false")
.parseJson() .parseJson()
.getJSONObject("chapter") .getJSONObject("chapter")
val pages = chapterJson.getJSONArray("data") val pages = chapterJson.getJSONArray("data")
val prefix = "https://uploads.$domain/data/${chapterJson.getString("hash")}/" val prefix = "https://uploads.$domain/data/${chapterJson.getString("hash")}/"
val referer = "https://$domain/" return List(pages.length()) { i ->
return List(pages.length()) { i -> val url = prefix + pages.getString(i)
val url = prefix + pages.getString(i) MangaPage(
MangaPage( id = generateUid(url),
id = generateUid(url), url = url,
url = url, preview = null, // TODO prefix + dataSaver.getString(i),
referer = referer, source = source,
preview = null, // TODO prefix + dataSaver.getString(i), )
source = source, }
) }
}
}
override suspend fun getTags(): Set<MangaTag> { override suspend fun getTags(): Set<MangaTag> {
val tags = webClient.httpGet("https://api.${domain}/manga/tag").parseJson() val tags = webClient.httpGet("https://api.${domain}/manga/tag").parseJson()
.getJSONArray("data") .getJSONArray("data")
return tags.mapJSONToSet { jo -> return tags.mapJSONToSet { jo ->
MangaTag( MangaTag(
title = jo.getJSONObject("attributes").getJSONObject("name").firstStringValue().toTitleCase(), title = jo.getJSONObject("attributes").getJSONObject("name").firstStringValue().toTitleCase(),
key = jo.getString("id"), key = jo.getString("id"),
source = source, source = source,
) )
} }
} }
private fun JSONObject.firstStringValue() = values().next() as String private fun JSONObject.firstStringValue() = values().next() as String
private fun JSONObject.selectByLocale(): String? { private fun JSONObject.selectByLocale(): String? {
val preferredLocales = context.getPreferredLocales() val preferredLocales = context.getPreferredLocales()
for (locale in preferredLocales) { for (locale in preferredLocales) {
getStringOrNull(locale.language)?.let { return it } getStringOrNull(locale.language)?.let { return it }
getStringOrNull(locale.toLanguageTag())?.let { return it } getStringOrNull(locale.toLanguageTag())?.let { return it }
} }
return getStringOrNull(LOCALE_FALLBACK) ?: values().nextOrNull() as? String return getStringOrNull(LOCALE_FALLBACK) ?: values().nextOrNull() as? String
} }
private suspend fun loadChapters(mangaId: String): List<JSONObject> { private suspend fun loadChapters(mangaId: String): List<JSONObject> {
val firstPage = loadChapters(mangaId, offset = 0, limit = CHAPTERS_FIRST_PAGE_SIZE) val firstPage = loadChapters(mangaId, offset = 0, limit = CHAPTERS_FIRST_PAGE_SIZE)
if (firstPage.size >= firstPage.total) { if (firstPage.size >= firstPage.total) {
return firstPage.data return firstPage.data
} }
val tail = coroutineScope { val tail = coroutineScope {
val leftCount = firstPage.total.coerceAtMost(CHAPTERS_MAX_COUNT) - firstPage.size val leftCount = firstPage.total.coerceAtMost(CHAPTERS_MAX_COUNT) - firstPage.size
val pages = (leftCount / CHAPTERS_MAX_PAGE_SIZE.toFloat()).toIntUp() val pages = (leftCount / CHAPTERS_MAX_PAGE_SIZE.toFloat()).toIntUp()
val dispatcher = Dispatchers.Default.limitedParallelism(CHAPTERS_PARALLELISM) val dispatcher = Dispatchers.Default.limitedParallelism(CHAPTERS_PARALLELISM)
List(pages) { page -> List(pages) { page ->
val offset = page * CHAPTERS_MAX_PAGE_SIZE + firstPage.size val offset = page * CHAPTERS_MAX_PAGE_SIZE + firstPage.size
async(dispatcher) { async(dispatcher) {
loadChapters(mangaId, offset, CHAPTERS_MAX_PAGE_SIZE) loadChapters(mangaId, offset, CHAPTERS_MAX_PAGE_SIZE)
} }
}.awaitAll() }.awaitAll()
} }
val result = ArrayList<JSONObject>(firstPage.total) val result = ArrayList<JSONObject>(firstPage.total)
result += firstPage.data result += firstPage.data
tail.flatMapTo(result) { it.data } tail.flatMapTo(result) { it.data }
return result return result
} }
private suspend fun loadChapters(mangaId: String, offset: Int, limit: Int): Chapters { private suspend fun loadChapters(mangaId: String, offset: Int, limit: Int): Chapters {
val limitedLimit = when { val limitedLimit = when {
offset >= CHAPTERS_MAX_COUNT -> return Chapters(emptyList(), CHAPTERS_MAX_COUNT) offset >= CHAPTERS_MAX_COUNT -> return Chapters(emptyList(), CHAPTERS_MAX_COUNT)
offset + limit > CHAPTERS_MAX_COUNT -> CHAPTERS_MAX_COUNT - offset offset + limit > CHAPTERS_MAX_COUNT -> CHAPTERS_MAX_COUNT - offset
else -> limit else -> limit
} }
val url = buildString { val url = buildString {
append("https://api.") append("https://api.")
append(domain) append(domain)
append("/manga/") append("/manga/")
append(mangaId) append(mangaId)
append("/feed") append("/feed")
append("?limit=") append("?limit=")
append(limitedLimit) append(limitedLimit)
append("&includes[]=scanlation_group&order[volume]=asc&order[chapter]=asc&offset=") append("&includes[]=scanlation_group&order[volume]=asc&order[chapter]=asc&offset=")
append(offset) append(offset)
append('&') append('&')
append(CONTENT_RATING) append(CONTENT_RATING)
} }
val json = webClient.httpGet(url).parseJson() val json = webClient.httpGet(url).parseJson()
if (json.getString("result") == "ok") { if (json.getString("result") == "ok") {
return Chapters( return Chapters(
data = json.optJSONArray("data")?.toJSONList().orEmpty(), data = json.optJSONArray("data")?.toJSONList().orEmpty(),
total = json.getInt("total"), total = json.getInt("total"),
) )
} else { } else {
val error = json.optJSONArray("errors").mapJSON { jo -> val error = json.optJSONArray("errors").mapJSON { jo ->
jo.getString("detail") jo.getString("detail")
}.joinToString("\n") }.joinToString("\n")
throw ParseException(error, url) throw ParseException(error, url)
} }
} }
private class Chapters( private class Chapters(
val data: List<JSONObject>, val data: List<JSONObject>,
val total: Int, val total: Int,
) { ) {
val size: Int val size: Int
get() = data.size get() = data.size
} }
} }

@ -13,141 +13,140 @@ private const val DEF_BRANCH_NAME = "Основний переклад"
@MangaSourceParser("MANGAINUA", "MANGA/in/UA", "uk") @MangaSourceParser("MANGAINUA", "MANGA/in/UA", "uk")
class MangaInUaParser(context: MangaLoaderContext) : PagedMangaParser( class MangaInUaParser(context: MangaLoaderContext) : PagedMangaParser(
context = context, context = context,
source = MangaSource.MANGAINUA, source = MangaSource.MANGAINUA,
pageSize = 24, pageSize = 24,
searchPageSize = 10, searchPageSize = 10,
) { ) {
override val sortOrders: Set<SortOrder> override val sortOrders: Set<SortOrder>
get() = Collections.singleton(SortOrder.UPDATED) get() = Collections.singleton(SortOrder.UPDATED)
override val configKeyDomain: ConfigKey.Domain = ConfigKey.Domain("manga.in.ua", null) override val configKeyDomain: ConfigKey.Domain = ConfigKey.Domain("manga.in.ua", null)
override suspend fun getListPage( override suspend fun getListPage(
page: Int, page: Int,
query: String?, query: String?,
tags: Set<MangaTag>?, tags: Set<MangaTag>?,
sortOrder: SortOrder, sortOrder: SortOrder,
): List<Manga> { ): List<Manga> {
val url = when { val url = when {
!query.isNullOrEmpty() -> ( !query.isNullOrEmpty() -> (
"/index.php?do=search" + "/index.php?do=search" +
"&subaction=search" + "&subaction=search" +
"&search_start=$page" + "&search_start=$page" +
"&full_search=1" + "&full_search=1" +
"&story=$query" + "&story=$query" +
"&titleonly=3" "&titleonly=3"
).toAbsoluteUrl(domain) ).toAbsoluteUrl(domain)
tags.isNullOrEmpty() -> "/mangas/page/$page".toAbsoluteUrl(domain) tags.isNullOrEmpty() -> "/mangas/page/$page".toAbsoluteUrl(domain)
tags.size == 1 -> "${tags.first().key}/page/$page" tags.size == 1 -> "${tags.first().key}/page/$page"
tags.size > 1 -> throw IllegalArgumentException("This source supports only 1 genre") tags.size > 1 -> throw IllegalArgumentException("This source supports only 1 genre")
else -> "/mangas/page/$page".toAbsoluteUrl(domain) else -> "/mangas/page/$page".toAbsoluteUrl(domain)
} }
val doc = webClient.httpGet(url).parseHtml() val doc = webClient.httpGet(url).parseHtml()
val container = doc.body().requireElementById("site-content") val container = doc.body().requireElementById("site-content")
val items = container.select("div.col-6") val items = container.select("div.col-6")
return items.mapNotNull { item -> return items.mapNotNull { item ->
val href = item.selectFirst("a")?.attrAsRelativeUrl("href") ?: return@mapNotNull null val href = item.selectFirst("a")?.attrAsRelativeUrl("href") ?: return@mapNotNull null
Manga( Manga(
id = generateUid(href), id = generateUid(href),
title = item.selectFirst("h3.card__title")?.text() ?: return@mapNotNull null, title = item.selectFirst("h3.card__title")?.text() ?: return@mapNotNull null,
coverUrl = item.selectFirst("header.card__cover")?.selectFirst("img")?.run { coverUrl = item.selectFirst("header.card__cover")?.selectFirst("img")?.run {
attrAsAbsoluteUrlOrNull("data-src") ?: attrAsAbsoluteUrlOrNull("src") attrAsAbsoluteUrlOrNull("data-src") ?: attrAsAbsoluteUrlOrNull("src")
}.orEmpty(), }.orEmpty(),
altTitle = null, altTitle = null,
author = null, author = null,
rating = item.selectFirst("div.card__short-rate--num") rating = item.selectFirst("div.card__short-rate--num")
?.text() ?.text()
?.toFloatOrNull() ?.toFloatOrNull()
?.div(10F) ?: RATING_UNKNOWN, ?.div(10F) ?: RATING_UNKNOWN,
url = href, url = href,
isNsfw = item.selectFirst("ul.card__list")?.select("li")?.lastOrNull()?.text() == "18+", isNsfw = item.selectFirst("ul.card__list")?.select("li")?.lastOrNull()?.text() == "18+",
tags = runCatching { tags = runCatching {
item.selectFirst("div.card__category")?.select("a")?.mapToSet { item.selectFirst("div.card__category")?.select("a")?.mapToSet {
MangaTag( MangaTag(
title = it.ownText(), title = it.ownText(),
key = it.attr("href").removeSuffix("/"), key = it.attr("href").removeSuffix("/"),
source = source, source = source,
) )
} }
}.getOrNull().orEmpty(), }.getOrNull().orEmpty(),
state = null, state = null,
publicUrl = href.toAbsoluteUrl(container.host ?: domain), publicUrl = href.toAbsoluteUrl(container.host ?: domain),
source = source, source = source,
) )
} }
} }
override suspend fun getDetails(manga: Manga): Manga { override suspend fun getDetails(manga: Manga): Manga {
val doc = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml() val doc = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml()
val root = doc.body().requireElementById("site-content") val root = doc.body().requireElementById("site-content")
val dateFormat = SimpleDateFormat("dd.MM.yyyy", Locale.US) val dateFormat = SimpleDateFormat("dd.MM.yyyy", Locale.US)
val chapterNodes = root.selectFirstOrThrow(".linkstocomics").select(".ltcitems") val chapterNodes = root.selectFirstOrThrow(".linkstocomics").select(".ltcitems")
var prevChapterName: String? = null var prevChapterName: String? = null
var i = 0 var i = 0
return manga.copy( return manga.copy(
description = root.selectFirst("div.item__full-description")?.text(), description = root.selectFirst("div.item__full-description")?.text(),
largeCoverUrl = root.selectFirst("div.item__full-sidebar--poster")?.selectFirst("img") largeCoverUrl = root.selectFirst("div.item__full-sidebar--poster")?.selectFirst("img")
?.attrAsAbsoluteUrlOrNull("src"), ?.attrAsAbsoluteUrlOrNull("src"),
chapters = chapterNodes.mapChapters { _, item -> chapters = chapterNodes.mapChapters { _, item ->
val href = item?.selectFirst("a")?.attrAsRelativeUrlOrNull("href") val href = item?.selectFirst("a")?.attrAsRelativeUrlOrNull("href")
?: return@mapChapters null ?: return@mapChapters null
val isAlternative = item.styleValueOrNull("background") != null val isAlternative = item.styleValueOrNull("background") != null
val name = item.selectFirst("a")?.text().orEmpty() val name = item.selectFirst("a")?.text().orEmpty()
if (!isAlternative) i++ if (!isAlternative) i++
MangaChapter( MangaChapter(
id = generateUid(href), id = generateUid(href),
name = if (isAlternative) { name = if (isAlternative) {
prevChapterName ?: return@mapChapters null prevChapterName ?: return@mapChapters null
} else { } else {
prevChapterName = name prevChapterName = name
name name
}, },
number = i, number = i,
url = href, url = href,
scanlator = null, scanlator = null,
branch = if (isAlternative) { branch = if (isAlternative) {
name.substringAfterLast(':').trim() name.substringAfterLast(':').trim()
} else { } else {
DEF_BRANCH_NAME DEF_BRANCH_NAME
}, },
uploadDate = dateFormat.tryParse(item.selectFirst("div.ltcright")?.text()), uploadDate = dateFormat.tryParse(item.selectFirst("div.ltcright")?.text()),
source = source, source = source,
) )
}, },
) )
} }
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> { override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val fullUrl = chapter.url.toAbsoluteUrl(domain) val fullUrl = chapter.url.toAbsoluteUrl(domain)
val doc = webClient.httpGet(fullUrl).parseHtml() val doc = webClient.httpGet(fullUrl).parseHtml()
val root = doc.body().requireElementById("comics").selectFirstOrThrow("ul.xfieldimagegallery") val root = doc.body().requireElementById("comics").selectFirstOrThrow("ul.xfieldimagegallery")
return root.select("li").map { ul -> return root.select("li").map { ul ->
val img = ul.selectFirstOrThrow("img") val img = ul.selectFirstOrThrow("img")
val url = img.attrAsAbsoluteUrl("data-src") val url = img.attrAsAbsoluteUrl("data-src")
MangaPage( MangaPage(
id = generateUid(url), id = generateUid(url),
url = url, url = url,
preview = null, preview = null,
referer = fullUrl, source = source,
source = source, )
) }
} }
}
override suspend fun getTags(): Set<MangaTag> { override suspend fun getTags(): Set<MangaTag> {
val domain = domain val domain = domain
val doc = webClient.httpGet("https://$domain/mangas").parseHtml() val doc = webClient.httpGet("https://$domain/mangas").parseHtml()
val root = doc.body().requireElementById("menu_1").selectFirstOrThrow("div.menu__wrapper") val root = doc.body().requireElementById("menu_1").selectFirstOrThrow("div.menu__wrapper")
return root.select("li").mapNotNullToSet { li -> return root.select("li").mapNotNullToSet { li ->
val a = li.selectFirst("a") ?: return@mapNotNullToSet null val a = li.selectFirst("a") ?: return@mapNotNullToSet null
MangaTag( MangaTag(
title = a.ownText(), title = a.ownText(),
key = a.attr("href").removeSuffix("/"), key = a.attr("href").removeSuffix("/"),
source = source, source = source,
) )
} }
} }
} }

@ -13,205 +13,204 @@ import java.util.*
@MangaSourceParser("MANGATOWN", "MangaTown", "en") @MangaSourceParser("MANGATOWN", "MangaTown", "en")
internal class MangaTownParser(context: MangaLoaderContext) : MangaParser(context, MangaSource.MANGATOWN) { internal class MangaTownParser(context: MangaLoaderContext) : MangaParser(context, MangaSource.MANGATOWN) {
override val configKeyDomain = ConfigKey.Domain("www.mangatown.com", null) override val configKeyDomain = ConfigKey.Domain("www.mangatown.com", null)
override val sortOrders: Set<SortOrder> = EnumSet.of( override val sortOrders: Set<SortOrder> = EnumSet.of(
SortOrder.ALPHABETICAL, SortOrder.ALPHABETICAL,
SortOrder.RATING, SortOrder.RATING,
SortOrder.POPULARITY, SortOrder.POPULARITY,
SortOrder.UPDATED, SortOrder.UPDATED,
) )
private val regexTag = Regex("[^\\-]+-[^\\-]+-[^\\-]+-[^\\-]+-[^\\-]+-[^\\-]+") private val regexTag = Regex("[^\\-]+-[^\\-]+-[^\\-]+-[^\\-]+-[^\\-]+-[^\\-]+")
override suspend fun getList( override suspend fun getList(
offset: Int, offset: Int,
query: String?, query: String?,
tags: Set<MangaTag>?, tags: Set<MangaTag>?,
sortOrder: SortOrder, sortOrder: SortOrder,
): List<Manga> { ): List<Manga> {
val sortKey = when (sortOrder) { val sortKey = when (sortOrder) {
SortOrder.ALPHABETICAL -> "?name.az" SortOrder.ALPHABETICAL -> "?name.az"
SortOrder.RATING -> "?rating.za" SortOrder.RATING -> "?rating.za"
SortOrder.UPDATED -> "?last_chapter_time.za" SortOrder.UPDATED -> "?last_chapter_time.za"
else -> "" else -> ""
} }
val page = (offset / 30) + 1 val page = (offset / 30) + 1
val url = when { val url = when {
!query.isNullOrEmpty() -> { !query.isNullOrEmpty() -> {
if (offset != 0) { if (offset != 0) {
return emptyList() return emptyList()
} }
"/search?name=${query.urlEncoded()}".toAbsoluteUrl(domain) "/search?name=${query.urlEncoded()}".toAbsoluteUrl(domain)
} }
tags.isNullOrEmpty() -> "/directory/$page.htm$sortKey".toAbsoluteUrl(domain) tags.isNullOrEmpty() -> "/directory/$page.htm$sortKey".toAbsoluteUrl(domain)
tags.size == 1 -> "/directory/${tags.first().key}/$page.htm$sortKey".toAbsoluteUrl(domain) tags.size == 1 -> "/directory/${tags.first().key}/$page.htm$sortKey".toAbsoluteUrl(domain)
else -> tags.joinToString( else -> tags.joinToString(
prefix = "/search?page=$page".toAbsoluteUrl(domain), prefix = "/search?page=$page".toAbsoluteUrl(domain),
) { tag -> ) { tag ->
"&genres[${tag.key}]=1" "&genres[${tag.key}]=1"
} }
} }
val doc = webClient.httpGet(url).parseHtml() val doc = webClient.httpGet(url).parseHtml()
val root = doc.body().selectFirstOrThrow("ul.manga_pic_list") val root = doc.body().selectFirstOrThrow("ul.manga_pic_list")
return root.select("li").mapNotNull { li -> return root.select("li").mapNotNull { li ->
val a = li.selectFirst("a.manga_cover") val a = li.selectFirst("a.manga_cover")
val href = a?.attrAsRelativeUrlOrNull("href") val href = a?.attrAsRelativeUrlOrNull("href")
?: return@mapNotNull null ?: return@mapNotNull null
val views = li.select("p.view") val views = li.select("p.view")
val status = views.firstNotNullOfOrNull { it.ownText().takeIf { x -> x.startsWith("Status:") } } val status = views.firstNotNullOfOrNull { it.ownText().takeIf { x -> x.startsWith("Status:") } }
?.substringAfter(':')?.trim()?.lowercase(Locale.ROOT) ?.substringAfter(':')?.trim()?.lowercase(Locale.ROOT)
Manga( Manga(
id = generateUid(href), id = generateUid(href),
title = a.attr("title"), title = a.attr("title"),
coverUrl = a.selectFirst("img")?.absUrl("src").orEmpty(), coverUrl = a.selectFirst("img")?.absUrl("src").orEmpty(),
source = MangaSource.MANGATOWN, source = MangaSource.MANGATOWN,
altTitle = null, altTitle = null,
rating = li.selectFirst("p.score")?.selectFirst("b") rating = li.selectFirst("p.score")?.selectFirst("b")
?.ownText()?.toFloatOrNull()?.div(5f) ?: RATING_UNKNOWN, ?.ownText()?.toFloatOrNull()?.div(5f) ?: RATING_UNKNOWN,
author = views.firstNotNullOfOrNull { it.text().takeIf { x -> x.startsWith("Author:") } } author = views.firstNotNullOfOrNull { it.text().takeIf { x -> x.startsWith("Author:") } }
?.substringAfter(':') ?.substringAfter(':')
?.trim(), ?.trim(),
state = when (status) { state = when (status) {
"ongoing" -> MangaState.ONGOING "ongoing" -> MangaState.ONGOING
"completed" -> MangaState.FINISHED "completed" -> MangaState.FINISHED
else -> null else -> null
}, },
tags = li.selectFirst("p.keyWord")?.select("a")?.mapNotNullToSet tags@{ x -> tags = li.selectFirst("p.keyWord")?.select("a")?.mapNotNullToSet tags@{ x ->
MangaTag( MangaTag(
title = x.attr("title").toTitleCase(), title = x.attr("title").toTitleCase(),
key = x.attr("href").parseTagKey() ?: return@tags null, key = x.attr("href").parseTagKey() ?: return@tags null,
source = MangaSource.MANGATOWN, source = MangaSource.MANGATOWN,
) )
}.orEmpty(), }.orEmpty(),
url = href, url = href,
isNsfw = false, isNsfw = false,
publicUrl = href.toAbsoluteUrl(a.host ?: domain), publicUrl = href.toAbsoluteUrl(a.host ?: domain),
) )
} }
} }
override suspend fun getDetails(manga: Manga): Manga { override suspend fun getDetails(manga: Manga): Manga {
val doc = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml() val doc = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml()
val root = doc.body().selectFirstOrThrow("section.main") val root = doc.body().selectFirstOrThrow("section.main")
.selectFirstOrThrow("div.article_content") .selectFirstOrThrow("div.article_content")
val info = root.selectFirst("div.detail_info")?.selectFirst("ul") val info = root.selectFirst("div.detail_info")?.selectFirst("ul")
val chaptersList = root.selectFirst("div.chapter_content") val chaptersList = root.selectFirst("div.chapter_content")
?.selectFirst("ul.chapter_list")?.select("li")?.asReversed() ?.selectFirst("ul.chapter_list")?.select("li")?.asReversed()
val dateFormat = SimpleDateFormat("MMM dd,yyyy", Locale.US) val dateFormat = SimpleDateFormat("MMM dd,yyyy", Locale.US)
return manga.copy( return manga.copy(
tags = manga.tags + info?.select("li")?.find { x -> tags = manga.tags + info?.select("li")?.find { x ->
x.selectFirst("b")?.ownText() == "Genre(s):" x.selectFirst("b")?.ownText() == "Genre(s):"
}?.select("a")?.mapNotNull { a -> }?.select("a")?.mapNotNull { a ->
MangaTag( MangaTag(
title = a.attr("title").toTitleCase(), title = a.attr("title").toTitleCase(),
key = a.attr("href").parseTagKey() ?: return@mapNotNull null, key = a.attr("href").parseTagKey() ?: return@mapNotNull null,
source = MangaSource.MANGATOWN, source = MangaSource.MANGATOWN,
) )
}.orEmpty(), }.orEmpty(),
description = info?.getElementById("show")?.ownText(), description = info?.getElementById("show")?.ownText(),
chapters = chaptersList?.mapChapters { i, li -> chapters = chaptersList?.mapChapters { i, li ->
val href = li.selectFirst("a")?.attrAsRelativeUrlOrNull("href") val href = li.selectFirst("a")?.attrAsRelativeUrlOrNull("href")
?: return@mapChapters null ?: return@mapChapters null
val name = li.select("span") val name = li.select("span")
.filter { x -> x.className().isEmpty() } .filter { x -> x.className().isEmpty() }
.joinToString(" - ") { it.text() }.trim() .joinToString(" - ") { it.text() }.trim()
MangaChapter( MangaChapter(
id = generateUid(href), id = generateUid(href),
url = href, url = href,
source = MangaSource.MANGATOWN, source = MangaSource.MANGATOWN,
number = i + 1, number = i + 1,
uploadDate = parseChapterDate( uploadDate = parseChapterDate(
dateFormat, dateFormat,
li.selectFirst("span.time")?.text(), li.selectFirst("span.time")?.text(),
), ),
name = name.ifEmpty { "${manga.title} - ${i + 1}" }, name = name.ifEmpty { "${manga.title} - ${i + 1}" },
scanlator = null, scanlator = null,
branch = null, branch = null,
) )
} ?: bypassLicensedChapters(manga), } ?: bypassLicensedChapters(manga),
) )
} }
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> { override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val fullUrl = chapter.url.toAbsoluteUrl(domain) val fullUrl = chapter.url.toAbsoluteUrl(domain)
val doc = webClient.httpGet(fullUrl).parseHtml() val doc = webClient.httpGet(fullUrl).parseHtml()
val root = doc.body().selectFirstOrThrow("div.page_select") val root = doc.body().selectFirstOrThrow("div.page_select")
return root.selectFirstOrThrow("select").selectOrThrow("option").mapNotNull { return root.selectFirstOrThrow("select").selectOrThrow("option").mapNotNull {
val href = it.attrAsRelativeUrlOrNull("value") val href = it.attrAsRelativeUrlOrNull("value")
if (href == null || href.endsWith("featured.html")) { if (href == null || href.endsWith("featured.html")) {
return@mapNotNull null return@mapNotNull null
} }
MangaPage( MangaPage(
id = generateUid(href), id = generateUid(href),
url = href, url = href,
preview = null, preview = null,
referer = fullUrl, source = MangaSource.MANGATOWN,
source = MangaSource.MANGATOWN, )
) }
} }
}
override suspend fun getPageUrl(page: MangaPage): String { override suspend fun getPageUrl(page: MangaPage): String {
val doc = webClient.httpGet(page.url.toAbsoluteUrl(domain)).parseHtml() val doc = webClient.httpGet(page.url.toAbsoluteUrl(domain)).parseHtml()
return doc.requireElementById("image").attrAsAbsoluteUrl("src") return doc.requireElementById("image").attrAsAbsoluteUrl("src")
} }
override suspend fun getTags(): Set<MangaTag> { override suspend fun getTags(): Set<MangaTag> {
val doc = webClient.httpGet("/directory/".toAbsoluteUrl(domain)).parseHtml() val doc = webClient.httpGet("/directory/".toAbsoluteUrl(domain)).parseHtml()
val root = doc.body().selectFirst("aside.right") val root = doc.body().selectFirst("aside.right")
?.getElementsContainingOwnText("Genres") ?.getElementsContainingOwnText("Genres")
?.first() ?.first()
?.nextElementSibling() ?: doc.parseFailed("Root not found") ?.nextElementSibling() ?: doc.parseFailed("Root not found")
return root.select("li").mapNotNullToSet { li -> return root.select("li").mapNotNullToSet { li ->
val a = li.selectFirst("a") ?: return@mapNotNullToSet null val a = li.selectFirst("a") ?: return@mapNotNullToSet null
val key = a.attr("href").parseTagKey() val key = a.attr("href").parseTagKey()
if (key.isNullOrEmpty()) { if (key.isNullOrEmpty()) {
return@mapNotNullToSet null return@mapNotNullToSet null
} }
MangaTag( MangaTag(
source = MangaSource.MANGATOWN, source = MangaSource.MANGATOWN,
key = key, key = key,
title = a.text().toTitleCase(), title = a.text().toTitleCase(),
) )
} }
} }
private fun parseChapterDate(dateFormat: DateFormat, date: String?): Long { private fun parseChapterDate(dateFormat: DateFormat, date: String?): Long {
return when { return when {
date.isNullOrEmpty() -> 0L date.isNullOrEmpty() -> 0L
date.contains("Today") -> Calendar.getInstance().timeInMillis date.contains("Today") -> Calendar.getInstance().timeInMillis
date.contains("Yesterday") -> Calendar.getInstance().apply { add(Calendar.DAY_OF_MONTH, -1) }.timeInMillis date.contains("Yesterday") -> Calendar.getInstance().apply { add(Calendar.DAY_OF_MONTH, -1) }.timeInMillis
else -> dateFormat.tryParse(date) else -> dateFormat.tryParse(date)
} }
} }
private suspend fun bypassLicensedChapters(manga: Manga): List<MangaChapter> { private suspend fun bypassLicensedChapters(manga: Manga): List<MangaChapter> {
val doc = webClient.httpGet(manga.url.toAbsoluteUrl(getDomain("m"))).parseHtml() val doc = webClient.httpGet(manga.url.toAbsoluteUrl(getDomain("m"))).parseHtml()
val list = doc.body().selectFirst("ul.detail-ch-list") ?: return emptyList() val list = doc.body().selectFirst("ul.detail-ch-list") ?: return emptyList()
val dateFormat = SimpleDateFormat("MMM dd,yyyy", Locale.US) val dateFormat = SimpleDateFormat("MMM dd,yyyy", Locale.US)
return list.select("li").asReversed().mapIndexedNotNull { i, li -> return list.select("li").asReversed().mapIndexedNotNull { i, li ->
val a = li.selectFirst("a") ?: return@mapIndexedNotNull null val a = li.selectFirst("a") ?: return@mapIndexedNotNull null
val href = a.attrAsRelativeUrl("href") val href = a.attrAsRelativeUrl("href")
val name = a.selectFirst("span.vol")?.text().orEmpty().ifEmpty { val name = a.selectFirst("span.vol")?.text().orEmpty().ifEmpty {
a.ownText() a.ownText()
} }
MangaChapter( MangaChapter(
id = generateUid(href), id = generateUid(href),
url = href, url = href,
source = MangaSource.MANGATOWN, source = MangaSource.MANGATOWN,
number = i + 1, number = i + 1,
uploadDate = parseChapterDate( uploadDate = parseChapterDate(
dateFormat, dateFormat,
li.selectFirst("span.time")?.text(), li.selectFirst("span.time")?.text(),
), ),
name = name.ifEmpty { "${manga.title} - ${i + 1}" }, name = name.ifEmpty { "${manga.title} - ${i + 1}" },
scanlator = null, scanlator = null,
branch = null, branch = null,
) )
} }
} }
private fun String.parseTagKey() = split('/').findLast { regexTag matches it } private fun String.parseTagKey() = split('/').findLast { regexTag matches it }
} }

@ -165,7 +165,6 @@ class Manhwa18Parser(context: MangaLoaderContext) :
MangaPage( MangaPage(
id = generateUid(url), id = generateUid(url),
url = url, url = url,
referer = chapterUrl,
preview = null, preview = null,
source = MangaSource.MANHWA18, source = MangaSource.MANHWA18,
) )

@ -17,172 +17,171 @@ import java.util.*
@MangaSourceParser("NHENTAI", "N-Hentai") @MangaSourceParser("NHENTAI", "N-Hentai")
class NHentaiParser(context: MangaLoaderContext) : PagedMangaParser(context, MangaSource.NHENTAI, pageSize = 25) { class NHentaiParser(context: MangaLoaderContext) : PagedMangaParser(context, MangaSource.NHENTAI, pageSize = 25) {
override val configKeyDomain: ConfigKey.Domain override val configKeyDomain: ConfigKey.Domain
get() = ConfigKey.Domain("nhentai.net", null) get() = ConfigKey.Domain("nhentai.net", null)
override val sortOrders: Set<SortOrder> override val sortOrders: Set<SortOrder>
get() = EnumSet.of(SortOrder.NEWEST, SortOrder.POPULARITY) get() = EnumSet.of(SortOrder.NEWEST, SortOrder.POPULARITY)
override suspend fun getListPage( override suspend fun getListPage(
page: Int, page: Int,
query: String?, query: String?,
tags: Set<MangaTag>?, tags: Set<MangaTag>?,
sortOrder: SortOrder, sortOrder: SortOrder,
): List<Manga> { ): List<Manga> {
if (query.isNullOrEmpty() && tags != null && tags.size > 1) { if (query.isNullOrEmpty() && tags != null && tags.size > 1) {
return getListPage(page, buildQuery(tags), emptySet(), sortOrder) return getListPage(page, buildQuery(tags), emptySet(), sortOrder)
} }
val domain = domain val domain = domain
val url = buildString { val url = buildString {
append("https://") append("https://")
append(domain) append(domain)
if (!query.isNullOrEmpty()) { if (!query.isNullOrEmpty()) {
append("/search/?q=") append("/search/?q=")
append(query.urlEncoded()) append(query.urlEncoded())
append("&page=") append("&page=")
append(page) append(page)
if (sortOrder == SortOrder.POPULARITY) { if (sortOrder == SortOrder.POPULARITY) {
append("&sort=popular") append("&sort=popular")
} }
} else { } else {
append('/') append('/')
if (!tags.isNullOrEmpty()) { if (!tags.isNullOrEmpty()) {
val tag = tags.single() val tag = tags.single()
append("tag/") append("tag/")
append(tag.key) append(tag.key)
append('/') append('/')
if (sortOrder == SortOrder.POPULARITY) { if (sortOrder == SortOrder.POPULARITY) {
append("popular") append("popular")
} }
append("?page=") append("?page=")
append(page) append(page)
} else { } else {
if (sortOrder == SortOrder.POPULARITY) { if (sortOrder == SortOrder.POPULARITY) {
append("?sort=popular&page=") append("?sort=popular&page=")
} else { } else {
append("?page=") append("?page=")
} }
append(page) append(page)
} }
} }
} }
val root = webClient.httpGet(url).parseHtml().body().requireElementById("content") val root = webClient.httpGet(url).parseHtml().body().requireElementById("content")
.selectLastOrThrow("div.index-container") .selectLastOrThrow("div.index-container")
val regexBrackets = Regex("\\[[^]]+]|\\([^)]+\\)") val regexBrackets = Regex("\\[[^]]+]|\\([^)]+\\)")
val regexSpaces = Regex("\\s+") val regexSpaces = Regex("\\s+")
return root.select(".gallery").map { div -> return root.select(".gallery").map { div ->
val a = div.selectFirstOrThrow("a.cover") val a = div.selectFirstOrThrow("a.cover")
val href = a.attrAsRelativeUrl("href") val href = a.attrAsRelativeUrl("href")
val img = div.selectFirstOrThrow("img") val img = div.selectFirstOrThrow("img")
val title = div.selectFirstOrThrow(".caption").text() val title = div.selectFirstOrThrow(".caption").text()
Manga( Manga(
id = generateUid(href), id = generateUid(href),
title = title.replace(regexBrackets, "") title = title.replace(regexBrackets, "")
.replace(regexSpaces, " ") .replace(regexSpaces, " ")
.trim(), .trim(),
altTitle = null, altTitle = null,
url = href, url = href,
publicUrl = href.toAbsoluteUrl(domain), publicUrl = href.toAbsoluteUrl(domain),
rating = RATING_UNKNOWN, rating = RATING_UNKNOWN,
isNsfw = true, isNsfw = true,
coverUrl = img.attrAsAbsoluteUrlOrNull("data-src") coverUrl = img.attrAsAbsoluteUrlOrNull("data-src")
?: img.attrAsAbsoluteUrl("src"), ?: img.attrAsAbsoluteUrl("src"),
tags = setOf(), tags = setOf(),
state = null, state = null,
author = null, author = null,
largeCoverUrl = null, largeCoverUrl = null,
description = null, description = null,
chapters = listOf(), chapters = listOf(),
source = source, source = source,
) )
} }
} }
override suspend fun getDetails(manga: Manga): Manga { override suspend fun getDetails(manga: Manga): Manga {
val root = webClient.httpGet( val root = webClient.httpGet(
url = manga.url.toAbsoluteUrl(domain), url = manga.url.toAbsoluteUrl(domain),
).parseHtml().body().requireElementById("bigcontainer") ).parseHtml().body().requireElementById("bigcontainer")
val img = root.requireElementById("cover").selectFirstOrThrow("img") val img = root.requireElementById("cover").selectFirstOrThrow("img")
val tagContainers = root.requireElementById("tags").select(".tag-container") val tagContainers = root.requireElementById("tags").select(".tag-container")
val dateFormat = SimpleDateFormat( val dateFormat = SimpleDateFormat(
"yyyy-MM-dd'T'HH:mm:ss.SSSSSS'+00:00'", "yyyy-MM-dd'T'HH:mm:ss.SSSSSS'+00:00'",
Locale.ROOT, Locale.ROOT,
) )
return manga.copy( return manga.copy(
tags = tagContainers.find { x -> x.ownText() == "Tags:" }?.parseTags() ?: manga.tags, tags = tagContainers.find { x -> x.ownText() == "Tags:" }?.parseTags() ?: manga.tags,
author = tagContainers.find { x -> x.ownText() == "Artists:" } author = tagContainers.find { x -> x.ownText() == "Artists:" }
?.selectFirst("span.name")?.text()?.toCamelCase(), ?.selectFirst("span.name")?.text()?.toCamelCase(),
largeCoverUrl = img.attrAsAbsoluteUrlOrNull("data-src") largeCoverUrl = img.attrAsAbsoluteUrlOrNull("data-src")
?: img.attrAsAbsoluteUrl("src"), ?: img.attrAsAbsoluteUrl("src"),
description = null, description = null,
chapters = listOf( chapters = listOf(
MangaChapter( MangaChapter(
id = manga.id, id = manga.id,
name = manga.title, name = manga.title,
number = 1, number = 1,
url = manga.url, url = manga.url,
scanlator = null, scanlator = null,
uploadDate = dateFormat.tryParse( uploadDate = dateFormat.tryParse(
tagContainers.find { x -> x.ownText() == "Uploaded:" } tagContainers.find { x -> x.ownText() == "Uploaded:" }
?.selectFirst("time") ?.selectFirst("time")
?.attr("datetime"), ?.attr("datetime"),
), ),
branch = null, branch = null,
source = source, source = source,
), ),
), ),
) )
} }
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> { override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val url = chapter.url.toAbsoluteUrl(domain) val url = chapter.url.toAbsoluteUrl(domain)
val root = webClient.httpGet(url).parseHtml().requireElementById("thumbnail-container") val root = webClient.httpGet(url).parseHtml().requireElementById("thumbnail-container")
return root.select(".thumb-container").map { div -> return root.select(".thumb-container").map { div ->
val a = div.selectFirstOrThrow("a") val a = div.selectFirstOrThrow("a")
val img = div.selectFirstOrThrow("img") val img = div.selectFirstOrThrow("img")
val href = a.attrAsRelativeUrl("href") val href = a.attrAsRelativeUrl("href")
MangaPage( MangaPage(
id = generateUid(href), id = generateUid(href),
url = href, url = href,
referer = url, preview = img.attrAsAbsoluteUrlOrNull("data-src")
preview = img.attrAsAbsoluteUrlOrNull("data-src") ?: img.attrAsAbsoluteUrl("src"),
?: img.attrAsAbsoluteUrl("src"), source = source,
source = source, )
) }
} }
}
override suspend fun getPageUrl(page: MangaPage): String { override suspend fun getPageUrl(page: MangaPage): String {
val root = webClient.httpGet(page.url.toAbsoluteUrl(domain)).parseHtml().body() val root = webClient.httpGet(page.url.toAbsoluteUrl(domain)).parseHtml().body()
.requireElementById("image-container") .requireElementById("image-container")
return root.selectFirstOrThrow("img").attrAsAbsoluteUrl("src") return root.selectFirstOrThrow("img").attrAsAbsoluteUrl("src")
} }
override suspend fun getTags(): Set<MangaTag> { override suspend fun getTags(): Set<MangaTag> {
return coroutineScope { return coroutineScope {
// parse first 3 pages of tags // parse first 3 pages of tags
(1..3).map { page -> (1..3).map { page ->
async { getTags(page) } async { getTags(page) }
} }
}.awaitAll().flattenTo(ArraySet(360)) }.awaitAll().flattenTo(ArraySet(360))
} }
private suspend fun getTags(page: Int): Set<MangaTag> { private suspend fun getTags(page: Int): Set<MangaTag> {
val root = webClient.httpGet("https://${domain}/tags/popular?page=$page").parseHtml().body() val root = webClient.httpGet("https://${domain}/tags/popular?page=$page").parseHtml().body()
.getElementById("tag-container") .getElementById("tag-container")
return root?.parseTags().orEmpty() return root?.parseTags().orEmpty()
} }
private fun Element.parseTags() = select("a.tag").mapToSet { a -> private fun Element.parseTags() = select("a.tag").mapToSet { a ->
val href = a.attr("href").removeSuffix('/') val href = a.attr("href").removeSuffix('/')
MangaTag( MangaTag(
title = a.selectFirstOrThrow(".name").text().toTitleCase(), title = a.selectFirstOrThrow(".name").text().toTitleCase(),
key = href.substringAfterLast('/'), key = href.substringAfterLast('/'),
source = source, source = source,
) )
} }
private fun buildQuery(tags: Collection<MangaTag>) = tags.joinToString(separator = " ") { tag -> private fun buildQuery(tags: Collection<MangaTag>) = tags.joinToString(separator = " ") { tag ->
"tag:\"${tag.key}\"" "tag:\"${tag.key}\""
} }
} }

@ -16,209 +16,208 @@ import java.util.*
@MangaSourceParser("NETTRUYEN", "NetTruyen", "vi") @MangaSourceParser("NETTRUYEN", "NetTruyen", "vi")
class NetTruyenParser(context: MangaLoaderContext) : class NetTruyenParser(context: MangaLoaderContext) :
PagedMangaParser(context, MangaSource.NETTRUYEN, pageSize = 36) { PagedMangaParser(context, MangaSource.NETTRUYEN, pageSize = 36) {
override val configKeyDomain: ConfigKey.Domain override val configKeyDomain: ConfigKey.Domain
get() = ConfigKey.Domain("nettruyenin.com", null) get() = ConfigKey.Domain("nettruyenin.com", null)
override val sortOrders: Set<SortOrder> override val sortOrders: Set<SortOrder>
get() = EnumSet.of(SortOrder.UPDATED, SortOrder.POPULARITY, SortOrder.NEWEST, SortOrder.RATING) get() = EnumSet.of(SortOrder.UPDATED, SortOrder.POPULARITY, SortOrder.NEWEST, SortOrder.RATING)
private val mutex = Mutex() private val mutex = Mutex()
private val dateFormat = SimpleDateFormat("dd/MM/yy", Locale.US) private val dateFormat = SimpleDateFormat("dd/MM/yy", Locale.US)
private var tagCache: ArrayMap<String, MangaTag>? = null private var tagCache: ArrayMap<String, MangaTag>? = null
override suspend fun getDetails(manga: Manga): Manga { override suspend fun getDetails(manga: Manga): Manga {
val doc = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml() val doc = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml()
val rating = doc.selectFirst("span[itemprop=ratingValue]") val rating = doc.selectFirst("span[itemprop=ratingValue]")
?.ownText() ?.ownText()
?.toFloatOrNull() ?: 0f ?.toFloatOrNull() ?: 0f
val chapterElements = doc.getElementById("nt_listchapter")?.select("ul > li") ?: doc.parseFailed() val chapterElements = doc.getElementById("nt_listchapter")?.select("ul > li") ?: doc.parseFailed()
val chapters = chapterElements.asReversed().mapChapters { index, element -> val chapters = chapterElements.asReversed().mapChapters { index, element ->
val a = element.selectFirst("div.chapter > a") ?: return@mapChapters null val a = element.selectFirst("div.chapter > a") ?: return@mapChapters null
val relativeUrl = a.attrAsRelativeUrlOrNull("href") ?: return@mapChapters null val relativeUrl = a.attrAsRelativeUrlOrNull("href") ?: return@mapChapters null
val timeText = element.selectFirst("div.col-xs-4.text-center.no-wrap.small")?.text() val timeText = element.selectFirst("div.col-xs-4.text-center.no-wrap.small")?.text()
MangaChapter( MangaChapter(
id = generateUid(relativeUrl), id = generateUid(relativeUrl),
name = a.text(), name = a.text(),
number = index + 1, number = index + 1,
url = relativeUrl, url = relativeUrl,
scanlator = null, scanlator = null,
uploadDate = parseChapterTime(timeText), uploadDate = parseChapterTime(timeText),
branch = null, branch = null,
source = source, source = source,
) )
} }
return manga.copy( return manga.copy(
rating = rating / 5, rating = rating / 5,
chapters = chapters, chapters = chapters,
description = doc.selectFirst("div.detail-content > p")?.html(), description = doc.selectFirst("div.detail-content > p")?.html(),
isNsfw = doc.selectFirst("div.alert.alert-danger > strong:contains(Cảnh báo độ tuổi)") != null, isNsfw = doc.selectFirst("div.alert.alert-danger > strong:contains(Cảnh báo độ tuổi)") != null,
) )
} }
// 20 giây trước // 20 giây trước
// 52 phút trước // 52 phút trước
// 6 giờ trước // 6 giờ trước
// 2 ngày trước // 2 ngày trước
// 19:09 30/07 // 19:09 30/07
// 23/12/21 // 23/12/21
private fun parseChapterTime(timeText: String?): Long { private fun parseChapterTime(timeText: String?): Long {
if (timeText.isNullOrEmpty()) { if (timeText.isNullOrEmpty()) {
return 0L return 0L
} }
val timeWords = arrayOf("giây", "phút", "giờ", "ngày") val timeWords = arrayOf("giây", "phút", "giờ", "ngày")
val calendar = Calendar.getInstance() val calendar = Calendar.getInstance()
val timeArr = timeText.split(' ') val timeArr = timeText.split(' ')
if (WordSet(*timeWords).anyWordIn(timeText)) { if (WordSet(*timeWords).anyWordIn(timeText)) {
val timeSuffix = timeArr.getOrNull(1) val timeSuffix = timeArr.getOrNull(1)
val timeDiff = timeArr.getOrNull(0)?.toIntOrNull() ?: return 0L val timeDiff = timeArr.getOrNull(0)?.toIntOrNull() ?: return 0L
when (timeSuffix) { when (timeSuffix) {
timeWords[0] -> calendar.add(Calendar.SECOND, -timeDiff) timeWords[0] -> calendar.add(Calendar.SECOND, -timeDiff)
timeWords[1] -> calendar.add(Calendar.MINUTE, -timeDiff) timeWords[1] -> calendar.add(Calendar.MINUTE, -timeDiff)
timeWords[2] -> calendar.add(Calendar.HOUR, -timeDiff) timeWords[2] -> calendar.add(Calendar.HOUR, -timeDiff)
timeWords[3] -> calendar.add(Calendar.DATE, -timeDiff) timeWords[3] -> calendar.add(Calendar.DATE, -timeDiff)
else -> return 0L else -> return 0L
} }
} else { } else {
val relativeDate = timeArr.lastOrNull() ?: return 0L val relativeDate = timeArr.lastOrNull() ?: return 0L
val dateString = when (relativeDate.split('/').size) { val dateString = when (relativeDate.split('/').size) {
2 -> { 2 -> {
val currentYear = calendar.get(Calendar.YEAR).toString().takeLast(2) val currentYear = calendar.get(Calendar.YEAR).toString().takeLast(2)
"$relativeDate/$currentYear" "$relativeDate/$currentYear"
} }
3 -> relativeDate 3 -> relativeDate
else -> return 0L else -> return 0L
} }
calendar.timeInMillis = dateFormat.tryParse(dateString) calendar.timeInMillis = dateFormat.tryParse(dateString)
} }
return calendar.time.time return calendar.time.time
} }
override suspend fun getListPage( override suspend fun getListPage(
page: Int, page: Int,
query: String?, query: String?,
tags: Set<MangaTag>?, tags: Set<MangaTag>?,
sortOrder: SortOrder, sortOrder: SortOrder,
): List<Manga> { ): List<Manga> {
val isSearching = !query.isNullOrEmpty() val isSearching = !query.isNullOrEmpty()
val url = buildString { val url = buildString {
append("https://") append("https://")
append(domain) append(domain)
if (isSearching) { if (isSearching) {
append("/tim-truyen?keyword=") append("/tim-truyen?keyword=")
append(query!!.urlEncoded()) append(query!!.urlEncoded())
append("&page=") append("&page=")
append(page) append(page)
} else { } else {
val tagQuery = tags.orEmpty().joinToString(",") { it.key } val tagQuery = tags.orEmpty().joinToString(",") { it.key }
append("/tim-truyen-nang-cao?genres=$tagQuery") append("/tim-truyen-nang-cao?genres=$tagQuery")
append("&notgenres=&gender=-1&status=-1&minchapter=1&sort=${getSortOrderKey(sortOrder)}") append("&notgenres=&gender=-1&status=-1&minchapter=1&sort=${getSortOrderKey(sortOrder)}")
append("&page=$page") append("&page=$page")
} }
} }
val response = if (isSearching) { val response = if (isSearching) {
val result = runCatchingCancellable { webClient.httpGet(url) } val result = runCatchingCancellable { webClient.httpGet(url) }
val exception = result.exceptionOrNull() val exception = result.exceptionOrNull()
if (exception is NotFoundException) { if (exception is NotFoundException) {
return emptyList() return emptyList()
} }
result.getOrThrow() result.getOrThrow()
} else { } else {
webClient.httpGet(url) webClient.httpGet(url)
} }
val itemsElements = response.parseHtml() val itemsElements = response.parseHtml()
.select("div.ModuleContent > div.items") .select("div.ModuleContent > div.items")
.select("div.item") .select("div.item")
return itemsElements.mapNotNull { item -> return itemsElements.mapNotNull { item ->
val tooltipElement = item.selectFirst("div.box_tootip") ?: return@mapNotNull null val tooltipElement = item.selectFirst("div.box_tootip") ?: return@mapNotNull null
val absUrl = item.selectFirst("div.image > a")?.attrAsAbsoluteUrlOrNull("href") ?: return@mapNotNull null val absUrl = item.selectFirst("div.image > a")?.attrAsAbsoluteUrlOrNull("href") ?: return@mapNotNull null
val slug = absUrl.substringAfterLast('/') val slug = absUrl.substringAfterLast('/')
val mangaState = when (tooltipElement.selectFirst("div.message_main > p:contains(Tình trạng)")?.ownText()) { val mangaState = when (tooltipElement.selectFirst("div.message_main > p:contains(Tình trạng)")?.ownText()) {
"Đang tiến hành" -> MangaState.ONGOING "Đang tiến hành" -> MangaState.ONGOING
"Hoàn thành" -> MangaState.FINISHED "Hoàn thành" -> MangaState.FINISHED
else -> null else -> null
} }
val tagMap = getOrCreateTagMap() val tagMap = getOrCreateTagMap()
val tagsElement = tooltipElement.selectFirst("div.message_main > p:contains(Thể loại)")?.ownText().orEmpty() val tagsElement = tooltipElement.selectFirst("div.message_main > p:contains(Thể loại)")?.ownText().orEmpty()
val mangaTags = tagsElement.split(',').mapNotNullToSet { tagMap[it.trim()] } val mangaTags = tagsElement.split(',').mapNotNullToSet { tagMap[it.trim()] }
Manga( Manga(
id = generateUid(slug), id = generateUid(slug),
title = tooltipElement.selectFirst("div.title")?.text().orEmpty(), title = tooltipElement.selectFirst("div.title")?.text().orEmpty(),
altTitle = null, altTitle = null,
url = absUrl.toRelativeUrl(domain), url = absUrl.toRelativeUrl(domain),
publicUrl = absUrl, publicUrl = absUrl,
rating = RATING_UNKNOWN, rating = RATING_UNKNOWN,
isNsfw = false, isNsfw = false,
coverUrl = item.selectFirst("div.image a img")?.absUrl("data-original").orEmpty(), coverUrl = item.selectFirst("div.image a img")?.absUrl("data-original").orEmpty(),
largeCoverUrl = null, largeCoverUrl = null,
tags = mangaTags, tags = mangaTags,
state = mangaState, state = mangaState,
author = tooltipElement.selectFirst("div.message_main > p:contains(Tác giả)")?.ownText(), author = tooltipElement.selectFirst("div.message_main > p:contains(Tác giả)")?.ownText(),
description = tooltipElement.selectFirst("div.box_text")?.text(), description = tooltipElement.selectFirst("div.box_text")?.text(),
chapters = null, chapters = null,
source = source, source = source,
) )
} }
} }
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> { override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val pageElements = webClient.httpGet(chapter.url.toAbsoluteUrl(domain)).parseHtml() val pageElements = webClient.httpGet(chapter.url.toAbsoluteUrl(domain)).parseHtml()
.select("div.reading-detail.box_doc > div img") .select("div.reading-detail.box_doc > div img")
return pageElements.map { element -> return pageElements.map { element ->
val url = element.attrAsAbsoluteUrl("data-original") val url = element.attrAsAbsoluteUrl("data-original")
MangaPage( MangaPage(
id = generateUid(url), id = generateUid(url),
url = url, url = url,
referer = domain, preview = null,
preview = null, source = source,
source = source, )
) }
} }
}
override suspend fun getTags(): Set<MangaTag> {
override suspend fun getTags(): Set<MangaTag> { val map = getOrCreateTagMap()
val map = getOrCreateTagMap() val tagSet = ArraySet<MangaTag>(map.size)
val tagSet = ArraySet<MangaTag>(map.size) for (entry in map) {
for (entry in map) { tagSet.add(entry.value)
tagSet.add(entry.value) }
}
return tagSet
return tagSet }
}
private suspend fun getOrCreateTagMap(): ArrayMap<String, MangaTag> = mutex.withLock {
private suspend fun getOrCreateTagMap(): ArrayMap<String, MangaTag> = mutex.withLock { tagCache?.let { return@withLock it }
tagCache?.let { return@withLock it } val doc = webClient.httpGet("/tim-truyen-nang-cao".toAbsoluteUrl(domain)).parseHtml()
val doc = webClient.httpGet("/tim-truyen-nang-cao".toAbsoluteUrl(domain)).parseHtml() val tagItems = doc.select("div.genre-item")
val tagItems = doc.select("div.genre-item") val result = ArrayMap<String, MangaTag>(tagItems.size)
val result = ArrayMap<String, MangaTag>(tagItems.size) for (item in tagItems) {
for (item in tagItems) { val title = item.text().trim()
val title = item.text().trim() val key = item.select("span[data-id]").attr("data-id")
val key = item.select("span[data-id]").attr("data-id") result[title] = MangaTag(title = title, key = key, source = source)
result[title] = MangaTag(title = title, key = key, source = source) }
} tagCache = result
tagCache = result result
result }
}
private fun getSortOrderKey(sortOrder: SortOrder) = when (sortOrder) {
private fun getSortOrderKey(sortOrder: SortOrder) = when (sortOrder) { SortOrder.UPDATED -> 0
SortOrder.UPDATED -> 0 SortOrder.POPULARITY -> 10
SortOrder.POPULARITY -> 10 SortOrder.NEWEST -> 15
SortOrder.NEWEST -> 15 SortOrder.RATING -> 20
SortOrder.RATING -> 20 else -> throw IllegalArgumentException("Sort order ${sortOrder.name} not supported")
else -> throw IllegalArgumentException("Sort order ${sortOrder.name} not supported") }
}
} }

@ -12,174 +12,173 @@ private const val STATUS_FINISHED = "完結"
@MangaSourceParser("NICOVIDEO_SEIGA", "Nicovideo Seiga", "ja") @MangaSourceParser("NICOVIDEO_SEIGA", "Nicovideo Seiga", "ja")
class NicovideoSeigaParser(context: MangaLoaderContext) : class NicovideoSeigaParser(context: MangaLoaderContext) :
MangaParser(context, MangaSource.NICOVIDEO_SEIGA), MangaParser(context, MangaSource.NICOVIDEO_SEIGA),
MangaParserAuthProvider { MangaParserAuthProvider {
override val authUrl: String override val authUrl: String
get() = "https://${getDomain("account")}/login?site=seiga" get() = "https://${getDomain("account")}/login?site=seiga"
override val isAuthorized: Boolean override val isAuthorized: Boolean
get() = context.cookieJar.getCookies(getDomain("seiga")).any { get() = context.cookieJar.getCookies(getDomain("seiga")).any {
it.name == "user_session" it.name == "user_session"
} }
override suspend fun getUsername(): String { override suspend fun getUsername(): String {
val body = webClient.httpGet("https://${getDomain("app")}/my/apps").parseHtml().body() val body = webClient.httpGet("https://${getDomain("app")}/my/apps").parseHtml().body()
return body.selectFirst("#userinfo > div > div > strong")?.text() ?: throw AuthRequiredException(source) return body.selectFirst("#userinfo > div > div > strong")?.text() ?: throw AuthRequiredException(source)
} }
override val sortOrders: Set<SortOrder> = EnumSet.of( override val sortOrders: Set<SortOrder> = EnumSet.of(
SortOrder.UPDATED, SortOrder.UPDATED,
SortOrder.POPULARITY, SortOrder.POPULARITY,
) )
override val configKeyDomain: ConfigKey.Domain = ConfigKey.Domain("nicovideo.jp", null) override val configKeyDomain: ConfigKey.Domain = ConfigKey.Domain("nicovideo.jp", null)
@InternalParsersApi @InternalParsersApi
override suspend fun getList( override suspend fun getList(
offset: Int, offset: Int,
query: String?, query: String?,
tags: Set<MangaTag>?, tags: Set<MangaTag>?,
sortOrder: SortOrder, sortOrder: SortOrder,
): List<Manga> { ): List<Manga> {
val page = (offset / 20f).toIntUp().inc() val page = (offset / 20f).toIntUp().inc()
val domain = getDomain("seiga") val domain = getDomain("seiga")
val url = when { val url = when {
!query.isNullOrEmpty() -> return if (offset == 0) getSearchList(query, page) else emptyList() !query.isNullOrEmpty() -> return if (offset == 0) getSearchList(query, page) else emptyList()
tags.isNullOrEmpty() -> "https://$domain/manga/list?page=$page&sort=${getSortKey(sortOrder)}" tags.isNullOrEmpty() -> "https://$domain/manga/list?page=$page&sort=${getSortKey(sortOrder)}"
tags.size == 1 -> "https://$domain/manga/list?category=${tags.first().key}&page=$page" + tags.size == 1 -> "https://$domain/manga/list?category=${tags.first().key}&page=$page" +
"&sort=${getSortKey(sortOrder)}" "&sort=${getSortKey(sortOrder)}"
tags.size > 1 -> throw IllegalArgumentException("This source supports only 1 category") tags.size > 1 -> throw IllegalArgumentException("This source supports only 1 category")
else -> "https://$domain/manga/list?page=$page&sort=${getSortKey(sortOrder)}" else -> "https://$domain/manga/list?page=$page&sort=${getSortKey(sortOrder)}"
} }
val doc = webClient.httpGet(url).parseHtml() val doc = webClient.httpGet(url).parseHtml()
val comicList = doc.body().select("#comic_list > ul > li") ?: doc.parseFailed("Container not found") val comicList = doc.body().select("#comic_list > ul > li") ?: doc.parseFailed("Container not found")
val items = comicList.select("div > .description > div > div") val items = comicList.select("div > .description > div > div")
return items.mapNotNull { item -> return items.mapNotNull { item ->
val href = val href =
item.selectFirst(".comic_icon > div > a")?.attrAsRelativeUrlOrNull("href") ?: return@mapNotNull null item.selectFirst(".comic_icon > div > a")?.attrAsRelativeUrlOrNull("href") ?: return@mapNotNull null
val statusText = item.selectFirst(".mg_description_header > .mg_icon > .content_status > span")?.text() val statusText = item.selectFirst(".mg_description_header > .mg_icon > .content_status > span")?.text()
Manga( Manga(
id = generateUid(href), id = generateUid(href),
title = item.selectFirst(".mg_body > .title > a")?.text() ?: return@mapNotNull null, title = item.selectFirst(".mg_body > .title > a")?.text() ?: return@mapNotNull null,
coverUrl = item.selectFirst(".comic_icon > div > a > img")?.attrAsAbsoluteUrl("src").orEmpty(), coverUrl = item.selectFirst(".comic_icon > div > a > img")?.attrAsAbsoluteUrl("src").orEmpty(),
altTitle = null, altTitle = null,
author = item.selectFirst(".mg_description_header > .mg_author > a")?.text(), author = item.selectFirst(".mg_description_header > .mg_author > a")?.text(),
rating = RATING_UNKNOWN, rating = RATING_UNKNOWN,
url = href, url = href,
isNsfw = false, isNsfw = false,
tags = item.getElementsByAttributeValueContaining("href", "?category=").mapToSet { a -> tags = item.getElementsByAttributeValueContaining("href", "?category=").mapToSet { a ->
MangaTag( MangaTag(
key = a.attr("href").substringAfterLast('='), key = a.attr("href").substringAfterLast('='),
title = a.ownText().trim(), title = a.ownText().trim(),
source = source, source = source,
) )
}, },
state = when (statusText) { state = when (statusText) {
STATUS_ONGOING -> MangaState.ONGOING STATUS_ONGOING -> MangaState.ONGOING
STATUS_FINISHED -> MangaState.FINISHED STATUS_FINISHED -> MangaState.FINISHED
else -> null else -> null
}, },
publicUrl = href.toAbsoluteUrl(item.host ?: getDomain("seiga")), publicUrl = href.toAbsoluteUrl(item.host ?: getDomain("seiga")),
source = source, source = source,
) )
} }
} }
override suspend fun getDetails(manga: Manga): Manga { override suspend fun getDetails(manga: Manga): Manga {
val doc = webClient.httpGet(manga.url.toAbsoluteUrl(getDomain("seiga"))).parseHtml() val doc = webClient.httpGet(manga.url.toAbsoluteUrl(getDomain("seiga"))).parseHtml()
val contents = doc.body().selectFirstOrThrow("#contents") val contents = doc.body().selectFirstOrThrow("#contents")
val statusText = contents val statusText = contents
.select("div.mg_work_detail > div > div:nth-child(2) > div.tip.content_status.status_series > span") .select("div.mg_work_detail > div > div:nth-child(2) > div.tip.content_status.status_series > span")
.text() .text()
return manga.copy( return manga.copy(
description = contents.selectFirst("div.mg_work_detail > div > div.row > div.description_text")?.html(), description = contents.selectFirst("div.mg_work_detail > div > div.row > div.description_text")?.html(),
largeCoverUrl = contents.selectFirst("div.primaries > div.main_visual > a > img") largeCoverUrl = contents.selectFirst("div.primaries > div.main_visual > a > img")
?.attrAsAbsoluteUrlOrNull("src"), ?.attrAsAbsoluteUrlOrNull("src"),
state = when (statusText) { state = when (statusText) {
STATUS_ONGOING -> MangaState.ONGOING STATUS_ONGOING -> MangaState.ONGOING
STATUS_FINISHED -> MangaState.FINISHED STATUS_FINISHED -> MangaState.FINISHED
else -> null else -> null
}, },
isNsfw = contents.select(".icon_adult").isNotEmpty(), isNsfw = contents.select(".icon_adult").isNotEmpty(),
chapters = contents.select("#episode_list > ul > li").mapChapters { i, li -> chapters = contents.select("#episode_list > ul > li").mapChapters { i, li ->
val href = li.selectFirst("div > div.description > div.title > a") val href = li.selectFirst("div > div.description > div.title > a")
?.attrAsRelativeUrl("href") ?: li.parseFailed() ?.attrAsRelativeUrl("href") ?: li.parseFailed()
MangaChapter( MangaChapter(
id = generateUid(href), id = generateUid(href),
name = li.select("div > div.description > div.title > a").text(), name = li.select("div > div.description > div.title > a").text(),
number = i + 1, number = i + 1,
url = href, url = href,
scanlator = null, scanlator = null,
branch = null, branch = null,
uploadDate = 0, uploadDate = 0,
source = source, source = source,
) )
}, },
) )
} }
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> { override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val fullUrl = chapter.url.toAbsoluteUrl(getDomain("seiga")) val fullUrl = chapter.url.toAbsoluteUrl(getDomain("seiga"))
val doc = webClient.httpGet(fullUrl).parseHtml() val doc = webClient.httpGet(fullUrl).parseHtml()
if (!doc.select("#login_manga").isEmpty()) if (!doc.select("#login_manga").isEmpty())
throw AuthRequiredException(source) throw AuthRequiredException(source)
val root = doc.body().select("#page_contents > li") val root = doc.body().select("#page_contents > li")
return root.map { li -> return root.map { li ->
val url = li.select("div > img").attr("data-original") val url = li.select("div > img").attr("data-original")
MangaPage( MangaPage(
id = generateUid(url), id = generateUid(url),
url = url, url = url,
preview = null, preview = null,
referer = fullUrl, source = source,
source = source, )
) }
} }
}
override suspend fun getTags(): Set<MangaTag> { override suspend fun getTags(): Set<MangaTag> {
val doc = webClient.httpGet("https://${getDomain("seiga")}/manga/list").parseHtml() val doc = webClient.httpGet("https://${getDomain("seiga")}/manga/list").parseHtml()
val root = doc.body().selectOrThrow("#mg_category_list > ul > li") val root = doc.body().selectOrThrow("#mg_category_list > ul > li")
return root.mapToSet { li -> return root.mapToSet { li ->
val a = li.selectFirstOrThrow("a") val a = li.selectFirstOrThrow("a")
MangaTag( MangaTag(
title = a.text(), title = a.text(),
key = a.attrAsRelativeUrlOrNull("href").orEmpty(), key = a.attrAsRelativeUrlOrNull("href").orEmpty(),
source = source, source = source,
) )
} }
} }
private suspend fun getSearchList(query: String, page: Int): List<Manga> { private suspend fun getSearchList(query: String, page: Int): List<Manga> {
val domain = getDomain("seiga") val domain = getDomain("seiga")
val doc = webClient.httpGet("https://$domain/manga/search/?q=$query&page=$page&sort=score").parseHtml() val doc = webClient.httpGet("https://$domain/manga/search/?q=$query&page=$page&sort=score").parseHtml()
val root = doc.body().select(".search_result__item") val root = doc.body().select(".search_result__item")
return root.mapNotNull { item -> return root.mapNotNull { item ->
val href = item.selectFirst(".search_result__item__thumbnail > a") val href = item.selectFirst(".search_result__item__thumbnail > a")
?.attrAsRelativeUrl("href") ?: doc.parseFailed() ?.attrAsRelativeUrl("href") ?: doc.parseFailed()
Manga( Manga(
id = generateUid(href), id = generateUid(href),
url = href, url = href,
publicUrl = href.toAbsoluteUrl(item.host ?: domain), publicUrl = href.toAbsoluteUrl(item.host ?: domain),
title = item.selectFirst(".search_result__item__info > .search_result__item__info--title > a") title = item.selectFirst(".search_result__item__info > .search_result__item__info--title > a")
?.text()?.trim() ?: return@mapNotNull null, ?.text()?.trim() ?: return@mapNotNull null,
altTitle = null, altTitle = null,
author = null, author = null,
tags = emptySet(), tags = emptySet(),
rating = RATING_UNKNOWN, rating = RATING_UNKNOWN,
state = null, state = null,
isNsfw = false, isNsfw = false,
source = source, source = source,
coverUrl = item.selectFirst(".search_result__item__thumbnail > a > img") coverUrl = item.selectFirst(".search_result__item__thumbnail > a > img")
?.attrAsAbsoluteUrl("data-original").orEmpty(), ?.attrAsAbsoluteUrl("data-original").orEmpty(),
) )
} }
} }
private fun getSortKey(sortOrder: SortOrder) = when (sortOrder) { private fun getSortKey(sortOrder: SortOrder) = when (sortOrder) {
SortOrder.POPULARITY -> "manga_view" SortOrder.POPULARITY -> "manga_view"
SortOrder.UPDATED -> "manga_updated" SortOrder.UPDATED -> "manga_updated"
else -> "manga_view" else -> "manga_view"
} }
} }

@ -12,248 +12,247 @@ import java.text.SimpleDateFormat
import java.util.* import java.util.*
internal abstract class NineMangaParser( internal abstract class NineMangaParser(
context: MangaLoaderContext, context: MangaLoaderContext,
source: MangaSource, source: MangaSource,
defaultDomain: String, defaultDomain: String,
) : PagedMangaParser(context, source, pageSize = 26) { ) : PagedMangaParser(context, source, pageSize = 26) {
override val configKeyDomain = ConfigKey.Domain(defaultDomain, null) override val configKeyDomain = ConfigKey.Domain(defaultDomain, null)
init { init {
context.cookieJar.insertCookies(domain, "ninemanga_template_desk=yes") context.cookieJar.insertCookies(domain, "ninemanga_template_desk=yes")
} }
override val headers = Headers.Builder() override val headers = Headers.Builder()
.add("Accept-Language", "en-US;q=0.7,en;q=0.3") .add("Accept-Language", "en-US;q=0.7,en;q=0.3")
.build() .build()
override val sortOrders: Set<SortOrder> = Collections.singleton( override val sortOrders: Set<SortOrder> = Collections.singleton(
SortOrder.POPULARITY, SortOrder.POPULARITY,
) )
override suspend fun getListPage( override suspend fun getListPage(
page: Int, page: Int,
query: String?, query: String?,
tags: Set<MangaTag>?, tags: Set<MangaTag>?,
sortOrder: SortOrder, sortOrder: SortOrder,
): List<Manga> { ): List<Manga> {
val url = buildString { val url = buildString {
append("https://") append("https://")
append(domain) append(domain)
when { when {
!query.isNullOrEmpty() -> { !query.isNullOrEmpty() -> {
append("/search/?name_sel=&wd=") append("/search/?name_sel=&wd=")
append(query.urlEncoded()) append(query.urlEncoded())
append("&page=") append("&page=")
} }
!tags.isNullOrEmpty() -> { !tags.isNullOrEmpty() -> {
append("/search/?category_id=") append("/search/?category_id=")
for (tag in tags) { for (tag in tags) {
append(tag.key) append(tag.key)
append(',') append(',')
} }
append("&page=") append("&page=")
} }
else -> { else -> {
append("/category/index_") append("/category/index_")
} }
} }
append(page) append(page)
append(".html") append(".html")
} }
val doc = webClient.httpGet(url).parseHtml() val doc = webClient.httpGet(url).parseHtml()
val root = doc.body().selectFirst("ul.direlist") val root = doc.body().selectFirst("ul.direlist")
?: doc.parseFailed("Cannot find root") ?: doc.parseFailed("Cannot find root")
val baseHost = root.baseUri().toHttpUrl().host val baseHost = root.baseUri().toHttpUrl().host
return root.select("li").map { node -> return root.select("li").map { node ->
val href = node.selectFirst("a")?.absUrl("href") val href = node.selectFirst("a")?.absUrl("href")
?: node.parseFailed("Link not found") ?: node.parseFailed("Link not found")
val relUrl = href.toRelativeUrl(baseHost) val relUrl = href.toRelativeUrl(baseHost)
val dd = node.selectFirst("dd") val dd = node.selectFirst("dd")
Manga( Manga(
id = generateUid(relUrl), id = generateUid(relUrl),
url = relUrl, url = relUrl,
publicUrl = href, publicUrl = href,
title = dd?.selectFirst("a.bookname")?.text()?.toCamelCase().orEmpty(), title = dd?.selectFirst("a.bookname")?.text()?.toCamelCase().orEmpty(),
altTitle = null, altTitle = null,
coverUrl = node.selectFirst("img")?.absUrl("src").orEmpty(), coverUrl = node.selectFirst("img")?.absUrl("src").orEmpty(),
rating = RATING_UNKNOWN, rating = RATING_UNKNOWN,
author = null, author = null,
isNsfw = false, isNsfw = false,
tags = emptySet(), tags = emptySet(),
state = null, state = null,
source = source, source = source,
description = dd?.selectFirst("p")?.html(), description = dd?.selectFirst("p")?.html(),
) )
} }
} }
override suspend fun getDetails(manga: Manga): Manga { override suspend fun getDetails(manga: Manga): Manga {
val doc = webClient.httpGet( val doc = webClient.httpGet(
manga.url.toAbsoluteUrl(domain) + "?waring=1", manga.url.toAbsoluteUrl(domain) + "?waring=1",
).parseHtml() ).parseHtml()
val root = doc.body().selectFirstOrThrow("div.manga") val root = doc.body().selectFirstOrThrow("div.manga")
val infoRoot = root.selectFirstOrThrow("div.bookintro") val infoRoot = root.selectFirstOrThrow("div.bookintro")
return manga.copy( return manga.copy(
tags = infoRoot.getElementsByAttributeValue("itemprop", "genre").first() tags = infoRoot.getElementsByAttributeValue("itemprop", "genre").first()
?.select("a")?.mapToSet { a -> ?.select("a")?.mapToSet { a ->
MangaTag( MangaTag(
title = a.text().toTitleCase(), title = a.text().toTitleCase(),
key = a.attr("href").substringBetween("/", "."), key = a.attr("href").substringBetween("/", "."),
source = source, source = source,
) )
}.orEmpty(), }.orEmpty(),
author = infoRoot.getElementsByAttributeValue("itemprop", "author").first()?.text(), author = infoRoot.getElementsByAttributeValue("itemprop", "author").first()?.text(),
state = parseStatus(infoRoot.select("li a.red").text()), state = parseStatus(infoRoot.select("li a.red").text()),
description = infoRoot.getElementsByAttributeValue("itemprop", "description").first() description = infoRoot.getElementsByAttributeValue("itemprop", "description").first()
?.html()?.substringAfter("</b>"), ?.html()?.substringAfter("</b>"),
chapters = root.selectFirst("div.chapterbox")?.select("ul.sub_vol_ul > li") chapters = root.selectFirst("div.chapterbox")?.select("ul.sub_vol_ul > li")
?.asReversed()?.mapChapters { i, li -> ?.asReversed()?.mapChapters { i, li ->
val a = li.selectFirst("a.chapter_list_a") val a = li.selectFirst("a.chapter_list_a")
val href = a?.attrAsRelativeUrlOrNull("href") val href = a?.attrAsRelativeUrlOrNull("href")
?.replace("%20", " ") ?: li.parseFailed("Link not found") ?.replace("%20", " ") ?: li.parseFailed("Link not found")
MangaChapter( MangaChapter(
id = generateUid(href), id = generateUid(href),
name = a.text(), name = a.text(),
number = i + 1, number = i + 1,
url = href, url = href,
uploadDate = parseChapterDateByLang(li.selectFirst("span")?.text().orEmpty()), uploadDate = parseChapterDateByLang(li.selectFirst("span")?.text().orEmpty()),
source = source, source = source,
scanlator = null, scanlator = null,
branch = null, branch = null,
) )
}, },
) )
} }
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> { override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val doc = webClient.httpGet(chapter.url.toAbsoluteUrl(domain)).parseHtml() val doc = webClient.httpGet(chapter.url.toAbsoluteUrl(domain)).parseHtml()
return doc.body().getElementById("page")?.select("option")?.map { option -> return doc.body().getElementById("page")?.select("option")?.map { option ->
val url = option.attr("value") val url = option.attr("value")
MangaPage( MangaPage(
id = generateUid(url), id = generateUid(url),
url = url, url = url,
referer = chapter.url.toAbsoluteUrl(domain), preview = null,
preview = null, source = source,
source = source, )
) } ?: doc.parseFailed("Pages list not found")
} ?: doc.parseFailed("Pages list not found") }
}
override suspend fun getPageUrl(page: MangaPage): String { override suspend fun getPageUrl(page: MangaPage): String {
val doc = webClient.httpGet(page.url.toAbsoluteUrl(domain)).parseHtml() val doc = webClient.httpGet(page.url.toAbsoluteUrl(domain)).parseHtml()
val root = doc.body() val root = doc.body()
return root.selectFirst("a.pic_download")?.absUrl("href") return root.selectFirst("a.pic_download")?.absUrl("href")
?: doc.parseFailed("Page image not found") ?: doc.parseFailed("Page image not found")
} }
override suspend fun getTags(): Set<MangaTag> { override suspend fun getTags(): Set<MangaTag> {
val doc = webClient.httpGet("https://${domain}/search/?type=high") val doc = webClient.httpGet("https://${domain}/search/?type=high")
.parseHtml() .parseHtml()
val root = doc.body().getElementById("search_form") val root = doc.body().getElementById("search_form")
return root?.select("li.cate_list")?.mapNotNullToSet { li -> return root?.select("li.cate_list")?.mapNotNullToSet { li ->
val cateId = li.attr("cate_id") ?: return@mapNotNullToSet null val cateId = li.attr("cate_id") ?: return@mapNotNullToSet null
val a = li.selectFirst("a") ?: return@mapNotNullToSet null val a = li.selectFirst("a") ?: return@mapNotNullToSet null
MangaTag( MangaTag(
title = a.text().toTitleCase(), title = a.text().toTitleCase(),
key = cateId, key = cateId,
source = source, source = source,
) )
} ?: doc.parseFailed("Root not found") } ?: doc.parseFailed("Root not found")
} }
private fun parseStatus(status: String) = when { private fun parseStatus(status: String) = when {
status.contains("Ongoing") -> MangaState.ONGOING status.contains("Ongoing") -> MangaState.ONGOING
status.contains("Completed") -> MangaState.FINISHED status.contains("Completed") -> MangaState.FINISHED
else -> null else -> null
} }
private fun parseChapterDateByLang(date: String): Long { private fun parseChapterDateByLang(date: String): Long {
val dateWords = date.split(" ") val dateWords = date.split(" ")
if (dateWords.size == 3) { if (dateWords.size == 3) {
if (dateWords[1].contains(",")) { if (dateWords[1].contains(",")) {
SimpleDateFormat("MMM d, yyyy", Locale.ENGLISH).tryParse(date) SimpleDateFormat("MMM d, yyyy", Locale.ENGLISH).tryParse(date)
} else { } else {
val timeAgo = Integer.parseInt(dateWords[0]) val timeAgo = Integer.parseInt(dateWords[0])
return Calendar.getInstance().apply { return Calendar.getInstance().apply {
when (dateWords[1]) { when (dateWords[1]) {
"minutes" -> Calendar.MINUTE // EN-FR "minutes" -> Calendar.MINUTE // EN-FR
"hours" -> Calendar.HOUR // EN "hours" -> Calendar.HOUR // EN
"minutos" -> Calendar.MINUTE // ES "minutos" -> Calendar.MINUTE // ES
"horas" -> Calendar.HOUR "horas" -> Calendar.HOUR
// "minutos" -> Calendar.MINUTE // BR // "minutos" -> Calendar.MINUTE // BR
"hora" -> Calendar.HOUR "hora" -> Calendar.HOUR
"минут" -> Calendar.MINUTE // RU "минут" -> Calendar.MINUTE // RU
"часа" -> Calendar.HOUR "часа" -> Calendar.HOUR
"Stunden" -> Calendar.HOUR // DE "Stunden" -> Calendar.HOUR // DE
"minuti" -> Calendar.MINUTE // IT "minuti" -> Calendar.MINUTE // IT
"ore" -> Calendar.HOUR "ore" -> Calendar.HOUR
"heures" -> Calendar.HOUR // FR ("minutes" also French word) "heures" -> Calendar.HOUR // FR ("minutes" also French word)
else -> null else -> null
}?.let { }?.let {
add(it, -timeAgo) add(it, -timeAgo)
} }
}.timeInMillis }.timeInMillis
} }
} }
return 0L return 0L
} }
@MangaSourceParser("NINEMANGA_EN", "NineManga English", "en") @MangaSourceParser("NINEMANGA_EN", "NineManga English", "en")
class English(context: MangaLoaderContext) : NineMangaParser( class English(context: MangaLoaderContext) : NineMangaParser(
context, context,
MangaSource.NINEMANGA_EN, MangaSource.NINEMANGA_EN,
"www.ninemanga.com", "www.ninemanga.com",
) )
@MangaSourceParser("NINEMANGA_ES", "NineManga Español", "es") @MangaSourceParser("NINEMANGA_ES", "NineManga Español", "es")
class Spanish(context: MangaLoaderContext) : NineMangaParser( class Spanish(context: MangaLoaderContext) : NineMangaParser(
context, context,
MangaSource.NINEMANGA_ES, MangaSource.NINEMANGA_ES,
"es.ninemanga.com", "es.ninemanga.com",
) )
@MangaSourceParser("NINEMANGA_RU", "NineManga Русский", "ru") @MangaSourceParser("NINEMANGA_RU", "NineManga Русский", "ru")
class Russian(context: MangaLoaderContext) : NineMangaParser( class Russian(context: MangaLoaderContext) : NineMangaParser(
context, context,
MangaSource.NINEMANGA_RU, MangaSource.NINEMANGA_RU,
"ru.ninemanga.com", "ru.ninemanga.com",
) )
@MangaSourceParser("NINEMANGA_DE", "NineManga Deutsch", "de") @MangaSourceParser("NINEMANGA_DE", "NineManga Deutsch", "de")
class Deutsch(context: MangaLoaderContext) : NineMangaParser( class Deutsch(context: MangaLoaderContext) : NineMangaParser(
context, context,
MangaSource.NINEMANGA_DE, MangaSource.NINEMANGA_DE,
"de.ninemanga.com", "de.ninemanga.com",
) )
@MangaSourceParser("NINEMANGA_BR", "NineManga Brasil", "pt") @MangaSourceParser("NINEMANGA_BR", "NineManga Brasil", "pt")
class Brazil(context: MangaLoaderContext) : NineMangaParser( class Brazil(context: MangaLoaderContext) : NineMangaParser(
context, context,
MangaSource.NINEMANGA_BR, MangaSource.NINEMANGA_BR,
"br.ninemanga.com", "br.ninemanga.com",
) )
@MangaSourceParser("NINEMANGA_IT", "NineManga Italiano", "it") @MangaSourceParser("NINEMANGA_IT", "NineManga Italiano", "it")
class Italiano(context: MangaLoaderContext) : NineMangaParser( class Italiano(context: MangaLoaderContext) : NineMangaParser(
context, context,
MangaSource.NINEMANGA_IT, MangaSource.NINEMANGA_IT,
"it.ninemanga.com", "it.ninemanga.com",
) )
@MangaSourceParser("NINEMANGA_FR", "NineManga Français", "fr") @MangaSourceParser("NINEMANGA_FR", "NineManga Français", "fr")
class Francais(context: MangaLoaderContext) : NineMangaParser( class Francais(context: MangaLoaderContext) : NineMangaParser(
context, context,
MangaSource.NINEMANGA_FR, MangaSource.NINEMANGA_FR,
"fr.ninemanga.com", "fr.ninemanga.com",
) )
} }

@ -15,213 +15,212 @@ private const val MAX_THUMB_INDEX = 19
@MangaSourceParser("NUDEMOON", "Nude-Moon", "ru") @MangaSourceParser("NUDEMOON", "Nude-Moon", "ru")
internal class NudeMoonParser( internal class NudeMoonParser(
context: MangaLoaderContext, context: MangaLoaderContext,
) : MangaParser(context, MangaSource.NUDEMOON), MangaParserAuthProvider { ) : MangaParser(context, MangaSource.NUDEMOON), MangaParserAuthProvider {
override val configKeyDomain = ConfigKey.Domain( override val configKeyDomain = ConfigKey.Domain(
defaultValue = "nude-moon.org", defaultValue = "nude-moon.org",
presetValues = arrayOf("nude-moon.org", "nude-moon.net"), presetValues = arrayOf("nude-moon.org", "nude-moon.net"),
) )
override val authUrl: String override val authUrl: String
get() = "https://${domain}/index.php" get() = "https://${domain}/index.php"
override val isAuthorized: Boolean override val isAuthorized: Boolean
get() { get() {
return context.cookieJar.getCookies(domain).any { return context.cookieJar.getCookies(domain).any {
it.name == "fusion_user" it.name == "fusion_user"
} }
} }
override val sortOrders: Set<SortOrder> = EnumSet.of( override val sortOrders: Set<SortOrder> = EnumSet.of(
SortOrder.NEWEST, SortOrder.NEWEST,
SortOrder.POPULARITY, SortOrder.POPULARITY,
SortOrder.RATING, SortOrder.RATING,
) )
init { init {
context.cookieJar.insertCookies( context.cookieJar.insertCookies(
domain, domain,
"NMfYa=1;", "NMfYa=1;",
"nm_mobile=0;", "nm_mobile=0;",
) )
} }
override suspend fun getList( override suspend fun getList(
offset: Int, offset: Int,
query: String?, query: String?,
tags: Set<MangaTag>?, tags: Set<MangaTag>?,
sortOrder: SortOrder, sortOrder: SortOrder,
): List<Manga> { ): List<Manga> {
val domain = domain val domain = domain
val url = when { val url = when {
!query.isNullOrEmpty() -> "https://$domain/search?stext=${query.urlEncoded()}&rowstart=$offset" !query.isNullOrEmpty() -> "https://$domain/search?stext=${query.urlEncoded()}&rowstart=$offset"
!tags.isNullOrEmpty() -> tags.joinToString( !tags.isNullOrEmpty() -> tags.joinToString(
separator = "_", separator = "_",
prefix = "https://$domain/tags/", prefix = "https://$domain/tags/",
postfix = "&rowstart=$offset", postfix = "&rowstart=$offset",
transform = { it.key.urlEncoded() }, transform = { it.key.urlEncoded() },
) )
else -> "https://$domain/all_manga?${getSortKey(sortOrder)}&rowstart=$offset" else -> "https://$domain/all_manga?${getSortKey(sortOrder)}&rowstart=$offset"
} }
val doc = webClient.httpGet(url).parseHtml() val doc = webClient.httpGet(url).parseHtml()
val root = doc.body().run { val root = doc.body().run {
selectFirst("td.main-bg") ?: selectFirst("td.main-body") selectFirst("td.main-bg") ?: selectFirst("td.main-body")
} ?: doc.parseFailed("Cannot find root") } ?: doc.parseFailed("Cannot find root")
return root.select("table.news_pic2").mapNotNull { row -> return root.select("table.news_pic2").mapNotNull { row ->
val a = row.selectFirst("td.bg_style1")?.selectFirst("a") val a = row.selectFirst("td.bg_style1")?.selectFirst("a")
?: return@mapNotNull null ?: return@mapNotNull null
val href = a.attrAsRelativeUrl("href") val href = a.attrAsRelativeUrl("href")
val title = a.selectFirst("h2")?.text().orEmpty() val title = a.selectFirst("h2")?.text().orEmpty()
val info = row.selectFirst("td[width=100%]") ?: return@mapNotNull null val info = row.selectFirst("td[width=100%]") ?: return@mapNotNull null
Manga( Manga(
id = generateUid(href), id = generateUid(href),
url = href, url = href,
title = title.substringAfter(" / "), title = title.substringAfter(" / "),
altTitle = title.substringBefore(" / ", "") altTitle = title.substringBefore(" / ", "")
.takeUnless { it.isBlank() }, .takeUnless { it.isBlank() },
author = info.getElementsContainingOwnText("Автор:").firstOrNull() author = info.getElementsContainingOwnText("Автор:").firstOrNull()
?.nextElementSibling()?.ownText(), ?.nextElementSibling()?.ownText(),
coverUrl = row.selectFirst("img.news_pic2")?.absUrl("data-src") coverUrl = row.selectFirst("img.news_pic2")?.absUrl("data-src")
.orEmpty(), .orEmpty(),
tags = row.selectFirst("span.tag-links")?.select("a") tags = row.selectFirst("span.tag-links")?.select("a")
?.mapToSet { ?.mapToSet {
MangaTag( MangaTag(
title = it.text().toTitleCase(), title = it.text().toTitleCase(),
key = it.attr("href").substringAfterLast('/'), key = it.attr("href").substringAfterLast('/'),
source = source, source = source,
) )
}.orEmpty(), }.orEmpty(),
source = source, source = source,
publicUrl = a.absUrl("href"), publicUrl = a.absUrl("href"),
rating = RATING_UNKNOWN, rating = RATING_UNKNOWN,
isNsfw = true, isNsfw = true,
description = row.selectFirst("div.description")?.html(), description = row.selectFirst("div.description")?.html(),
state = null, state = null,
) )
} }
} }
override suspend fun getDetails(manga: Manga): Manga { override suspend fun getDetails(manga: Manga): Manga {
val body = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml().body() val body = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml().body()
val root = body.selectFirst("table.shoutbox") val root = body.selectFirst("table.shoutbox")
?: body.parseFailed("Cannot find root") ?: body.parseFailed("Cannot find root")
val info = root.select("div.tbl2") val info = root.select("div.tbl2")
val lastInfo = info.last() val lastInfo = info.last()
return manga.copy( return manga.copy(
largeCoverUrl = body.selectFirst("img.news_pic2")?.absUrl("src"), largeCoverUrl = body.selectFirst("img.news_pic2")?.absUrl("src"),
description = info.select("div.blockquote").lastOrNull()?.html() ?: manga.description, description = info.select("div.blockquote").lastOrNull()?.html() ?: manga.description,
tags = info.select("span.tag-links").firstOrNull()?.select("a")?.mapToSet { tags = info.select("span.tag-links").firstOrNull()?.select("a")?.mapToSet {
MangaTag( MangaTag(
title = it.text().toTitleCase(), title = it.text().toTitleCase(),
key = it.attr("href").substringAfterLast('/'), key = it.attr("href").substringAfterLast('/'),
source = source, source = source,
) )
}?.plus(manga.tags) ?: manga.tags, }?.plus(manga.tags) ?: manga.tags,
author = lastInfo?.getElementsByAttributeValueContaining("href", "mangaka/")?.text() author = lastInfo?.getElementsByAttributeValueContaining("href", "mangaka/")?.text()
?: manga.author, ?: manga.author,
chapters = listOf( chapters = listOf(
MangaChapter( MangaChapter(
id = manga.id, id = manga.id,
url = getReadLink(manga.url), url = getReadLink(manga.url),
source = source, source = source,
number = 1, number = 1,
name = manga.title, name = manga.title,
scanlator = lastInfo?.getElementsByAttributeValueContaining("href", "perevod/")?.text(), scanlator = lastInfo?.getElementsByAttributeValueContaining("href", "perevod/")?.text(),
uploadDate = lastInfo?.getElementsContainingOwnText("Дата:") uploadDate = lastInfo?.getElementsContainingOwnText("Дата:")
?.firstOrNull() ?.firstOrNull()
?.html() ?.html()
?.parseDate() ?: 0L, ?.parseDate() ?: 0L,
branch = null, branch = null,
), ),
), ),
) )
} }
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> { override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val fullUrl = chapter.url.toAbsoluteUrl(domain) val fullUrl = chapter.url.toAbsoluteUrl(domain)
val doc = webClient.httpGet(fullUrl).parseHtml() val doc = webClient.httpGet(fullUrl).parseHtml()
val mangaId = chapter.url.substringAfterLast('/').substringBefore('-').toIntOrNull() val mangaId = chapter.url.substringAfterLast('/').substringBefore('-').toIntOrNull()
val script = doc.select("script").firstNotNullOfOrNull { val script = doc.select("script").firstNotNullOfOrNull {
it.html().takeIf { x -> x.contains(" images = new ") } it.html().takeIf { x -> x.contains(" images = new ") }
} ?: if (isAuthorized) { } ?: if (isAuthorized) {
doc.parseFailed("Cannot find pages list") doc.parseFailed("Cannot find pages list")
} else { } else {
throw AuthRequiredException(source) throw AuthRequiredException(source)
} }
val pagesRegex = Regex("images\\[(\\d+)].src\\s*=\\s*'([^']+)'", RegexOption.MULTILINE) val pagesRegex = Regex("images\\[(\\d+)].src\\s*=\\s*'([^']+)'", RegexOption.MULTILINE)
return pagesRegex.findAll(script).map { match -> return pagesRegex.findAll(script).map { match ->
val i = match.groupValues[1].toInt() val i = match.groupValues[1].toInt()
val url = match.groupValues[2] val url = match.groupValues[2]
MangaPage( MangaPage(
id = generateUid(url), id = generateUid(url),
url = url, url = url,
referer = fullUrl, preview = if (i <= MAX_THUMB_INDEX && mangaId != null) {
preview = if (i <= MAX_THUMB_INDEX && mangaId != null) { val part2 = url.substringBeforeLast('/')
val part2 = url.substringBeforeLast('/') val part3 = url.substringAfterLast('/')
val part3 = url.substringAfterLast('/') val part1 = part2.substringBeforeLast('/')
val part1 = part2.substringBeforeLast('/') "$part1/thumb/$mangaId/thumb_$part3"
"$part1/thumb/$mangaId/thumb_$part3" } else {
} else { null
null },
}, source = source,
source = source, )
) }.toList()
}.toList() }
}
override suspend fun getTags(): Set<MangaTag> {
override suspend fun getTags(): Set<MangaTag> { val domain = domain
val domain = domain val doc = webClient.httpGet("https://$domain/all_manga").parseHtml()
val doc = webClient.httpGet("https://$domain/all_manga").parseHtml() val root = doc.body().getElementsContainingOwnText("Поиск манги по тегам")
val root = doc.body().getElementsContainingOwnText("Поиск манги по тегам") .firstOrNull()?.parents()?.find { it.tag().normalName() == "tbody" }
.firstOrNull()?.parents()?.find { it.tag().normalName() == "tbody" } ?.selectFirst("td.textbox")?.selectFirst("td.small")
?.selectFirst("td.textbox")?.selectFirst("td.small") ?: doc.parseFailed("Tags root not found")
?: doc.parseFailed("Tags root not found") return root.select("a").mapToSet {
return root.select("a").mapToSet { MangaTag(
MangaTag( title = it.text().toTitleCase(),
title = it.text().toTitleCase(), key = it.attr("href").substringAfterLast('/')
key = it.attr("href").substringAfterLast('/') .removeSuffix("+"),
.removeSuffix("+"), source = source,
source = source, )
) }
} }
}
override suspend fun getUsername(): String {
override suspend fun getUsername(): String { val body = webClient.httpGet("https://${domain}/").parseHtml()
val body = webClient.httpGet("https://${domain}/").parseHtml() .body()
.body() return body
return body .getElementsContainingOwnText("Профиль")
.getElementsContainingOwnText("Профиль") .firstOrNull()
.firstOrNull() ?.attr("href")
?.attr("href") ?.substringAfterLast('/')
?.substringAfterLast('/') ?: run {
?: run { throw if (body.selectFirst("form[name=\"loginform\"]") != null) {
throw if (body.selectFirst("form[name=\"loginform\"]") != null) { AuthRequiredException(source)
AuthRequiredException(source) } else {
} else { body.parseFailed("Cannot find username")
body.parseFailed("Cannot find username") }
} }
} }
}
private fun getSortKey(sortOrder: SortOrder) =
private fun getSortKey(sortOrder: SortOrder) = when (sortOrder) {
when (sortOrder) { SortOrder.POPULARITY -> "views"
SortOrder.POPULARITY -> "views" SortOrder.NEWEST -> "date"
SortOrder.NEWEST -> "date" SortOrder.RATING -> "like"
SortOrder.RATING -> "like" else -> "like"
else -> "like" }
}
private fun String.parseDate(): Long {
private fun String.parseDate(): Long { val dateString = substringBetweenFirst("Дата:", "<")?.trim() ?: return 0
val dateString = substringBetweenFirst("Дата:", "<")?.trim() ?: return 0 val dateFormat = SimpleDateFormat("d MMMM yyyy", Locale("ru"))
val dateFormat = SimpleDateFormat("d MMMM yyyy", Locale("ru")) return dateFormat.tryParse(dateString)
return dateFormat.tryParse(dateString) }
}
private fun getReadLink(url: String): String {
private fun getReadLink(url: String): String { val prefix = url.substringBefore('-', "")
val prefix = url.substringBefore('-', "") val suffix = url.substringAfter('-').trimStart('-')
val suffix = url.substringAfter('-').trimStart('-') return "$prefix-online-$suffix"
return "$prefix-online-$suffix" }
}
} }

@ -28,259 +28,258 @@ private const val STATUS_FINISHED = 0
@MangaSourceParser("REMANGA", "Remanga", "ru") @MangaSourceParser("REMANGA", "Remanga", "ru")
internal class RemangaParser( internal class RemangaParser(
context: MangaLoaderContext, context: MangaLoaderContext,
) : PagedMangaParser(context, MangaSource.REMANGA, PAGE_SIZE), MangaParserAuthProvider { ) : PagedMangaParser(context, MangaSource.REMANGA, PAGE_SIZE), MangaParserAuthProvider {
private val baseHeaders = Headers.Builder() private val baseHeaders = Headers.Builder()
.add("User-Agent", "Mozilla/5.0 (Android 13; Mobile; rv:68.0) Gecko/68.0 Firefox/109.0") .add("User-Agent", "Mozilla/5.0 (Android 13; Mobile; rv:68.0) Gecko/68.0 Firefox/109.0")
.build() .build()
override val headers override val headers
get() = getApiHeaders() get() = getApiHeaders()
override val configKeyDomain = ConfigKey.Domain("remanga.org", null) override val configKeyDomain = ConfigKey.Domain("remanga.org", null)
override val authUrl: String override val authUrl: String
get() = "https://${domain}/user/login" get() = "https://${domain}/user/login"
override val sortOrders: Set<SortOrder> = EnumSet.of( override val sortOrders: Set<SortOrder> = EnumSet.of(
SortOrder.UPDATED, SortOrder.UPDATED,
SortOrder.POPULARITY, SortOrder.POPULARITY,
SortOrder.RATING, SortOrder.RATING,
SortOrder.NEWEST, SortOrder.NEWEST,
) )
override val isAuthorized: Boolean override val isAuthorized: Boolean
get() { get() {
return context.cookieJar.getCookies(domain).any { return context.cookieJar.getCookies(domain).any {
it.name == "user" it.name == "user"
} }
} }
private val regexLastUrlPath = Regex("/[^/]+/?$") private val regexLastUrlPath = Regex("/[^/]+/?$")
override suspend fun getListPage( override suspend fun getListPage(
page: Int, page: Int,
query: String?, query: String?,
tags: Set<MangaTag>?, tags: Set<MangaTag>?,
sortOrder: SortOrder, sortOrder: SortOrder,
): List<Manga> { ): List<Manga> {
copyCookies() copyCookies()
val domain = domain val domain = domain
val urlBuilder = StringBuilder() val urlBuilder = StringBuilder()
.append("https://api.") .append("https://api.")
.append(domain) .append(domain)
if (query != null) { if (query != null) {
urlBuilder.append("/api/search/?query=") urlBuilder.append("/api/search/?query=")
.append(query.urlEncoded()) .append(query.urlEncoded())
} else { } else {
urlBuilder.append("/api/search/catalog/?ordering=") urlBuilder.append("/api/search/catalog/?ordering=")
.append(getSortKey(sortOrder)) .append(getSortKey(sortOrder))
tags?.forEach { tag -> tags?.forEach { tag ->
urlBuilder.append("&genres=") urlBuilder.append("&genres=")
urlBuilder.append(tag.key) urlBuilder.append(tag.key)
} }
} }
urlBuilder urlBuilder
.append("&page=") .append("&page=")
.append(page) .append(page)
.append("&count=") .append("&count=")
.append(PAGE_SIZE) .append(PAGE_SIZE)
val content = webClient.httpGet(urlBuilder.toString()).parseJson() val content = webClient.httpGet(urlBuilder.toString()).parseJson()
.getJSONArray("content") .getJSONArray("content")
return content.mapJSON { jo -> return content.mapJSON { jo ->
val url = "/manga/${jo.getString("dir")}" val url = "/manga/${jo.getString("dir")}"
val img = jo.getJSONObject("img") val img = jo.getJSONObject("img")
Manga( Manga(
id = generateUid(url), id = generateUid(url),
url = url, url = url,
publicUrl = "https://$domain$url", publicUrl = "https://$domain$url",
title = jo.getString("rus_name"), title = jo.getString("rus_name"),
altTitle = jo.getString("en_name"), altTitle = jo.getString("en_name"),
rating = jo.getString("avg_rating").toFloatOrNull()?.div(10f) ?: RATING_UNKNOWN, rating = jo.getString("avg_rating").toFloatOrNull()?.div(10f) ?: RATING_UNKNOWN,
coverUrl = "https://api.$domain${img.getString("mid")}", coverUrl = "https://api.$domain${img.getString("mid")}",
largeCoverUrl = "https://api.$domain${img.getString("high")}", largeCoverUrl = "https://api.$domain${img.getString("high")}",
author = null, author = null,
isNsfw = false, isNsfw = false,
state = null, state = null,
tags = jo.optJSONArray("genres")?.mapJSONToSet { g -> tags = jo.optJSONArray("genres")?.mapJSONToSet { g ->
MangaTag( MangaTag(
title = g.getString("name").toTitleCase(), title = g.getString("name").toTitleCase(),
key = g.getInt("id").toString(), key = g.getInt("id").toString(),
source = MangaSource.REMANGA, source = MangaSource.REMANGA,
) )
}.orEmpty(), }.orEmpty(),
source = MangaSource.REMANGA, source = MangaSource.REMANGA,
) )
} }
} }
override suspend fun getDetails(manga: Manga): Manga { override suspend fun getDetails(manga: Manga): Manga {
copyCookies() copyCookies()
val domain = domain val domain = domain
val slug = manga.url.find(regexLastUrlPath) val slug = manga.url.find(regexLastUrlPath)
?: throw ParseException("Cannot obtain slug from ${manga.url}", manga.publicUrl) ?: throw ParseException("Cannot obtain slug from ${manga.url}", manga.publicUrl)
val data = webClient.httpGet( val data = webClient.httpGet(
url = "https://api.$domain/api/titles$slug/", url = "https://api.$domain/api/titles$slug/",
).handle401().parseJson() ).handle401().parseJson()
val content = try { val content = try {
data.getJSONObject("content") data.getJSONObject("content")
} catch (e: JSONException) { } catch (e: JSONException) {
throw ParseException(data.optString("msg"), manga.publicUrl, e) throw ParseException(data.optString("msg"), manga.publicUrl, e)
} }
val branchId = content.getJSONArray("branches").optJSONObject(0) val branchId = content.getJSONArray("branches").optJSONObject(0)
?.getLong("id") ?: throw ParseException("No branches found", manga.publicUrl) ?.getLong("id") ?: throw ParseException("No branches found", manga.publicUrl)
val chapters = grabChapters(domain, branchId) val chapters = grabChapters(domain, branchId)
val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US) val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US)
return manga.copy( return manga.copy(
description = content.getString("description"), description = content.getString("description"),
state = when (content.optJSONObject("status")?.getInt("id")) { state = when (content.optJSONObject("status")?.getInt("id")) {
STATUS_ONGOING -> MangaState.ONGOING STATUS_ONGOING -> MangaState.ONGOING
STATUS_FINISHED -> MangaState.FINISHED STATUS_FINISHED -> MangaState.FINISHED
else -> null else -> null
}, },
tags = content.getJSONArray("genres").mapJSONToSet { g -> tags = content.getJSONArray("genres").mapJSONToSet { g ->
MangaTag( MangaTag(
title = g.getString("name").toTitleCase(), title = g.getString("name").toTitleCase(),
key = g.getInt("id").toString(), key = g.getInt("id").toString(),
source = MangaSource.REMANGA, source = MangaSource.REMANGA,
) )
}, },
chapters = chapters.mapChapters { i, jo -> chapters = chapters.mapChapters { i, jo ->
if ( if (
jo.getBooleanOrDefault("is_paid", false) && jo.getBooleanOrDefault("is_paid", false) &&
!jo.getBooleanOrDefault("is_bought", false) !jo.getBooleanOrDefault("is_bought", false)
) { ) {
return@mapChapters null return@mapChapters null
} }
val id = jo.getLong("id") val id = jo.getLong("id")
val name = jo.getString("name").toTitleCase(Locale.ROOT) val name = jo.getString("name").toTitleCase(Locale.ROOT)
val publishers = jo.optJSONArray("publishers") val publishers = jo.optJSONArray("publishers")
MangaChapter( MangaChapter(
id = generateUid(id), id = generateUid(id),
url = "/api/titles/chapters/$id/", url = "/api/titles/chapters/$id/",
number = chapters.size - i, number = chapters.size - i,
name = buildString { name = buildString {
append("Том ") append("Том ")
append(jo.optString("tome", "0")) append(jo.optString("tome", "0"))
append(". ") append(". ")
append("Глава ") append("Глава ")
append(jo.optString("chapter", "0")) append(jo.optString("chapter", "0"))
if (name.isNotEmpty()) { if (name.isNotEmpty()) {
append(" - ") append(" - ")
append(name) append(name)
} }
}, },
uploadDate = dateFormat.tryParse(jo.getString("upload_date")), uploadDate = dateFormat.tryParse(jo.getString("upload_date")),
scanlator = publishers?.optJSONObject(0)?.getStringOrNull("name"), scanlator = publishers?.optJSONObject(0)?.getStringOrNull("name"),
source = MangaSource.REMANGA, source = MangaSource.REMANGA,
branch = null, branch = null,
) )
}.asReversed(), }.asReversed(),
) )
} }
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> { override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val referer = "https://${domain}/" val referer = "https://${domain}/"
val content = webClient.httpGet(chapter.url.toAbsoluteUrl(getDomain("api"))) val content = webClient.httpGet(chapter.url.toAbsoluteUrl(getDomain("api")))
.handle401() .handle401()
.parseJson() .parseJson()
.getJSONObject("content") .getJSONObject("content")
val pages = content.optJSONArray("pages") val pages = content.optJSONArray("pages")
if (pages == null) { if (pages == null) {
val pubDate = content.getStringOrNull("pub_date")?.let { val pubDate = content.getStringOrNull("pub_date")?.let {
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US).tryParse(it) SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US).tryParse(it)
} }
if (pubDate != null && pubDate > System.currentTimeMillis()) { if (pubDate != null && pubDate > System.currentTimeMillis()) {
val at = SimpleDateFormat.getDateInstance(DateFormat.LONG).format(Date(pubDate)) val at = SimpleDateFormat.getDateInstance(DateFormat.LONG).format(Date(pubDate))
throw ContentUnavailableException("Глава станет доступной $at") throw ContentUnavailableException("Глава станет доступной $at")
} else { } else {
throw ContentUnavailableException("Глава недоступна") throw ContentUnavailableException("Глава недоступна")
} }
} }
val result = ArrayList<MangaPage>(pages.length()) val result = ArrayList<MangaPage>(pages.length())
for (i in 0 until pages.length()) { for (i in 0 until pages.length()) {
when (val item = pages.get(i)) { when (val item = pages.get(i)) {
is JSONObject -> result += parsePage(item, referer) is JSONObject -> result += parsePage(item, referer)
is JSONArray -> item.mapJSONTo(result) { parsePage(it, referer) } is JSONArray -> item.mapJSONTo(result) { parsePage(it, referer) }
else -> throw ParseException("Unknown json item $item", chapter.url) else -> throw ParseException("Unknown json item $item", chapter.url)
} }
} }
return result return result
} }
override suspend fun getTags(): Set<MangaTag> { override suspend fun getTags(): Set<MangaTag> {
val domain = domain val domain = domain
val content = webClient.httpGet("https://api.$domain/api/forms/titles/?get=genres") val content = webClient.httpGet("https://api.$domain/api/forms/titles/?get=genres")
.parseJson().getJSONObject("content").getJSONArray("genres") .parseJson().getJSONObject("content").getJSONArray("genres")
return content.mapJSONToSet { jo -> return content.mapJSONToSet { jo ->
MangaTag( MangaTag(
title = jo.getString("name").toTitleCase(), title = jo.getString("name").toTitleCase(),
key = jo.getInt("id").toString(), key = jo.getInt("id").toString(),
source = source, source = source,
) )
} }
} }
override suspend fun getUsername(): String { override suspend fun getUsername(): String {
val jo = webClient.httpGet( val jo = webClient.httpGet(
url = "https://api.${domain}/api/users/current/", url = "https://api.${domain}/api/users/current/",
).handle401().parseJson() ).handle401().parseJson()
return jo.getJSONObject("content").getString("username") return jo.getJSONObject("content").getString("username")
} }
private fun getApiHeaders(): Headers { private fun getApiHeaders(): Headers {
val userCookie = context.cookieJar.getCookies(domain).find { val userCookie = context.cookieJar.getCookies(domain).find {
it.name == "user" it.name == "user"
} ?: return baseHeaders } ?: return baseHeaders
val jo = JSONObject(URLDecoder.decode(userCookie.value, Charsets.UTF_8.name())) val jo = JSONObject(URLDecoder.decode(userCookie.value, Charsets.UTF_8.name()))
val accessToken = jo.getStringOrNull("access_token") ?: return baseHeaders val accessToken = jo.getStringOrNull("access_token") ?: return baseHeaders
return baseHeaders.newBuilder().add("authorization", "bearer $accessToken").build() return baseHeaders.newBuilder().add("authorization", "bearer $accessToken").build()
} }
private fun copyCookies() { private fun copyCookies() {
val domain = domain val domain = domain
context.cookieJar.copyCookies(domain, "api.$domain") context.cookieJar.copyCookies(domain, "api.$domain")
} }
private fun getSortKey(order: SortOrder?) = when (order) { private fun getSortKey(order: SortOrder?) = when (order) {
SortOrder.UPDATED -> "-chapter_date" SortOrder.UPDATED -> "-chapter_date"
SortOrder.POPULARITY -> "-rating" SortOrder.POPULARITY -> "-rating"
SortOrder.RATING -> "-votes" SortOrder.RATING -> "-votes"
SortOrder.NEWEST -> "-id" SortOrder.NEWEST -> "-id"
else -> "-chapter_date" else -> "-chapter_date"
} }
private fun parsePage(jo: JSONObject, referer: String) = MangaPage( private fun parsePage(jo: JSONObject, referer: String) = MangaPage(
id = generateUid(jo.getLong("id")), id = generateUid(jo.getLong("id")),
url = jo.getString("link"), url = jo.getString("link"),
preview = null, preview = null,
referer = referer, source = source,
source = source, )
)
private suspend fun grabChapters(domain: String, branchId: Long): List<JSONObject> { private suspend fun grabChapters(domain: String, branchId: Long): List<JSONObject> {
val result = ArrayList<JSONObject>(100) val result = ArrayList<JSONObject>(100)
var page = 1 var page = 1
while (true) { while (true) {
val content = webClient.httpGet( val content = webClient.httpGet(
url = "https://api.$domain/api/titles/chapters/?branch_id=$branchId&page=$page&count=100", url = "https://api.$domain/api/titles/chapters/?branch_id=$branchId&page=$page&count=100",
).handle401().parseJson().getJSONArray("content") ).handle401().parseJson().getJSONArray("content")
val len = content.length() val len = content.length()
if (len == 0) { if (len == 0) {
break break
} }
result.ensureCapacity(result.size + len) result.ensureCapacity(result.size + len)
for (i in 0 until len) { for (i in 0 until len) {
result.add(content.getJSONObject(i)) result.add(content.getJSONObject(i))
} }
page++ page++
} }
return result return result
} }
private fun Response.handle401() = apply { private fun Response.handle401() = apply {
if (code == HttpURLConnection.HTTP_UNAUTHORIZED) { if (code == HttpURLConnection.HTTP_UNAUTHORIZED) {
throw AuthRequiredException(source) throw AuthRequiredException(source)
} }
} }
} }

@ -124,7 +124,6 @@ class TruyentranhLHParser(context: MangaLoaderContext) :
MangaPage( MangaPage(
id = generateUid(imageUrl), id = generateUid(imageUrl),
url = imageUrl, url = imageUrl,
referer = url,
preview = null, preview = null,
source = source, source = source,
) )

@ -122,7 +122,6 @@ class UnionMangasParser(context: MangaLoaderContext) : PagedMangaParser(context,
MangaPage( MangaPage(
id = generateUid(href), id = generateUid(href),
url = href, url = href,
referer = fullUrl,
preview = null, preview = null,
source = source, source = source,
) )

@ -226,7 +226,6 @@ internal abstract class GroupleParser(
id = generateUid(url), id = generateUid(url),
url = "$primaryServer|$serversStr|$url", url = "$primaryServer|$serversStr|$url",
preview = null, preview = null,
referer = chapter.url,
source = source, source = source,
) )
} }

@ -112,7 +112,6 @@ abstract class Madara5Parser @InternalParsersApi constructor(
MangaPage( MangaPage(
id = generateUid(url), id = generateUid(url),
url = url, url = url,
referer = fullUrl,
preview = null, preview = null,
source = source, source = source,
) )
@ -180,7 +179,5 @@ abstract class Madara5Parser @InternalParsersApi constructor(
) )
@MangaSourceParser("MANGAOWLS", "BeautyManga", "en") @MangaSourceParser("MANGAOWLS", "BeautyManga", "en")
class BeautyManga(context: MangaLoaderContext) : Madara5Parser(context, MangaSource.MANGAOWLS, "beautymanga.com") { class BeautyManga(context: MangaLoaderContext) : Madara5Parser(context, MangaSource.MANGAOWLS, "beautymanga.com")
}
} }

@ -186,7 +186,6 @@ internal abstract class MadaraParser(
id = generateUid(url), id = generateUid(url),
url = url, url = url,
preview = null, preview = null,
referer = fullUrl,
source = source, source = source,
) )
} }

@ -88,7 +88,6 @@ internal class NeatManga(context: MangaLoaderContext) : MadaraParser(context, Ma
id = generateUid(url), id = generateUid(url),
url = url, url = url,
preview = null, preview = null,
referer = fullUrl,
source = source, source = source,
) )
} }

@ -187,7 +187,6 @@ internal abstract class MangaReaderParser(
MangaPage( MangaPage(
id = generateUid(images.getString(i)), id = generateUid(images.getString(i)),
url = images.getString(i), url = images.getString(i),
referer = chapterUrl,
preview = null, preview = null,
source = source, source = source,
), ),

@ -10,174 +10,173 @@ import java.text.SimpleDateFormat
import java.util.* import java.util.*
internal abstract class ChanParser( internal abstract class ChanParser(
context: MangaLoaderContext, context: MangaLoaderContext,
source: MangaSource, source: MangaSource,
) : MangaParser(context, source), MangaParserAuthProvider { ) : MangaParser(context, source), MangaParserAuthProvider {
override val sortOrders: Set<SortOrder> = EnumSet.of( override val sortOrders: Set<SortOrder> = EnumSet.of(
SortOrder.NEWEST, SortOrder.NEWEST,
SortOrder.POPULARITY, SortOrder.POPULARITY,
SortOrder.ALPHABETICAL, SortOrder.ALPHABETICAL,
) )
override val authUrl: String override val authUrl: String
get() = "https://${domain}" get() = "https://${domain}"
override val isAuthorized: Boolean override val isAuthorized: Boolean
get() = context.cookieJar.getCookies(domain).any { it.name == "dle_user_id" } get() = context.cookieJar.getCookies(domain).any { it.name == "dle_user_id" }
override suspend fun getList( override suspend fun getList(
offset: Int, offset: Int,
query: String?, query: String?,
tags: Set<MangaTag>?, tags: Set<MangaTag>?,
sortOrder: SortOrder, sortOrder: SortOrder,
): List<Manga> { ): List<Manga> {
val domain = domain val domain = domain
val url = when { val url = when {
!query.isNullOrEmpty() -> { !query.isNullOrEmpty() -> {
if (offset != 0) { if (offset != 0) {
return emptyList() return emptyList()
} }
"https://$domain/?do=search&subaction=search&story=${query.urlEncoded()}" "https://$domain/?do=search&subaction=search&story=${query.urlEncoded()}"
} }
!tags.isNullOrEmpty() -> tags.joinToString( !tags.isNullOrEmpty() -> tags.joinToString(
prefix = "https://$domain/tags/", prefix = "https://$domain/tags/",
postfix = "&n=${getSortKey2(sortOrder)}?offset=$offset", postfix = "&n=${getSortKey2(sortOrder)}?offset=$offset",
separator = "+", separator = "+",
) { tag -> tag.key } ) { tag -> tag.key }
else -> "https://$domain/${getSortKey(sortOrder)}?offset=$offset" else -> "https://$domain/${getSortKey(sortOrder)}?offset=$offset"
} }
val doc = webClient.httpGet(url).parseHtml() val doc = webClient.httpGet(url).parseHtml()
val root = doc.body().selectFirst("div.main_fon")?.getElementById("content") val root = doc.body().selectFirst("div.main_fon")?.getElementById("content")
?: doc.parseFailed("Cannot find root") ?: doc.parseFailed("Cannot find root")
return root.select("div.content_row").mapNotNull { row -> return root.select("div.content_row").mapNotNull { row ->
val a = row.selectFirst("div.manga_row1")?.selectFirst("h2")?.selectFirst("a") val a = row.selectFirst("div.manga_row1")?.selectFirst("h2")?.selectFirst("a")
?: return@mapNotNull null ?: return@mapNotNull null
val href = a.attrAsRelativeUrl("href") val href = a.attrAsRelativeUrl("href")
Manga( Manga(
id = generateUid(href), id = generateUid(href),
url = href, url = href,
publicUrl = href.toAbsoluteUrl(a.host ?: domain), publicUrl = href.toAbsoluteUrl(a.host ?: domain),
altTitle = a.attr("title"), altTitle = a.attr("title"),
title = a.text().substringAfterLast('(').substringBeforeLast(')'), title = a.text().substringAfterLast('(').substringBeforeLast(')'),
author = row.getElementsByAttributeValueStarting( author = row.getElementsByAttributeValueStarting(
"href", "href",
"/mangaka", "/mangaka",
).firstOrNull()?.text(), ).firstOrNull()?.text(),
coverUrl = row.selectFirst("div.manga_images")?.selectFirst("img") coverUrl = row.selectFirst("div.manga_images")?.selectFirst("img")
?.absUrl("src").orEmpty(), ?.absUrl("src").orEmpty(),
tags = runCatching { tags = runCatching {
row.selectFirst("div.genre")?.select("a")?.mapToSet { row.selectFirst("div.genre")?.select("a")?.mapToSet {
MangaTag( MangaTag(
title = it.text().toTagName(), title = it.text().toTagName(),
key = it.attr("href").substringAfterLast('/').urlEncoded(), key = it.attr("href").substringAfterLast('/').urlEncoded(),
source = source, source = source,
) )
} }
}.getOrNull().orEmpty(), }.getOrNull().orEmpty(),
rating = RATING_UNKNOWN, rating = RATING_UNKNOWN,
state = null, state = null,
isNsfw = false, isNsfw = false,
source = source, source = source,
) )
} }
} }
override suspend fun getDetails(manga: Manga): Manga { override suspend fun getDetails(manga: Manga): Manga {
val doc = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml() val doc = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml()
val root = doc.body().getElementById("dle-content") ?: doc.parseFailed("Cannot find root") val root = doc.body().getElementById("dle-content") ?: doc.parseFailed("Cannot find root")
val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.US) val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.US)
return manga.copy( return manga.copy(
description = root.getElementById("description")?.html()?.substringBeforeLast("<div"), description = root.getElementById("description")?.html()?.substringBeforeLast("<div"),
largeCoverUrl = root.getElementById("cover")?.absUrl("src"), largeCoverUrl = root.getElementById("cover")?.absUrl("src"),
chapters = root.select("table.table_cha tr:gt(1)").reversed().mapChapters { i, tr -> chapters = root.select("table.table_cha tr:gt(1)").reversed().mapChapters { i, tr ->
val href = tr?.selectFirst("a")?.attrAsRelativeUrlOrNull("href") val href = tr?.selectFirst("a")?.attrAsRelativeUrlOrNull("href")
?: return@mapChapters null ?: return@mapChapters null
MangaChapter( MangaChapter(
id = generateUid(href), id = generateUid(href),
name = tr.selectFirst("a")?.text().orEmpty(), name = tr.selectFirst("a")?.text().orEmpty(),
number = i + 1, number = i + 1,
url = href, url = href,
scanlator = null, scanlator = null,
branch = null, branch = null,
uploadDate = dateFormat.tryParse(tr.selectFirst("div.date")?.text()), uploadDate = dateFormat.tryParse(tr.selectFirst("div.date")?.text()),
source = source, source = source,
) )
}, },
) )
} }
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> { override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val fullUrl = chapter.url.toAbsoluteUrl(domain) val fullUrl = chapter.url.toAbsoluteUrl(domain)
val doc = webClient.httpGet(fullUrl).parseHtml() val doc = webClient.httpGet(fullUrl).parseHtml()
val scripts = doc.select("script") val scripts = doc.select("script")
for (script in scripts) { for (script in scripts) {
val data = script.html() val data = script.html()
val pos = data.indexOf("\"fullimg") val pos = data.indexOf("\"fullimg")
if (pos == -1) { if (pos == -1) {
continue continue
} }
val json = data.substring(pos).substringAfter('[').substringBefore(';') val json = data.substring(pos).substringAfter('[').substringBefore(';')
.substringBeforeLast(']') .substringBeforeLast(']')
val domain = domain val domain = domain
return json.split(",").mapNotNull { return json.split(",").mapNotNull {
it.trim() it.trim()
.removeSurrounding('"', '\'') .removeSurrounding('"', '\'')
.toRelativeUrl(domain) .toRelativeUrl(domain)
.takeUnless(String::isBlank) .takeUnless(String::isBlank)
}.map { url -> }.map { url ->
MangaPage( MangaPage(
id = generateUid(url), id = generateUid(url),
url = url, url = url,
preview = null, preview = null,
referer = fullUrl, source = source,
source = source, )
) }
} }
} doc.parseFailed("Pages list not found at ${chapter.url}")
doc.parseFailed("Pages list not found at ${chapter.url}") }
}
override suspend fun getTags(): Set<MangaTag> { override suspend fun getTags(): Set<MangaTag> {
val domain = domain val domain = domain
val doc = webClient.httpGet("https://$domain/mostfavorites&sort=manga").parseHtml() val doc = webClient.httpGet("https://$domain/mostfavorites&sort=manga").parseHtml()
val root = doc.body().selectFirst("div.main_fon")?.getElementById("side") val root = doc.body().selectFirst("div.main_fon")?.getElementById("side")
?.select("ul")?.last() ?: doc.parseFailed("Cannot find root") ?.select("ul")?.last() ?: doc.parseFailed("Cannot find root")
return root.select("li.sidetag").mapToSet { li -> return root.select("li.sidetag").mapToSet { li ->
val a = li.children().lastOrNull() ?: li.parseFailed("a is null") val a = li.children().lastOrNull() ?: li.parseFailed("a is null")
MangaTag( MangaTag(
title = a.text().toTagName(), title = a.text().toTagName(),
key = a.attr("href").substringAfterLast('/'), key = a.attr("href").substringAfterLast('/'),
source = source, source = source,
) )
} }
} }
override suspend fun getUsername(): String { override suspend fun getUsername(): String {
val doc = webClient.httpGet("https://${domain}").parseHtml().body() val doc = webClient.httpGet("https://${domain}").parseHtml().body()
val root = doc.requireElementById("top_user") val root = doc.requireElementById("top_user")
val a = root.getElementsByAttributeValueContaining("href", "/user/").firstOrNull() val a = root.getElementsByAttributeValueContaining("href", "/user/").firstOrNull()
?: throw AuthRequiredException(source) ?: throw AuthRequiredException(source)
return a.attr("href").removeSuffix('/').substringAfterLast('/') return a.attr("href").removeSuffix('/').substringAfterLast('/')
} }
private fun getSortKey(sortOrder: SortOrder) = private fun getSortKey(sortOrder: SortOrder) =
when (sortOrder) { when (sortOrder) {
SortOrder.ALPHABETICAL -> "catalog" SortOrder.ALPHABETICAL -> "catalog"
SortOrder.POPULARITY -> "mostfavorites" SortOrder.POPULARITY -> "mostfavorites"
SortOrder.NEWEST -> "manga/new" SortOrder.NEWEST -> "manga/new"
else -> "mostfavorites" else -> "mostfavorites"
} }
private fun getSortKey2(sortOrder: SortOrder) = private fun getSortKey2(sortOrder: SortOrder) =
when (sortOrder) { when (sortOrder) {
SortOrder.ALPHABETICAL -> "abcasc" SortOrder.ALPHABETICAL -> "abcasc"
SortOrder.POPULARITY -> "favdesc" SortOrder.POPULARITY -> "favdesc"
SortOrder.NEWEST -> "datedesc" SortOrder.NEWEST -> "datedesc"
else -> "favdesc" else -> "favdesc"
} }
private fun String.toTagName() = replace('_', ' ').toTitleCase() private fun String.toTagName() = replace('_', ' ').toTitleCase()
} }

@ -20,284 +20,283 @@ import java.text.SimpleDateFormat
import java.util.* import java.util.*
internal open class MangaLibParser( internal open class MangaLibParser(
context: MangaLoaderContext, context: MangaLoaderContext,
source: MangaSource, source: MangaSource,
) : PagedMangaParser(context, source, pageSize = 60), MangaParserAuthProvider { ) : PagedMangaParser(context, source, pageSize = 60), MangaParserAuthProvider {
override val configKeyDomain = ConfigKey.Domain("mangalib.me", null) override val configKeyDomain = ConfigKey.Domain("mangalib.me", null)
override val authUrl: String override val authUrl: String
get() = "https://${domain}/login" get() = "https://${domain}/login"
override val sortOrders: Set<SortOrder> = EnumSet.of( override val sortOrders: Set<SortOrder> = EnumSet.of(
SortOrder.RATING, SortOrder.RATING,
SortOrder.ALPHABETICAL, SortOrder.ALPHABETICAL,
SortOrder.POPULARITY, SortOrder.POPULARITY,
SortOrder.UPDATED, SortOrder.UPDATED,
SortOrder.NEWEST, SortOrder.NEWEST,
) )
override suspend fun getListPage( override suspend fun getListPage(
page: Int, page: Int,
query: String?, query: String?,
tags: Set<MangaTag>?, tags: Set<MangaTag>?,
sortOrder: SortOrder, sortOrder: SortOrder,
): List<Manga> { ): List<Manga> {
if (!query.isNullOrEmpty()) { if (!query.isNullOrEmpty()) {
return if (page == searchPaginator.firstPage) search(query) else emptyList() return if (page == searchPaginator.firstPage) search(query) else emptyList()
} }
val url = buildString { val url = buildString {
append("https://") append("https://")
append(domain) append(domain)
append("/manga-list?dir=") append("/manga-list?dir=")
append(getSortKey(sortOrder)) append(getSortKey(sortOrder))
append("&page=") append("&page=")
append(page) append(page)
tags?.forEach { tag -> tags?.forEach { tag ->
append("&genres[include][]=") append("&genres[include][]=")
append(tag.key) append(tag.key)
} }
} }
val doc = webClient.httpGet(url).parseHtml() val doc = webClient.httpGet(url).parseHtml()
val root = doc.body().getElementById("manga-list") ?: doc.parseFailed("Root not found") val root = doc.body().getElementById("manga-list") ?: doc.parseFailed("Root not found")
val items = root.selectFirst("div.media-cards-grid")?.select("div.media-card-wrap") val items = root.selectFirst("div.media-cards-grid")?.select("div.media-card-wrap")
?: return emptyList() ?: return emptyList()
return items.mapNotNull { card -> return items.mapNotNull { card ->
val a = card.selectFirst("a.media-card") ?: return@mapNotNull null val a = card.selectFirst("a.media-card") ?: return@mapNotNull null
val href = a.attrAsRelativeUrl("href") val href = a.attrAsRelativeUrl("href")
Manga( Manga(
id = generateUid(href), id = generateUid(href),
title = card.selectFirst("h3")?.text().orEmpty(), title = card.selectFirst("h3")?.text().orEmpty(),
coverUrl = a.absUrl("data-src"), coverUrl = a.absUrl("data-src"),
altTitle = null, altTitle = null,
author = null, author = null,
rating = RATING_UNKNOWN, rating = RATING_UNKNOWN,
url = href, url = href,
publicUrl = href.toAbsoluteUrl(a.host ?: domain), publicUrl = href.toAbsoluteUrl(a.host ?: domain),
tags = emptySet(), tags = emptySet(),
state = null, state = null,
isNsfw = false, isNsfw = false,
source = source, source = source,
) )
} }
} }
override suspend fun getDetails(manga: Manga): Manga { override suspend fun getDetails(manga: Manga): Manga {
val fullUrl = manga.url.toAbsoluteUrl(domain) val fullUrl = manga.url.toAbsoluteUrl(domain)
val doc = webClient.httpGet("$fullUrl?section=info").parseHtml() val doc = webClient.httpGet("$fullUrl?section=info").parseHtml()
val root = doc.body().getElementById("main-page") ?: throw ParseException("Root not found", fullUrl) val root = doc.body().getElementById("main-page") ?: throw ParseException("Root not found", fullUrl)
val title = root.selectFirst("div.media-header__wrap")?.children() val title = root.selectFirst("div.media-header__wrap")?.children()
val info = root.selectFirst("div.media-content") val info = root.selectFirst("div.media-content")
val chaptersDoc = webClient.httpGet("$fullUrl?section=chapters").parseHtml() val chaptersDoc = webClient.httpGet("$fullUrl?section=chapters").parseHtml()
val scripts = chaptersDoc.select("script") val scripts = chaptersDoc.select("script")
val dateFormat = SimpleDateFormat("yyy-MM-dd", Locale.US) val dateFormat = SimpleDateFormat("yyy-MM-dd", Locale.US)
var chapters: ChaptersListBuilder? = null var chapters: ChaptersListBuilder? = null
scripts@ for (script in scripts) { scripts@ for (script in scripts) {
val raw = script.html().lines() val raw = script.html().lines()
for (line in raw) { for (line in raw) {
if (line.startsWith("window.__DATA__")) { if (line.startsWith("window.__DATA__")) {
val json = JSONObject(line.substringAfter('=').substringBeforeLast(';')) val json = JSONObject(line.substringAfter('=').substringBeforeLast(';'))
val list = json.getJSONObject("chapters").getJSONArray("list") val list = json.getJSONObject("chapters").getJSONArray("list")
val id = json.optJSONObject("user")?.getLong("id")?.toString() ?: "not" val id = json.optJSONObject("user")?.getLong("id")?.toString() ?: "not"
val total = list.length() val total = list.length()
chapters = ChaptersListBuilder(total) chapters = ChaptersListBuilder(total)
for (i in 0 until total) { for (i in 0 until total) {
val item = list.getJSONObject(i) val item = list.getJSONObject(i)
val chapterId = item.getLong("chapter_id") val chapterId = item.getLong("chapter_id")
val scanlator = item.getStringOrNull("username") val scanlator = item.getStringOrNull("username")
val url = buildString { val url = buildString {
if (isAuthorized) { if (isAuthorized) {
append(manga.url) append(manga.url)
append("/v") append("/v")
append(item.getInt("chapter_volume")) append(item.getInt("chapter_volume"))
append("/c") append("/c")
append(item.getString("chapter_number")) append(item.getString("chapter_number"))
append("?ui=") append("?ui=")
append(id) append(id)
} else { } else {
append(manga.url) append(manga.url)
append("/v") append("/v")
append(item.getInt("chapter_volume")) append(item.getInt("chapter_volume"))
append("/c") append("/c")
append(item.getString("chapter_number")) append(item.getString("chapter_number"))
} }
} }
val nameChapter = item.getStringOrNull("chapter_name") val nameChapter = item.getStringOrNull("chapter_name")
val volume = item.getInt("chapter_volume") val volume = item.getInt("chapter_volume")
val number = item.getString("chapter_number") val number = item.getString("chapter_number")
val fullNameChapter = "Том $volume. Глава $number" val fullNameChapter = "Том $volume. Глава $number"
chapters.add( chapters.add(
MangaChapter( MangaChapter(
id = generateUid(chapterId), id = generateUid(chapterId),
url = url, url = url,
source = source, source = source,
number = total - i, number = total - i,
uploadDate = dateFormat.tryParse( uploadDate = dateFormat.tryParse(
item.getString("chapter_created_at").substringBefore(" "), item.getString("chapter_created_at").substringBefore(" "),
), ),
scanlator = scanlator, scanlator = scanlator,
branch = null, branch = null,
name = if (nameChapter.isNullOrBlank()) fullNameChapter else "$fullNameChapter - $nameChapter", name = if (nameChapter.isNullOrBlank()) fullNameChapter else "$fullNameChapter - $nameChapter",
), ),
) )
} }
chapters.reverse() chapters.reverse()
break@scripts break@scripts
} }
} }
} }
return manga.copy( return manga.copy(
title = title?.getOrNull(0)?.text()?.takeUnless(String::isBlank) ?: manga.title, title = title?.getOrNull(0)?.text()?.takeUnless(String::isBlank) ?: manga.title,
altTitle = title?.getOrNull(1)?.text()?.substringBefore('/')?.trim(), altTitle = title?.getOrNull(1)?.text()?.substringBefore('/')?.trim(),
rating = root.selectFirst("div.media-stats-item__score") rating = root.selectFirst("div.media-stats-item__score")
?.selectFirst("span") ?.selectFirst("span")
?.text()?.toFloatOrNull()?.div(5f) ?: manga.rating, ?.text()?.toFloatOrNull()?.div(5f) ?: manga.rating,
author = info?.getElementsMatchingOwnText("Автор")?.firstOrNull() author = info?.getElementsMatchingOwnText("Автор")?.firstOrNull()
?.nextElementSibling()?.text() ?: manga.author, ?.nextElementSibling()?.text() ?: manga.author,
tags = info?.selectFirst("div.media-tags") tags = info?.selectFirst("div.media-tags")
?.select("a.media-tag-item")?.mapNotNullToSet { a -> ?.select("a.media-tag-item")?.mapNotNullToSet { a ->
val href = a.attr("href") val href = a.attr("href")
if (href.contains("genres")) { if (href.contains("genres")) {
MangaTag( MangaTag(
title = a.text().toTitleCase(), title = a.text().toTitleCase(),
key = href.substringAfterLast('='), key = href.substringAfterLast('='),
source = source, source = source,
) )
} else null } else null
} ?: manga.tags, } ?: manga.tags,
isNsfw = isNsfw(doc), isNsfw = isNsfw(doc),
description = info?.selectFirst("div.media-description__text")?.html(), description = info?.selectFirst("div.media-description__text")?.html(),
chapters = chapters?.toList(), chapters = chapters?.toList(),
) )
} }
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> { override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val fullUrl = chapter.url.toAbsoluteUrl(domain) val fullUrl = chapter.url.toAbsoluteUrl(domain)
val doc = webClient.httpGet(fullUrl).parseHtml() val doc = webClient.httpGet(fullUrl).parseHtml()
if (doc.location().substringBefore('?').endsWith("/register")) { if (doc.location().substringBefore('?').endsWith("/register")) {
throw AuthRequiredException(source) throw AuthRequiredException(source)
} }
val scripts = doc.head().select("script") val scripts = doc.head().select("script")
val pg = (doc.body().getElementById("pg")?.html() ?: doc.parseFailed("Element #pg not found")) val pg = (doc.body().getElementById("pg")?.html() ?: doc.parseFailed("Element #pg not found"))
.substringAfter('=') .substringAfter('=')
.substringBeforeLast(';') .substringBeforeLast(';')
val pages = JSONArray(pg) val pages = JSONArray(pg)
for (script in scripts) { for (script in scripts) {
val raw = script.html().trim() val raw = script.html().trim()
if (raw.contains("window.__info")) { if (raw.contains("window.__info")) {
val json = JSONObject( val json = JSONObject(
raw.substringAfter("window.__info") raw.substringAfter("window.__info")
.substringAfter('=') .substringAfter('=')
.substringBeforeLast(';'), .substringBeforeLast(';'),
) )
val domain = json.getJSONObject("servers").run { val domain = json.getJSONObject("servers").run {
getStringOrNull("main") ?: getString( getStringOrNull("main") ?: getString(
json.getJSONObject("img").getString("server"), json.getJSONObject("img").getString("server"),
) )
} }
val url = json.getJSONObject("img").getString("url") val url = json.getJSONObject("img").getString("url")
return pages.mapJSON { x -> return pages.mapJSON { x ->
val pageUrl = "$domain/$url${x.getString("u")}" val pageUrl = "$domain/$url${x.getString("u")}"
MangaPage( MangaPage(
id = generateUid(pageUrl), id = generateUid(pageUrl),
url = pageUrl, url = pageUrl,
preview = null, preview = null,
referer = fullUrl, source = source,
source = source, )
) }
} }
} }
} throw ParseException("Script with info not found", fullUrl)
throw ParseException("Script with info not found", fullUrl) }
}
override suspend fun getTags(): Set<MangaTag> { override suspend fun getTags(): Set<MangaTag> {
val url = "https://${domain}/manga-list" val url = "https://${domain}/manga-list"
val doc = webClient.httpGet(url).parseHtml() val doc = webClient.httpGet(url).parseHtml()
val scripts = doc.body().select("script") val scripts = doc.body().select("script")
for (script in scripts) { for (script in scripts) {
val raw = script.html().trim() val raw = script.html().trim()
if (raw.startsWith("window.__DATA")) { if (raw.startsWith("window.__DATA")) {
val json = JSONObject(raw.substringAfter('=').substringBeforeLast(';')) val json = JSONObject(raw.substringAfter('=').substringBeforeLast(';'))
val genres = json.getJSONObject("filters").getJSONArray("genres") val genres = json.getJSONObject("filters").getJSONArray("genres")
val result = ArraySet<MangaTag>(genres.length()) val result = ArraySet<MangaTag>(genres.length())
for (x in genres.JSONIterator()) { for (x in genres.JSONIterator()) {
result += MangaTag( result += MangaTag(
source = source, source = source,
key = x.getInt("id").toString(), key = x.getInt("id").toString(),
title = x.getString("name").toTitleCase(), title = x.getString("name").toTitleCase(),
) )
} }
return result return result
} }
} }
throw ParseException("Script with genres not found", url) throw ParseException("Script with genres not found", url)
} }
override val isAuthorized: Boolean override val isAuthorized: Boolean
get() { get() {
return context.cookieJar.getCookies(domain).any { return context.cookieJar.getCookies(domain).any {
it.name.startsWith("remember_web_") it.name.startsWith("remember_web_")
} }
} }
override suspend fun getUsername(): String { override suspend fun getUsername(): String {
val body = webClient.httpGet("https://${LibConst.LIB_SOCIAL_LINK}/messages").parseHtml().body() val body = webClient.httpGet("https://${LibConst.LIB_SOCIAL_LINK}/messages").parseHtml().body()
if (body.baseUri().endsWith("/login")) { if (body.baseUri().endsWith("/login")) {
throw AuthRequiredException(source) throw AuthRequiredException(source)
} }
return body.selectFirst(".profile-user__username")?.text() ?: body.parseFailed("Cannot find username") return body.selectFirst(".profile-user__username")?.text() ?: body.parseFailed("Cannot find username")
} }
protected open fun isNsfw(doc: Document): Boolean { protected open fun isNsfw(doc: Document): Boolean {
val sidebar = doc.body().run { val sidebar = doc.body().run {
selectFirst(".media-sidebar") ?: selectFirst(".media-info") selectFirst(".media-sidebar") ?: selectFirst(".media-info")
} ?: doc.parseFailed("Sidebar not found") } ?: doc.parseFailed("Sidebar not found")
return sidebar.getElementsContainingOwnText("18+").isNotEmpty() return sidebar.getElementsContainingOwnText("18+").isNotEmpty()
} }
private fun getSortKey(sortOrder: SortOrder?) = when (sortOrder) { private fun getSortKey(sortOrder: SortOrder?) = when (sortOrder) {
SortOrder.RATING -> "desc&sort=rate" SortOrder.RATING -> "desc&sort=rate"
SortOrder.ALPHABETICAL -> "asc&sort=name" SortOrder.ALPHABETICAL -> "asc&sort=name"
SortOrder.POPULARITY -> "desc&sort=views" SortOrder.POPULARITY -> "desc&sort=views"
SortOrder.UPDATED -> "desc&sort=last_chapter_at" SortOrder.UPDATED -> "desc&sort=last_chapter_at"
SortOrder.NEWEST -> "desc&sort=created_at" SortOrder.NEWEST -> "desc&sort=created_at"
else -> "desc&sort=last_chapter_at" else -> "desc&sort=last_chapter_at"
} }
private suspend fun search(query: String): List<Manga> { private suspend fun search(query: String): List<Manga> {
val domain = domain val domain = domain
val json = webClient.httpGet("https://$domain/search?type=manga&q=$query") val json = webClient.httpGet("https://$domain/search?type=manga&q=$query")
.parseJsonArray() .parseJsonArray()
return json.mapJSON { jo -> return json.mapJSON { jo ->
val slug = jo.getString("slug") val slug = jo.getString("slug")
val url = "/$slug" val url = "/$slug"
val covers = jo.getJSONObject("covers") val covers = jo.getJSONObject("covers")
val title = jo.getString("rus_name").ifEmpty { jo.getString("name") } val title = jo.getString("rus_name").ifEmpty { jo.getString("name") }
Manga( Manga(
id = generateUid(url), id = generateUid(url),
url = url, url = url,
publicUrl = "https://$domain/$slug", publicUrl = "https://$domain/$slug",
title = title, title = title,
altTitle = jo.getString("name").takeUnless { it == title }, altTitle = jo.getString("name").takeUnless { it == title },
author = null, author = null,
tags = emptySet(), tags = emptySet(),
rating = jo.getString("rate_avg").toFloatOrNull()?.div(5f) ?: RATING_UNKNOWN, rating = jo.getString("rate_avg").toFloatOrNull()?.div(5f) ?: RATING_UNKNOWN,
state = null, state = null,
isNsfw = false, isNsfw = false,
source = source, source = source,
coverUrl = covers.getString("thumbnail").toAbsoluteUrl(domain), coverUrl = covers.getString("thumbnail").toAbsoluteUrl(domain),
largeCoverUrl = covers.getString("default").toAbsoluteUrl(domain), largeCoverUrl = covers.getString("default").toAbsoluteUrl(domain),
) )
} }
} }
@MangaSourceParser("MANGALIB", "MangaLib", "ru") @MangaSourceParser("MANGALIB", "MangaLib", "ru")
class Impl(context: MangaLoaderContext) : MangaLibParser(context, MangaSource.MANGALIB) class Impl(context: MangaLoaderContext) : MangaLibParser(context, MangaSource.MANGALIB)
object LibConst { object LibConst {
val LIB_SOCIAL_LINK = "lib.social" val LIB_SOCIAL_LINK = "lib.social"
} }
} }

Loading…
Cancel
Save