diff --git a/.github/summary.yaml b/.github/summary.yaml index aab494fc1..a83c28076 100644 --- a/.github/summary.yaml +++ b/.github/summary.yaml @@ -1 +1 @@ -total: 1244 \ No newline at end of file +total: 1245 \ No newline at end of file diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/madara/ar/ArabsHentai.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/madara/ar/ArabsHentai.kt new file mode 100644 index 000000000..e1293c93a --- /dev/null +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/madara/ar/ArabsHentai.kt @@ -0,0 +1,382 @@ +package org.koitharu.kotatsu.parsers.site.madara.ar + +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import org.jsoup.nodes.Document +import org.jsoup.nodes.Element +import org.koitharu.kotatsu.parsers.MangaLoaderContext +import org.koitharu.kotatsu.parsers.MangaSourceParser +import org.koitharu.kotatsu.parsers.exception.ParseException +import org.koitharu.kotatsu.parsers.model.ContentRating +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.MangaListFilter +import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities +import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions +import org.koitharu.kotatsu.parsers.model.MangaPage +import org.koitharu.kotatsu.parsers.model.MangaParserSource +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.site.madara.MadaraParser +import org.koitharu.kotatsu.parsers.util.attrAsRelativeUrl +import org.koitharu.kotatsu.parsers.util.generateUid +import org.koitharu.kotatsu.parsers.util.mapChapters +import org.koitharu.kotatsu.parsers.util.mapNotNullToSet +import org.koitharu.kotatsu.parsers.util.oneOrThrowIfMany +import org.koitharu.kotatsu.parsers.util.parseHtml +import org.koitharu.kotatsu.parsers.util.selectFirstOrThrow +import org.koitharu.kotatsu.parsers.util.src +import org.koitharu.kotatsu.parsers.util.toAbsoluteUrl +import org.koitharu.kotatsu.parsers.util.toRelativeUrl +import org.koitharu.kotatsu.parsers.util.urlEncoded +import java.text.SimpleDateFormat +import java.util.EnumSet +import java.util.Locale + +@MangaSourceParser("ARABSHENTAI", "Arabs Hentai", "ar", ContentType.HENTAI) +internal class ArabsHentai(context: MangaLoaderContext) : + MadaraParser(context, MangaParserSource.ARABSHENTAI, domain = "arabshentai.com", 25) { + + override val withoutAjax = true + override val sourceLocale: Locale = Locale("ar") + override val listUrl = "manga/" + override val datePattern = "yyyy-MM-dd" + override val selectDate = ".chapterdate" + override val selectDesc = "#manga-info .wp-content p" + override val selectState = "#manga-info div b:contains(حالة المانجا)" + override val selectAlt = "#manga-info div b:contains(أسماء أُخرى) + span" + override val selectGenre = ".data .sgeneros a" + override val selectPage = ".chapter_image img.wp-manga-chapter-img" + override val selectChapter = "#chapter-list a[href*='/manga/'], .oneshot-reader" + + override val availableSortOrders: Set = EnumSet.of( + SortOrder.UPDATED, + SortOrder.NEWEST, + SortOrder.ALPHABETICAL, + ) + + override val filterCapabilities: MangaListFilterCapabilities + get() = MangaListFilterCapabilities( + isMultipleTagsSupported = true, + isSearchSupported = true, + isSearchWithFiltersSupported = true, + isAuthorSearchSupported = true, + ) + + override suspend fun getFilterOptions() = MangaListFilterOptions( + availableTags = fetchAvailableTags(), + availableStates = EnumSet.of( + MangaState.ONGOING, + MangaState.FINISHED, + MangaState.ABANDONED, + MangaState.PAUSED, + ), + availableContentTypes = EnumSet.of( + ContentType.MANGA, + ContentType.MANHWA, + ContentType.MANHUA, + ContentType.DOUJINSHI, + ContentType.ONE_SHOT, + ), + ) + + override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List { + val pages = page + 1 + + val url = when { + !filter.query.isNullOrEmpty() || filter.tags.isNotEmpty() -> { + buildString { + append("https://") + append(domain) + if (pages > 1) { + append("/page/") + append(pages) + } + append("/?s=") + append(filter.query?.urlEncoded() ?: "") + + filter.tags.forEach { tag -> + append("&genre%5B%5D=") + append(tag.key.urlEncoded()) + } + + if (filter.tags.size > 1) { + append("&op=1") + } + + filter.states.forEach { state -> + append("&status%5B%5D=") + append( + when (state) { + MangaState.ONGOING -> "on-going" + MangaState.FINISHED -> "end" + MangaState.ABANDONED -> "canceled" + MangaState.PAUSED -> "on-hold" + else -> "" + }, + ) + } + + append("&alternative=&author=&artist=") + } + } + + else -> { + buildString { + append("https://") + append(domain) + append("/manga/") + if (pages > 1) { + append("page/") + append(pages) + append("/") + } + + val params = mutableListOf() + + filter.types.forEach { type -> + params.add( + "type=" + when (type) { + ContentType.MANGA -> "manga" + ContentType.MANHWA -> "manhwa" + ContentType.MANHUA -> "manhua" + ContentType.DOUJINSHI -> "doujinshi" + ContentType.ONE_SHOT -> "one-shot" + else -> "manga" + }, + ) + } + + params.add( + "orderby=" + when (order) { + SortOrder.NEWEST -> "new-manga" + SortOrder.ALPHABETICAL -> "alphabet" + SortOrder.UPDATED -> "new_chapter" + else -> "new_chapter" + }, + ) + + filter.states.oneOrThrowIfMany()?.let { state -> + params.add( + "state=" + when (state) { + MangaState.ONGOING -> "on-going" + MangaState.FINISHED -> "end" + MangaState.ABANDONED -> "canceled" + MangaState.PAUSED -> "on-hold" + else -> "" + }, + ) + } + + if (params.isNotEmpty()) { + append("?") + append(params.joinToString("&")) + } + } + } + } + + return parseMangaList(webClient.httpGet(url).parseHtml()) + } + + override fun parseMangaList(doc: Document): List { + val searchElements = doc.select(".search-page .result-item article:not(:has(.tvshows))") + + if (searchElements.isNotEmpty()) { + return searchElements.map { element -> + val titleElement = element.selectFirstOrThrow(".details .title a") + val href = titleElement.attrAsRelativeUrl("href") + + val coverUrl = element.run { + val postId = attr("id").substringAfter("post-").ifBlank { null } + val img = selectFirst(".image .thumbnail a img") + val lazySrc = img?.attr("data-src") + + if (postId != null && !lazySrc.isNullOrBlank() && lazySrc.contains("/uploads/")) { + "${lazySrc.substringBeforeLast('/')}/cover-$postId.webp" + } else { + img?.src() + } + } + + Manga( + id = generateUid(href), + url = href, + publicUrl = href.toAbsoluteUrl(domain), + title = titleElement.text(), + coverUrl = coverUrl, + source = source, + contentRating = ContentRating.ADULT, + altTitles = emptySet(), + rating = RATING_UNKNOWN, + tags = emptySet(), + authors = emptySet(), + state = null, + ) + } + } + + return doc.select("#archive-content .wp-manga").map { element -> + val titleElement = element.selectFirstOrThrow(".data h3 a") + val href = titleElement.attrAsRelativeUrl("href") + + val coverUrl = element.run { + val postId = attr("id").substringAfter("post-").ifBlank { null } + val img = selectFirst("a .poster img") + val lazySrc = img?.attr("data-src") + + if (postId != null && !lazySrc.isNullOrBlank() && lazySrc.contains("/uploads/")) { + "${lazySrc.substringBeforeLast('/')}/cover-$postId.webp" + } else { + img?.src() + } + } + + Manga( + id = generateUid(href), + url = href, + publicUrl = href.toAbsoluteUrl(domain), + title = titleElement.text(), + coverUrl = coverUrl, + source = source, + contentRating = ContentRating.ADULT, + altTitles = emptySet(), rating = RATING_UNKNOWN, tags = emptySet(), authors = emptySet(), state = null, + ) + } + } + + override suspend fun fetchAvailableTags(): Set { + val doc = webClient.httpGet("https://$domain/تصنيفات/").parseHtml() + + return doc.select("#archive-content ul.genre-list li.item-genre .genre-data a") + .mapNotNullToSet { a -> + val key = a.attr("href").substringAfter(tagPrefix).removeSuffix("/") + val title = a.ownText().trim() + + MangaTag( + key = key, + title = title, + source = source, + ) + } + } + + override suspend fun getChapters(manga: Manga, doc: Document): List { + val oneshotReader = doc.selectFirst(".oneshot-reader") + if (oneshotReader != null) { + val firstImageLink = oneshotReader.selectFirst(".image-item a[href*='?style=paged']") + val chapterUrl = firstImageLink?.attr("href")?.substringBeforeLast("?") ?: manga.url + + return listOf( + MangaChapter( + id = generateUid(chapterUrl), + title = "ونشوت", + number = 1f, + volume = 0, + url = chapterUrl.toRelativeUrl(domain), + uploadDate = 0L, + source = source, + scanlator = null, + branch = null, + ), + ) + } + + val dateFormat = SimpleDateFormat(datePattern, sourceLocale) + return doc.select("#chapter-list a[href*='/manga/']").mapChapters(reversed = true) { i, element -> + val href = element.attr("href") + MangaChapter( + id = generateUid(href), + title = element.select(".chapternum").text().ifEmpty { "Chapter ${i + 1}" }, + number = i + 1f, + volume = 0, + url = href.toRelativeUrl(domain), + uploadDate = parseChapterDate(dateFormat, element.select(selectDate).text()), + source = source, + scanlator = null, + branch = null, + ) + } + } + + override suspend fun getDetails(manga: Manga): Manga = coroutineScope { + val doc = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml() + + val chaptersDeferred = async { getChapters(manga, doc) } + + manga.copy( + title = doc.selectFirst(".sheader .data h1")?.text() ?: manga.title, + coverUrl = doc.selectFirst(".sheader .poster img")?.src() ?: manga.coverUrl, + description = doc.select(selectDesc).text(), + altTitles = doc.select(selectAlt) + .text() + .split(",") + .map { it.trim() } + .filter { it.isNotEmpty() } + .toSet(), + authors = doc.select("#manga-info div b:contains(الكاتب) + span a") + .mapNotNullToSet { it.text().takeIf { text -> text.isNotEmpty() } }, + tags = doc.select(selectGenre) + .mapNotNullToSet { a -> + MangaTag( + key = a.attr("href").substringAfter(tagPrefix).removeSuffix("/"), + title = a.text(), + source = source, + ) + }, + rating = doc.selectFirst(".dt_rating_vgs")?.text()?.toFloatOrNull()?.div(10f) ?: RATING_UNKNOWN, + state = parseStatus(doc.select("#manga-info div b:contains(حالة المانجا) + span").text()), + chapters = chaptersDeferred.await(), + ) + } + + private fun parseStatus(status: String): MangaState? { + return when { + status.contains("مستمر", ignoreCase = true) -> MangaState.ONGOING + status.contains("مكتمل", ignoreCase = true) -> MangaState.FINISHED + status.contains("متوقف", ignoreCase = true) -> MangaState.PAUSED + status.contains("ملغية", ignoreCase = true) -> MangaState.ABANDONED + else -> null + } + } + + override suspend fun getPages(chapter: MangaChapter): List { + val fullUrl = chapter.url.toAbsoluteUrl(domain) + val doc = webClient.httpGet(fullUrl).parseHtml() + + val oneshotReader = doc.selectFirst(".oneshot-reader") + return oneshotReader?.select(".image-item img.oneshot-chapter-img")?.map { img -> + val url = img.imgAttr() ?: throw ParseException("Image URL not found", fullUrl) + MangaPage( + id = generateUid(url), + url = url, + preview = null, + source = source, + ) + } + ?: doc.select(selectPage).map { img -> + val url = img.imgAttr() ?: throw ParseException("Image URL not found", fullUrl) + MangaPage( + id = generateUid(url), + url = url, + preview = null, + source = source, + ) + } + } + + private fun Element.imgAttr(): String? { + return when { + hasAttr("data-src") -> attr("abs:data-src") + hasAttr("src") && attr("src").isNotEmpty() -> attr("abs:src") + hasAttr("srcset") -> attr("abs:srcset").substringBefore(" ") + hasAttr("data-cfsrc") -> attr("abs:data-cfsrc") + hasAttr("data-lazy-src") -> attr("abs:data-lazy-src") + hasAttr("bv-data-src") -> attr("bv-data-src") + else -> null + } + } +}