From e7316b5cd004e3e713a1d97b8c73bb62b8b1053c Mon Sep 17 00:00:00 2001 From: Naga <94557604+NagaYZ@users.noreply.github.com> Date: Tue, 29 Jul 2025 18:05:44 +0200 Subject: [PATCH] [InovaScanManga] Remake parser (Close #1988) --- .../kotatsu/parsers/site/fr/InovaScanManga.kt | 271 ++++++++++++++++++ .../parsers/site/madara/fr/InovaScanManga.kt | 14 - 2 files changed, 271 insertions(+), 14 deletions(-) create mode 100644 src/main/kotlin/org/koitharu/kotatsu/parsers/site/fr/InovaScanManga.kt delete mode 100644 src/main/kotlin/org/koitharu/kotatsu/parsers/site/madara/fr/InovaScanManga.kt diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/fr/InovaScanManga.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/fr/InovaScanManga.kt new file mode 100644 index 00000000..d47fa0f3 --- /dev/null +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/fr/InovaScanManga.kt @@ -0,0 +1,271 @@ +package org.koitharu.kotatsu.parsers.site.fr + +import okhttp3.HttpUrl +import org.json.JSONObject +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.SinglePageMangaParser +import org.koitharu.kotatsu.parsers.model.* +import org.koitharu.kotatsu.parsers.util.* +import org.koitharu.kotatsu.parsers.util.json.getStringOrNull +import org.koitharu.kotatsu.parsers.util.json.mapJSON +import java.text.SimpleDateFormat +import java.util.* + +@MangaSourceParser("INOVASCANMANGA", "InovaScanManga", "fr", type = ContentType.HENTAI) +internal class InovaScanManga(context: MangaLoaderContext) : + SinglePageMangaParser(context, MangaParserSource.INOVASCANMANGA) { + override val configKeyDomain = ConfigKey.Domain("inovascanmanga.com") + + override val availableSortOrders: Set = EnumSet.of( + SortOrder.POPULARITY, + SortOrder.NEWEST, + ) + + override val filterCapabilities: MangaListFilterCapabilities + get() = MangaListFilterCapabilities( + isMultipleTagsSupported = true, + isSearchSupported = true, + isSearchWithFiltersSupported = true, + ) + + private var genreCache: Set? = null + + override suspend fun getFilterOptions(): MangaListFilterOptions { + return MangaListFilterOptions( + availableTags = fetchAvailableGenres(), + availableStates = EnumSet.of( + MangaState.ONGOING, + MangaState.FINISHED, + MangaState.PAUSED, + ), + availableContentTypes = EnumSet.of( + ContentType.MANGA, + ContentType.MANHWA, + ContentType.MANHUA, + ), + ) + } + + private suspend fun fetchAvailableGenres(): Set { + genreCache?.let { return it } + + val url = buildApiUrl( + search = "", + sort = "trending", + status = "all", + genre = "all", + type = "all", + ) + + val json = webClient.httpGet(url).parseJson() + val genresArray = json.getJSONArray("availableGenres") + val genres = HashSet(genresArray.length()) + + for (i in 0 until genresArray.length()) { + val genreName = genresArray.getString(i) + genres.add( + MangaTag( + key = genreName.toTitleCase(sourceLocale), + title = genreName.toTitleCase(sourceLocale), + source = source, + ) + ) + } + genreCache = genres + return genres + } + + override suspend fun getList(order: SortOrder, filter: MangaListFilter): List { + + val url = buildApiUrl( + search = filter.query.orEmpty(), + sort = when (order) { + SortOrder.POPULARITY -> "popular" + SortOrder.NEWEST -> "new" + else -> "popular" + }, + status = filter.states.oneOrThrowIfMany()?.let { + when (it) { + MangaState.ONGOING -> "ongoing" + MangaState.FINISHED -> "completed" + MangaState.PAUSED -> "hiatus" + else -> "all" + } + } ?: "all", + genre = filter.tags.oneOrThrowIfMany()?.key ?: "all", + type = filter.types.oneOrThrowIfMany()?.let { + when (it) { + ContentType.MANGA -> "Manga" + ContentType.MANHWA -> "Manhwa" + ContentType.MANHUA -> "Manhua" + else -> "all" + } + } ?: "all", + ) + + val json = webClient.httpGet(url).parseJson() + + return json.getJSONArray("manga").mapJSON { jo -> + parseMangaFromList(jo) + } + } + + override suspend fun getDetails(manga: Manga): Manga { + val json = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseJson() + val mangaJson = json.getJSONObject("manga") + val mangaId = mangaJson.getInt("id") + val chaptersUrl = "https://$domain/api/manga/$mangaId/chapters" + val chaptersJson = webClient.httpGet(chaptersUrl).parseJson() + val allChapters = parseAllChapters(chaptersJson, mangaId, mangaJson.optString("team_name").nullIfEmpty()) + + return parseMangaDetails(mangaJson, allChapters) + } + + override suspend fun getPages(chapter: MangaChapter): List { + val json = webClient.httpGet(chapter.url.toAbsoluteUrl(domain)).parseJson() + return json.getJSONArray("pages").mapJSON { jo -> + val url = jo.getString("url").toAbsoluteUrl(domain) + MangaPage( + id = generateUid(url), + url = url, + preview = null, + source = source, + ) + } + } + + private fun buildApiUrl( + search: String, + sort: String, + status: String, + genre: String, + type: String, + ): String = buildString { + append("https://") + append(domain) + append("/api/manga/discover?") + append("search=").append(search.urlEncoded()) + append("&sort=").append(sort) + append("&status=").append(status) + append("&genre=").append(genre.urlEncoded()) + append("&type=").append(type) + append("&year=").append("all") + } + + private fun parseMangaFromList(jo: JSONObject): Manga { + val mangaId = jo.getInt("id") + val mangaUrl = "/api/manga/$mangaId" + val genres = jo.getJSONArray("genres") + + return Manga( + id = generateUid(mangaUrl), + url = mangaUrl, + publicUrl = "https://$domain/manga/$mangaId", + coverUrl = jo.getStringOrNull("cover_url")?.toAbsoluteUrl(domain), + title = jo.getString("title"), + altTitles = emptySet(), + rating = jo.optDouble("rating", 0.0).let { if (it > 0) it.toFloat() / 10f else RATING_UNKNOWN }, + tags = parseGenreTags(genres), + authors = emptySet(), + state = parseStatus(jo.getString("status")), + source = source, + contentRating = if (genres.toString().contains("Adulte") || genres.toString().contains("Mature") || isNsfwSource) { + ContentRating.ADULT + } else null, + ) + } + + private fun parseMangaDetails(mangaJson: JSONObject, chapters: List): Manga { + val mangaId = mangaJson.getInt("id") + val mangaUrl = "/api/manga/$mangaId" + val genres = mangaJson.getJSONArray("genres") + + val authors = parseStringArray(mangaJson.optJSONArray("authors")) + val artists = parseStringArray(mangaJson.optJSONArray("artists")) + + return Manga( + id = generateUid(mangaUrl), + url = mangaUrl, + publicUrl = "https://$domain/manga/$mangaId", + coverUrl = mangaJson.getStringOrNull("cover_url")?.toAbsoluteUrl(domain), + title = mangaJson.getString("title"), + altTitles = parseStringArray(mangaJson.optJSONArray("alternative_titles")), + rating = mangaJson.optDouble("rating", 0.0).let { if (it > 0) it.toFloat() / 10f else RATING_UNKNOWN }, + tags = parseGenreTags(genres), + authors = authors + artists, + state = parseStatus(mangaJson.getString("status")), + source = source, + contentRating = if (genres.toString().contains("Adulte") || genres.toString().contains("Mature")) { + ContentRating.ADULT + } else null, + description = mangaJson.getString("description"), + chapters = chapters, + ) + } + + private fun parseAllChapters(json: JSONObject, mangaId: Int, teamName: String?): List { + return json.getJSONArray("chapters").mapJSON { jo -> + val chapterId = jo.getInt("id") + val chapterUrl = "/api/manga/$mangaId/chapters/$chapterId/pages" + val numberStr = jo.getString("number") + + val chapterNumber = numberStr.substringBefore('.').substringBefore(' ').toFloatOrNull() ?: 0f + + MangaChapter( + id = generateUid(chapterUrl), + title = jo.optString("title").nullIfEmpty(), + number = chapterNumber, + volume = 0, + url = chapterUrl, + scanlator = teamName, + uploadDate = parseDate(jo.getString("date")), + branch = null, + source = source, + ) + }.reversed() + } + + private fun parseGenreTags(genresArray: org.json.JSONArray): Set { + val tags = HashSet(genresArray.length()) + for (i in 0 until genresArray.length()) { + val genreName = genresArray.getString(i) + tags.add( + MangaTag( + key = genreName.toTitleCase(), + title = genreName.toTitleCase(), + source = source, + ) + ) + } + return tags + } + + private fun parseStringArray(array: org.json.JSONArray?): Set { + if (array == null) return emptySet() + val result = HashSet(array.length()) + for (i in 0 until array.length()) { + result.add(array.getString(i)) + } + return result + } + + private fun parseStatus(status: String): MangaState? = when (status) { + "ongoing" -> MangaState.ONGOING + "completed" -> MangaState.FINISHED + "hiatus" -> MangaState.PAUSED + else -> null + } + + private fun parseDate(dateStr: String): Long { + return SimpleDateFormat("dd/MM/yyyy", Locale.FRENCH).parse(dateStr) + ?.time ?: 0L + } + + override suspend fun resolveLink(resolver: LinkResolver, link: HttpUrl): Manga? { + val mangaId = link.pathSegments.lastOrNull()?.toIntOrNull() ?: return null + val apiUrl = "/api/manga/$mangaId" + return resolver.resolveManga(this, url = apiUrl, id = generateUid(apiUrl)) + } +} diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/madara/fr/InovaScanManga.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/madara/fr/InovaScanManga.kt deleted file mode 100644 index 1c3a13e9..00000000 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/madara/fr/InovaScanManga.kt +++ /dev/null @@ -1,14 +0,0 @@ -package org.koitharu.kotatsu.parsers.site.madara.fr - -import org.koitharu.kotatsu.parsers.Broken -import org.koitharu.kotatsu.parsers.MangaLoaderContext -import org.koitharu.kotatsu.parsers.MangaSourceParser -import org.koitharu.kotatsu.parsers.model.MangaParserSource -import org.koitharu.kotatsu.parsers.site.madara.MadaraParser - -@Broken("Source change template") -@MangaSourceParser("INOVASCANMANGA", "InovaScanManga", "fr") -internal class InovaScanManga(context: MangaLoaderContext) : - MadaraParser(context, MangaParserSource.INOVASCANMANGA, "inovascanmanga.com") { - override val datePattern = "d MMMM yyyy" -}