From a4824a3582781274388bf675a93f4a3e59776d62 Mon Sep 17 00:00:00 2001 From: Naga <94557604+NagaYZ@users.noreply.github.com> Date: Thu, 14 Aug 2025 22:07:15 +0200 Subject: [PATCH] RaijinScans: Fixes (#2070) --- .../parsers/site/madara/fr/RaijinScans.kt | 284 +++++++++++++++++- 1 file changed, 283 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/madara/fr/RaijinScans.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/madara/fr/RaijinScans.kt index d0cc50c82..7a9c2e09e 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/madara/fr/RaijinScans.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/madara/fr/RaijinScans.kt @@ -1,12 +1,294 @@ package org.koitharu.kotatsu.parsers.site.madara.fr +import org.jsoup.nodes.Document import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaSourceParser +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.model.YEAR_UNKNOWN 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.parseHtml +import org.koitharu.kotatsu.parsers.util.selectFirstOrThrow +import org.koitharu.kotatsu.parsers.util.selectLast +import org.koitharu.kotatsu.parsers.util.src +import org.koitharu.kotatsu.parsers.util.textOrNull +import org.koitharu.kotatsu.parsers.util.toAbsoluteUrl +import org.koitharu.kotatsu.parsers.util.toTitleCase +import org.koitharu.kotatsu.parsers.util.urlEncoded +import java.text.SimpleDateFormat +import java.util.Base64 +import java.util.Calendar +import java.util.EnumSet +import java.util.Locale @MangaSourceParser("RAIJINSCANS", "RaijinScans", "fr") internal class RaijinScans(context: MangaLoaderContext) : - MadaraParser(context, MangaParserSource.RAIJINSCANS, "raijinscans.net") { + MadaraParser(context, MangaParserSource.RAIJINSCANS, "raijinscan.co", 21) { + override val datePattern = "dd/MM/yyyy" + override val withoutAjax = true + override val listUrl = "" + override val tagPrefix = "genre/" + override val selectBodyPage = "div.protected-image-data" + override val selectChapter = "ul.scroll-sm li.item" + override val selectDate = "span:nth-of-type(2)" + override val selectPage = "div.protected-image-data" + override val selectGenre = "div.genre-list div.genre-link" + override val selectDesc = "div.description-content" + override val selectState = "div.stat-item:has(span:contains(État du titre)) span.manga" + + override val availableSortOrders: Set = EnumSet.of( + SortOrder.UPDATED, + SortOrder.POPULARITY, + SortOrder.ALPHABETICAL, + ) + + override val filterCapabilities: MangaListFilterCapabilities + get() = MangaListFilterCapabilities( + isSearchSupported = true, + isMultipleTagsSupported = true, + isYearSupported = true, + isSearchWithFiltersSupported = true, + ) + + private lateinit var tagMap: Map + + override suspend fun getFilterOptions(): MangaListFilterOptions { + val availableTags = fetchAvailableTags() + tagMap = availableTags.associateBy({ it.title }, { it.key }) + + return MangaListFilterOptions( + availableTags = availableTags, + availableStates = EnumSet.of(MangaState.ONGOING, MangaState.FINISHED), + availableContentTypes = EnumSet.of( + ContentType.MANGA, + ContentType.MANHWA, + ContentType.MANHUA, + ), + ) + } + + override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List { + val url = buildString { + append("https://$domain") + if (page > 0) append("/page/${page + 1}") + + append("?post_type=wp-manga&s=") + filter.query?.let { append(it.urlEncoded()) } + + if (filter.year != YEAR_UNKNOWN) append("&release[]=${filter.year}") + if (!filter.tags.isEmpty()) { + append("&genre_mode=and") + filter.tags.forEach { append("&genre[]=${it.key}") } + } + + filter.states.forEach { + val status = when (it) { + MangaState.ONGOING -> "on-going" + MangaState.FINISHED -> "end" + else -> "" + } + if (status.isNotEmpty()) append("&status[]=$status") + } + + val sortOrder = when (order) { + SortOrder.POPULARITY -> "most_viewed" + SortOrder.UPDATED -> "recently_added" + SortOrder.ALPHABETICAL -> "title_az" + else -> "recently_added" + } + if (sortOrder.isNotEmpty()) append("&sort=$sortOrder") + } + + val doc = webClient.httpGet(url).parseHtml() + return parseMangaList(doc) + } + + + override fun parseMangaList(doc: Document): List { + val elements = doc.select("div.original.card-lg div.unit") + return elements.map { element -> + val linkElement = + element.selectFirst("a.c-title") ?: element.selectFirst("div.info > a") ?: element.selectFirst("a") + ?: error("link not found") + + val href = linkElement.attrAsRelativeUrl("href") + val title = linkElement.text() + val cover = element.selectLast("div.poster-image-wrapper > img")?.src() + + Manga( + id = generateUid(href), + url = href, + publicUrl = href.toAbsoluteUrl(domain), + coverUrl = cover, + title = title, + altTitles = emptySet(), + rating = RATING_UNKNOWN, + tags = emptySet(), + authors = emptySet(), + state = null, + source = source, + contentRating = ContentRating.SAFE, + ) + } + } + + override suspend fun getDetails(manga: Manga): Manga { + val doc = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml() + + val title = doc.selectFirst("h1.serie-title")?.text() ?: manga.title + val cover = doc.selectFirst("img.cover")?.src() ?: manga.coverUrl + + val author = doc.selectFirst("div.stat-item:has(span:contains(Auteur)) span.stat-value")?.text() + + val description = doc.selectFirst(selectDesc)?.text() + + val genres = doc.select(selectGenre).mapNotNullToSet { a -> + val href = a.attr("href") + val genreSlug = href.substringAfter("/manga-genre/").substringBefore("/").toTitleCase() + val genreId = tagMap[genreSlug] + + if (genreId != null) { + MangaTag( + key = genreId, + title = a.text(), + source = source, + ) + } else { + error("Error: Genre '$genreSlug' from detail page not found in filter options map.") + } + } + + val state = doc.selectFirst(selectState)?.text()?.lowercase()?.let { stateText -> + when { + "en cours" in stateText -> MangaState.ONGOING + "terminé" in stateText -> MangaState.FINISHED + else -> null + } + } + + val rating = doc.select(".vote-count").textOrNull()?.toFloat()?.div(10f) ?: RATING_UNKNOWN + + return manga.copy( + title = title, + coverUrl = cover, + authors = setOfNotNull(author), + description = description, + tags = genres, + state = state, + chapters = getChapters(manga, doc), + rating = rating, + ) + } + + override suspend fun getChapters(manga: Manga, doc: Document): List { + return doc.select(selectChapter).mapChapters(reversed = true) { i, element -> + val link = element.selectFirstOrThrow("a") + val href = link.attrAsRelativeUrl("href") + val name = link.attr("title").trim() + val dateText = link.selectFirst(selectDate)?.text() + + MangaChapter( + id = generateUid(href), + title = name, + number = i + 1f, + volume = 0, + url = href, + uploadDate = parseRelativeDate(dateText ?: ""), + source = source, + scanlator = null, + branch = null, + ) + } + } + + override suspend fun getPages(chapter: MangaChapter): List { + val doc = webClient.httpGet(chapter.url.toAbsoluteUrl(domain)).parseHtml() + + return doc.select(selectPage).map { element -> + val encodedUrl = element.attr("data-src") + val imageUrl = String(Base64.getDecoder().decode(encodedUrl)) + + MangaPage( + id = generateUid(imageUrl), + url = imageUrl, + preview = null, + source = source, + ) + } + } + + override suspend fun fetchAvailableTags(): Set { + val doc = webClient.httpGet("https://$domain/?post_type=wp-manga&s=").parseHtml() + + return doc.select("ul.dropdown-menu.c1 li input[type=checkbox][name='genre[]']").mapNotNullToSet { input -> + val value = input.attr("value") + val label = input.nextElementSibling()?.text()?.trim() + + if (value.isNotEmpty() && !label.isNullOrEmpty()) { + MangaTag( + key = value, + title = label.toTitleCase(), + source = source, + ) + } else { + null + } + } + } + + private fun parseRelativeDate(date: String): Long { + val lcDate = date.lowercase(Locale.FRENCH).trim() + val cal = Calendar.getInstance() + val number = """(\d+)""".toRegex().find(lcDate)?.value?.toIntOrNull() + + return when { + "aujourd'hui" in lcDate -> cal.timeInMillis + "hier" in lcDate -> cal.apply { add(Calendar.DAY_OF_MONTH, -1) }.timeInMillis + number != null -> when { + ("h" in lcDate || "heure" in lcDate) && "chapitre" !in lcDate -> cal.apply { + add( + Calendar.HOUR_OF_DAY, + -number, + ) + }.timeInMillis + + "min" in lcDate -> cal.apply { add(Calendar.MINUTE, -number) }.timeInMillis + "jour" in lcDate || lcDate.endsWith("j") -> cal.apply { + add( + Calendar.DAY_OF_MONTH, + -number, + ) + }.timeInMillis + + "semaine" in lcDate -> cal.apply { add(Calendar.WEEK_OF_YEAR, -number) }.timeInMillis + "mois" in lcDate || (lcDate.endsWith("m") && "min" !in lcDate) -> cal.apply { + add( + Calendar.MONTH, + -number, + ) + }.timeInMillis + + "an" in lcDate -> cal.apply { add(Calendar.YEAR, -number) }.timeInMillis + else -> 0L + } + + else -> parseChapterDate(SimpleDateFormat(datePattern, sourceLocale), date) + } + } }