diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/all/LineWebtoonsParser.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/all/LineWebtoonsParser.kt index 33cbe5af..80120a6d 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/all/LineWebtoonsParser.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/all/LineWebtoonsParser.kt @@ -132,83 +132,117 @@ internal abstract class LineWebtoonsParser( } } - override suspend fun getList( - offset: Int, - query: String?, - tags: Set?, - sortOrder: SortOrder, - ): List { - 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") - } + override suspend fun getList(offset: Int, filter: MangaListFilter?): List { + val manga = + when (filter) { + is MangaListFilter.Search -> { + makeRequest("/lineWebtoon/webtoon/searchChallenge?query=${filter.query.urlEncoded()}&startIndex=${offset + 1}&pageSize=20") + .getJSONObject("challengeSearch") + .getJSONArray("titleList") + .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 = RATING_UNKNOWN, + isNsfw = isNsfwSource, + coverUrl = jo.getString("thumbnail").toAbsoluteUrl(staticDomain), + largeCoverUrl = null, + tags = emptySet(), + author = jo.getStringOrNull("writingAuthorName"), + description = null, + state = null, + source = source, + ) + } + } - makeRequest("/lineWebtoon/webtoon/searchChallenge?query=${query.urlEncoded()}&startIndex=${offset + 1}&pageSize=20") - .getJSONObject("challengeSearch") - .getJSONArray("titleList") - .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 = RATING_UNKNOWN, - isNsfw = isNsfwSource, - coverUrl = jo.getString("thumbnail").toAbsoluteUrl(staticDomain), - largeCoverUrl = null, - tags = emptySet(), - author = jo.getStringOrNull("writingAuthorName"), - description = null, - state = null, - source = source, - ) + 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 = + makeRequest("/lineWebtoon/webtoon/challengeGenreTitleList.json?genre=$genre&sortOrder=$sortOrderStr&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, + ) + } } - } else { - val result = - makeRequest("/lineWebtoon/webtoon/challengeGenreTitleList.json?genre=$genre&sortOrder=$sortOrderStr&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, - ) + + 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 } @@ -309,10 +343,10 @@ internal abstract class LineWebtoonsParser( } fun makeEncryptUrl(urlBuilder: HttpUrl.Builder) { - val msgpad = Calendar.getInstance().timeInMillis.toString() - val digest = getMessageDigest(getMessage(urlBuilder.build().toString(), msgpad)) + val msgPad = Calendar.getInstance().timeInMillis.toString() + val digest = getMessageDigest(getMessage(urlBuilder.build().toString(), msgPad)) urlBuilder - .addQueryParameter("msgpad", msgpad) + .addQueryParameter("msgpad", msgPad) .addQueryParameter("md", digest) // .addEncodedQueryParameter("md", digest.urlEncoded()) } diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/madara/MadaraParser.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/madara/MadaraParser.kt index 1bf55d38..405afa56 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/madara/MadaraParser.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/madara/MadaraParser.kt @@ -180,15 +180,16 @@ internal abstract class MadaraParser( is MangaListFilter.Advanced -> { - val tag = filter.tags.oneOrThrowIfMany() if (filter.tags.isNotEmpty()) { - append("/$tagPrefix") - append(tag?.key.orEmpty()) - if (pages > 1) { - append("/page/") - append(pages.toString()) + filter.tags.oneOrThrowIfMany()?.let { + append("/$tagPrefix") + append(it.key) + if (pages > 1) { + append("/page/") + append(pages.toString()) + } + append("/?") } - append("/?") } else { if (pages > 1) { @@ -241,8 +242,9 @@ internal abstract class MadaraParser( is MangaListFilter.Advanced -> { - val tag = filter.tags.oneOrThrowIfMany() - payload["vars[wp-manga-genre]"] = tag?.key.orEmpty() + filter.tags.oneOrThrowIfMany()?.let { + payload["vars[wp-manga-genre]"] = it.key + } when (filter.sortOrder) { SortOrder.POPULARITY -> payload["vars[meta_key]"] = "_wp_manga_views" diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/vi/BlogTruyenParser.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/vi/BlogTruyenParser.kt index 3ec7263b..f5bee237 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/vi/BlogTruyenParser.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/vi/BlogTruyenParser.kt @@ -29,98 +29,38 @@ class BlogTruyenParser(context: MangaLoaderContext) : .add("User-Agent", UserAgents.CHROME_DESKTOP) .build() + override val isMultipleTagsSupported = false + private val dateFormat = SimpleDateFormat("dd/MM/yyyy HH:mm", Locale.US) 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 - } + override suspend fun getListPage(page: Int, filter: MangaListFilter?): List { - 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 + return when (filter) { - 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")?.imageUrl(), - state = state, - rating = rating ?: RATING_UNKNOWN, - isNsfw = doc.getElementById("warningCategory") != null, - ) - } - - private fun parseChapterList(doc: Document): List { - 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( - page: Int, - query: String?, - tags: Set?, - sortOrder: SortOrder, - ): List { - return when { - !query.isNullOrEmpty() -> { - val searchUrl = "https://${domain}/timkiem/nangcao/1/0/-1/-1?txt=${query.urlEncoded()}&p=$page" + is MangaListFilter.Search -> { + val searchUrl = "https://${domain}/timkiem/nangcao/1/0/-1/-1?txt=${filter.query.urlEncoded()}&p=$page" val searchContent = webClient.httpGet(searchUrl).parseHtml() .selectFirst("section.list-manga-bycate > div.list") parseMangaList(searchContent) } - !tags.isNullOrEmpty() -> { - val tag = tags.oneOrThrowIfMany()!! - val categoryAjax = - "https://${domain}/ajax/Category/AjaxLoadMangaByCategory?id=${tag.key}&orderBy=5&p=$page" - val listContent = webClient.httpGet(categoryAjax).parseHtml().selectFirst("div.list") - parseMangaList(listContent) + is MangaListFilter.Advanced -> { + + if (filter.tags.isNotEmpty()) { + filter.tags.oneOrThrowIfMany().let { + val categoryAjax = + "https://${domain}/ajax/Category/AjaxLoadMangaByCategory?id=${it?.key}&orderBy=5&p=$page" + val listContent = webClient.httpGet(categoryAjax).parseHtml().selectFirst("div.list") + 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(), url = relativeUrl, publicUrl = relativeUrl.toAbsoluteUrl(domain), - coverUrl = linkTag.selectLast("img")?.imageUrl().orEmpty(), + coverUrl = linkTag.selectLast("img")?.src().orEmpty(), source = source, tags = tags ?: emptySet(), isNsfw = false, @@ -171,7 +111,7 @@ class BlogTruyenParser(context: MangaLoaderContext) : description = mangaInfo.select("div.al-j.fs-12").text(), url = relativeUrl, publicUrl = relativeUrl.toAbsoluteUrl(domain), - coverUrl = mangaInfo.selectFirst("div > img.img")?.imageUrl().orEmpty(), + coverUrl = mangaInfo.selectFirst("div > img.img")?.src().orEmpty(), isNsfw = false, rating = RATING_UNKNOWN, tags = emptySet(), @@ -182,6 +122,91 @@ class BlogTruyenParser(context: MangaLoaderContext) : } } + override suspend fun getAvailableTags(): Set { + return cacheTags.get().values.toSet() + } + + private suspend fun fetchTags(): Map { + val doc = webClient.httpGet("/timkiem/nangcao".toAbsoluteUrl(domain)).parseHtml() + val tagItems = doc.select("li[data-id]") + val tagMap = ArrayMap(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 { + 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 { fun generateImageId(index: Int) = generateUid("${chapter.url}/$index") @@ -191,7 +216,7 @@ class BlogTruyenParser(context: MangaLoaderContext) : pages.add( MangaPage( id = generateImageId(pages.size), - url = img.imageUrl(), + url = img.src().orEmpty(), preview = null, source = source, ), @@ -218,30 +243,4 @@ class BlogTruyenParser(context: MangaLoaderContext) : return pages } - - override suspend fun getAvailableTags(): Set { - return cacheTags.get().values.toSet() - } - - - private suspend fun fetchTags(): Map { - val doc = webClient.httpGet("/timkiem/nangcao".toAbsoluteUrl(domain)).parseHtml() - val tagItems = doc.select("li[data-id]") - val tagMap = ArrayMap(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") - ?: "" - } } diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/vi/HentaiVNParser.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/vi/HentaiVNParser.kt index 54edb7a6..5b398558 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/vi/HentaiVNParser.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/vi/HentaiVNParser.kt @@ -35,6 +35,57 @@ class HentaiVNParser(context: MangaLoaderContext) : MangaParser(context, MangaSo SortOrder.NEWEST, ) + override suspend fun getList(offset: Int, filter: MangaListFilter?): List { + 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 { val chapterDeferred = async { fetchChapters(manga.url) } 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?, sortOrder: SortOrder): List { - 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 { val docs = webClient.httpGet(chapter.url.toAbsoluteUrl(domain)).parseHtml() return docs.select("#image > img").map { diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/vi/LxManga.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/vi/LxManga.kt index 6fdd9b0a..bd61e0aa 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/vi/LxManga.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/vi/LxManga.kt @@ -20,57 +20,76 @@ internal class LxManga(context: MangaLoaderContext) : PagedMangaParser(context, SortOrder.NEWEST, SortOrder.POPULARITY, ) + override val availableStates: Set = EnumSet.of(MangaState.ONGOING, MangaState.FINISHED) override val configKeyDomain = ConfigKey.Domain("lxmanga.net") + override val isMultipleTagsSupported = false + override val headers: Headers = Headers.Builder() .add("User-Agent", UserAgents.CHROME_DESKTOP) .build() - override suspend fun getListPage( - page: Int, - query: String?, - tags: Set?, - sortOrder: SortOrder, - ): List { + override suspend fun getListPage(page: Int, filter: MangaListFilter?): List { val url = buildString { append("https://") append(domain) - when { - !query.isNullOrEmpty() -> { + + when (filter) { + + is MangaListFilter.Search -> { val skey = "filter[name]=".urlEncoded() append("/tim-kiem?$skey") - append(query.urlEncoded()) + append(filter.query.urlEncoded()) + append("&page=") + append(page.toString()) } - !tags.isNullOrEmpty() -> { - append("/the-loai/") - for (tag in tags) { - append(tag.key) + is MangaListFilter.Advanced -> { + if (filter.tags.isNotEmpty()) { + filter.tags.oneOrThrowIfMany()?.let { + append("/the-loai/") + append(it.key) + } + } else { + append("/danh-sach") + } + append("?page=") + 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=") + when (filter.sortOrder) { + SortOrder.POPULARITY -> append("-views") + SortOrder.UPDATED -> append("-updated_at") + SortOrder.NEWEST -> append("-created_at") + SortOrder.ALPHABETICAL -> append("name") + else -> append("-updated_at") } } - else -> { - append("/danh-sach") + null -> { + append("/danh-sach?sort=-updated_at&page=") + append(page.toString()) } } - append("?page=") - append(page.toString()) - append("&sort=") - when (sortOrder) { - SortOrder.POPULARITY -> append("-views") - SortOrder.UPDATED -> append("-updated_at") - SortOrder.NEWEST -> append("-created_at") - SortOrder.ALPHABETICAL -> append("name") - - else -> append("-updated_at") - } - } + } val doc = webClient.httpGet(url).parseHtml() - return doc.select("div.grid div.manga-vertical") .map { div -> val href = div.selectFirstOrThrow("a").attr("href") @@ -95,7 +114,6 @@ internal class LxManga(context: MangaLoaderContext) : PagedMangaParser(context, override suspend fun getDetails(manga: Manga): Manga { val root = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml() val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US) - return manga.copy( 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()) { @@ -135,9 +153,7 @@ internal class LxManga(context: MangaLoaderContext) : PagedMangaParser(context, override suspend fun getPages(chapter: MangaChapter): List { val fullUrl = chapter.url.toAbsoluteUrl(domain) - val doc = webClient.httpGet(fullUrl).parseHtml() - return doc.select("div.text-center img.lazy").map { img -> val url = img.attrAsRelativeUrlOrNull("data-src") ?: img.attrAsRelativeUrlOrNull("src") ?: img.parseFailed("Image src not found") diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/vi/NetTruyenParser.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/vi/NetTruyenParser.kt deleted file mode 100644 index db799ac0..00000000 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/vi/NetTruyenParser.kt +++ /dev/null @@ -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 - 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? = 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?, - sortOrder: SortOrder, - ): List { - 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("¬genres=&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 { - 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 { - val map = getOrCreateTagMap() - val tagSet = ArraySet(map.size) - for (entry in map) { - tagSet.add(entry.value) - } - - return tagSet - } - - private suspend fun getOrCreateTagMap(): ArrayMap = 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(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") - } -} diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/vi/Truyenqq.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/vi/Truyenqq.kt index 6341e2be..256892d7 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/vi/Truyenqq.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/vi/Truyenqq.kt @@ -14,40 +14,70 @@ internal class Truyenqq(context: MangaLoaderContext) : PagedMangaParser(context, override val availableSortOrders: Set = EnumSet.of(SortOrder.UPDATED, SortOrder.POPULARITY, SortOrder.NEWEST) + + override val availableStates: Set = EnumSet.of(MangaState.ONGOING, MangaState.FINISHED) + override val configKeyDomain = ConfigKey.Domain("truyenqqvn.com") - override suspend fun getListPage( - page: Int, - query: String?, - tags: Set?, - sortOrder: SortOrder, - ): List { - val tagQuery = if (tags.isNullOrEmpty()) "" else tags.joinToString(separator = ",") { it.key } - val url = if (!query.isNullOrEmpty()) { - buildString { - append("https://") - append(domain) - append("/tim-kiem/trang-$page.html") - append("?q=") - append(query.urlEncoded()) - } - } else { - buildString { - append("https://") - append(domain) - append("/tim-kiem-nang-cao/trang-$page.html") - append("?status=-1&country=0&sort=") - when (sortOrder) { - SortOrder.POPULARITY -> append("4") - SortOrder.UPDATED -> append("2") - SortOrder.NEWEST -> append("0") - else -> append("2") + override suspend fun getListPage(page: Int, filter: MangaListFilter?): List { + + val url = + when (filter) { + is MangaListFilter.Search -> { + buildString { + append("https://") + append(domain) + append("/tim-kiem/trang-$page.html") + append("?q=") + append(filter.query.urlEncoded()) + } + } + + is MangaListFilter.Advanced -> { + buildString { + append("https://") + append(domain) + append("/tim-kiem-nang-cao/trang-") + append(page.toString()) + append(".html?country=0&sort=") + when (filter.sortOrder) { + SortOrder.POPULARITY -> append("4") + SortOrder.UPDATED -> append("2") + SortOrder.NEWEST -> append("0") + 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(filter.tags.joinToString(separator = ",") { it.key }) + append("¬category=&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=¬category=&minchapter=0") + } + } - append("&category=") - append(tagQuery) - append("¬category=&minchapter=0") } - } val doc = webClient.httpGet(url).parseHtml() return doc.requireElementById("main_homepage").select("li").map { li -> val href = li.selectFirstOrThrow("a").attrAsRelativeUrl("href") diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/vi/TruyentranhLHParser.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/vi/TruyentranhLHParser.kt index 640a089b..02fa5fc0 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/vi/TruyentranhLHParser.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/vi/TruyentranhLHParser.kt @@ -19,10 +19,84 @@ class TruyentranhLHParser(context: MangaLoaderContext) : override val configKeyDomain: ConfigKey.Domain = ConfigKey.Domain("truyentranhlh.net") override val availableSortOrders: Set = EnumSet.allOf(SortOrder::class.java) + override val availableStates: Set = + EnumSet.of(MangaState.ONGOING, MangaState.FINISHED, MangaState.PAUSED) private val mutex = Mutex() private var tagCache: Map? = null + override suspend fun getListPage(page: Int, filter: MangaListFilter?): List { + + 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 { val docs = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml() 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()) { "Đang tiến hành" -> MangaState.ONGOING "Đã hoàn thành" -> MangaState.FINISHED + "Tạm ngưng" -> MangaState.PAUSED else -> null } val rating = infoHeaderEl?.let { @@ -68,53 +143,6 @@ class TruyentranhLHParser(context: MangaLoaderContext) : ) } - override suspend fun getListPage( - page: Int, - query: String?, - tags: Set?, - sortOrder: SortOrder, - ): List { - 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 { val url = chapter.url.toAbsoluteUrl(domain) return webClient.httpGet(url).parseHtml().select("#chapter-content > img").mapNotNull { diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/vi/YurinekoParser.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/vi/YurinekoParser.kt index 1cdd0b6e..336e7e88 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/vi/YurinekoParser.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/vi/YurinekoParser.kt @@ -5,30 +5,12 @@ 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.model.ContentType -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.model.* import org.koitharu.kotatsu.parsers.util.domain import org.koitharu.kotatsu.parsers.util.generateUid -import org.koitharu.kotatsu.parsers.util.json.asIterable -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.json.* import org.koitharu.kotatsu.parsers.util.mapChapters -import org.koitharu.kotatsu.parsers.util.parseHtml -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 org.koitharu.kotatsu.parsers.util.* import java.text.SimpleDateFormat import java.util.EnumSet import java.util.Locale @@ -44,45 +26,25 @@ class YurinekoParser(context: MangaLoaderContext) : PagedMangaParser(context, Ma private val apiDomain get() = "api.$domain" - 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 getListPage(page: Int, filter: MangaListFilter?): List { + val listUrl = + when (filter) { + + is MangaListFilter.Search -> { + "/search?query=${filter.query.urlEncoded()}&page=$page" + } + + is MangaListFilter.Advanced -> { + if (filter.tags.isNotEmpty()) { + val tagKeys = filter.tags.joinToString(separator = ",") { it.key } + "/advancedSearch?genre=$tagKeys¬Genre=&sort=7&minChapter=1&status=0&page=$page" + } else { + "/lastest2?page=$page" + } + } - override suspend fun getListPage( - page: Int, - query: String?, - tags: Set?, - sortOrder: SortOrder, - ): List { - 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¬Genre=&sort=7&minChapter=1&status=0&page=$page" + null -> "/lastest2?page=$page" } - } val jsonResponse = webClient.httpGet(listUrl.toAbsoluteUrl(apiDomain)).parseJson() return jsonResponse.getJSONArray("result") .mapJSON { jo -> @@ -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 { val jsonData = webClient.httpGet(chapter.url.toAbsoluteUrl(domain)).parseHtml() .requireElementById("__NEXT_DATA__") diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/vmp/VmpParser.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/vmp/VmpParser.kt index 5cbd5844..69b9c256 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/vmp/VmpParser.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/vmp/VmpParser.kt @@ -16,8 +16,11 @@ internal abstract class VmpParser( ) : PagedMangaParser(context, source, pageSize) { override val configKeyDomain = ConfigKey.Domain(domain) + override val availableSortOrders: Set = EnumSet.of(SortOrder.UPDATED) + override val isMultipleTagsSupported = false + protected open val listUrl = "xxx/" protected open val geneUrl = "genero/" @@ -26,27 +29,42 @@ internal abstract class VmpParser( searchPaginator.firstPage = 1 } - override suspend fun getListPage( - page: Int, - query: String?, - tags: Set?, - sortOrder: SortOrder, - ): List { - val tag = tags.oneOrThrowIfMany() + override suspend fun getListPage(page: Int, filter: MangaListFilter?): List { + val url = buildString { - append("https://$domain/") - if (!tags.isNullOrEmpty()) { - append(geneUrl) - append(tag?.key.orEmpty()) - append("/page/") - append(page.toString()) - } else { - append(listUrl) - append("/page/") - append(page.toString()) - if (!query.isNullOrEmpty()) { + append("https://") + append(domain) + append('/') + when (filter) { + + is MangaListFilter.Search -> { + append(listUrl) + append("/page/") + append(page.toString()) append("?s=") - append(query.urlEncoded()) + append(filter.query.urlEncoded()) + } + + is MangaListFilter.Advanced -> { + + if (filter.tags.isNotEmpty()) { + filter.tags.oneOrThrowIfMany()?.let { + append(geneUrl) + append(it.key) + append("/page/") + append(page.toString()) + } + } else { + append(listUrl) + append("/page/") + append(page.toString()) + } + } + + null -> { + append(listUrl) + append("/page/") + append(page.toString()) } } } diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/wpcomics/WpComicsParser.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/wpcomics/WpComicsParser.kt index 952f365f..b6c3c5b4 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/wpcomics/WpComicsParser.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/wpcomics/WpComicsParser.kt @@ -1,11 +1,16 @@ package org.koitharu.kotatsu.parsers.site.wpcomics +import androidx.collection.ArrayMap +import androidx.collection.ArraySet import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import org.jsoup.nodes.Document import org.koitharu.kotatsu.parsers.MangaLoaderContext 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.DateFormat @@ -21,15 +26,16 @@ internal abstract class WpComicsParser( override val configKeyDomain = ConfigKey.Domain(domain) - override val isMultipleTagsSupported = false - override val availableSortOrders: Set = EnumSet.of( SortOrder.UPDATED, SortOrder.NEWEST, SortOrder.POPULARITY, + SortOrder.RATING, ) - protected open val listUrl = "/the-loai" + override val availableStates: Set = EnumSet.of(MangaState.ONGOING, MangaState.FINISHED) + + protected open val listUrl = "/tim-truyen-nang-cao" protected open val datePattern = "dd/MM/yy" @@ -48,75 +54,136 @@ internal abstract class WpComicsParser( @JvmField protected val finished: Set = setOf( "Hoàn thành", - "Completed ", + "Completed", ) - override suspend fun getListPage( - page: Int, - query: String?, - tags: Set?, - sortOrder: SortOrder, - ): List { - val tag = tags.oneOrThrowIfMany() - val url = buildString { - append("https://") - append(domain) - append(listUrl) - - if (!tags.isNullOrEmpty()) { - append("/") - append(tag?.key.orEmpty()) - } + override suspend fun getListPage(page: Int, filter: MangaListFilter?): List { + val response = + when (filter) { + is MangaListFilter.Search -> { + val url = buildString { + append("https://") + append(domain) + append("/tim-truyen?keyword=") + append(filter.query.urlEncoded()) + append("&page=") + append(page.toString()) + } + + val result = runCatchingCancellable { webClient.httpGet(url) } + val exception = result.exceptionOrNull() + if (exception is NotFoundException) { + return emptyList() + } + result.getOrThrow() + } - append("?page=") - append(page.toString()) + 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("¬genres=&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()) + } + + webClient.httpGet(url) + } - if (!query.isNullOrEmpty()) { - append("&keyword=") - append(query.urlEncoded()) + null -> { + val url = buildString { + append("https://") + append(domain) + append("/tim-truyen-nang-cao?genres=¬genres=&gender=-1&status=-1&minchapter=1&sort=0&page=") + append(page.toString()) + } + webClient.httpGet(url) + } } - - append("&sort=") - when (sortOrder) { - SortOrder.POPULARITY -> append("10") - SortOrder.UPDATED -> append("") - SortOrder.NEWEST -> append("15") - else -> append("") + 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 href = div.selectFirstOrThrow("a").attrAsRelativeUrl("href") + 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(href), - url = href, - publicUrl = href.toAbsoluteUrl(div.host ?: domain), - coverUrl = div.selectFirst("img")?.src().orEmpty(), - title = div.selectFirstOrThrow("h3").text().orEmpty(), + id = generateUid(slug), + title = tooltipElement.selectFirst("div.title")?.text().orEmpty(), altTitle = null, + url = absUrl.toRelativeUrl(domain), + publicUrl = absUrl, rating = RATING_UNKNOWN, - tags = emptySet(), - author = null, - state = null, + 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, - isNsfw = isNsfwSource, ) } } override suspend fun getAvailableTags(): Set { - 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, - ) + val map = getOrCreateTagMap() + val tagSet = ArraySet(map.size) + for (entry in map) { + tagSet.add(entry.value) } + return tagSet + } + + + private val mutex = Mutex() + private var tagCache: ArrayMap? = null + + private suspend fun getOrCreateTagMap(): ArrayMap = 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(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" @@ -127,13 +194,9 @@ internal abstract class WpComicsParser( override suspend fun getDetails(manga: Manga): Manga = coroutineScope { val fullUrl = manga.url.toAbsoluteUrl(domain) 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 @@ -141,17 +204,8 @@ internal abstract class WpComicsParser( 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, @@ -165,19 +219,16 @@ internal abstract class WpComicsParser( protected open val selectChapter = "div#nt_listchapter li:not(.heading)" protected open suspend fun getChapters(doc: Document): List { - return doc.body().select(selectChapter).mapChapters(reversed = true) { i, li -> val a = li.selectFirstOrThrow("a") val href = a.attrAsRelativeUrl("href") val dateText = li.selectFirst(selectDate)?.text() - val findHours = dateText?.contains(":") val dateFormat = if (findHours == true) { SimpleDateFormat("HH:mm dd/MM", sourceLocale) } else { SimpleDateFormat(datePattern, sourceLocale) } - MangaChapter( id = generateUid(href), name = a.text(), @@ -295,5 +346,4 @@ internal abstract class WpComicsParser( else -> 0 } } - } diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/wpcomics/en/XoxoComics.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/wpcomics/en/XoxoComics.kt index 581cacfa..047b3cbf 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/wpcomics/en/XoxoComics.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/wpcomics/en/XoxoComics.kt @@ -1,5 +1,7 @@ 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.MangaSourceParser import org.koitharu.kotatsu.parsers.model.* @@ -9,11 +11,13 @@ import java.util.EnumSet @MangaSourceParser("XOXOCOMICS", "XoxoComics", "en", ContentType.COMICS) 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 isMultipleTagsSupported = false + override val availableSortOrders: Set = EnumSet.of( SortOrder.UPDATED, SortOrder.NEWEST, @@ -21,46 +25,66 @@ internal class XoxoComics(context: MangaLoaderContext) : SortOrder.ALPHABETICAL, ) - override suspend fun getListPage( - page: Int, - query: String?, - tags: Set?, - sortOrder: SortOrder, - ): List { - val tag = tags.oneOrThrowIfMany() + override suspend fun getListPage(page: Int, filter: MangaListFilter?): List { val url = buildString { append("https://") append(domain) + when (filter) { - if (!query.isNullOrEmpty()) { - append("/search?keyword=") - append(query.urlEncoded()) - append("&page=") - append(page.toString()) - } else { - append(listUrl) - if (!tags.isNullOrEmpty()) { - append("/") - append(tag?.key.orEmpty()) + is MangaListFilter.Search -> { + append("/search-comic?keyword=") + append(filter.query.urlEncoded()) + append("&page=") + append(page.toString()) } - append("/") - when (sortOrder) { - SortOrder.POPULARITY -> append("popular") - SortOrder.UPDATED -> append("") - SortOrder.NEWEST -> append("newest") - SortOrder.ALPHABETICAL -> append("alphabet") - else -> append("") - } + is MangaListFilter.Advanced -> { + + if (filter.tags.isNotEmpty()) { + filter.tags.oneOrThrowIfMany()?.let { + append("/") + append(it.key) + } + } + + filter.states.oneOrThrowIfMany()?.let { + 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) + } - append("?page=") - append(page.toString()) + 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.toString()) + } + null -> { + append(listUrl) + append("/?page=") + append(page.toString()) + } } } 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") Manga( id = generateUid(href), @@ -79,12 +103,53 @@ internal class XoxoComics(context: MangaLoaderContext) : } } + override suspend fun getAvailableTags(): Set { + 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 { - val fullUrl = chapter.url.toAbsoluteUrl(domain) + "/all" + override suspend fun getDetails(manga: Manga): Manga = coroutineScope { + val fullUrl = manga.url.toAbsoluteUrl(domain) 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 { + val fullUrl = chapter.url.toAbsoluteUrl(domain) + "/all" + val doc = webClient.httpGet(fullUrl).parseHtml() return doc.select(selectPage).map { url -> val img = url.src()?.toRelativeUrl(domain) ?: url.parseFailed("Image src not found") MangaPage( diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/wpcomics/vi/NetTruyen.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/wpcomics/vi/NetTruyen.kt new file mode 100644 index 00000000..81691cf7 --- /dev/null +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/wpcomics/vi/NetTruyen.kt @@ -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", + ) +} diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/wpcomics/vi/Nettruyenmax.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/wpcomics/vi/Nettruyenmax.kt index 37868bde..070093fb 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/wpcomics/vi/Nettruyenmax.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/wpcomics/vi/Nettruyenmax.kt @@ -7,6 +7,4 @@ import org.koitharu.kotatsu.parsers.site.wpcomics.WpComicsParser @MangaSourceParser("NETTRUYENMAX", "NettruyenMax", "vi") internal class Nettruyenmax(context: MangaLoaderContext) : - WpComicsParser(context, MangaSource.NETTRUYENMAX, "www.nettruyenus.com", 36) { - override val listUrl = "/tim-truyen" -} + WpComicsParser(context, MangaSource.NETTRUYENMAX, "www.nettruyenus.com", 36) diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/zmanga/ZMangaParser.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/zmanga/ZMangaParser.kt index 486f0b25..6c928a53 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/zmanga/ZMangaParser.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/zmanga/ZMangaParser.kt @@ -21,13 +21,9 @@ internal abstract class ZMangaParser( override val configKeyDomain = ConfigKey.Domain(domain) - override val availableSortOrders: Set = EnumSet.of( - SortOrder.UPDATED, - SortOrder.POPULARITY, - SortOrder.ALPHABETICAL, - SortOrder.NEWEST, - SortOrder.RATING, - ) + override val availableSortOrders: Set = EnumSet.allOf(SortOrder::class.java) + + override val availableStates: Set = EnumSet.of(MangaState.ONGOING, MangaState.FINISHED) protected open val listUrl = "advanced-search/" protected open val datePattern = "MMMM d, yyyy" @@ -50,42 +46,56 @@ internal abstract class ZMangaParser( "Completed", ) - override suspend fun getListPage( - page: Int, - query: String?, - tags: Set?, - sortOrder: SortOrder, - ): List { + override suspend fun getListPage(page: Int, filter: MangaListFilter?): List { val url = buildString { append("https://") append(domain) - append("/$listUrl") + append('/') + append(listUrl) if (page > 1) { append("page/") append(page.toString()) - append("/") + append('/') } - append("?order=") - when (sortOrder) { - SortOrder.POPULARITY -> append("popular") - SortOrder.UPDATED -> append("update") - SortOrder.ALPHABETICAL -> append("title") - SortOrder.NEWEST -> append("latest") - SortOrder.RATING -> append("rating") - } - if (!query.isNullOrEmpty()) { - append("&title=") - append(query.urlEncoded()) - } + when (filter) { - if (!tags.isNullOrEmpty()) { - for (tag in tags) { - append("&") - append("genre[]".urlEncoded()) - append("=") - append(tag.key) + is MangaListFilter.Search -> { + append("&title=") + append(filter.query.urlEncoded()) } + + is MangaListFilter.Advanced -> { + + append("?order=") + when (filter.sortOrder) { + SortOrder.POPULARITY -> append("popular") + SortOrder.UPDATED -> append("update") + SortOrder.ALPHABETICAL -> append("title") + SortOrder.NEWEST -> append("latest") + SortOrder.RATING -> append("rating") + } + + filter.tags.forEach { + append("&") + append("genre[]".urlEncoded()) + append("=") + append(it.key) + } + + filter.states.oneOrThrowIfMany()?.let { + append("&status=") + append( + when (it) { + MangaState.ONGOING -> "ongoing" + MangaState.FINISHED -> "completed" + else -> "" + }, + ) + } + } + + null -> append("?order=update") } }