fix getlist VmpParser, LineWebtoonsParser, BlogTruyenParser, HentaiVNParser, YurinekoParser

add filter on WpComicsParser, ZMangaParser, LxManga, Truyenqq, TruyentranhLHParser

Move NetTruyen to template WpComicsParser and add multitags on WpComicsParser
pull/401/head
devi 2 years ago
parent 6ad78f1cba
commit 56eb059bcd

@ -132,27 +132,11 @@ internal abstract class LineWebtoonsParser(
} }
} }
override suspend fun getList( override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> {
offset: Int, val manga =
query: String?, when (filter) {
tags: Set<MangaTag>?, is MangaListFilter.Search -> {
sortOrder: SortOrder, makeRequest("/lineWebtoon/webtoon/searchChallenge?query=${filter.query.urlEncoded()}&startIndex=${offset + 1}&pageSize=20")
): List<Manga> {
val genre = tags.oneOrThrowIfMany()?.key ?: "ALL"
val sortOrderStr = when (sortOrder) {
SortOrder.UPDATED -> "UPDATE"
SortOrder.POPULARITY -> "READ_COUNT"
SortOrder.RATING -> "LIKEIT"
else -> throw IllegalArgumentException("Unsupported sort order: $sortOrder")
}
val manga = if (query != null) {
if (!tags.isNullOrEmpty()) {
throw IllegalArgumentException("This source does not support search with tags")
}
makeRequest("/lineWebtoon/webtoon/searchChallenge?query=${query.urlEncoded()}&startIndex=${offset + 1}&pageSize=20")
.getJSONObject("challengeSearch") .getJSONObject("challengeSearch")
.getJSONArray("titleList") .getJSONArray("titleList")
.mapJSON { jo -> .mapJSON { jo ->
@ -175,7 +159,19 @@ internal abstract class LineWebtoonsParser(
source = source, source = source,
) )
} }
} else { }
is MangaListFilter.Advanced -> {
val genre = filter.tags.oneOrThrowIfMany()?.key ?: "ALL"
val sortOrderStr = when (filter.sortOrder) {
SortOrder.UPDATED -> "UPDATE"
SortOrder.POPULARITY -> "READ_COUNT"
SortOrder.RATING -> "LIKEIT"
else -> throw IllegalArgumentException("Unsupported sort order: ${filter.sortOrder}")
}
val result = val result =
makeRequest("/lineWebtoon/webtoon/challengeGenreTitleList.json?genre=$genre&sortOrder=$sortOrderStr&startIndex=${offset + 1}&pageSize=20") makeRequest("/lineWebtoon/webtoon/challengeGenreTitleList.json?genre=$genre&sortOrder=$sortOrderStr&startIndex=${offset + 1}&pageSize=20")
@ -210,6 +206,44 @@ internal abstract class LineWebtoonsParser(
} }
} }
null -> {
val result =
makeRequest("/lineWebtoon/webtoon/challengeGenreTitleList.json?genre=ALL&sortOrder=UPDATE&startIndex=${offset + 1}&pageSize=20")
val genres = result.getJSONObject("genreList")
.getJSONArray("challengeGenres")
.mapJSON { jo -> parseTag(jo) }
.associateBy { tag -> tag.key }
result
.getJSONObject("titleList")
.getJSONArray("titles")
.mapJSON { jo ->
val titleNo = jo.getLong("titleNo")
Manga(
id = generateUid(titleNo),
title = jo.getString("title"),
altTitle = null,
url = titleNo.toString(),
publicUrl = "https://$domain/$languageCode/canvas/a/list?title_no=$titleNo",
rating = jo.getFloatOrDefault("starScoreAverage", -10f) / 10f,
isNsfw = jo.getBooleanOrDefault("ageGradeNotice", isNsfwSource),
coverUrl = jo.getString("thumbnail").toAbsoluteUrl(staticDomain),
largeCoverUrl = jo.getStringOrNull("thumbnailVertical")?.toAbsoluteUrl(staticDomain),
tags = setOfNotNull(genres[jo.getString("representGenre")]),
author = jo.getStringOrNull("writingAuthorName"),
description = jo.getString("synopsis"),
// I don't think the API provides this info
state = null,
source = source,
)
}
}
}
return manga return manga
} }
@ -309,10 +343,10 @@ internal abstract class LineWebtoonsParser(
} }
fun makeEncryptUrl(urlBuilder: HttpUrl.Builder) { fun makeEncryptUrl(urlBuilder: HttpUrl.Builder) {
val msgpad = Calendar.getInstance().timeInMillis.toString() val msgPad = Calendar.getInstance().timeInMillis.toString()
val digest = getMessageDigest(getMessage(urlBuilder.build().toString(), msgpad)) val digest = getMessageDigest(getMessage(urlBuilder.build().toString(), msgPad))
urlBuilder urlBuilder
.addQueryParameter("msgpad", msgpad) .addQueryParameter("msgpad", msgPad)
.addQueryParameter("md", digest) .addQueryParameter("md", digest)
// .addEncodedQueryParameter("md", digest.urlEncoded()) // .addEncodedQueryParameter("md", digest.urlEncoded())
} }

