diff --git a/.github/summary.yaml b/.github/summary.yaml index c055f902..9274529e 100644 --- a/.github/summary.yaml +++ b/.github/summary.yaml @@ -1 +1 @@ -total: 1193 \ No newline at end of file +total: 1194 \ No newline at end of file diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/vi/NhentaiWorld.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/vi/NhentaiWorld.kt new file mode 100644 index 00000000..7e0f7323 --- /dev/null +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/vi/NhentaiWorld.kt @@ -0,0 +1,227 @@ +package org.koitharu.kotatsu.parsers.site.vi + +import okhttp3.Headers +import org.json.JSONArray +import org.json.JSONObject +import org.koitharu.kotatsu.parsers.Broken +import org.koitharu.kotatsu.parsers.MangaLoaderContext +import org.koitharu.kotatsu.parsers.MangaSourceParser +import org.koitharu.kotatsu.parsers.config.ConfigKey +import org.koitharu.kotatsu.parsers.core.LegacyPagedMangaParser +import org.koitharu.kotatsu.parsers.model.* +import org.koitharu.kotatsu.parsers.util.* +import org.koitharu.kotatsu.parsers.util.json.getStringOrNull +import java.text.SimpleDateFormat +import java.util.* + +@Broken // TODO +@MangaSourceParser("NHENTAIWORLD", "Nhentai World", "vi", ContentType.HENTAI) +internal class NhentaiWorld(context: MangaLoaderContext) : + LegacyPagedMangaParser(context, MangaParserSource.NHENTAIWORLD, 24) { + + override val configKeyDomain = ConfigKey.Domain("nhentaiworld-h1.info") + + override fun onCreateConfig(keys: MutableCollection>) { + super.onCreateConfig(keys) + keys.add(userAgentKey) + } + + override fun getRequestHeaders(): Headers = Headers.Builder() + .add("origin", "https://$domain") + .add("referer", "https://$domain") + .build() + + override val availableSortOrders: Set = EnumSet.of( + SortOrder.UPDATED, + SortOrder.POPULARITY, + ) + + override val filterCapabilities: MangaListFilterCapabilities + get() = MangaListFilterCapabilities( + isSearchSupported = true, + isSearchWithFiltersSupported = true, + isMultipleTagsSupported = false, + ) + + override suspend fun getFilterOptions() = MangaListFilterOptions( + availableTags = fetchTags(), + availableStates = EnumSet.of(MangaState.ONGOING, MangaState.FINISHED), + ) + + override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List { + val url = buildString { + append("/genre/") + if (filter.tags.isEmpty()) { + append("all") + } else { + append(filter.tags.first().key) + } + + append("?sort=") + append( + when (order) { + SortOrder.UPDATED -> "recent-update" + SortOrder.POPULARITY -> "view" + else -> "recent-update" + }, + ) + + if (!filter.query.isNullOrEmpty()) { + append("&search=") + append(filter.query.urlEncoded()) + } + + if (filter.states.isNotEmpty()) { + append("&status=") + append( + when (filter.states.first()) { + MangaState.ONGOING -> "progress" + MangaState.FINISHED -> "completed" + else -> "" + }, + ) + } + + append("&page=") + append(page) + } + + val doc = webClient.httpGet(url.toAbsoluteUrl(domain)).parseHtml() + return doc.select("div.relative.mb-1.h-full.max-h-\\[375px\\]").map { div -> + val img = div.selectFirst("img.hover\\:scale-105.transition-all.w-full.h-full") + val a = div.selectFirstOrThrow("a") + + val title = img?.attr("alt").orEmpty() + val coverUrl = img?.attrAsAbsoluteUrlOrNull("src") + val href = a.attrAsRelativeUrl("href") + + Manga( + id = generateUid(url), + title = title, + altTitles = emptySet(), + url = href, + publicUrl = href.toAbsoluteUrl(domain), + rating = RATING_UNKNOWN, + contentRating = ContentRating.ADULT, + coverUrl = coverUrl, + tags = emptySet(), + state = null, + authors = emptySet(), + source = source, + ) + } + } + + + override suspend fun getDetails(manga: Manga): Manga { + val doc = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml() + val root = doc.selectFirst("div.flex-1.bg-neutral-900") ?: return manga + val chapterDateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.ROOT).apply { + timeZone = TimeZone.getTimeZone("GMT+7") + } + + val tags = root.select("div.flex.flex-wrap.gap-2 button").mapNotNullToSet { button -> + val tagName = button.text().toTitleCase(sourceLocale) + val tagUrl = button.parent()?.attrOrNull("href")?.substringAfterLast('/') + if (tagUrl != null) { + MangaTag(title = tagName, key = tagUrl, source = source) + } else { + null + } + } + + val state = when { + root.selectFirst("a[href*='status=completed']") != null -> MangaState.FINISHED + root.selectFirst("a[href*='status=progress']") != null -> MangaState.ONGOING + else -> null + } + + val description = root.selectFirst("div#introduction-wrap p.font-light")?.html()?.nullIfEmpty() + + val altTitles = description?.split("\n")?.mapNotNullToSet { line -> + when { + line.startsWith("Tên tiếng anh:", ignoreCase = true) -> + line.substringAfter(':').substringBefore("Tên gốc:").trim() + + line.startsWith("Tên gốc:", ignoreCase = true) -> + line.substringAfter(':').trim().substringBefore(' ') + + else -> null + } + } + + val scriptTag = doc.select("script").firstOrNull { it.data().contains("\"data\":") }?.data() + + val chapters = ArrayList() + if (!scriptTag.isNullOrEmpty()) { + val jsonData = JSONObject(scriptTag) + + val viChaptersArray: JSONArray = jsonData.optJSONArray("data") ?: JSONArray() + val enChaptersArray: JSONArray = jsonData.optJSONArray("chapterListEn") ?: JSONArray() + + listOf( + Pair("Tiếng Việt", viChaptersArray), + Pair("English", enChaptersArray), + ).flatMapTo(chapters) { (branch: String, chaptersArray: JSONArray) -> + List(chaptersArray.length()) { i -> + val chapterObj = chaptersArray.getJSONObject(i) + val chapterName = chapterObj.getStringOrNull("name") + val uploadDateStr = chapterObj.getStringOrNull("createdAt") + val uploadDate = chapterDateFormat.tryParse(uploadDateStr) + + if (!chapterName.isNullOrEmpty()) { + MangaChapter( + id = generateUid("${manga.url}/$chapterName?lang=${if (branch == "Tiếng Việt") "VI" else "EN"}"), + title = chapterName, + number = chapterName.toFloatOrNull() ?: (i + 1).toFloat(), + url = "/read/${manga.id}/$chapterName?lang=${if (branch == "Tiếng Việt") "VI" else "EN"}", + scanlator = null, + uploadDate = uploadDate, + branch = branch, + source = source, + volume = 0, + ) + } else null + }.filterNotNull() + } + } + + return manga.copy( + tags = tags, + state = state, + description = description, + altTitles = altTitles.orEmpty(), + chapters = chapters, + ) + } + + + override suspend fun getPages(chapter: MangaChapter): List { + val doc = webClient.httpGet(chapter.url.toAbsoluteUrl(domain)).parseHtml() + return doc.select("img.m-auto.read-image.w-auto.h-auto.md\\:min-h-\\[800px\\].min-h-\\[300px\\]") + .map { img -> + val url = img.requireSrc() + MangaPage( + id = generateUid(url), + url = url, + preview = null, + source = source, + ) + } + } + + private suspend fun fetchTags(): Set { + val doc = webClient.httpGet("$domain/genre/all").parseHtml() + val tagItems = doc.select("div.genre-list a") + return tagItems.mapNotNullToSet { item -> + val title = item.text().toTitleCase(sourceLocale) + val key = item.attr("href").substringAfterLast('/') + if (key.isNotEmpty() && title.isNotEmpty()) { + MangaTag(title = title, key = key, source = source) + } else { + null + } + } + } +} +