@ -180,15 +180,16 @@ internal abstract class MadaraParser(
is MangaListFilter.Advanced -> { is MangaListFilter.Advanced -> {
val tag = filter.tags.oneOrThrowIfMany()
if (filter.tags.isNotEmpty()) { if (filter.tags.isNotEmpty()) {
filter.tags.oneOrThrowIfMany()?.let {
append("/$tagPrefix") append("/$tagPrefix")
append(tag?.key.orEmpty()) append(it.key)
if (pages > 1) { if (pages > 1) {
append("/page/") append("/page/")
append(pages.toString()) append(pages.toString())
} }
append("/?") append("/?")
}
} else { } else {
if (pages > 1) { if (pages > 1) {
@ -241,8 +242,9 @@ internal abstract class MadaraParser(
is MangaListFilter.Advanced -> { is MangaListFilter.Advanced -> {
val tag = filter.tags.oneOrThrowIfMany() filter.tags.oneOrThrowIfMany()?.let {
payload["vars[wp-manga-genre]"] = tag?.key.orEmpty() payload["vars[wp-manga-genre]"] = it.key
}
when (filter.sortOrder) { when (filter.sortOrder) {
SortOrder.POPULARITY -> payload["vars[meta_key]"] = "_wp_manga_views" SortOrder.POPULARITY -> payload["vars[meta_key]"] = "_wp_manga_views"

@ -29,98 +29,38 @@ class BlogTruyenParser(context: MangaLoaderContext) :
.add("User-Agent", UserAgents.CHROME_DESKTOP) .add("User-Agent", UserAgents.CHROME_DESKTOP)
.build() .build()
override val isMultipleTagsSupported = false
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 = SuspendLazy(::fetchTags) private var cacheTags = SuspendLazy(::fetchTags)
override suspend fun getDetails(manga: Manga): Manga {
val doc = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml()
val descriptionElement = doc.selectFirstOrThrow("div.description")
val statusText = descriptionElement
.selectFirst("p:contains(Trạng thái) > span.color-red")
?.text()
val state = when (statusText) {
"Đang tiến hành" -> MangaState.ONGOING
"Đã hoàn thành" -> MangaState.FINISHED
else -> null
}
val rating = doc.selectFirst("span.total-vote")?.attr("ng-init")?.let { text ->
val like = text.substringAfter("TotalLike=")
.substringBefore(';')
.toIntOrNull() ?: return@let RATING_UNKNOWN
val dislike = text.substringAfter("TotalDisLike=")
.toIntOrNull() ?: return@let RATING_UNKNOWN
when { override suspend fun getListPage(page: Int, filter: MangaListFilter?): List<Manga> {
like == 0 && dislike == 0 -> RATING_UNKNOWN
else -> like.toFloat() / (like + dislike)
}
}
val tags = cacheTags.tryGet().getOrNull()?.let { tagMap ->
descriptionElement.select("p > span.category").mapNotNullToSet {
val tagName = it.selectFirst("a")?.text()?.trim() ?: return@mapNotNullToSet null
tagMap[tagName]
}
}
return manga.copy(
tags = tags ?: emptySet(),
author = descriptionElement.selectFirst("p:contains(Tác giả) > a")?.text(),
description = doc.selectFirst(".detail .content")?.html(),
chapters = parseChapterList(doc),
largeCoverUrl = doc.selectLast("div.thumbnail > img")?.imageUrl(),
state = state,
rating = rating ?: RATING_UNKNOWN,
isNsfw = doc.getElementById("warningCategory") != null,
)
}
private fun parseChapterList(doc: Document): List<MangaChapter> { return when (filter) {
val chapterList = doc.select("#list-chapters > p")
return chapterList.mapChapters(reversed = true) { index, element ->
val titleElement = element.selectFirst("span.title > a") ?: return@mapChapters null
val name = titleElement.text()
val relativeUrl = titleElement.attrAsRelativeUrl("href")
val id = relativeUrl.substringAfter('/').substringBefore('/')
val uploadDate = dateFormat.tryParse(element.select("span.publishedDate").text())
MangaChapter(
id = generateUid(id),
name = name,
number = index + 1,
url = relativeUrl,
scanlator = null,
uploadDate = uploadDate,
branch = null,
source = source,
)
}
}
override suspend fun getListPage( is MangaListFilter.Search -> {
page: Int, val searchUrl = "https://${domain}/timkiem/nangcao/1/0/-1/-1?txt=${filter.query.urlEncoded()}&p=$page"
query: String?,
tags: Set<MangaTag>?,
sortOrder: SortOrder,
): List<Manga> {
return when {
!query.isNullOrEmpty() -> {
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() -> { is MangaListFilter.Advanced -> {
val tag = tags.oneOrThrowIfMany()!!
if (filter.tags.isNotEmpty()) {
filter.tags.oneOrThrowIfMany().let {
val categoryAjax = val categoryAjax =
"https://${domain}/ajax/Category/AjaxLoadMangaByCategory?id=${tag.key}&orderBy=5&p=$page" "https://${domain}/ajax/Category/AjaxLoadMangaByCategory?id=${it?.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) null -> getNormalList(page)
} }
} }
@ -146,7 +86,7 @@ class BlogTruyenParser(context: MangaLoaderContext) :
description = el.selectFirst("p.al-j.break.line-height-15")?.text(), description = el.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")?.src().orEmpty(),
source = source, source = source,
tags = tags ?: emptySet(), tags = tags ?: emptySet(),
isNsfw = false, isNsfw = false,
@ -171,7 +111,7 @@ class BlogTruyenParser(context: MangaLoaderContext) :
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")?.src().orEmpty(),
isNsfw = false, isNsfw = false,
rating = RATING_UNKNOWN, rating = RATING_UNKNOWN,
tags = emptySet(), tags = emptySet(),
@ -182,6 +122,91 @@ class BlogTruyenParser(context: MangaLoaderContext) :
} }
} }
override suspend fun getAvailableTags(): Set<MangaTag> {
return cacheTags.get().values.toSet()
}
private suspend fun fetchTags(): Map<String, MangaTag> {
val doc = webClient.httpGet("/timkiem/nangcao".toAbsoluteUrl(domain)).parseHtml()
val tagItems = doc.select("li[data-id]")
val tagMap = ArrayMap<String, MangaTag>(tagItems.size)
for (tag in tagItems) {
val title = tag.text().trim()
tagMap[tag.text().trim()] = MangaTag(
title = title,
key = tag.attr("data-id"),
source = source,
)
}
return tagMap
}
override suspend fun getDetails(manga: Manga): Manga {
val doc = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml()
val descriptionElement = doc.selectFirstOrThrow("div.description")
val statusText = descriptionElement
.selectFirst("p:contains(Trạng thái) > span.color-red")
?.text()
val state = when (statusText) {
"Đang tiến hành" -> MangaState.ONGOING
"Đã hoàn thành" -> MangaState.FINISHED
else -> null
}
val rating = doc.selectFirst("span.total-vote")?.attr("ng-init")?.let { text ->
val like = text.substringAfter("TotalLike=")
.substringBefore(';')
.toIntOrNull() ?: return@let RATING_UNKNOWN
val dislike = text.substringAfter("TotalDisLike=")
.toIntOrNull() ?: return@let RATING_UNKNOWN
when {
like == 0 && dislike == 0 -> RATING_UNKNOWN
else -> like.toFloat() / (like + dislike)
}
}
val tags = cacheTags.tryGet().getOrNull()?.let { tagMap ->
descriptionElement.select("p > span.category").mapNotNullToSet {
val tagName = it.selectFirst("a")?.text()?.trim() ?: return@mapNotNullToSet null
tagMap[tagName]
}
}
return manga.copy(
tags = tags ?: emptySet(),
author = descriptionElement.selectFirst("p:contains(Tác giả) > a")?.text(),
description = doc.selectFirst(".detail .content")?.html(),
chapters = parseChapterList(doc),
largeCoverUrl = doc.selectLast("div.thumbnail > img")?.src().orEmpty(),
state = state,
rating = rating ?: RATING_UNKNOWN,
isNsfw = doc.getElementById("warningCategory") != null,
)
}
private fun parseChapterList(doc: Document): List<MangaChapter> {
val chapterList = doc.select("#list-chapters > p")
return chapterList.mapChapters(reversed = true) { index, element ->
val titleElement = element.selectFirst("span.title > a") ?: return@mapChapters null
val name = titleElement.text()
val relativeUrl = titleElement.attrAsRelativeUrl("href")
val id = relativeUrl.substringAfter('/').substringBefore('/')
val uploadDate = dateFormat.tryParse(element.select("span.publishedDate").text())
MangaChapter(
id = generateUid(id),
name = name,
number = index + 1,
url = relativeUrl,
scanlator = null,
uploadDate = uploadDate,
branch = null,
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")
@ -191,7 +216,7 @@ class BlogTruyenParser(context: MangaLoaderContext) :
pages.add( pages.add(
MangaPage( MangaPage(
id = generateImageId(pages.size), id = generateImageId(pages.size),
url = img.imageUrl(), url = img.src().orEmpty(),
preview = null, preview = null,
source = source, source = source,
), ),
@ -218,30 +243,4 @@ class BlogTruyenParser(context: MangaLoaderContext) :
return pages return pages
} }
override suspend fun getAvailableTags(): Set<MangaTag> {
return cacheTags.get().values.toSet()
}
private suspend fun fetchTags(): Map<String, MangaTag> {
val doc = webClient.httpGet("/timkiem/nangcao".toAbsoluteUrl(domain)).parseHtml()
val tagItems = doc.select("li[data-id]")
val tagMap = ArrayMap<String, MangaTag>(tagItems.size)
for (tag in tagItems) {
val title = tag.text().trim()
tagMap[tag.text().trim()] = MangaTag(
title = title,
key = tag.attr("data-id"),
source = source,
)
}
return tagMap
}
private fun Element.imageUrl(): String {
return attrAsAbsoluteUrlOrNull("src")
?: attrAsAbsoluteUrlOrNull("data-cfsrc")
?: ""
}
} }

@ -35,6 +35,57 @@ class HentaiVNParser(context: MangaLoaderContext) : MangaParser(context, MangaSo
SortOrder.NEWEST, SortOrder.NEWEST,
) )
override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> {
return when (filter) {
is MangaListFilter.Search -> {
val page = (offset / PAGE_SIZE.toFloat()).toIntUp() + 1
urlBuilder()
val searchUrl =
"/tim-kiem-truyen.html?key=${filter.query.urlEncoded()}&page=$page".toAbsoluteUrl(domain)
val docs = webClient.httpGet(searchUrl).parseHtml()
parseMainList(docs, page)
}
is MangaListFilter.Advanced -> {
val pageSize = if (filter.tags.isEmpty()) PAGE_SIZE else SEARCH_PAGE_SIZE
val page = (offset / pageSize.toFloat()).toIntUp() + 1
if (filter.tags.isNotEmpty()) {
val url = buildString {
val tagKey = "tag[]".urlEncoded()
append("/forum/search-plus.php?name=")
append("&dou=&char=")
filter.tags.forEach { tag ->
append("&")
append(tagKey)
append("=")
append(tag.key)
}
append("&search=")
append("&page=")
append(page)
}.toAbsoluteUrl(domain)
val docs = webClient.httpGet(url).parseHtml()
return parseAdvanceSearch(docs, page)
} else {
val site = if (filter.sortOrder == SortOrder.UPDATED) "/chap-moi" else "/danh-sach"
val url = "$site.html?page=$page".toAbsoluteUrl(domain)
context.cookieJar.insertCookies(domain, *getSortCookies(filter.sortOrder))
val docs = webClient.httpGet(url).parseHtml()
parseMainList(docs, page)
}
}
null -> {
val page = (offset / PAGE_SIZE.toFloat()).toIntUp() + 1
val url = "/chap-moi.html?page=$page".toAbsoluteUrl(domain)
parseMainList(webClient.httpGet(url).parseHtml(), page)
}
}
}
override suspend fun getDetails(manga: Manga): Manga = coroutineScope { override suspend fun getDetails(manga: Manga): Manga = coroutineScope {
val chapterDeferred = async { fetchChapters(manga.url) } val chapterDeferred = async { fetchChapters(manga.url) }
val docs = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml() val docs = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml()
@ -79,48 +130,6 @@ class HentaiVNParser(context: MangaLoaderContext) : MangaParser(context, MangaSo
) )
} }
override suspend fun getList(offset: Int, query: String?, tags: Set<MangaTag>?, sortOrder: SortOrder): List<Manga> {
val pageSize = if (tags.isNullOrEmpty()) PAGE_SIZE else SEARCH_PAGE_SIZE
val page = (offset / pageSize.toFloat()).toIntUp() + 1
return when {
!tags.isNullOrEmpty() -> {
val url = buildString {
val tagKey = "tag[]".urlEncoded()
append("/forum/search-plus.php?name=")
append(query?.urlEncoded().orEmpty())
append("&dou=&char=")
tags.forEach { tag ->
append("&")
append(tagKey)
append("=")
append(tag.key)
}
append("&search=")
append("&page=")
append(page)
}.toAbsoluteUrl(domain)
val docs = webClient.httpGet(url).parseHtml()
return parseAdvanceSearch(docs, page)
}
!query.isNullOrEmpty() -> {
urlBuilder()
val searchUrl = "/tim-kiem-truyen.html?key=${query.urlEncoded()}&page=$page".toAbsoluteUrl(domain)
val docs = webClient.httpGet(searchUrl).parseHtml()
parseMainList(docs, page)
}
else -> {
val site = if (sortOrder == SortOrder.UPDATED) "/chap-moi" else "/danh-sach"
val url = "$site.html?page=$page".toAbsoluteUrl(domain)
context.cookieJar.insertCookies(domain, *getSortCookies(sortOrder))
val docs = webClient.httpGet(url).parseHtml()
parseMainList(docs, page)
}
}
}
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> { override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val docs = webClient.httpGet(chapter.url.toAbsoluteUrl(domain)).parseHtml() val docs = webClient.httpGet(chapter.url.toAbsoluteUrl(domain)).parseHtml()
return docs.select("#image > img").map { return docs.select("#image > img").map {

@ -20,56 +20,75 @@ internal class LxManga(context: MangaLoaderContext) : PagedMangaParser(context,
SortOrder.NEWEST, SortOrder.NEWEST,
SortOrder.POPULARITY, SortOrder.POPULARITY,
) )
override val availableStates: Set<MangaState> = EnumSet.of(MangaState.ONGOING, MangaState.FINISHED)
override val configKeyDomain = ConfigKey.Domain("lxmanga.net") override val configKeyDomain = ConfigKey.Domain("lxmanga.net")
override val isMultipleTagsSupported = false
override val headers: Headers = Headers.Builder() override val headers: Headers = Headers.Builder()
.add("User-Agent", UserAgents.CHROME_DESKTOP) .add("User-Agent", UserAgents.CHROME_DESKTOP)
.build() .build()
override suspend fun getListPage( override suspend fun getListPage(page: Int, filter: MangaListFilter?): List<Manga> {
page: Int,
query: String?,
tags: Set<MangaTag>?,
sortOrder: SortOrder,
): List<Manga> {
val url = buildString { val url = buildString {
append("https://") append("https://")
append(domain) append(domain)
when {
!query.isNullOrEmpty() -> { when (filter) {
is MangaListFilter.Search -> {
val skey = "filter[name]=".urlEncoded() val skey = "filter[name]=".urlEncoded()
append("/tim-kiem?$skey") append("/tim-kiem?$skey")
append(query.urlEncoded()) append(filter.query.urlEncoded())
append("&page=")
append(page.toString())
} }
!tags.isNullOrEmpty() -> { is MangaListFilter.Advanced -> {
if (filter.tags.isNotEmpty()) {
filter.tags.oneOrThrowIfMany()?.let {
append("/the-loai/") append("/the-loai/")
for (tag in tags) { append(it.key)
append(tag.key)
}
} }
} else {
else -> {
append("/danh-sach") append("/danh-sach")
} }
}
append("?page=") append("?page=")
append(page.toString()) append(page.toString())
if (filter.states.isNotEmpty()) {
append("&filter[status]=")
filter.states.forEach {
append(
when (it) {
MangaState.ONGOING -> "2,"
MangaState.FINISHED -> "1,"
else -> "1,2"
},
)
}
}
append("&sort=") append("&sort=")
when (sortOrder) { when (filter.sortOrder) {
SortOrder.POPULARITY -> append("-views") SortOrder.POPULARITY -> append("-views")
SortOrder.UPDATED -> append("-updated_at") SortOrder.UPDATED -> append("-updated_at")
SortOrder.NEWEST -> append("-created_at") SortOrder.NEWEST -> append("-created_at")
SortOrder.ALPHABETICAL -> append("name") SortOrder.ALPHABETICAL -> append("name")
else -> append("-updated_at") else -> append("-updated_at")
} }
} }
null -> {
append("/danh-sach?sort=-updated_at&page=")
append(page.toString())
}
}
val doc = webClient.httpGet(url).parseHtml() }
val doc = webClient.httpGet(url).parseHtml()
return doc.select("div.grid div.manga-vertical") return doc.select("div.grid div.manga-vertical")
.map { div -> .map { div ->
@ -95,7 +114,6 @@ internal class LxManga(context: MangaLoaderContext) : PagedMangaParser(context,
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()
val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US) val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
return manga.copy( return manga.copy(
altTitle = root.select(".divider2:contains(Noms associés :)").firstOrNull()?.text(), altTitle = root.select(".divider2:contains(Noms associés :)").firstOrNull()?.text(),
state = when (root.select("div.grow div.mt-2:contains(Tình trạng) a").first()!!.text()) { state = when (root.select("div.grow div.mt-2:contains(Tình trạng) a").first()!!.text()) {
@ -135,9 +153,7 @@ internal class LxManga(context: MangaLoaderContext) : PagedMangaParser(context,
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()
return doc.select("div.text-center img.lazy").map { img -> return doc.select("div.text-center img.lazy").map { img ->
val url = img.attrAsRelativeUrlOrNull("data-src") ?: img.attrAsRelativeUrlOrNull("src") val url = img.attrAsRelativeUrlOrNull("data-src") ?: img.attrAsRelativeUrlOrNull("src")
?: img.parseFailed("Image src not found") ?: img.parseFailed("Image src not found")

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

@ -14,40 +14,70 @@ internal class Truyenqq(context: MangaLoaderContext) : PagedMangaParser(context,
override val availableSortOrders: Set<SortOrder> = override val availableSortOrders: Set<SortOrder> =
EnumSet.of(SortOrder.UPDATED, SortOrder.POPULARITY, SortOrder.NEWEST) EnumSet.of(SortOrder.UPDATED, SortOrder.POPULARITY, SortOrder.NEWEST)
override val availableStates: Set<MangaState> = EnumSet.of(MangaState.ONGOING, MangaState.FINISHED)
override val configKeyDomain = ConfigKey.Domain("truyenqqvn.com") override val configKeyDomain = ConfigKey.Domain("truyenqqvn.com")
override suspend fun getListPage( override suspend fun getListPage(page: Int, filter: MangaListFilter?): List<Manga> {
page: Int,
query: String?, val url =
tags: Set<MangaTag>?, when (filter) {
sortOrder: SortOrder, is MangaListFilter.Search -> {
): List<Manga> {
val tagQuery = if (tags.isNullOrEmpty()) "" else tags.joinToString(separator = ",") { it.key }
val url = if (!query.isNullOrEmpty()) {
buildString { buildString {
append("https://") append("https://")
append(domain) append(domain)
append("/tim-kiem/trang-$page.html") append("/tim-kiem/trang-$page.html")
append("?q=") append("?q=")
append(query.urlEncoded()) append(filter.query.urlEncoded())
} }
} else { }
is MangaListFilter.Advanced -> {
buildString { buildString {
append("https://") append("https://")
append(domain) append(domain)
append("/tim-kiem-nang-cao/trang-$page.html") append("/tim-kiem-nang-cao/trang-")
append("?status=-1&country=0&sort=") append(page.toString())
when (sortOrder) { append(".html?country=0&sort=")
when (filter.sortOrder) {
SortOrder.POPULARITY -> append("4") SortOrder.POPULARITY -> append("4")
SortOrder.UPDATED -> append("2") SortOrder.UPDATED -> append("2")
SortOrder.NEWEST -> append("0") SortOrder.NEWEST -> append("0")
else -> append("2") else -> append("2")
} }
if (filter.states.isNotEmpty()) {
filter.states.oneOrThrowIfMany()?.let {
append("&status=")
append(
when (it) {
MangaState.ONGOING -> "0"
MangaState.FINISHED -> "1"
else -> "-1"
},
)
}
} else {
append("&status=-1")
}
append("&category=") append("&category=")
append(tagQuery) append(filter.tags.joinToString(separator = ",") { it.key })
append("&notcategory=&minchapter=0") append("&notcategory=&minchapter=0")
} }
} }
null -> {
buildString {
append("https://")
append(domain)
append("/tim-kiem-nang-cao/trang-")
append(page.toString())
append(".html?status=-1&country=0&sort=2&category=&notcategory=&minchapter=0")
}
}
}
val doc = webClient.httpGet(url).parseHtml() val doc = webClient.httpGet(url).parseHtml()
return doc.requireElementById("main_homepage").select("li").map { li -> return doc.requireElementById("main_homepage").select("li").map { li ->
val href = li.selectFirstOrThrow("a").attrAsRelativeUrl("href") val href = li.selectFirstOrThrow("a").attrAsRelativeUrl("href")

@ -19,10 +19,84 @@ class TruyentranhLHParser(context: MangaLoaderContext) :
override val configKeyDomain: ConfigKey.Domain = ConfigKey.Domain("truyentranhlh.net") override val configKeyDomain: ConfigKey.Domain = ConfigKey.Domain("truyentranhlh.net")
override val availableSortOrders: Set<SortOrder> = EnumSet.allOf(SortOrder::class.java) override val availableSortOrders: Set<SortOrder> = EnumSet.allOf(SortOrder::class.java)
override val availableStates: Set<MangaState> =
EnumSet.of(MangaState.ONGOING, MangaState.FINISHED, MangaState.PAUSED)
private val mutex = Mutex() private val mutex = Mutex()
private var tagCache: Map<String, MangaTag>? = null private var tagCache: Map<String, MangaTag>? = null
override suspend fun getListPage(page: Int, filter: MangaListFilter?): List<Manga> {
val url = urlBuilder().apply {
addPathSegment("tim-kiem")
addQueryParameter("page", page.toString())
when (filter) {
is MangaListFilter.Search -> {
addQueryParameter("q", filter.query)
}
is MangaListFilter.Advanced -> {
addQueryParameter(
"sort",
when (filter.sortOrder) {
SortOrder.UPDATED -> "update"
SortOrder.NEWEST -> "new"
SortOrder.RATING -> "like"
SortOrder.POPULARITY -> "top"
SortOrder.ALPHABETICAL -> "az"
},
)
if (filter.states.isNotEmpty()) {
filter.states.oneOrThrowIfMany()?.let {
addQueryParameter(
"status",
when (it) {
MangaState.ONGOING -> "1"
MangaState.FINISHED -> "3"
MangaState.PAUSED -> "2"
else -> "0"
},
)
}
}
if (filter.tags.isNotEmpty()) {
val tagsQuery = filter.tags.joinToString(separator = ",") { it.key }
addEncodedQueryParameter("accept_genres", tagsQuery)
}
}
null -> {
addQueryParameter("sort", "update")
}
}
}.build()
return webClient.httpGet(url).parseHtml()
.select(".container .card.card-dark .row > .thumb-item-flow")
.mapNotNull {
val a = it.selectFirstOrThrow(".thumb-wrapper a")
Manga(
id = generateUid(a.attrAsRelativeUrl("href")),
url = a.attrAsRelativeUrl("href"),
publicUrl = a.attrAsAbsoluteUrl("href"),
title = it.select(".thumb_attr.series-title").text(),
altTitle = null,
rating = RATING_UNKNOWN,
isNsfw = false,
coverUrl = a.selectFirst("div[data-bg]")?.attrAsAbsoluteUrl("data-bg").orEmpty(),
tags = emptySet(),
state = null,
author = null,
source = source,
)
}
}
override suspend fun getDetails(manga: Manga): Manga { override suspend fun getDetails(manga: Manga): Manga {
val docs = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml() val docs = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml()
val infoHeaderEl = docs.selectFirst("main.section-body") val infoHeaderEl = docs.selectFirst("main.section-body")
@ -33,6 +107,7 @@ class TruyentranhLHParser(context: MangaLoaderContext) :
val state = when (infoEl?.selectFirst(".info-item:contains(Tình trạng) > .info-value")?.text()) { val state = when (infoEl?.selectFirst(".info-item:contains(Tình trạng) > .info-value")?.text()) {
"Đ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
"Tạm ngưng" -> MangaState.PAUSED
else -> null else -> null
} }
val rating = infoHeaderEl?.let { val rating = infoHeaderEl?.let {
@ -68,53 +143,6 @@ class TruyentranhLHParser(context: MangaLoaderContext) :
) )
} }
override suspend fun getListPage(
page: Int,
query: String?,
tags: Set<MangaTag>?,
sortOrder: SortOrder,
): List<Manga> {
val sortQuery = when (sortOrder) {
SortOrder.UPDATED -> "update"
SortOrder.NEWEST -> "new"
SortOrder.RATING -> "like"
SortOrder.POPULARITY -> "top"
SortOrder.ALPHABETICAL -> "az"
}
val url = urlBuilder().apply {
addPathSegment("tim-kiem")
addQueryParameter("sort", sortQuery)
addQueryParameter("page", page.toString())
if (!query.isNullOrEmpty()) {
addQueryParameter("q", query)
}
if (!tags.isNullOrEmpty()) {
val tagsQuery = tags.joinToString(separator = ",") { it.key }
addEncodedQueryParameter("accept_genres", tagsQuery)
}
}.build()
return webClient.httpGet(url).parseHtml()
.select(".container .card.card-dark .row > .thumb-item-flow")
.mapNotNull {
val a = it.selectFirstOrThrow(".thumb-wrapper a")
Manga(
id = generateUid(a.attrAsRelativeUrl("href")),
url = a.attrAsRelativeUrl("href"),
publicUrl = a.attrAsAbsoluteUrl("href"),
title = it.select(".thumb_attr.series-title").text(),
altTitle = null,
rating = RATING_UNKNOWN,
isNsfw = false,
coverUrl = a.selectFirst("div[data-bg]")?.attrAsAbsoluteUrl("data-bg").orEmpty(),
tags = emptySet(),
state = null,
author = null,
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)
return webClient.httpGet(url).parseHtml().select("#chapter-content > img").mapNotNull { return webClient.httpGet(url).parseHtml().select("#chapter-content > img").mapNotNull {

@ -5,30 +5,12 @@ import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaSourceParser import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.PagedMangaParser import org.koitharu.kotatsu.parsers.PagedMangaParser
import org.koitharu.kotatsu.parsers.config.ConfigKey import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.model.ContentType import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.RATING_UNKNOWN
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.domain import org.koitharu.kotatsu.parsers.util.domain
import org.koitharu.kotatsu.parsers.util.generateUid import org.koitharu.kotatsu.parsers.util.generateUid
import org.koitharu.kotatsu.parsers.util.json.asIterable import org.koitharu.kotatsu.parsers.util.json.*
import org.koitharu.kotatsu.parsers.util.json.getStringOrNull
import org.koitharu.kotatsu.parsers.util.json.mapJSON
import org.koitharu.kotatsu.parsers.util.json.mapJSONToSet
import org.koitharu.kotatsu.parsers.util.json.toJSONList
import org.koitharu.kotatsu.parsers.util.mapChapters import org.koitharu.kotatsu.parsers.util.mapChapters
import org.koitharu.kotatsu.parsers.util.parseHtml import org.koitharu.kotatsu.parsers.util.*
import org.koitharu.kotatsu.parsers.util.parseJson
import org.koitharu.kotatsu.parsers.util.parseJsonArray
import org.koitharu.kotatsu.parsers.util.requireElementById
import org.koitharu.kotatsu.parsers.util.toAbsoluteUrl
import org.koitharu.kotatsu.parsers.util.tryParse
import org.koitharu.kotatsu.parsers.util.urlEncoded
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.EnumSet import java.util.EnumSet
import java.util.Locale import java.util.Locale
@ -44,44 +26,24 @@ class YurinekoParser(context: MangaLoaderContext) : PagedMangaParser(context, Ma
private val apiDomain private val apiDomain
get() = "api.$domain" get() = "api.$domain"
override suspend fun getDetails(manga: Manga): Manga { override suspend fun getListPage(page: Int, filter: MangaListFilter?): List<Manga> {
val response = webClient.httpGet(manga.url.toAbsoluteUrl(apiDomain)).parseJson() val listUrl =
val df = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US) when (filter) {
return manga.copy(
chapters = response.getJSONArray("chapters") is MangaListFilter.Search -> {
.toJSONList() "/search?query=${filter.query.urlEncoded()}&page=$page"
.mapChapters(true) { i, jo ->
val mangaId = jo.getInt("mangaID")
val chapterId = jo.getInt("id")
MangaChapter(
id = generateUid(chapterId.toLong()),
name = jo.getString("name"),
number = i + 1,
scanlator = null,
url = "/read/$mangaId/$chapterId",
uploadDate = df.tryParse(jo.getString("date")),
branch = null,
source = source,
)
},
)
} }
override suspend fun getListPage( is MangaListFilter.Advanced -> {
page: Int, if (filter.tags.isNotEmpty()) {
query: String?, val tagKeys = filter.tags.joinToString(separator = ",") { it.key }
tags: Set<MangaTag>?,
sortOrder: SortOrder,
): List<Manga> {
val listUrl = when {
!query.isNullOrEmpty() -> "/search?query=${query.urlEncoded()}&page=$page"
tags.isNullOrEmpty() -> "/lastest2?page=$page"
tags.size == 1 -> "/searchType?type=tag&id=${tags.first().key}&page=$page"
else -> {
// Sort order is different when filter with multiple tags
val tagKeys = tags.joinToString(separator = ",") { it.key }
"/advancedSearch?genre=$tagKeys&notGenre=&sort=7&minChapter=1&status=0&page=$page" "/advancedSearch?genre=$tagKeys&notGenre=&sort=7&minChapter=1&status=0&page=$page"
} else {
"/lastest2?page=$page"
}
} }
null -> "/lastest2?page=$page"
} }
val jsonResponse = webClient.httpGet(listUrl.toAbsoluteUrl(apiDomain)).parseJson() val jsonResponse = webClient.httpGet(listUrl.toAbsoluteUrl(apiDomain)).parseJson()
return jsonResponse.getJSONArray("result") return jsonResponse.getJSONArray("result")
@ -119,6 +81,29 @@ class YurinekoParser(context: MangaLoaderContext) : PagedMangaParser(context, Ma
} }
} }
override suspend fun getDetails(manga: Manga): Manga {
val response = webClient.httpGet(manga.url.toAbsoluteUrl(apiDomain)).parseJson()
val df = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US)
return manga.copy(
chapters = response.getJSONArray("chapters")
.toJSONList()
.mapChapters(true) { i, jo ->
val mangaId = jo.getInt("mangaID")
val chapterId = jo.getInt("id")
MangaChapter(
id = generateUid(chapterId.toLong()),
name = jo.getString("name"),
number = i + 1,
scanlator = null,
url = "/read/$mangaId/$chapterId",
uploadDate = df.tryParse(jo.getString("date")),
branch = null,
source = source,
)
},
)
}
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> { override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val jsonData = webClient.httpGet(chapter.url.toAbsoluteUrl(domain)).parseHtml() val jsonData = webClient.httpGet(chapter.url.toAbsoluteUrl(domain)).parseHtml()
.requireElementById("__NEXT_DATA__") .requireElementById("__NEXT_DATA__")

@ -16,8 +16,11 @@ internal abstract class VmpParser(
) : PagedMangaParser(context, source, pageSize) { ) : PagedMangaParser(context, source, pageSize) {
override val configKeyDomain = ConfigKey.Domain(domain) override val configKeyDomain = ConfigKey.Domain(domain)
override val availableSortOrders: Set<SortOrder> = EnumSet.of(SortOrder.UPDATED) override val availableSortOrders: Set<SortOrder> = EnumSet.of(SortOrder.UPDATED)
override val isMultipleTagsSupported = false
protected open val listUrl = "xxx/" protected open val listUrl = "xxx/"
protected open val geneUrl = "genero/" protected open val geneUrl = "genero/"
@ -26,27 +29,42 @@ internal abstract class VmpParser(
searchPaginator.firstPage = 1 searchPaginator.firstPage = 1
} }
override suspend fun getListPage( override suspend fun getListPage(page: Int, filter: MangaListFilter?): List<Manga> {
page: Int,
query: String?,
tags: Set<MangaTag>?,
sortOrder: SortOrder,
): List<Manga> {
val tag = tags.oneOrThrowIfMany()
val url = buildString { val url = buildString {
append("https://$domain/") append("https://")
if (!tags.isNullOrEmpty()) { append(domain)
append('/')
when (filter) {
is MangaListFilter.Search -> {
append(listUrl)
append("/page/")
append(page.toString())
append("?s=")
append(filter.query.urlEncoded())
}
is MangaListFilter.Advanced -> {
if (filter.tags.isNotEmpty()) {
filter.tags.oneOrThrowIfMany()?.let {
append(geneUrl) append(geneUrl)
append(tag?.key.orEmpty()) append(it.key)
append("/page/") append("/page/")
append(page.toString()) append(page.toString())
}
} else { } else {
append(listUrl) append(listUrl)
append("/page/") append("/page/")
append(page.toString()) append(page.toString())
if (!query.isNullOrEmpty()) { }
append("?s=") }
append(query.urlEncoded())
null -> {
append(listUrl)
append("/page/")
append(page.toString())
} }
} }
} }

@ -1,11 +1,16 @@
package org.koitharu.kotatsu.parsers.site.wpcomics package org.koitharu.kotatsu.parsers.site.wpcomics
import androidx.collection.ArrayMap
import androidx.collection.ArraySet
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.jsoup.nodes.Document import org.jsoup.nodes.Document
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.PagedMangaParser import org.koitharu.kotatsu.parsers.PagedMangaParser
import org.koitharu.kotatsu.parsers.config.ConfigKey import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.model.* import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.util.* import org.koitharu.kotatsu.parsers.util.*
import java.text.DateFormat import java.text.DateFormat
@ -21,15 +26,16 @@ internal abstract class WpComicsParser(
override val configKeyDomain = ConfigKey.Domain(domain) override val configKeyDomain = ConfigKey.Domain(domain)
override val isMultipleTagsSupported = false
override val availableSortOrders: Set<SortOrder> = EnumSet.of( override val availableSortOrders: Set<SortOrder> = EnumSet.of(
SortOrder.UPDATED, SortOrder.UPDATED,
SortOrder.NEWEST, SortOrder.NEWEST,
SortOrder.POPULARITY, SortOrder.POPULARITY,
SortOrder.RATING,
) )
protected open val listUrl = "/the-loai" override val availableStates: Set<MangaState> = EnumSet.of(MangaState.ONGOING, MangaState.FINISHED)
protected open val listUrl = "/tim-truyen-nang-cao"
protected open val datePattern = "dd/MM/yy" protected open val datePattern = "dd/MM/yy"
@ -48,75 +54,136 @@ internal abstract class WpComicsParser(
@JvmField @JvmField
protected val finished: Set<String> = setOf( protected val finished: Set<String> = setOf(
"Hoàn thành", "Hoàn thành",
"Completed ", "Completed",
) )
override suspend fun getListPage( override suspend fun getListPage(page: Int, filter: MangaListFilter?): List<Manga> {
page: Int, val response =
query: String?, when (filter) {
tags: Set<MangaTag>?, is MangaListFilter.Search -> {
sortOrder: SortOrder,
): List<Manga> {
val tag = tags.oneOrThrowIfMany()
val url = buildString { val url = buildString {
append("https://") append("https://")
append(domain) append(domain)
append(listUrl) append("/tim-truyen?keyword=")
append(filter.query.urlEncoded())
append("&page=")
append(page.toString())
}
if (!tags.isNullOrEmpty()) { val result = runCatchingCancellable { webClient.httpGet(url) }
append("/") val exception = result.exceptionOrNull()
append(tag?.key.orEmpty()) if (exception is NotFoundException) {
return emptyList()
}
result.getOrThrow()
} }
append("?page=") is MangaListFilter.Advanced -> {
val url = buildString {
append("https://")
append(domain)
val tagQuery = filter.tags.joinToString(",") { it.key }
append("/tim-truyen-nang-cao?genres=")
append(tagQuery)
append("&notgenres=&gender=-1&minchapter=1&sort=")
append(
when (filter.sortOrder) {
SortOrder.UPDATED -> 0
SortOrder.POPULARITY -> 10
SortOrder.NEWEST -> 15
SortOrder.RATING -> 20
else -> throw IllegalArgumentException("Sort order ${filter.sortOrder.name} not supported")
},
)
filter.states.oneOrThrowIfMany()?.let {
append("&status=")
append(
when (it) {
MangaState.ONGOING -> "1"
MangaState.FINISHED -> "2"
else -> "-1"
},
)
}
append("&page=")
append(page.toString()) append(page.toString())
if (!query.isNullOrEmpty()) {
append("&keyword=")
append(query.urlEncoded())
} }
webClient.httpGet(url)
}
append("&sort=") null -> {
when (sortOrder) { val url = buildString {
SortOrder.POPULARITY -> append("10") append("https://")
SortOrder.UPDATED -> append("") append(domain)
SortOrder.NEWEST -> append("15") append("/tim-truyen-nang-cao?genres=&notgenres=&gender=-1&status=-1&minchapter=1&sort=0&page=")
else -> append("") append(page.toString())
}
webClient.httpGet(url)
}
} }
val itemsElements = response.parseHtml()
.select("div.ModuleContent > div.items")
.select("div.item")
return itemsElements.mapNotNull { item ->
val tooltipElement = item.selectFirst("div.box_tootip") ?: return@mapNotNull null
val absUrl = item.selectFirst("div.image > a")?.attrAsAbsoluteUrlOrNull("href") ?: return@mapNotNull null
val slug = absUrl.substringAfterLast('/')
val mangaState = when (tooltipElement.selectFirst("div.message_main > p:contains(Tình trạng)")?.ownText()) {
"Đang tiến hành" -> MangaState.ONGOING
"Hoàn thành" -> MangaState.FINISHED
else -> null
} }
val doc = webClient.httpGet(url).parseHtml()
return doc.select("div.item").map { div -> val tagMap = getOrCreateTagMap()
val href = div.selectFirstOrThrow("a").attrAsRelativeUrl("href") val tagsElement = tooltipElement.selectFirst("div.message_main > p:contains(Thể loại)")?.ownText().orEmpty()
val mangaTags = tagsElement.split(',').mapNotNullToSet { tagMap[it.trim()] }
Manga( Manga(
id = generateUid(href), id = generateUid(slug),
url = href, title = tooltipElement.selectFirst("div.title")?.text().orEmpty(),
publicUrl = href.toAbsoluteUrl(div.host ?: domain),
coverUrl = div.selectFirst("img")?.src().orEmpty(),
title = div.selectFirstOrThrow("h3").text().orEmpty(),
altTitle = null, altTitle = null,
url = absUrl.toRelativeUrl(domain),
publicUrl = absUrl,
rating = RATING_UNKNOWN, rating = RATING_UNKNOWN,
tags = emptySet(), isNsfw = false,
author = null, coverUrl = item.selectFirst("div.image a img")?.absUrl("data-original").orEmpty(),
state = null, largeCoverUrl = null,
tags = mangaTags,
state = mangaState,
author = tooltipElement.selectFirst("div.message_main > p:contains(Tác giả)")?.ownText(),
description = tooltipElement.selectFirst("div.box_text")?.text(),
chapters = null,
source = source, source = source,
isNsfw = isNsfwSource,
) )
} }
} }
override suspend fun getAvailableTags(): Set<MangaTag> { override suspend fun getAvailableTags(): Set<MangaTag> {
val doc = webClient.httpGet("https://$domain$listUrl").parseHtml() val map = getOrCreateTagMap()
return doc.select("div.genres ul li:not(.active)").mapNotNullToSet { li -> val tagSet = ArraySet<MangaTag>(map.size)
val a = li.selectFirst("a") ?: return@mapNotNullToSet null for (entry in map) {
val href = a.attr("href").removeSuffix('/').substringAfterLast('/') tagSet.add(entry.value)
MangaTag(
key = href,
title = a.text(),
source = source,
)
} }
return tagSet
}
private val mutex = Mutex()
private var tagCache: ArrayMap<String, MangaTag>? = null
private suspend fun getOrCreateTagMap(): ArrayMap<String, MangaTag> = mutex.withLock {
tagCache?.let { return@withLock it }
val doc = webClient.httpGet("/tim-truyen-nang-cao".toAbsoluteUrl(domain)).parseHtml()
val tagItems = doc.select("div.genre-item")
val result = ArrayMap<String, MangaTag>(tagItems.size)
for (item in tagItems) {
val title = item.text().trim()
val key = item.select("span[data-id]").attr("data-id")
result[title] = MangaTag(title = title, key = key, source = source)
}
tagCache = result
result
} }
protected open val selectDesc = "div.detail-content p" protected open val selectDesc = "div.detail-content p"
@ -127,13 +194,9 @@ internal abstract class WpComicsParser(
override suspend fun getDetails(manga: Manga): Manga = coroutineScope { override suspend fun getDetails(manga: Manga): Manga = coroutineScope {
val fullUrl = manga.url.toAbsoluteUrl(domain) val fullUrl = manga.url.toAbsoluteUrl(domain)
val doc = webClient.httpGet(fullUrl).parseHtml() val doc = webClient.httpGet(fullUrl).parseHtml()
val chaptersDeferred = async { getChapters(doc) } val chaptersDeferred = async { getChapters(doc) }
val desc = doc.selectFirstOrThrow(selectDesc).html() val desc = doc.selectFirstOrThrow(selectDesc).html()
val stateDiv = doc.selectFirst(selectState) val stateDiv = doc.selectFirst(selectState)
val state = stateDiv?.let { val state = stateDiv?.let {
when (it.text()) { when (it.text()) {
in ongoing -> MangaState.ONGOING in ongoing -> MangaState.ONGOING
@ -141,17 +204,8 @@ internal abstract class WpComicsParser(
else -> null else -> null
} }
} }
val aut = doc.body().select(selectAut).text() val aut = doc.body().select(selectAut).text()
manga.copy( manga.copy(
tags = doc.body().select(selectTag).mapNotNullToSet { a ->
MangaTag(
key = a.attr("href").removeSuffix('/').substringAfterLast('/'),
title = a.text().toTitleCase(),
source = source,
)
},
description = desc, description = desc,
altTitle = null, altTitle = null,
author = aut, author = aut,
@ -165,19 +219,16 @@ internal abstract class WpComicsParser(
protected open val selectChapter = "div#nt_listchapter li:not(.heading)" protected open val selectChapter = "div#nt_listchapter li:not(.heading)"
protected open suspend fun getChapters(doc: Document): List<MangaChapter> { protected open suspend fun getChapters(doc: Document): List<MangaChapter> {
return doc.body().select(selectChapter).mapChapters(reversed = true) { i, li -> return doc.body().select(selectChapter).mapChapters(reversed = true) { i, li ->
val a = li.selectFirstOrThrow("a") val a = li.selectFirstOrThrow("a")
val href = a.attrAsRelativeUrl("href") val href = a.attrAsRelativeUrl("href")
val dateText = li.selectFirst(selectDate)?.text() val dateText = li.selectFirst(selectDate)?.text()
val findHours = dateText?.contains(":") val findHours = dateText?.contains(":")
val dateFormat = if (findHours == true) { val dateFormat = if (findHours == true) {
SimpleDateFormat("HH:mm dd/MM", sourceLocale) SimpleDateFormat("HH:mm dd/MM", sourceLocale)
} else { } else {
SimpleDateFormat(datePattern, sourceLocale) SimpleDateFormat(datePattern, sourceLocale)
} }
MangaChapter( MangaChapter(
id = generateUid(href), id = generateUid(href),
name = a.text(), name = a.text(),
@ -295,5 +346,4 @@ internal abstract class WpComicsParser(
else -> 0 else -> 0
} }
} }
} }

@ -1,5 +1,7 @@
package org.koitharu.kotatsu.parsers.site.wpcomics.en package org.koitharu.kotatsu.parsers.site.wpcomics.en
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaSourceParser import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.model.* import org.koitharu.kotatsu.parsers.model.*
@ -9,11 +11,13 @@ import java.util.EnumSet
@MangaSourceParser("XOXOCOMICS", "XoxoComics", "en", ContentType.COMICS) @MangaSourceParser("XOXOCOMICS", "XoxoComics", "en", ContentType.COMICS)
internal class XoxoComics(context: MangaLoaderContext) : internal class XoxoComics(context: MangaLoaderContext) :
WpComicsParser(context, MangaSource.XOXOCOMICS, "xoxocomics.net", 50) { WpComicsParser(context, MangaSource.XOXOCOMICS, "xoxocomic.com", 50) {
override val listUrl = "/genre" override val listUrl = "/comic-list"
override val datePattern = "MM/dd/yyyy" override val datePattern = "MM/dd/yyyy"
override val isMultipleTagsSupported = false
override val availableSortOrders: Set<SortOrder> = EnumSet.of( override val availableSortOrders: Set<SortOrder> = EnumSet.of(
SortOrder.UPDATED, SortOrder.UPDATED,
SortOrder.NEWEST, SortOrder.NEWEST,
@ -21,46 +25,66 @@ internal class XoxoComics(context: MangaLoaderContext) :
SortOrder.ALPHABETICAL, SortOrder.ALPHABETICAL,
) )
override suspend fun getListPage( override suspend fun getListPage(page: Int, filter: MangaListFilter?): List<Manga> {
page: Int,
query: String?,
tags: Set<MangaTag>?,
sortOrder: SortOrder,
): List<Manga> {
val tag = tags.oneOrThrowIfMany()
val url = buildString { val url = buildString {
append("https://") append("https://")
append(domain) append(domain)
when (filter) {
if (!query.isNullOrEmpty()) { is MangaListFilter.Search -> {
append("/search?keyword=") append("/search-comic?keyword=")
append(query.urlEncoded()) append(filter.query.urlEncoded())
append("&page=") append("&page=")
append(page.toString()) append(page.toString())
} else {
append(listUrl)
if (!tags.isNullOrEmpty()) {
append("/")
append(tag?.key.orEmpty())
} }
is MangaListFilter.Advanced -> {
if (filter.tags.isNotEmpty()) {
filter.tags.oneOrThrowIfMany()?.let {
append("/") append("/")
when (sortOrder) { append(it.key)
SortOrder.POPULARITY -> append("popular") }
SortOrder.UPDATED -> append("") }
SortOrder.NEWEST -> append("newest")
SortOrder.ALPHABETICAL -> append("alphabet") filter.states.oneOrThrowIfMany()?.let {
else -> append("") append(
when (it) {
MangaState.ONGOING -> "/ongoing"
MangaState.FINISHED -> "/completed"
else -> ""
},
)
if (filter.tags.isEmpty()) {
append("-comic")
}
} }
if (filter.states.isEmpty() && filter.tags.isEmpty()) {
append(listUrl)
}
when (filter.sortOrder) {
SortOrder.POPULARITY -> append("/popular")
SortOrder.UPDATED -> append("/latest")
SortOrder.NEWEST -> append("/newest")
SortOrder.ALPHABETICAL -> append("")
else -> append("/latest")
}
append("?page=") append("?page=")
append(page.toString()) append(page.toString())
}
null -> {
append(listUrl)
append("/?page=")
append(page.toString())
}
} }
} }
val doc = webClient.httpGet(url).parseHtml() val doc = webClient.httpGet(url).parseHtml()
return doc.select("div.item").map { div -> return doc.select("div.item, #nt_listchapter nav ul li").map { div ->
val href = div.selectFirstOrThrow("a").attrAsRelativeUrl("href") val href = div.selectFirstOrThrow("a").attrAsRelativeUrl("href")
Manga( Manga(
id = generateUid(href), id = generateUid(href),
@ -79,12 +103,53 @@ internal class XoxoComics(context: MangaLoaderContext) :
} }
} }
override suspend fun getAvailableTags(): Set<MangaTag> {
val doc = webClient.httpGet("https://$domain$listUrl").parseHtml()
return doc.select("div.genres ul li:not(.active)").mapNotNullToSet { li ->
val a = li.selectFirst("a") ?: return@mapNotNullToSet null
val href = a.attr("href").removeSuffix('/').substringAfterLast('/')
MangaTag(
key = href,
title = a.text(),
source = source,
)
}
}
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> { override suspend fun getDetails(manga: Manga): Manga = coroutineScope {
val fullUrl = chapter.url.toAbsoluteUrl(domain) + "/all" val fullUrl = manga.url.toAbsoluteUrl(domain)
val doc = webClient.httpGet(fullUrl).parseHtml() val doc = webClient.httpGet(fullUrl).parseHtml()
val chaptersDeferred = async { getChapters(doc) }
val desc = doc.selectFirstOrThrow(selectDesc).html()
val stateDiv = doc.selectFirst(selectState)
val state = stateDiv?.let {
when (it.text()) {
in ongoing -> MangaState.ONGOING
in finished -> MangaState.FINISHED
else -> null
}
}
val aut = doc.body().select(selectAut).text()
manga.copy(
tags = doc.body().select(selectTag).mapNotNullToSet { a ->
MangaTag(
key = a.attr("href").removeSuffix('/').substringAfterLast('/'),
title = a.text().toTitleCase(),
source = source,
)
},
description = desc,
altTitle = null,
author = aut,
state = state,
chapters = chaptersDeferred.await(),
)
}
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val fullUrl = chapter.url.toAbsoluteUrl(domain) + "/all"
val doc = webClient.httpGet(fullUrl).parseHtml()
return doc.select(selectPage).map { url -> return doc.select(selectPage).map { url ->
val img = url.src()?.toRelativeUrl(domain) ?: url.parseFailed("Image src not found") val img = url.src()?.toRelativeUrl(domain) ?: url.parseFailed("Image src not found")
MangaPage( MangaPage(

@ -0,0 +1,19 @@
package org.koitharu.kotatsu.parsers.site.wpcomics.vi
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.site.wpcomics.WpComicsParser
@MangaSourceParser("NETTRUYEN", "NetTruyen", "vi")
internal class NetTruyen(context: MangaLoaderContext) :
WpComicsParser(context, MangaSource.NETTRUYEN, "www.nettruyenlive.com", 36) {
override val configKeyDomain: ConfigKey.Domain = ConfigKey.Domain(
"www.nettruyenlive.com",
"www.nettruyenio.com",
"www.nettruyento.com",
"nettruyento.com",
"nettruyenin.com",
)
}

@ -7,6 +7,4 @@ import org.koitharu.kotatsu.parsers.site.wpcomics.WpComicsParser
@MangaSourceParser("NETTRUYENMAX", "NettruyenMax", "vi") @MangaSourceParser("NETTRUYENMAX", "NettruyenMax", "vi")
internal class Nettruyenmax(context: MangaLoaderContext) : internal class Nettruyenmax(context: MangaLoaderContext) :
WpComicsParser(context, MangaSource.NETTRUYENMAX, "www.nettruyenus.com", 36) { WpComicsParser(context, MangaSource.NETTRUYENMAX, "www.nettruyenus.com", 36)
override val listUrl = "/tim-truyen"
}

@ -21,13 +21,9 @@ internal abstract class ZMangaParser(
override val configKeyDomain = ConfigKey.Domain(domain) override val configKeyDomain = ConfigKey.Domain(domain)
override val availableSortOrders: Set<SortOrder> = EnumSet.of( override val availableSortOrders: Set<SortOrder> = EnumSet.allOf(SortOrder::class.java)
SortOrder.UPDATED,
SortOrder.POPULARITY, override val availableStates: Set<MangaState> = EnumSet.of(MangaState.ONGOING, MangaState.FINISHED)
SortOrder.ALPHABETICAL,
SortOrder.NEWEST,
SortOrder.RATING,
)
protected open val listUrl = "advanced-search/" protected open val listUrl = "advanced-search/"
protected open val datePattern = "MMMM d, yyyy" protected open val datePattern = "MMMM d, yyyy"
@ -50,42 +46,56 @@ internal abstract class ZMangaParser(
"Completed", "Completed",
) )
override suspend fun getListPage( override suspend fun getListPage(page: Int, filter: MangaListFilter?): List<Manga> {
page: Int,
query: String?,
tags: Set<MangaTag>?,
sortOrder: SortOrder,
): List<Manga> {
val url = buildString { val url = buildString {
append("https://") append("https://")
append(domain) append(domain)
append("/$listUrl") append('/')
append(listUrl)
if (page > 1) { if (page > 1) {
append("page/") append("page/")
append(page.toString()) append(page.toString())
append("/") append('/')
}
when (filter) {
is MangaListFilter.Search -> {
append("&title=")
append(filter.query.urlEncoded())
} }
is MangaListFilter.Advanced -> {
append("?order=") append("?order=")
when (sortOrder) { when (filter.sortOrder) {
SortOrder.POPULARITY -> append("popular") SortOrder.POPULARITY -> append("popular")
SortOrder.UPDATED -> append("update") SortOrder.UPDATED -> append("update")
SortOrder.ALPHABETICAL -> append("title") SortOrder.ALPHABETICAL -> append("title")
SortOrder.NEWEST -> append("latest") SortOrder.NEWEST -> append("latest")
SortOrder.RATING -> append("rating") SortOrder.RATING -> append("rating")
} }
if (!query.isNullOrEmpty()) {
append("&title=")
append(query.urlEncoded())
}
if (!tags.isNullOrEmpty()) { filter.tags.forEach {
for (tag in tags) {
append("&") append("&")
append("genre[]".urlEncoded()) append("genre[]".urlEncoded())
append("=") append("=")
append(tag.key) append(it.key)
} }
filter.states.oneOrThrowIfMany()?.let {
append("&status=")
append(
when (it) {
MangaState.ONGOING -> "ongoing"
MangaState.FINISHED -> "completed"
else -> ""
},
)
}
}
null -> append("?order=update")
} }
} }

Loading…
Cancel
Save