diff --git a/README.md b/README.md index 10228b04..f4b9f274 100644 --- a/README.md +++ b/README.md @@ -3,17 +3,8 @@ [![Stars][stars-shield]][stars-url] [![Issues][issues-shield]][issues-url] [![License][license-shield]][license-url] - -# Sources Requests - -Open up an Issue with these contents: - -- URL -- Name -- language? -- NSFW yes/no - -Or join the Support Server or contact me over Telegram (see Contact Info) +### Please give the repo a ⭐ +It motivates me. =^..^= --- @@ -31,65 +22,35 @@ Or join the Support Server or contact me over Telegram (see Contact Info) # Tutorial -
-How to add a Plugin in Usagi - -
- -There are actually two ways, one which you manually update the Plugin from time to time **or** let it do automatically. +> [!TIP] +> Two ways how to add the plugin: +> 1. Automatically (recommended) +> 2. Manually after every new release. -
---- +> [!IMPORTANT] +> The plugin will not update automatically unless you enable this feature (see `❓ Where?` in automatic category). +> You can manually check anytime in `Manage Plugins` for an `Install` button to update. -
-Manually way -
-📥 How to download .jar File - -![Screenshot][img3] +How to add a Plugin in Usagi -
-📷 Image Turoial - -![Screenshot][img5] - -
- -1. Download the latest version of the Plugin from the [Releases](https://github.com/InvalidDavid/UMA/releases) page. -Click on the blue Name or press the three dots on the right side and press Download. - - -2. Go back to the App and open up `Explore` - - -3. Click on `three Dots in the top right corner` and select `Manage Sources` - - -4. Go to `Manage Plugins` and press the `Add` / `+` button in the top right corner. - - -5. Select `Import from local storage` and select `UMA.jar` file which you downloaded in the previous step. - -And that's it! Select your new sources in the catalog and have fun reading. -
+Automatically (recommended)
---- -
-Automatically +📷 Image Tutorial -
+![Screenshot][img_automatic_tutorial] +
-📷 Image Tutorial +❓ Where? -![Screenshot][img] +![Screenshot][img_activate_auto]
@@ -120,22 +81,50 @@ InvalidDavid/UMA And that's it! Select your new sources in the catalog and have fun reading. -> **Note:** The plugin will not update automatically unless you enable this feature (see screenshot). -> You can manually check anytime in `Manage Plugins` for an `Install` button to update. +
+ + + +---
-❓ Where? +Manually way -![Screenshot][img4] +
+
+📥 How to download .jar File + +![Screenshot][img_how_to_download]
+
+📷 Image Turoial +![Screenshot][img_manual_tutorial]
+1. Download the latest version of the Plugin from the [Releases](https://github.com/InvalidDavid/UMA/releases) page. + Click on the blue Name or press the three dots on the right side and press Download. + + +2. Go back to the App and open up `Explore` + + +3. Click on `three Dots in the top right corner` and select `Manage Sources` + + +4. Go to `Manage Plugins` and press the `Add` / `+` button in the top right corner. + + +5. Select `Import from local storage` and select `UMA.jar` file which you downloaded in the previous step. + +And that's it! Select your new sources in the catalog and have fun reading. +
---- +
+ # Setup @@ -176,6 +165,31 @@ chmod +x gradlew && ./gradlew buildJar --- +# Sources Requests + +> [!NOTE] +> Open up an [Issue](https://github.com/InvalidDavid/UMA/issues/new) with these contents: +> Title Source Name +> - URL +> - language? +> - NSFW yes/no +> +> Or via Contact Info (see above) + +--- + +## Star History + + + + + + Star History Chart + + + +--- + # Credits * Thanks to [KotatsuApp](https://github.com/KotatsuApp) for providing some parsers and the core library. @@ -205,9 +219,7 @@ You may copy, distribute and modify the software as long as you track changes/da [telegram-shield]: https://img.shields.io/badge/-Telegram-black.svg?style=for-the-badge&logo=telegram&colorB=555 [telegram-url]: https://t.me/invalidxdavid -[img]: https://cdn.discordapp.com/attachments/1518283800830939176/1519723800327880826/image.png?ex=6a3e9895&is=6a3d4715&hm=818321ffb9a46f31c9148b5f4a234cac44b5a1ca559dc77c84206d451937ab80& -[img1]: https://cdn.discordapp.com/attachments/1518283800830939176/1519672301883363409/image.png?ex=6a3e689e&is=6a3d171e&hm=fca40dfdf33ea99b07e7237b4d9a44afea1991be7b59f750a6bc9a906146c53e& -[img2]: https://cdn.discordapp.com/attachments/1518283800830939176/1519672539532628068/image.png?ex=6a3e68d7&is=6a3d1757&hm=1551bf57fde5d3e8c7cb53c182347bfcb3b2ff58fa297c88148338e88c2b5fe1& -[img3]: https://cdn.discordapp.com/attachments/1518283800830939176/1519673238316126268/image.png?ex=6a3e697e&is=6a3d17fe&hm=ed8e12ca85af9b8aa461463bf8095d958e46614e5478b97f95363875a81ab62b& -[img4]: https://cdn.discordapp.com/attachments/1518283800830939176/1519673924554588321/image.png?ex=6a3e6a21&is=6a3d18a1&hm=2321b2d2a632080101312427741a790be7b1c3c15fd32cb82e5e3ba86fcfbe39& -[img5]: https://cdn.discordapp.com/attachments/1518283800830939176/1519813607251116074/image.png?ex=6a3eec38&is=6a3d9ab8&hm=3cc4e454a7afd4233000cd0cf8efb8aa423841312b7399573e6ac5dbc90abdfd& +[img_automatic_tutorial]: https://github.com/user-attachments/assets/1e4059f4-9c13-4bf9-9962-a9db28d2736d +[img_manual_tutorial]: https://github.com/user-attachments/assets/a587ebfa-1cd8-4ce7-a437-108c0a5340cd +[img_how_to_download]: https://github.com/user-attachments/assets/3539fb68-f417-4aac-a6d7-f7fdd7f5b7bb +[img_activate_auto]: https://github.com/user-attachments/assets/69d807be-3e2f-44ef-9cef-329b520f1437 diff --git a/build.gradle.kts b/build.gradle.kts index c0ab7906..81ed09e1 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -10,7 +10,7 @@ plugins { } group = "org.usagi" -version = "1.0.0" +version = "1.0.1" tasks.test { useJUnitPlatform() diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/DoujinDesuParser.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/DoujinDesuParser.kt new file mode 100644 index 00000000..cabf4992 --- /dev/null +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/DoujinDesuParser.kt @@ -0,0 +1,373 @@ +package org.koitharu.kotatsu.parsers.parsers + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import okhttp3.Headers +import org.json.JSONArray +import org.json.JSONObject +import org.koitharu.kotatsu.parsers.MangaLoaderContext +import org.koitharu.kotatsu.parsers.config.ConfigKey +import org.koitharu.kotatsu.parsers.core.PagedMangaParser +import org.koitharu.kotatsu.parsers.model.* +import org.koitharu.kotatsu.parsers.util.* +import java.net.URLDecoder +import java.time.Instant +import java.util.* +import kotlin.time.Duration.Companion.milliseconds + +internal abstract class DoujinDesuParser( + context: MangaLoaderContext, + source: MangaParserSource +): PagedMangaParser(context, source, pageSize = 18) { + + protected abstract val defaultTypes: String + protected abstract val availableContentTypes: Set + + override val configKeyDomain = ConfigKey.Domain("doujin.desu.xxx") + + override fun onCreateConfig(keys: MutableCollection>) { + super.onCreateConfig(keys) + keys.add(userAgentKey) + } + + @Volatile + private var genresCache: Set? = null + private val genresMutex = Mutex() + + @get:Synchronized + private val detailsCache = object : LinkedHashMap(16, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean = size > 10 + } + + override val defaultSortOrder: SortOrder = SortOrder.UPDATED + + override val availableSortOrders: Set = EnumSet.of( + SortOrder.UPDATED, SortOrder.NEWEST, SortOrder.ALPHABETICAL, SortOrder.POPULARITY + ) + + override val filterCapabilities = MangaListFilterCapabilities( + isMultipleTagsSupported = true, + isSearchSupported = true, + isSearchWithFiltersSupported = true, + isAuthorSearchSupported = false, + ) + + override suspend fun getFilterOptions() = MangaListFilterOptions( + availableTags = getOrFetchGenres(), + availableStates = EnumSet.of(MangaState.ONGOING, MangaState.FINISHED), + availableContentTypes = availableContentTypes, + ) + + private suspend fun getOrFetchGenres(): Set { + genresCache?.let { return it } + return genresMutex.withLock { + genresCache ?: run { + genresCache = fetchAvailableTags() + genresCache!! + } + } + } + + private suspend fun fetchAvailableTags(): Set { + val url = "/api/terms?taxonomy=genre".toAbsoluteUrl(domain) + val jsonResponse = executeWithCloudflareRetry(url, getRequestHeaders()).parseJson() + val decrypted = decrypt(jsonResponse.getString("_enc_resp_")) + val array = JSONArray(decrypted) + return (0 until array.length()).mapTo(mutableSetOf()) { i -> + val obj = array.getJSONObject(i) + MangaTag(key = obj.getString("slug"), title = obj.getString("name"), source = source) + } + } + + override fun getRequestHeaders() = Headers.Builder() + .add("X-Requested-With", "XMLHttpRequest") + .add("Referer", "https://$domain/") + .add("X-App-Secret", "dfdf72051dbfdc7d76889ebd31324e74") + .build() + + private suspend fun executeWithCloudflareRetry(url: String, headers: Headers): okhttp3.Response { + var response = webClient.httpGet(url, headers) + if (response.code == 403) { + val bodyString = response.body?.string().orEmpty() + if ("Unauthorized" in bodyString || "Access denied" in bodyString) { + response.close() + refreshCloudflare() + response = webClient.httpGet(url, headers) + } + } + if (response.code in 500..599) { + response.close() + kotlinx.coroutines.delay(1000L.milliseconds) + response = webClient.httpGet(url, headers) + } + return response + } + + private suspend fun refreshCloudflare() { + webClient.httpGet("https://$domain/", getRequestHeaders()).close() + } + + override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List { + val limit = pageSize + val offset = (page - 1) * limit + + val url = urlBuilder().apply { + addPathSegment("api") + addPathSegment("manga") + addQueryParameter("limit", limit.toString()) + addQueryParameter("offset", offset.toString()) + + filter.query?.let { addQueryParameter("search", it) } + + val sortParam = when (order) { + SortOrder.POPULARITY -> "rating" + SortOrder.ALPHABETICAL -> "title_asc" + SortOrder.NEWEST -> "newest" + SortOrder.UPDATED -> "latest_chapter" + else -> "latest_chapter" + } + addQueryParameter("sort", sortParam) + + filter.types.oneOrThrowIfMany()?.let { + when (it) { + ContentType.MANGA -> "manga" + ContentType.MANHWA -> "manhwa" + ContentType.DOUJINSHI -> "doujinshi" + else -> null + }?.let { type -> addQueryParameter("type", type) } + } + + filter.states.oneOrThrowIfMany()?.let { + when (it) { + MangaState.ONGOING -> "ongoing" + MangaState.FINISHED -> "completed" + else -> null + }?.let { status -> addQueryParameter("status", status) } + } + + if (filter.tags.isNotEmpty()) { + addQueryParameter("genre", filter.tags.joinToString(",") { it.key }) + } + }.build() + + val jsonResponse = executeWithCloudflareRetry(url.toString(), getRequestHeaders()).parseJson() + val decrypted = decrypt(jsonResponse.getString("_enc_resp_")) + val array = JSONArray(decrypted) + + return (0 until array.length()).map { i -> + val obj = array.getJSONObject(i) + val slug = obj.getString("slug") + val href = "/manga/$slug" + Manga( + id = generateUid(href), + title = obj.getString("title"), + altTitles = emptySet(), + url = href, + publicUrl = href.toAbsoluteUrl(domain), + rating = obj.optDouble("rating", 0.0).toFloat() / 10f, + contentRating = ContentRating.ADULT, + coverUrl = obj.optString("cover_url").takeIf { it.isNotEmpty() }?.let { cover -> + if (cover.startsWith("http")) cover else cover.toAbsoluteUrl(domain) + }, + tags = emptySet(), + state = null, + authors = emptySet(), + largeCoverUrl = null, + description = null, + source = source, + ) + } + } + + override suspend fun getDetails(manga: Manga): Manga { + detailsCache[manga.url]?.let { return it } + + val slug = manga.url.removePrefix("/manga/").removeSuffix("/") + val url = "/api/manga/$slug".toAbsoluteUrl(domain) + + val jsonResponse = executeWithCloudflareRetry(url, getRequestHeaders()).parseJson() + val decrypted = decrypt(jsonResponse.getString("_enc_resp_")) + val obj = JSONObject(decrypted) + + val state = when (obj.optString("status")) { + "completed" -> MangaState.FINISHED + "ongoing" -> MangaState.ONGOING + else -> null + } + + val author = obj.optString("author").takeIf { it.isNotEmpty() && it != "null" } + + val tags = mutableSetOf() + val genresArray = obj.optJSONArray("manga_genres") + if (genresArray != null) { + for (i in 0 until genresArray.length()) { + val genreObj = genresArray.getJSONObject(i).getJSONObject("genres") + tags.add( + MangaTag( + key = genreObj.getString("slug"), + title = genreObj.getString("name"), + source = source, + ) + ) + } + } + + val chaptersArray = obj.getJSONArray("chapters") + val chapters = ArrayList(chaptersArray.length()) + for (i in 0 until chaptersArray.length()) { + val chapObj = chaptersArray.getJSONObject(i) + val chNum = chapObj.optDouble("chapter_number", 0.0).toFloat() + val chTitle = if (chNum == chNum.toLong().toFloat()) { + "Chapter ${chNum.toLong()}" + } else { + "Chapter $chNum" + } + chapters.add( + MangaChapter( + id = generateUid("/reader/${chapObj.getString("id")}"), + title = chTitle, + number = chNum, + volume = 0, + url = "/reader/${chapObj.getString("id")}", + scanlator = null, + uploadDate = runCatching { Instant.parse(chapObj.optString("created_at")).toEpochMilli() }.getOrDefault(0L), + branch = null, + source = source, + ) + ) + } + + val rawDesc = obj.optString("description").takeIf { it.isNotEmpty() && it != "null" } + val cleanDesc = rawDesc?.let { html -> + val document = org.jsoup.Jsoup.parseBodyFragment(html) + val fullText = document.text() + val cutoffRegex = Regex("""download\s+(batch|volume)""", RegexOption.IGNORE_CASE) + val cutoffIndex = cutoffRegex.find(fullText)?.range?.first + val textBeforeDownload = if (cutoffIndex != null) fullText.substring(0, cutoffIndex) else fullText + textBeforeDownload + .replace(Regex("""^Sinopsis:\s*""", RegexOption.IGNORE_CASE), "") + .replace(Regex("""https?://\S+"""), "") + .replace(Regex("""\[url=.*?]|\[/url]"""), "") + .trim() + } + + val coverUrl = obj.optString("cover_url").takeIf { it.isNotEmpty() }?.let { cover -> + if (cover.startsWith("http")) cover else cover.toAbsoluteUrl(domain) + } + + val fullManga = manga.copy( + authors = setOfNotNull(author), + description = cleanDesc, + state = state, + rating = obj.optDouble("rating", 0.0).toFloat() / 10f, + tags = tags, + coverUrl = coverUrl ?: manga.coverUrl, + chapters = chapters.reversed() + ) + + detailsCache[manga.url] = fullManga + return fullManga + } + + override suspend fun getPages(chapter: MangaChapter): List { + val chId = chapter.url.removePrefix("/reader/").removeSuffix("/") + val url = "/api/chapters/$chId".toAbsoluteUrl(domain) + + val jsonResponse = executeWithCloudflareRetry(url, getRequestHeaders()).parseJson() + val decrypted = decrypt(jsonResponse.getString("_enc_resp_")) + val obj = JSONObject(decrypted) + + val pagesList = mutableListOf() + val contentUrls = obj.optJSONArray("content_urls") + if (contentUrls != null && contentUrls.length() > 0) { + for (i in 0 until contentUrls.length()) { + pagesList.add( + MangaPage( + id = generateUid(contentUrls.getString(i)), + url = transformPageUrl(contentUrls.getString(i)), + preview = null, + source = source, + ) + ) + } + } else { + val signedUrlsStr = obj.optString("signed_content_urls") + if (signedUrlsStr.isNotEmpty()) { + val signedUrls = JSONArray(signedUrlsStr) + for (i in 0 until signedUrls.length()) { + pagesList.add( + MangaPage( + id = generateUid(signedUrls.getString(i)), + url = transformPageUrl(signedUrls.getString(i)), + preview = null, + source = source, + ) + ) + } + } + } + + return pagesList + } + + private fun toSigned32(x: Long): Int { + var y = x and 0xFFFFFFFFL + if (y >= 0x80000000L) y -= 0x100000000L + return y.toInt() + } + + private fun wH(e: Int): String { + val t = "doujindesu-scrapers-cannot-read-this-super-secret-salt-2026-v2_$e" + var a = 0 + for (ch in t) { + a = (a shl 5) - a + ch.code + a = toSigned32(a.toLong()) + } + var l = if (a != 0) kotlin.math.abs(a).toLong() else 123456789L + return buildString { + repeat(32) { + l = (l * 1664525L + 1013904223L) % 4294967296L + append((33 + (l % 93)).toInt().toChar()) + } + } + } + + private fun lU(): List { + val now = System.currentTimeMillis() + val t = now / 3600000L + return listOf(wH(t.toInt()), wH((t - 1).toInt()), wH((t + 1).toInt())) + } + + private fun yre(encryptedHex: String, key: String): String { + val bytes = encryptedHex.chunked(2).mapNotNull { it.toIntOrNull(16) } + var d = 42 + val keyLen = key.length + return buildString { + for ((idx, byteVal) in bytes.withIndex()) { + val keyChar = key[idx % keyLen].code + val k = byteVal xor keyChar xor (idx * 13) xor d + append((k and 0xFF).toChar()) + d = (d + byteVal) % 256 + } + } + } + + private fun decrypt(encryptedHex: String): String { + for (key in lU()) { + try { + val decoded = yre(encryptedHex, key) + return URLDecoder.decode(decoded, "UTF-8") + } catch (_: Exception) { + } + } + throw RuntimeException("Decryption failed") + } + + private fun transformPageUrl(page: String): String = when { + page.contains("/uploads/") && !page.contains("/storage/uploads/") -> + page.replace("/uploads/", "/storage/uploads/") + page.contains("/upload/") && !page.contains("/storage/upload/") -> + page.replace("/upload/", "/storage/upload/") + else -> page + } +} diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/HiperParser.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/HiperParser.kt new file mode 100644 index 00000000..4b3b2a74 --- /dev/null +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/HiperParser.kt @@ -0,0 +1,380 @@ +package org.koitharu.kotatsu.parsers.parsers + +import okhttp3.Headers +import okhttp3.Request +import org.json.JSONArray +import org.json.JSONObject +import org.koitharu.kotatsu.parsers.MangaLoaderContext +import org.koitharu.kotatsu.parsers.config.ConfigKey +import org.koitharu.kotatsu.parsers.core.PagedMangaParser +import org.koitharu.kotatsu.parsers.model.* +import org.koitharu.kotatsu.parsers.network.OkHttpWebClient +import org.koitharu.kotatsu.parsers.util.* +import java.text.SimpleDateFormat +import java.util.* + +abstract class HiperParser( + context: MangaLoaderContext, + source: MangaParserSource, + protected val domainName: String, +) : PagedMangaParser(context, source, pageSize = 30) { + + protected open val availableContentTypes: Set = EnumSet.of(ContentType.MANGA, ContentType.MANHWA, ContentType.MANHUA) + + protected open val availableStates: Set = EnumSet.of(MangaState.ONGOING, MangaState.FINISHED, MangaState.PAUSED, MangaState.ABANDONED) + + protected open val availableContentRating: Set = EnumSet.of(ContentRating.SAFE, ContentRating.SUGGESTIVE, ContentRating.ADULT) + + protected open val mangaPath = "manga" + + override val configKeyDomain = ConfigKey.Domain(domainName) + override val availableSortOrders: Set = EnumSet.of( + SortOrder.POPULARITY, + SortOrder.UPDATED, + SortOrder.NEWEST, + SortOrder.NEWEST_ASC, + SortOrder.RATING, + SortOrder.ALPHABETICAL, + SortOrder.RELEVANCE + ) + override val filterCapabilities = MangaListFilterCapabilities( + isSearchSupported = true, + isSearchWithFiltersSupported = true, + isMultipleTagsSupported = true, + ) + + val client: OkHttpWebClient by lazy { + val httpClient = context.httpClient.newBuilder() + .addInterceptor { chain -> + val request = chain.request() + val newRequest = request.newBuilder() + .header("Referer", "https://$domainName/") + .header("Origin", "https://$domainName") + .build() + var response = chain.proceed(newRequest) + if (response.code == 401) { + response.close() + val acceptHeaders = Headers.Builder() + .set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") + .build() + val homeRequest = Request.Builder() + .url("https://$domainName/") + .headers(acceptHeaders) + .build() + chain.proceed(homeRequest).close() + response = chain.proceed(request) + } + response + } + .build() + OkHttpWebClient(httpClient, source) + } + + @Volatile + private var genresListCache: List? = null + + override suspend fun getFilterOptions(): MangaListFilterOptions { + if (genresListCache == null) { + try { + val input = "%7B%220%22%3A%7B%22json%22%3Anull%2C%22meta%22%3A%7B%22values%22%3A%5B%22undefined%22%5D%7D%7D%7D" + val res = client.httpGet(apiUrl("search.genres", input), getRequestHeaders()) + val body = res.body?.string() + if (body != null) { + val root = JSONArray(body) + val jsonArray = root.optJSONObject(0) + ?.optJSONObject("result") + ?.optJSONObject("data") + ?.optJSONArray("json") + if (jsonArray != null) { + val tags = mutableListOf() + for (i in 0 until jsonArray.length()) { + val obj = jsonArray.optJSONObject(i) ?: continue + val name = obj.optString("name", "") + if (name.isNotEmpty()) tags.add(MangaTag(name, name, source)) + } + if (tags.isNotEmpty()) genresListCache = tags + } + } + } catch (_: Exception) {} + } + return MangaListFilterOptions( + availableTags = genresListCache?.toSet() ?: emptySet(), + availableContentTypes = availableContentTypes, + availableStates = availableStates, + availableContentRating = availableContentRating, + ) + } + + private fun apiUrl(trpcPath: String, input: String) = "https://$domainName/api/trpc/$trpcPath?batch=1&input=${input.urlEncoded()}" + + private fun searchPayload(query: String, sort: String, limit: Int, offset: Int, filter: MangaListFilter): String { + val genres = filter.tags.map { it.key } + val types = filter.types.map { it.name.lowercase() } + val statusStr = filter.states.firstOrNull()?.let { + when (it) { + MangaState.ONGOING -> "ongoing" + MangaState.FINISHED -> "completed" + MangaState.PAUSED -> "hiatus" + MangaState.ABANDONED -> "cancelled" + else -> null + } + } + val contentRatingStr = filter.contentRating.firstOrNull()?.let { + when (it) { + ContentRating.SAFE -> "safe" + ContentRating.SUGGESTIVE -> "suggestive" + ContentRating.ADULT -> "pornographic" + } + } + return JSONObject().apply { + put("0", JSONObject().apply { + put("json", JSONObject().apply { + put("q", query) + put("sort", sort) + put("filters", JSONObject().apply { + if (genres.isNotEmpty()) put("genres", JSONArray(genres)) else put("genres", JSONObject.NULL) + if (types.isNotEmpty()) put("type", types.first()) else put("type", JSONObject.NULL) + if (statusStr != null) put("status", statusStr) else put("status", JSONObject.NULL) + if (contentRatingStr != null) put("contentRating", contentRatingStr) else put("contentRating", JSONObject.NULL) + put("author", JSONObject.NULL) + put("artist", JSONObject.NULL) + put("year", JSONObject.NULL) + }) + put("limit", limit) + put("offset", offset) + put("maxRating", "pornographic") + }) + put("meta", JSONObject().apply { + put("values", JSONObject().apply { + if (genres.isEmpty()) put("filters.genres", JSONArray().put("undefined")) + if (types.isEmpty()) put("filters.type", JSONArray().put("undefined")) + if (statusStr == null) put("filters.status", JSONArray().put("undefined")) + if (contentRatingStr == null) put("filters.contentRating", JSONArray().put("undefined")) + put("filters.author", JSONArray().put("undefined")) + put("filters.artist", JSONArray().put("undefined")) + put("filters.year", JSONArray().put("undefined")) + }) + }) + }) + }.toString() + } + + private fun detailsPayload(slug: String) = JSONObject().apply { + put("0", JSONObject().apply { + put("meta", JSONObject().apply { put("values", JSONArray().put("undefined")) }) + }) + put("1", JSONObject().apply { + put("json", JSONObject().apply { put("slug", slug) }) + }) + }.toString() + + private fun chaptersPayload(mangaId: Long) = JSONObject().apply { + put("0", JSONObject().apply { + put("json", JSONObject().apply { put("values", JSONArray().put("undefined")) }) + }) + put("1", JSONObject().apply { + put("json", JSONObject().apply { + put("seriesId", mangaId) + put("sort", "best") + put("page", 1) + put("limit", 20) + }) + put("meta", JSONObject().apply { + put("values", JSONObject().apply { put("chapterId", JSONArray().put("undefined")) }) + }) + }) + put("2", JSONObject().apply { + put("json", JSONObject().apply { put("seriesId", mangaId) }) + }) + }.toString() + + private fun pagesPayload(slug: String, number: Float) = JSONObject().apply { + put("0", JSONObject().apply { + put("meta", JSONObject().apply { put("values", JSONArray().put("undefined")) }) + }) + put("1", JSONObject().apply { + put("json", JSONObject().apply { put("slug", slug) }) + }) + put("2", JSONObject().apply { + put("json", JSONObject().apply { + put("seriesSlug", slug) + put("chapterNumber", number.toDouble()) + }) + }) + put("3", JSONObject().apply { + put("json", JSONObject().apply { put("position", "footer_bottom") }) + }) + }.toString() + + private fun parseLastItemJsonArray(body: String): JSONArray? = try { + val root = JSONArray(body) + root.optJSONObject(root.length() - 1) + ?.optJSONObject("result") + ?.optJSONObject("data") + ?.optJSONArray("json") + } catch (_: Exception) { null } + + override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List { + val q = filter.query?.trim().orEmpty() + val sort = when (order) { + SortOrder.POPULARITY -> "popular" + SortOrder.UPDATED -> "recent" + SortOrder.NEWEST -> "newest" + SortOrder.NEWEST_ASC -> "oldest" + SortOrder.RATING -> "score" + SortOrder.ALPHABETICAL -> "alphabetical" + SortOrder.RELEVANCE -> "relevance" + else -> "newest" + } + val input = searchPayload(q, sort, pageSize, (page - 1) * pageSize, filter) + val res = client.httpGet(apiUrl("search.query", input), getRequestHeaders()) + val body = res.body?.string() ?: return emptyList() + val root = JSONArray(body) + val first = root.optJSONObject(0) ?: return emptyList() + val hits = first.optJSONObject("result") + ?.optJSONObject("data") + ?.optJSONObject("json") + ?.optJSONArray("hits") ?: return emptyList() + return (0 until hits.length()).mapNotNull { i -> + hits.optJSONObject(i)?.let { obj -> + val id = obj.optLong("id", -1).takeIf { it > 0 } ?: return@mapNotNull null + val slug = obj.optString("slug", "").takeIf { it.isNotEmpty() } ?: return@mapNotNull null + val title = obj.optString("title", "") + val cover = obj.optString("coverUrl", "") + Manga( + id = generateUid("/$mangaPath/$slug#$id"), + url = "/$mangaPath/$slug#$id", + publicUrl = "https://$domainName/$mangaPath/$slug#$id", + title = title, coverUrl = cover, + altTitles = emptySet(), authors = emptySet(), tags = emptySet(), + rating = RATING_UNKNOWN, state = null, + contentRating = ContentRating.ADULT, source = source + ) + } + } + } + + override suspend fun getDetails(manga: Manga): Manga { + val slug = manga.url.substringAfterLast("$mangaPath/").substringBefore("#") + .takeIf { it.isNotBlank() } ?: return manga + val res = client.httpGet(apiUrl("auth.me,series.bySlugWithGenres", detailsPayload(slug)), getRequestHeaders()) + val body = res.body?.string() ?: return manga + val detailJson = try { + val arr = JSONArray(body) + arr.getJSONObject(arr.length() - 1) + .optJSONObject("result")?.optJSONObject("data")?.optJSONObject("json") + } catch (_: Exception) { null } ?: return manga + + val title = detailJson.optString("title", manga.title) + val desc = detailJson.optString("synopsis", manga.description) + val cover = detailJson.optString("coverUrl", manga.coverUrl) + val authors = detailJson.optJSONArray("authors")?.let { arr -> + (0 until arr.length()).map { arr.getString(it) } + } ?: emptyList() + val genres = detailJson.optJSONArray("genres")?.let { arr -> + (0 until arr.length()).map { arr.getString(it) } + } ?: emptyList() + val status = detailJson.optString("status", null) + val chapters = loadChapters(manga) + + return manga.copy( + title = title, description = desc, coverUrl = cover, + authors = authors.toSet(), + tags = genres.map { MangaTag(it.lowercase(), it, source) }.toSet(), + state = when (status?.lowercase()) { + "ongoing" -> MangaState.ONGOING + "completed" -> MangaState.FINISHED + "hiatus" -> MangaState.PAUSED + "cancelled" -> MangaState.ABANDONED + else -> null + }, + chapters = chapters, + ) + } + + private suspend fun loadChapters(manga: Manga): List { + val mangaId = manga.url.substringAfterLast("#").toLongOrNull() ?: return emptyList() + val slug = manga.url.substringAfterLast("$mangaPath/").substringBefore("#").ifBlank { return emptyList() } + val res = client.httpGet(apiUrl("auth.me,comments.list,series.chapters", chaptersPayload(mangaId)), getRequestHeaders()) + val body = res.body?.string() ?: return emptyList() + val chaptersArray = parseLastItemJsonArray(body) ?: return emptyList() + return (0 until chaptersArray.length()).mapNotNull { i -> + chaptersArray.optJSONObject(i)?.let { obj -> + val number = obj.optDouble("number", 0.0).toFloat() + val createdAt = obj.optString("createdAt", null) + val uploadDate = createdAt?.let { dateFormat.parse(it)?.time } ?: 0L + MangaChapter( + id = generateUid("/$mangaPath/$slug/$number"), + url = "/$mangaPath/$slug/$number", + title = "Chapter ${number.formatForTitle()}", + number = number, volume = 0, uploadDate = uploadDate, + scanlator = null, branch = null, source = source + ) + } + }.sortedBy { it.number } + } + + // triyng first tRPC API if it fails then chapter html scraping + override suspend fun getPages(chapter: MangaChapter): List { + val path = chapter.url.removePrefix("/$mangaPath/") + val slug = path.substringBefore("/") + val num = path.substringAfter("/").toFloatOrNull() ?: return emptyList() + + val input = pagesPayload(slug, num) + val res = client.httpGet(apiUrl("auth.me,series.bySlug,reader.chapterPages", input), getRequestHeaders()) + val body = res.body?.string() ?: return emptyList() + val apiPages = parsePages(body) + if (!apiPages.isNullOrEmpty()) return apiPages + + val chapterUrl = "https://$domainName/$mangaPath/$slug/$num" + val doc = client.httpGet(chapterUrl).parseHtml() + + // search in json results image URLs + val script = doc.select("script").find { it.data().contains("\"webpUrl\"") } + ?: doc.select("script").find { it.data().contains("\"avifUrl\"") } + if (script != null) { + val arrayRegex = Regex("""\[\s*\{.*?\"(?:webp|avif)Url\".*?\}\s*\]""", RegexOption.DOT_MATCHES_ALL) + val match = arrayRegex.find(script.data())?.value + if (match != null) { + return try { + val jsonArray = JSONArray(match) + (0 until jsonArray.length()).mapNotNull { i -> + val page = jsonArray.optJSONObject(i) ?: return@mapNotNull null + val url = page.optString("webpUrl", "").ifEmpty { + page.optString("avifUrl", "") + }.takeIf { it.isNotEmpty() } ?: return@mapNotNull null + MangaPage(id = generateUid(url), url = url, preview = null, source = source) + } + } catch (_: Exception) { emptyList() } + } + } + + return emptyList() + } + + private fun parsePages(body: String): List? { + val elements = try { JSONArray(body) } catch (_: Exception) { return null } + for (i in 0 until elements.length()) { + if (elements.optJSONObject(i)?.has("error") == true) return null + } + val last = elements.optJSONObject(elements.length() - 1) ?: return null + val items = last.optJSONObject("result") + ?.optJSONObject("data") + ?.optJSONArray("json") ?: return null + return (0 until items.length()).mapNotNull { i -> + val page = items.optJSONObject(i) ?: return@mapNotNull null + val url = page.optString("webpUrl", "").ifEmpty { + page.optString("avifUrl", "") + }.takeIf { it.isNotEmpty() } ?: return@mapNotNull null + MangaPage(id = generateUid(url), url = url, preview = null, source = source) + } + } + + override suspend fun getPageUrl(page: MangaPage) = page.url + + private fun Float.formatForTitle() = if (this == toLong().toFloat()) toLong().toString() else toString() + + companion object { + private val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.ROOT) + } +} diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/Manga18Parser.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/Manga18Parser.kt new file mode 100644 index 00000000..31033fd4 --- /dev/null +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/Manga18Parser.kt @@ -0,0 +1,248 @@ +package org.koitharu.kotatsu.parsers.parsers + +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import okhttp3.Headers +import org.jsoup.nodes.Document +import org.koitharu.kotatsu.parsers.MangaLoaderContext +import org.koitharu.kotatsu.parsers.config.ConfigKey +import org.koitharu.kotatsu.parsers.core.PagedMangaParser +import org.koitharu.kotatsu.parsers.model.* +import org.koitharu.kotatsu.parsers.util.* +import java.text.SimpleDateFormat +import java.util.* + +internal abstract class Manga18Parser( + context: MangaLoaderContext, + source: MangaParserSource, + domain: String, + pageSize: Int = 20, +) : PagedMangaParser(context, source, pageSize) { + + override val configKeyDomain = ConfigKey.Domain(domain) + + override fun onCreateConfig(keys: MutableCollection>) { + super.onCreateConfig(keys) + keys.add(userAgentKey) + } + + override val availableSortOrders: Set = EnumSet.of( + SortOrder.UPDATED, + SortOrder.POPULARITY, + SortOrder.ALPHABETICAL, + ) + + override val filterCapabilities: MangaListFilterCapabilities + get() = MangaListFilterCapabilities( + isSearchSupported = true, + isSearchWithFiltersSupported = true, + isMultipleTagsSupported = false, + ) + + @Volatile + private var genresCache: Set? = null + private val genresMutex = Mutex() + + private suspend fun getOrFetchGenres(): Set { + genresCache?.let { return it } + return genresMutex.withLock { + genresCache ?: run { + genresCache = fetchAvailableTags() + genresCache!! + } + } + } + + private suspend fun fetchAvailableTags(): Set { + val doc = webClient.httpGet("https://$domain/$listUrl/").parseHtml() + return doc.select("div.grid_cate li").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 fun getRequestHeaders() = Headers.Builder() + .add("Referer", "https://$domain/") + .build() + + override suspend fun getFilterOptions() = MangaListFilterOptions( + availableTags = getOrFetchGenres(), + ) + + init { + paginator.firstPage = 1 + searchPaginator.firstPage = 1 + } + + protected open val ongoing: Set = setOf("On Going") + protected open val finished: Set = setOf("Completed") + + protected open val listUrl = "list-manga/" + protected open val tagUrl = "manga-list/" + protected open val datePattern = "dd-MM-yyyy" + + protected open val selectDesc = "div.detail_reviewContent" + protected open val selectState = "div.item:contains(Status) div.info_value" + protected open val selectAlt = "div.item:contains(Other name) div.info_value" + protected open val selectTag = "div.info_value > a[href*=/manga-list/]" + protected open val selectAuthor = "div.info_label:contains(author) + div.info_value, div.info_label:contains(autor) + div.info_value" + protected open val selectArtist = "div.info_label:contains(artist) + div.info_value" + protected open val selectDate = "div.item p" + protected open val selectChapter = "div.chapter_box li" + + override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List { + val query = filter.query?.trim().orEmpty() + val tag = filter.tags.firstOrNull() + + val url = buildString(120) { + append("https://") + append(domain) + append('/') + + when { + tag != null && query.isNotEmpty() -> { + throw IllegalArgumentException("Search is not supported with tags") + } + tag != null -> { + append(tagUrl) + append(tag.key) + append('/') + append(page) + } + query.isNotEmpty() -> { + append(listUrl) + append(page) + append("?search=") + append(query.urlEncoded()) + append("&order_by=latest") + } + else -> { + append(listUrl) + append(page) + } + } + if (query.isEmpty()) { + append("?order_by=") + when (order) { + SortOrder.POPULARITY -> append("views") + SortOrder.UPDATED -> append("latest") + SortOrder.ALPHABETICAL -> append("name") + else -> append("latest") + } + } + } + + val doc = webClient.httpGet(url).parseHtml() + return parseMangaList(doc) + } + + protected open fun parseMangaList(doc: Document): List { + return doc.select("div.story_item").map { div -> + val a = div.selectFirstOrThrow("a") + val href = a.attrAsRelativeUrl("href") + val title = div.selectFirst("div.mg_info") + ?.selectFirst("div.mg_name a") + ?.text() + ?: a.attr("title") + val cover = div.selectFirst("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 = if (isNsfwSource) ContentRating.ADULT else null, + ) + } + } + + override suspend fun getDetails(manga: Manga): Manga = coroutineScope { + val fullUrl = manga.url.toAbsoluteUrl(domain) + val doc = webClient.httpGet(fullUrl).parseHtml() + val info = doc.body().selectFirstOrThrow("div.detail_listInfo") + val chaptersDeferred = async { getChapters(doc) } + val desc = doc.selectFirst(selectDesc)?.html()?.nullIfEmpty() + val state = info.selectFirst(selectState)?.text()?.let { + when (it) { + in ongoing -> MangaState.ONGOING + in finished -> MangaState.FINISHED + else -> null + } + } + val alt = info.selectFirst(selectAlt)?.textOrNull()?.takeUnless { it == "Updating" } + val author = info.selectFirst(selectAuthor)?.textOrNull()?.takeUnless { it == "Updating" } + val artist = info.selectFirst(selectArtist)?.textOrNull()?.takeUnless { it == "Updating" } + val tags = doc.body().select(selectTag).mapToSet { a -> + val key = a.attr("href").removeSuffix('/').substringAfterLast('/') + MangaTag( + key = key, + title = a.text().toTitleCase(), + source = source, + ) + } + manga.copy( + tags = tags, + description = desc.nullIfEmpty(), + altTitles = setOfNotNull(alt), + authors = setOfNotNull(author, artist), + state = state, + chapters = chaptersDeferred.await(), + ) + } + + protected open suspend fun getChapters(doc: Document): List { + val dateFormat = SimpleDateFormat(datePattern, sourceLocale) + 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() + MangaChapter( + id = generateUid(href), + title = a.textOrNull(), + number = i + 1f, + volume = 0, + url = href, + uploadDate = dateFormat.parseSafe(dateText), + source = source, + scanlator = null, + branch = null, + ) + } + } + + override suspend fun getPages(chapter: MangaChapter): List { + val fullUrl = chapter.url.toAbsoluteUrl(domain) + val doc = webClient.httpGet(fullUrl).parseHtml() + val script = doc.selectFirstOrThrow("script:containsData(slides_p_path)") + val encoded = script.data() + .substringAfter('[') + .substringBefore(",]") + .replace("\"", "") + .split(",") + + return encoded.map { encodedUrl -> + val decoded = context.decodeBase64(encodedUrl).toString(Charsets.UTF_8) + val absoluteUrl = decoded.toAbsoluteUrl(domain) + MangaPage( + id = generateUid(absoluteUrl), + url = absoluteUrl, + preview = null, + source = source, + ) + } + } +} diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/MangaReaderParser.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/MangaReaderParser.kt new file mode 100644 index 00000000..438354da --- /dev/null +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/MangaReaderParser.kt @@ -0,0 +1,417 @@ +package org.koitharu.kotatsu.parsers.parsers + +import androidx.collection.ArrayMap +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import okhttp3.* +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.internal.closeQuietly +import org.json.JSONObject +import org.jsoup.nodes.Document +import org.koitharu.kotatsu.parsers.MangaLoaderContext +import org.koitharu.kotatsu.parsers.config.ConfigKey +import org.koitharu.kotatsu.parsers.core.PagedMangaParser +import org.koitharu.kotatsu.parsers.model.* +import org.koitharu.kotatsu.parsers.util.* +import java.text.SimpleDateFormat +import java.util.Base64 +import java.util.EnumSet + +internal abstract class MangaReaderParser( + context: MangaLoaderContext, + source: MangaParserSource, + domain: String, + pageSize: Int, + searchPageSize: Int, +) : PagedMangaParser(context, source, pageSize, searchPageSize), Interceptor { + + override val configKeyDomain = ConfigKey.Domain(domain) + + override fun onCreateConfig(keys: MutableCollection>) { + super.onCreateConfig(keys) + keys.add(userAgentKey) + } + + override val availableSortOrders: Set + get() = EnumSet.of( + SortOrder.UPDATED, + SortOrder.POPULARITY, + SortOrder.ALPHABETICAL, + SortOrder.ALPHABETICAL_DESC, + SortOrder.NEWEST, + ) + + override val filterCapabilities: MangaListFilterCapabilities + get() = MangaListFilterCapabilities( + isMultipleTagsSupported = true, + isTagsExclusionSupported = true, + isSearchSupported = true, + ) + + override suspend fun getFilterOptions() = MangaListFilterOptions( + availableTags = getOrCreateTagMap().values.toSet(), + availableStates = EnumSet.of(MangaState.ONGOING, MangaState.FINISHED, MangaState.PAUSED), + availableContentTypes = EnumSet.of( + ContentType.MANGA, + ContentType.MANHWA, + ContentType.MANHUA, + ContentType.COMICS, + ContentType.NOVEL, + ), + ) + + protected open val listUrl = "/manga" + protected open val datePattern = "MMMM d, yyyy" + protected open val isNetShieldProtected = false + + protected var tagCache: ArrayMap? = null + protected val mutex = Mutex() + + override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List { + val query = filter.query + val url = buildString { + append("https://") + append(domain) + + when { + + !query.isNullOrEmpty() -> { + append("/page/") + append(page.toString()) + append("/?s=") + append(query.urlEncoded()) + } + + else -> { + append(listUrl) + + append("/?order=") + append( + when (order) { + SortOrder.ALPHABETICAL -> "title" + SortOrder.ALPHABETICAL_DESC -> "titlereverse" + SortOrder.NEWEST -> "latest" + SortOrder.POPULARITY -> "popular" + SortOrder.UPDATED -> "update" + else -> "" + }, + ) + + filter.tags.forEach { + append("&") + append("genre[]".urlEncoded()) + append("=") + append(it.key) + } + + filter.tagsExclude.forEach { + append("&") + append("genre[]".urlEncoded()) + append("=-") + append(it.key) + } + + if (filter.states.isNotEmpty()) { + filter.states.oneOrThrowIfMany()?.let { + append("&status=") + when (it) { + MangaState.ONGOING -> append("ongoing") + MangaState.FINISHED -> append("completed") + MangaState.PAUSED -> append("hiatus") + else -> append("") + } + } + } + + filter.types.oneOrThrowIfMany()?.let { + append("&type=") + append( + when (it) { + ContentType.MANGA -> "manga" + ContentType.MANHWA -> "manhwa" + ContentType.MANHUA -> "manhua" + ContentType.COMICS -> "comic" + ContentType.NOVEL -> "novel" + else -> "" + }, + ) + } + + append("&page=") + append(page.toString()) + } + } + } + return parseMangaList(webClient.httpGet(url).parseHtml()) + } + + protected open val selectMangaList = ".postbody .listupd .bs .bsx" + protected open val selectMangaListImg = "img.ts-post-image" + protected open val selectMangaListTitle = "div.tt" + + protected open fun parseMangaList(docs: Document): List { + return docs.select(selectMangaList).mapNotNull { + val a = it.selectFirst("a") ?: return@mapNotNull null + val relativeUrl = a.attrAsRelativeUrl("href") + val rating = it.selectFirst(".numscore")?.text() + ?.toFloatOrNull()?.div(10) ?: RATING_UNKNOWN + Manga( + id = generateUid(relativeUrl), + url = relativeUrl, + title = it.selectFirst(selectMangaListTitle)?.text() ?: a.attr("title"), + altTitles = emptySet(), + publicUrl = a.attrAsAbsoluteUrl("href"), + rating = rating, + contentRating = if (isNsfwSource) ContentRating.ADULT else null, + coverUrl = it.selectFirst(selectMangaListImg)?.src(), + tags = emptySet(), + state = null, + authors = emptySet(), + source = source, + ) + } + } + + protected open val selectChapter = "#chapterlist > ul > li" + override suspend fun getDetails(manga: Manga): Manga { + val docs = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml() + val dateFormat = SimpleDateFormat(datePattern, sourceLocale) + val chapters = docs.select(selectChapter).mapChapters(reversed = true) { index, element -> + val url = element.selectFirst("a")?.attrAsRelativeUrlOrNull("href") ?: return@mapChapters null + MangaChapter( + id = generateUid(url), + title = element.selectFirst(".chapternum")?.textOrNull(), + url = url, + number = index + 1f, + volume = 0, + scanlator = null, + uploadDate = dateFormat.parseSafe(element.selectFirst(".chapterdate")?.text()), + branch = null, + source = source, + ) + } + return parseInfo(docs, manga, chapters) + } + + protected open val detailsDescriptionSelector = "div.entry-content" + + open suspend fun parseInfo(docs: Document, manga: Manga, chapters: List): Manga { + /// set if is table + val tableMode = + docs.selectFirst("div.seriestucontent > div.seriestucontentr") ?: docs.selectFirst("div.seriestucontentr") + ?: docs.selectFirst("div.seriestucon") + + val tagMap = getOrCreateTagMap() + + val selectTag = if (tableMode != null) { + docs.select(".seriestugenre > a") + } else { + docs.select(".wd-full .mgen > a") + } + + val tags = selectTag.mapNotNullToSet { tagMap[it.text()] } + + val stateSelect = if (tableMode != null) { + tableMode.selectFirst(".infotable td:contains(Status)") + ?: tableMode.selectFirst(".infotable td:contains(Statut)") + ?: tableMode.selectFirst(".infotable td:contains(حالة العمل)") + ?: tableMode.selectFirst(".infotable td:contains(الحالة)") + ?: tableMode.selectFirst(".infotable td:contains(Estado)") + ?: tableMode.selectFirst(".infotable td:contains(สถานะ)") + ?: tableMode.selectFirst(".infotable td:contains(Stato )") + ?: tableMode.selectFirst(".infotable td:contains(Durum)") + ?: tableMode.selectFirst(".infotable td:contains(Statüsü)") + + } else { + docs.selectFirst(".tsinfo div:contains(Status)") ?: docs.selectFirst(".tsinfo div:contains(Statut)") + ?: docs.selectFirst(".tsinfo div:contains(حالة العمل)") ?: docs.selectFirst(".tsinfo div:contains(Estado)") + ?: docs.selectFirst(".tsinfo div:contains(สถานะ)") ?: docs.selectFirst(".tsinfo div:contains(Stato )") + ?: docs.selectFirst(".tsinfo div:contains(Durum)") ?: docs.selectFirst(".tsinfo div:contains(Statüsü)") + ?: docs.selectFirst(".tsinfo div:contains(Statü)") ?: docs.selectFirst(".tsinfo div:contains(الحالة)") + } + + val state = if (tableMode != null) { + stateSelect?.lastElementSibling() + + } else { + stateSelect?.lastElementChild() + } + + val mangaState = state?.let { + when (it.text()) { + + "مستمرة", "En curso", "En Curso", "Ongoing", "OnGoing", "On going", "Ativo", "En Cours", "En cours", "En cours \uD83D\uDFE2", + "En cours de publication", "Đang tiến hành", "Em lançamento", "em lançamento", "Em Lançamento", "Онгоінг", "Publishing", + "Devam Ediyor", "Em Andamento", "In Corso", "Güncel", "Berjalan", "Продолжается", "Updating", "Lançando", "In Arrivo", "Emision", + "En emision", "مستمر", "Curso", "En marcha", "Publicandose", "Publicando", "连载中", "Devam ediyor", "Devam Etmekte", + -> MangaState.ONGOING + + "Completed", "Completo", "Complété", "Fini", "Achevé", "Terminé", "Terminé ⚫", "Tamamlandı", "Đã hoàn thành", "Hoàn Thành", + "مكتملة", "Завершено", "Finished", "Finalizado", "Completata", "One-Shot", "Bitti", "Tamat", "Completado", "Concluído", + "Concluido", "已完结", "Bitmiş", + -> MangaState.FINISHED + + "Canceled", "Cancelled", "Cancelado", "cancellato", "Cancelados", "Dropped", "Discontinued", "abandonné", "Abandonné", + -> MangaState.ABANDONED + + "Hiatus", "On Hold", "Pausado", "En espera", "En pause", "En Pause", "En attente", + -> MangaState.PAUSED + + else -> null + } + } + + val author = tableMode?.selectFirst(".infotable td:contains(Author)")?.lastElementSibling()?.textOrNull() + ?: docs.selectFirst(".tsinfo div:contains(Author)")?.lastElementChild()?.textOrNull() + ?: docs.selectFirst(".tsinfo div:contains(Auteur)")?.lastElementChild()?.textOrNull() + ?: docs.selectFirst(".tsinfo div:contains(Artist)")?.lastElementChild()?.textOrNull() + ?: docs.selectFirst(".tsinfo div:contains(Durum)")?.lastElementChild()?.textOrNull() + + val nsfw = docs.selectFirst(".restrictcontainer") != null + || docs.selectFirst(".info-right .alr") != null + || docs.selectFirst(".postbody .alr") != null + + val title = docs.selectFirst("h1.entry-title")?.text() + ?: docs.selectFirst("h1")?.text() + ?: docs.selectFirst(".entry-title")?.text() + ?: docs.selectFirst(".seriestucontent h1")?.text() + ?: docs.selectFirst(".postbody h1")?.text() + ?: docs.selectFirst("title")?.text()?.substringBefore(" - ")?.trim() + ?: manga.title + + return manga.copy( + title = title, + description = docs.selectFirst(detailsDescriptionSelector)?.text(), + state = mangaState, + authors = setOfNotNull(author), + contentRating = if (manga.contentRating == ContentRating.ADULT || nsfw) { + ContentRating.ADULT + } else { + ContentRating.SAFE + }, + tags = tags, + chapters = chapters, + ) + } + + override suspend fun getRelatedManga(seed: Manga): List { + return parseMangaList(webClient.httpGet(seed.url.toAbsoluteUrl(domain)).parseHtml()) + } + + protected open val encodedSrc = false + protected open val selectScript = "div.wrapper script" + protected open val selectPage = "div#readerarea img" + + protected open val selectTestScript = "script:containsData(ts_reader)" + override suspend fun getPages(chapter: MangaChapter): List { + val chapterUrl = chapter.url.toAbsoluteUrl(domain) + val docs = webClient.httpGet(chapterUrl).parseHtml() + + val test = docs.select(selectTestScript) + if (test.isEmpty() and !encodedSrc) { + return docs.select(selectPage).map { img -> + val url = img.requireSrc().toRelativeUrl(domain) + MangaPage( + id = generateUid(url), + url = url, + preview = null, + source = source, + ) + } + } else { + val images = if (encodedSrc) { + val script = docs.select(selectScript) + var decode = "" + for (i in script) { + if (i.attr("src").startsWith("data:text/javascript;base64,")) { + decode = Base64.getDecoder().decode(i.attr("src").replace("data:text/javascript;base64,", "")) + .decodeToString() + if (decode.startsWith("ts_reader.run")) { + break + } + } + } + JSONObject(decode.substringAfter('(').substringBeforeLast(')')) + .getJSONArray("sources") + .getJSONObject(0) + .getJSONArray("images") + + } else { + val script = docs.selectFirstOrThrow(selectTestScript) + JSONObject(script.data().substringAfter('(').substringBeforeLast(')')) + .getJSONArray("sources") + .getJSONObject(0) + .getJSONArray("images") + } + + val pages = ArrayList(images.length()) + for (i in 0 until images.length()) { + pages.add( + MangaPage( + id = generateUid(images.getString(i)), + url = images.getString(i), + preview = null, + source = source, + ), + ) + } + return pages + } + } + + protected open suspend fun getOrCreateTagMap(): Map = mutex.withLock { + tagCache?.let { return@withLock it } + val url = listUrl.toAbsoluteUrl(domain) + val tagElements = webClient.httpGet(url).parseHtml().select("ul.genrez > li") + val tagMap = ArrayMap(tagElements.size) + for (el in tagElements) { + tagMap[el.text()] = MangaTag( + title = el.textOrNull()?.toTitleCase(sourceLocale) ?: continue, + key = el.selectFirst("input")?.attrOrNull("value") ?: continue, + source = source, + ) + } + tagCache = tagMap + return@withLock tagMap + } + + override fun intercept(chain: Interceptor.Chain): Response { + val response = chain.proceed(chain.request()) + if (!isNetShieldProtected) { + return response + } + val contentType = response.mimeType + if ( + contentType?.endsWith("/html") != false && + context.cookieJar.getCookies(domain).none { it.name.contains("NetShield") } + ) { + val cookie = runBlocking { response.copy().parseHtml().getNetShieldCookie() } ?: return response + context.cookieJar.insertCookie(domain, cookie) + return chain.proceed(response.request.newBuilder().build()).also { + response.closeQuietly() + } + } + return response + } + + @Suppress("DEPRECATION") + private suspend fun Document.getNetShieldCookie(): Cookie? = runCatchingCancellable { + val script = select("script").firstNotNullOfOrNull { s -> + s.html().takeIf { x -> x.contains("slowAES.decrypt") } + } ?: return@runCatchingCancellable null + val min = webClient.httpGet("https://$domain/min.js").parseRaw() + val res = context.evaluateJs(min + "\n\n" + script.replace(Regex("document.cookie\\s*=\\s*"), "return ")) + res?.let { + Cookie.parse(baseUri().toHttpUrl(), it) + } + }.getOrNull() + + override suspend fun resolveLink(resolver: LinkResolver, link: HttpUrl): Manga? { + val mangaSlug = link.pathSegments[1].nullIfEmpty() ?: return null + val url = "/manga/$mangaSlug/" + return resolver.resolveManga( + this, url, generateUid(url), + ) + } +} diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/MangaThemesia.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/MangaThemesia.kt index 7f7e784f..86436d32 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/MangaThemesia.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/MangaThemesia.kt @@ -1,10 +1,11 @@ package org.koitharu.kotatsu.parsers.parsers -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonPrimitive +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.json.JSONArray import org.jsoup.nodes.Document import org.jsoup.nodes.Element +import org.jsoup.select.Elements import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.config.ConfigKey import org.koitharu.kotatsu.parsers.core.PagedMangaParser @@ -17,635 +18,374 @@ abstract class MangaThemesia( context: MangaLoaderContext, source: MangaParserSource, domain: String, -) : PagedMangaParser( - context, - source, - 20, -) { + pageSize: Int = 20, +) : PagedMangaParser(context, source, pageSize) { - private val domainName = domain + override val configKeyDomain = ConfigKey.Domain(domain) - override val configKeyDomain = - ConfigKey.Domain(domainName) - - - protected open val dateFormat = - SimpleDateFormat( - "MMMM dd, yyyy", - Locale.US, - ) - - - override val availableSortOrders = - EnumSet.of( - SortOrder.UPDATED, - SortOrder.POPULARITY, - SortOrder.NEWEST, - )!! - - - override val filterCapabilities = - MangaListFilterCapabilities( - isSearchSupported = true, - isMultipleTagsSupported = true, - isAuthorSearchSupported = true, - ) - - - protected open val mangaDirectory = - "manga" + override fun onCreateConfig(keys: MutableCollection>) { + super.onCreateConfig(keys) + keys.add(userAgentKey) + } - protected open val relatedSelector = - ".related-posts .bsx, .bixbox .bsx, .related-manga .related-reading-wrap" + override fun getRequestHeaders() = super.getRequestHeaders().newBuilder() + .set("Referer", "https://$domain/") + .build() + + protected open val mangaDirectory = "manga" + protected open val dateFormat = SimpleDateFormat("MMMM dd, yyyy", Locale.US) + protected open val withoutAjax = false + protected open val searchSelector = ".utao .uta .imgu, .listupd .bs .bsx, .listo .bs .bsx" + protected open val relatedSelector = ".related-posts .bsx, .bixbox .bsx, .related-manga .related-reading-wrap" + protected open val chapterListSelector = "div.bxcl li, div.cl li, #chapterlist li, ul li:has(div.chbox):has(div.eph-num), div.bxcl li:not(a[data-bs-target='#lockedChapterModal'])" + protected open val pageSelector = "div#readerarea img" + + override val availableSortOrders: EnumSet = EnumSet.of( + SortOrder.ALPHABETICAL, + SortOrder.ALPHABETICAL_DESC, + SortOrder.UPDATED, + SortOrder.ADDED, + SortOrder.POPULARITY, + ) + + override val filterCapabilities = MangaListFilterCapabilities( + isSearchSupported = true, + isMultipleTagsSupported = true, + isTagsExclusionSupported = true, + ) + + @Volatile + private var genreCache: Set? = null + private val genreMutex = Mutex() + + override suspend fun getFilterOptions() = MangaListFilterOptions( + availableTags = getOrFetchGenres(), + availableStates = EnumSet.of( + MangaState.ONGOING, + MangaState.FINISHED, + MangaState.PAUSED, + MangaState.ABANDONED, + ), + availableContentTypes = EnumSet.of( + ContentType.MANGA, + ContentType.MANHWA, + ContentType.MANHUA, + ContentType.COMICS, + ContentType.NOVEL, + ), + ) + + private suspend fun getOrFetchGenres(): Set { + genreCache?.let { return it } + return genreMutex.withLock { + genreCache ?: fetchGenres().also { genreCache = it } + } + } - protected open val searchSelector = - ".utao .uta .imgu, .listupd .bs .bsx, .listo .bs .bsx" + protected open suspend fun fetchGenres(): Set { + val doc = webClient.httpGet("https://$domain/$mangaDirectory/").parseHtml() + val genrez = doc.selectFirst("ul.genrez") ?: return emptySet() + return genrez.select("li").mapNotNull { li -> + val label = li.selectFirst("label")?.text()?.trim() ?: return@mapNotNull null + val input = li.selectFirst("input[type=checkbox]") ?: return@mapNotNull null + val value = input.attr("value").trim() + if (label.isBlank() || value.isBlank()) null + else MangaTag(key = value, title = label, source = source) + }.toSet() + } - override suspend fun getListPage( - page: Int, - order: SortOrder, - filter: MangaListFilter, - ): List { - val query = filter.query + override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List { + val query = filter.query?.trim().orEmpty() val url = buildString { - append("https://") - append(domainName) - if(query?.isNotBlank() == true) { - append("/?s=") - append(query.urlEncoded()) + append("https://$domain/$mangaDirectory/") + if (query.isNotEmpty()) { + append("?s=${query.urlEncoded()}") } else { - append("/") - append(mangaDirectory) - append("?page=") - append(page) - } - filter.author - ?.takeIf { it.isNotBlank() } - ?.let { - append("&author=") - append(it.urlEncoded()) - } - - if (filter.year != -1) { - append("&yearx=") - append(filter.year) + append("?page=$page") } - filter.states.firstOrNull()?.let { append("&status=") - append( - when(it) { - MangaState.ONGOING -> "ongoing" - MangaState.FINISHED -> "completed" - MangaState.PAUSED -> "hiatus" - MangaState.ABANDONED -> "dropped" - else -> "" - } - ) + append(when (it) { + MangaState.ONGOING -> "ongoing" + MangaState.FINISHED -> "completed" + MangaState.PAUSED -> "hiatus" + MangaState.ABANDONED -> "dropped" + else -> "" + }) } - filter.types.firstOrNull()?.let { append("&type=") - append( - when(it) { - ContentType.MANGA -> "Manga" - ContentType.MANHWA -> "Manhwa" - ContentType.MANHUA -> "Manhua" - else -> "" - } - ) - } - filter.tags.forEach { - append("&genre[]=") - append(it.key.urlEncoded()) - } - filter.tagsExclude.forEach { - append("&genre[]=-") - append(it.key.urlEncoded()) - } - - when(order) { - SortOrder.UPDATED -> - append("&order=update") - SortOrder.POPULARITY -> - append("&order=popular") - SortOrder.NEWEST -> - append("&order=latest") - else -> {} + append(when (it) { + ContentType.MANGA -> "manga" + ContentType.MANHWA -> "manhwa" + ContentType.MANHUA -> "manhua" + ContentType.COMICS -> "comic" + ContentType.NOVEL -> "novel" + else -> "" + }) } + filter.tags.forEach { append("&genre[]=${it.key.urlEncoded()}") } + filter.tagsExclude.forEach { append("&genre[]=-${it.key.urlEncoded()}") } + append("&order=") + append(when (order) { + SortOrder.UPDATED -> "update" + SortOrder.POPULARITY -> "popular" + SortOrder.ADDED -> "latest" + SortOrder.ALPHABETICAL -> "title" + SortOrder.ALPHABETICAL_DESC -> "titlereverse" + else -> "update" + }) } - - val document = - webClient - .httpGet( - url, - ) - .parseHtml() - - - return document - .select(searchSelector) - .mapNotNull { - runCatching { - parseManga(it) - }.getOrNull() - } + val doc = webClient.httpGet(url).parseHtml() + return parseMangaList(doc) } - - protected open fun parseManga( - element: Element, - ): Manga { - val link = - element.selectFirst("a") - ?: return Manga( - id = generateUid("empty"), - url = "", - publicUrl = "", - title = "", - altTitles = emptySet(), - authors = emptySet(), - tags = emptySet(), - coverUrl = null, - rating = RATING_UNKNOWN, - state = null, - contentRating = - if(isNsfwSource) - ContentRating.ADULT - else - null, - source = source, - ) - - val url = - link.attr("href") - .toRelativeUrl(domainName) - - return Manga( - id = generateUid(url), - url = url, - publicUrl = url.toAbsoluteUrl(domainName), - title = - link.attr("title") - .ifBlank { - link.text() - }, - altTitles = emptySet(), - authors = emptySet(), - coverUrl = - element - .selectFirst("img, source") - ?.imgAttr(), - tags = emptySet(), - rating = RATING_UNKNOWN, - state = null, - contentRating = - if(isNsfwSource) - ContentRating.ADULT - else - null, - source = source, - ) + protected open fun parseMangaList(doc: Document): List { + return doc.select(searchSelector).mapNotNull { element -> + parseMangaElement(element) + } } + override suspend fun getDetails(manga: Manga): Manga { + val fullUrl = manga.url.toAbsoluteUrl(domain) + val doc = webClient.httpGet(fullUrl).parseHtml() + + val title = doc.selectFirst("h1.entry-title, .ts-breadcrumb li:last-child span, .infomanga h1, .animefull h1") + ?.text() ?: manga.title + + val description = doc.selectFirst( + ".desc, .entry-content[itemprop=description], .summary__content, .sinopsis, .entry-content p" + )?.text()?.trim().orEmpty() + + val coverUrl = doc.selectFirst( + ".thumb img, .infomanga img, .summary_image img, .cover img, img.wp-post-image" + )?.imgAttr() ?: manga.coverUrl + + val table = doc.selectFirst("table.infotable") + val hasTable = table != null + + val authors = if (hasTable) { + val authorCell = table.selectFirst("tr:has(td:contains(Author)) td:last-child") + val artistCell = table.selectFirst("tr:has(td:contains(Artist)) td:last-child") + listOfNotNull( + authorCell?.text()?.trim()?.takeIf { it.isNotBlank() && it != "n/a" && it != "N/A" }, + artistCell?.text()?.trim()?.takeIf { it.isNotBlank() && it != "n/a" && it != "N/A" } + ).toSet() + } else { + doc.select(".author, .artist, .fmed span, .tsinfo .imptdt:contains(Author) i, .spe span:contains(Author) a") + .map { it.text().trim() } + .filter { it.isNotBlank() } + .toSet() + } - override suspend fun getRelatedManga( - seed: Manga, - ): List { - val document = - webClient - .httpGet( - seed.url.toAbsoluteUrl(domainName), - ) - .parseHtml() - return document - .select(relatedSelector) - .mapNotNull { - runCatching { - val link = - it.selectFirst("a") - ?: return@runCatching null - val url = - link - .attr("href") - .toRelativeUrl(domainName) - Manga( - id = generateUid(url), - url = url, - publicUrl = url.toAbsoluteUrl(domainName), - title = - link.attr("title") - .ifBlank { - link.text() - }, - altTitles = emptySet(), - authors = emptySet(), - coverUrl = - it - .selectFirst("img") - ?.imgAttr(), - tags = emptySet(), - rating = RATING_UNKNOWN, - state = null, - contentRating = - if(isNsfwSource) - ContentRating.ADULT - else - null, - source = source, - ) + val tags = doc.select(".mgen a, .gnr a, .seriestugenre a, .genres-content a") + .mapNotNull { a -> + val text = a.text().trim() + if (text.isNotBlank()) MangaTag(key = text.lowercase(), title = text, source = source) else null + }.toSet() + + val statusText = if (hasTable) { + table.selectFirst("tr:has(td:contains(Status)) td:last-child")?.text() + } else { + doc.select(".imptdt:contains(Status) i, .tsinfo .imptdt:contains(Status) a, .fmed b:contains(Status)+span span") + .text() + .ifEmpty { + doc.selectFirst("div.post-content_item:contains(Status) div.summary-content")?.text() } - .getOrNull() - } - } + } + val state = parseStatus(statusText) + val rating = doc.selectFirst(".num[itemprop=ratingValue]")?.attr("content")?.toFloatOrNull() + ?: doc.selectFirst(".num")?.text()?.toFloatOrNull() + val normalizedRating = if (rating != null && rating > 0) rating / 10f else RATING_UNKNOWN + + val chapters = loadChapters(doc, manga.url) - override suspend fun getDetails( - manga: Manga, - ): Manga { - val document = - webClient - .httpGet( - manga.url.toAbsoluteUrl(domainName), - ) - .parseHtml() - - val chapters = - loadChapters( - document, - manga, - ) return manga.copy( - title = - document - .selectFirst( - "h1.entry-title, .ts-breadcrumb li:last-child span" - ) - ?.text() - ?: manga.title, - description = - document - .selectFirst( - ".desc, .entry-content[itemprop=description]" - ) - ?.text() - ?.trim() - .orEmpty(), - coverUrl = - document - .selectFirst( - ".thumb img, .infomanga img, .summary_image img, .cover img, img.wp-post-image" - ) - ?.imgAttr() - ?: manga.coverUrl, - authors = - document - .select( - ".author, .artist, .fmed span" - ) - .map { - it.text() - } - .filter { - it.isNotBlank() - } - .toSet(), - tags = - document - .select( - ".mgen a, .gnr a, .seriestugenre a" - ) - .map { - - MangaTag( - key = it.text().lowercase(), - title = it.text(), - source = source, - ) - - } - .toSet(), - state = - parseState( - document - .select( - ".imptdt, .status" - ) - .text() - ), + title = title, + description = description, + coverUrl = coverUrl, + authors = authors, + tags = tags, + state = state, + rating = normalizedRating, chapters = chapters, ) } - - protected open suspend fun loadChapters( - document: Document, - manga: Manga, - ): List { - - return document - .select( - "div.bxcl li, div.cl li, #chapterlist li, ul li:has(div.chbox)" - ) - .mapNotNull { element -> - - val url = - element - .selectFirst("a") - ?.attr("href") - ?: return@mapNotNull null - - val title = - cleanChapterTitle( - element - .selectFirst("a") - ?.text() - .orEmpty() - ) - - val chapterNumber = - extractChapterNumber(title) - ?: return@mapNotNull null - - MangaChapter( - id = generateUid(url), - url = url.toRelativeUrl(domainName), - title = title, - number = chapterNumber, - volume = 0, - branch = null, - uploadDate = - element - .selectFirst(".chapterdate") - ?.text() - ?.let { - parseDate(it) - } - ?: 0, - scanlator = null, - source = source, - ) - } - .sortedBy { it.number } + protected open fun parseStatus(text: String?): MangaState? { + val status = text?.lowercase() ?: return null + return when (status) { + in ongoing -> MangaState.ONGOING + in finished -> MangaState.FINISHED + in paused -> MangaState.PAUSED + in abandoned -> MangaState.ABANDONED + else -> null + } } - private fun parseDate( - value:String, - ):Long { - return try { - dateFormat - .parse(value) - ?.time - ?: 0 - } catch(_:Exception){ - 0 + protected open suspend fun loadChapters(doc: Document, mangaUrl: String): List { + val chapterElements = doc.select(chapterListSelector) + if (chapterElements.isEmpty() && !withoutAjax) { + return loadChaptersAjax(mangaUrl) } + return parseChapters(chapterElements) } + protected open suspend fun loadChaptersAjax(mangaUrl: String): List { + val ajaxUrl = mangaUrl.toAbsoluteUrl(domain).removeSuffix("/") + "/ajax/chapters/" + val doc = webClient.httpPost(ajaxUrl, emptyMap()).parseHtml() + return parseChapters(doc.select(chapterListSelector)) + } - override suspend fun getPages( - chapter: MangaChapter, - ): List { - val document = - webClient - .httpGet( - chapter.url.toAbsoluteUrl(domainName), - ) - .parseHtml() - - val images = - Regex( - "\"images\"\\s*:\\s*(\\[.*?])", - setOf( - RegexOption.DOT_MATCHES_ALL - ) + private fun parseChapters(elements: Elements): List { + return elements.mapChapters(reversed = true) { i, element -> + val a = element.selectFirst("a") ?: return@mapChapters null + val href = a.attrAsRelativeUrl("href") + val name = a.selectFirst(".chapternum")?.text() ?: a.ownText() + val dateStr = element.selectFirst(".chapterdate")?.text() + val number = extractChapterNumber(name) ?: (i + 1).toFloat() + MangaChapter( + id = generateUid(href), + url = href, + title = name, + number = number, + volume = 0, + uploadDate = parseChapterDate(dateStr), + scanlator = null, + branch = null, + source = source, ) - .find(document.html()) - ?.groupValues - ?.getOrNull(1) - - if(!images.isNullOrBlank()) { - val pages = - runCatching { - Json - .parseToJsonElement(images) - .jsonArray - .map { - val url = - it.jsonPrimitive.content - MangaPage( - id = generateUid(url), - url = url, - preview = null, - source = source, - ) - } - } - .getOrNull() - if(!pages.isNullOrEmpty()) - return pages } + } - return document - .select(pageSelector) - .mapNotNull { - val url = - it.imgAttr() - if(url.isBlank()) - null - else - MangaPage( - id = generateUid(url), - url = url, - preview = null, - source = source, - ) - } + private fun extractChapterNumber(title: String): Float? { + return Regex("""(?i)(?:chapter|ch\.?)\s*([0-9]+(?:\.[0-9]+)?)""") + .find(title) + ?.groupValues + ?.getOrNull(1) + ?.toFloatOrNull() } + protected open fun parseChapterDate(date: String?): Long { + if (date.isNullOrBlank()) return 0L + val clean = date.trim() + return when { + WordSet(" ago", "atrás", " hace", " назад", " önce", " trước", "مضت", "قبل").endsWith(clean) -> parseRelativeDate(clean) + WordSet("há ", "منذ", "il y a", "hace", "giờ", "phút").startsWith(clean) -> parseRelativeDate(clean) + WordSet("yesterday", "يوم واحد").startsWith(clean) -> yesterday() + WordSet("today").startsWith(clean) -> today() + WordSet("يومين").startsWith(clean) -> dayBeforeYesterday() + else -> dateFormat.parseSafe(clean) + } + } - override suspend fun getPageUrl( - page:MangaPage, - ):String { - return page.url + private fun parseRelativeDate(text: String): Long { + val number = Regex("""(\d+)""").find(text)?.value?.toIntOrNull() ?: return 0L + val cal = Calendar.getInstance() + return when { + WordSet("detik", "segundo", "second", "วินาที", "giây", "ثوان").anyWordIn(text) -> cal.apply { add(Calendar.SECOND, -number) }.timeInMillis + WordSet("menit", "dakika", "min", "minute", "minuto", "mins", "นาที", "دقائق", "phút", "минут").anyWordIn(text) -> cal.apply { add(Calendar.MINUTE, -number) }.timeInMillis + WordSet("jam", "saat", "heure", "hora", "hour", "hours", "ชั่วโมง", "giờ", "ore", "ساعة", "小时").anyWordIn(text) -> cal.apply { add(Calendar.HOUR, -number) }.timeInMillis + WordSet("hari", "gün", "jour", "día", "dia", "day", "days", "días", "วัน", "ngày", "giorni", "أيام", "天", "день").anyWordIn(text) -> cal.apply { add(Calendar.DAY_OF_MONTH, -number) }.timeInMillis + WordSet("week", "semana", "tuần", "أسابيع", "أسبوع").anyWordIn(text) -> cal.apply { add(Calendar.DAY_OF_MONTH, -number * 7) }.timeInMillis + WordSet("month", "months", "mes", "meses", "tháng", "أشهر", "mois").anyWordIn(text) -> cal.apply { add(Calendar.MONTH, -number) }.timeInMillis + WordSet("year", "año", "năm").anyWordIn(text) -> cal.apply { add(Calendar.YEAR, -number) }.timeInMillis + else -> 0L + } } + private fun yesterday() = Calendar.getInstance().apply { add(Calendar.DAY_OF_MONTH, -1) }.timeInMillis + private fun today() = Calendar.getInstance().apply { set(Calendar.HOUR_OF_DAY, 0); set(Calendar.MINUTE, 0) }.timeInMillis + private fun dayBeforeYesterday() = Calendar.getInstance().apply { add(Calendar.DAY_OF_MONTH, -2) }.timeInMillis + + override suspend fun getPages(chapter: MangaChapter): List { + val doc = webClient.httpGet(chapter.url.toAbsoluteUrl(domain)).parseHtml() + + // json image list + val jsonRegex = Regex(""""images"\s*:\s*(\[.*?])""", RegexOption.DOT_MATCHES_ALL) + val jsonMatch = jsonRegex.find(doc.html()) + if (jsonMatch != null) { + val jsonArray = JSONArray(jsonMatch.groupValues[1]) + return (0 until jsonArray.length()).map { i -> + val url = jsonArray.getString(i) + MangaPage(id = generateUid(url), url = url, preview = null, source = source) + } + } - protected open fun parseState( - value:String?, - ):MangaState? { - val text = - value - ?.lowercase() - ?: return null - - return when{ - listOf( - "ongoing", - "on going", - "publishing", - "updating", - ) - .any { - text.contains(it) - } -> - MangaState.ONGOING - - listOf( - "completed", - "complete", - "finished", - ) - .any { - text.contains(it) - } -> - MangaState.FINISHED - - listOf( - "hiatus", - "paused", - "on hold", - ) - .any { - text.contains(it) - } -> - MangaState.PAUSED - - listOf( - "dropped", - "cancelled", - "canceled", - ) - .any { - text.contains(it) - } -> - MangaState.ABANDONED - else -> - null + // direct + return doc.select(pageSelector).mapNotNull { img -> + val url = img.imgAttr().takeIf { it.isNotBlank() } ?: return@mapNotNull null + MangaPage(id = generateUid(url), url = url, preview = null, source = source) } } + override suspend fun getRelatedManga(seed: Manga): List { + val doc = webClient.httpGet(seed.url.toAbsoluteUrl(domain)).parseHtml() + return doc.select(relatedSelector).mapNotNull { element -> + parseMangaElement(element) + } + } - override suspend fun getFilterOptions(): - MangaListFilterOptions { - val tags = - listOf( - "Action", - "Adventure", - "Comedy", - "Drama", - "Fantasy", - "Historical", - "Horror", - "Isekai", - "Romance", - "School Life", - "Sci-Fi", - "Shoujo", - "Slice of Life", - "Supernatural", - "Thriller", - ) - .map { - MangaTag( - key = it.lowercase(), - title = it, - source = source, - ) - } - .toSet() + protected open fun parseMangaElement(element: Element): Manga? { + val a = element.selectFirst("a") ?: return null + val href = a.attrAsRelativeUrl("href") + val title = a.attr("title").ifBlank { a.text() } + val coverUrl = element.selectFirst("img")?.imgAttr() + return makeManga(href, title, coverUrl) + } - return MangaListFilterOptions( - availableTags = - tags, - availableStates = - EnumSet.of( - MangaState.ONGOING, - MangaState.FINISHED, - MangaState.PAUSED, - MangaState.ABANDONED, - ), - availableContentTypes = - EnumSet.of( - ContentType.MANGA, - ContentType.MANHWA, - ContentType.MANHUA, - ), + private fun makeManga(href: String, title: String, coverUrl: String?): Manga { + return Manga( + id = generateUid(href), + url = href, + publicUrl = href.toAbsoluteUrl(domain), + title = title, + altTitles = emptySet(), + authors = emptySet(), + coverUrl = coverUrl, + rating = RATING_UNKNOWN, + tags = emptySet(), + state = null, + contentRating = if (isNsfwSource) ContentRating.ADULT else null, + source = source, ) } protected fun Element.imgAttr(): String { - val attributes = listOf( - "data-src", - "data-lazy-src", - "data-original", - "data-cfsrc", - "data-image", - "data-bg", - "src" - ) - - for(attribute in attributes) { - val value = attr(attribute) - if(value.isNotBlank()) { - return fixImageExtension( - value.toAbsoluteUrl(domainName) - ) - } + for (attr in listOf("data-src", "data-lazy-src", "data-original", "data-cfsrc", "data-image", "src")) { + val value = attr(attr).trim().takeIf { it.isNotEmpty() } ?: continue + return value.toAbsoluteUrl(domain).substringBefore("?") // remove WP resize params } - - val srcset = attr("srcset") - if(srcset.isNotBlank()) { - return fixImageExtension( - srcset - .split(",") - .last() - .trim() - .split(" ") - .first() - .toAbsoluteUrl(domainName) - ) + if (srcset.isNotBlank()) { + return srcset.split(",").last().trim().split(" ").first().toAbsoluteUrl(domain) } return "" } - private fun fixImageExtension(url: String): String { - var result = url - // Remove WordPress image proxy - result = result.replace( - Regex( - "https://i[0-3]\\.wp\\.com/" - ), - "https://" - ) - // Remove WordPress resize params if they exist - result = result.substringBefore("?") - return result - } - + private fun SimpleDateFormat.parseSafe(date: String): Long = + runCatching { parse(date)?.time ?: 0L }.getOrDefault(0L) - - private fun cleanChapterTitle( - value: String, - ): String { - return value - .replace(Regex("\\s+(January|February|March|April|May|June|July|August|September|October|November|December).*", RegexOption.IGNORE_CASE,), "",) - .replace(Regex("\\s+\\d{1,2}[./-]\\d{1,2}[./-]\\d{2,4}.*",), "",) - .trim() - } - - private fun extractChapterNumber(title: String): Float? { - return Regex( - """(?:chapter|ch\.?)\s*([0-9]+(?:\.[0-9]+)?)""", - RegexOption.IGNORE_CASE, + companion object { + val ongoing = setOf( + "ongoing", "on going", "publishing", "updating", "en curso", "ativo", "en cours", + "đang tiến hành", "em lançamento", "devam ediyor", "in corso", "güncel", "berjalan", + "продолжается", "lançando", "in arrivo", "连载中", "devam etmekte", "مستمرة", + ) + val finished = setOf( + "completed", "complete", "finished", "finalizado", "terminé", "tamamlandı", + "đã hoàn thành", "hoàn thành", "مكتملة", "завершено", "completato", "one-shot", + "bitti", "tamat", "concluído", "已完结", "bitmiş", "achevé", + ) + val paused = setOf( + "hiatus", "on hold", "pausado", "en espera", "en pause", "en attente", + "durduruldu", "beklemede", "đang chờ", "متوقف", "заморожено", + ) + val abandoned = setOf( + "canceled", "cancelled", "cancelado", "cancellato", "dropped", "discontinued", + "abandonné", "iptal edildi", "đã hủy", "ملغي", "заброшено", ) - .find(title) - ?.groupValues - ?.getOrNull(1) - ?.toFloatOrNull() } - - abstract val pageSelector: String } diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/ManhwaZ.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/ManhwaZ.kt deleted file mode 100644 index 5a6471fa..00000000 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/ManhwaZ.kt +++ /dev/null @@ -1,287 +0,0 @@ -package org.koitharu.kotatsu.parsers.parsers - -import org.koitharu.kotatsu.parsers.MangaLoaderContext -import org.koitharu.kotatsu.parsers.config.ConfigKey -import org.koitharu.kotatsu.parsers.core.PagedMangaParser -import org.koitharu.kotatsu.parsers.model.ContentRating -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.network.CommonHeaders -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.mapToSet -import org.koitharu.kotatsu.parsers.util.parseHtml -import org.koitharu.kotatsu.parsers.util.parseSafe -import org.koitharu.kotatsu.parsers.util.selectOrThrow -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.EnumSet -import java.util.Locale - -internal abstract class ManhwaZ( - context: MangaLoaderContext, - source: MangaParserSource, - domain: String, - pageSize: Int = 20, -) : PagedMangaParser(context, source, pageSize) { - - override val configKeyDomain = ConfigKey.Domain(domain) - - override fun onCreateConfig(keys: MutableCollection>) { - super.onCreateConfig(keys) - keys.add(userAgentKey) - } - - override fun getRequestHeaders() = super.getRequestHeaders().newBuilder() - .add(CommonHeaders.ORIGIN, "https://$domain") - .add(CommonHeaders.REFERER, "https://$domain/") - .build() - - override val availableSortOrders: Set = EnumSet.of( - SortOrder.UPDATED, - SortOrder.POPULARITY, - SortOrder.ALPHABETICAL, - SortOrder.RATING, - ) - - override val filterCapabilities: MangaListFilterCapabilities - get() = MangaListFilterCapabilities( - isSearchSupported = true, - ) - - override suspend fun getFilterOptions(): MangaListFilterOptions { - return MangaListFilterOptions( - availableStates = EnumSet.of(MangaState.ONGOING, MangaState.FINISHED), - availableTags = fetchTags(), - ) - } - - protected open val searchPath = "/search" - - override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List { - val url = buildString { - append("https://") - append(domain) - if (!filter.query.isNullOrEmpty()) { - append("$searchPath?s=") - append((filter.query?.urlEncoded() ?: "")) - append("&page=") - append(page.toString()) - } else { - if (filter.tags.isNotEmpty()) { - append("/genre/") - append(filter.tags.first().key) - append("/") - } else { - append("/") - } - append("?page=") - append(page.toString()) - val sortQuery = getSortOrderQuery(order, filter.tags.isNotEmpty()) - if (sortQuery.isNotEmpty()) { - append("&") - append(sortQuery) - } - } - } - - val doc = webClient.httpGet(url).parseHtml() - return doc.select(".page-item-detail").mapNotNull { element -> - val href = element.selectFirst(".item-summary a")?.attrAsRelativeUrl("href") ?: return@mapNotNull null - Manga( - id = generateUid(href), - url = href, - publicUrl = href.toAbsoluteUrl(domain), - title = element.selectFirst(".item-summary a")?.text().orEmpty(), - coverUrl = element.selectFirst(".item-thumb img")?.src().orEmpty(), - altTitles = emptySet(), - rating = RATING_UNKNOWN, - tags = emptySet(), - authors = emptySet(), - state = null, - source = source, - contentRating = if (isNsfwSource) ContentRating.ADULT else null, - ) - } - } - - protected open val selectAuthor = - "div.summary-heading:contains(Tác giả) + div.summary-content" - protected open val selectState = - "div.summary-heading:contains(Trạng thái) + div.summary-content" - - override suspend fun getDetails(manga: Manga): Manga { - val doc = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml() - val author = doc.selectFirst(selectAuthor)?.textOrNull() - return manga.copy( - altTitles = setOfNotNull(doc.selectFirst("h2.other-name")?.textOrNull()), - authors = setOfNotNull(author), - tags = doc.select("div.genres-content a[rel=tag]").mapToSet { a -> - MangaTag( - key = a.attr("href").substringAfterLast('/'), - title = a.text().toTitleCase(sourceLocale), - source = source, - ) - }, - description = doc.selectFirst("div.summary__content")?.html(), - state = when (doc.selectFirst(selectState)?.text()?.lowercase()) { - "đang ra" -> MangaState.ONGOING - "hoàn thành" -> MangaState.FINISHED - else -> null - }, - chapters = doc.select("li.wp-manga-chapter").mapChapters(reversed = true) { i, element -> - val a = element.selectFirst("a") ?: return@mapChapters null - MangaChapter( - id = generateUid(a.attrAsRelativeUrl("href")), - title = a.text(), - number = i + 1f, - url = a.attrAsRelativeUrl("href"), - uploadDate = parseChapterDate( - element.selectFirst("span.chapter-release-date")?.text() - ), - branch = null, - scanlator = null, - source = source, - volume = 0, - ) - }, - ) - } - - override suspend fun getPages(chapter: MangaChapter): List { - val fullUrl = chapter.url.toAbsoluteUrl(domain) - val doc = webClient.httpGet(fullUrl).parseHtml() - return doc.selectOrThrow("div.page-break img").map { img -> - val url = img.src().orEmpty() - MangaPage( - id = generateUid(url), - url = url, - preview = null, - source = source, - ) - } - } - - @JvmField - protected val secondAgo: Set = setOf( - "giây trước", - "second ago", - "seconds ago" - ) - - @JvmField - protected val minuteAgo: Set = setOf( - "phút trước", - "minute ago", - "minutes ago" - ) - - @JvmField - protected val hourAgo: Set = setOf( - "giờ trước", - "hour ago", - "hours ago" - ) - - @JvmField - protected val dayAgo: Set = setOf( - "ngày trước", - "day ago", - "days ago" - ) - - @JvmField - protected val weekAgo: Set = setOf( - "tuần trước", - "week ago", - "weeks ago" - ) - - @JvmField - protected val monthAgo: Set = setOf( - "tháng trước", - "month ago", - "months ago" - ) - - @JvmField - protected val yearAgo: Set = setOf( - "năm trước", - "year ago", - "years ago" - ) - - private fun parseChapterDate(date: String?): Long { - if (date == null) return 0L - - return when { - secondAgo.any { date.contains(it) } -> - System.currentTimeMillis() - date.removeSuffixes(secondAgo).toLong() * 1000 - - minuteAgo.any { date.contains(it) } -> - System.currentTimeMillis() - date.removeSuffixes(minuteAgo).toLong() * 60 * 1000 - - hourAgo.any { date.contains(it) } -> - System.currentTimeMillis() - date.removeSuffixes(hourAgo).toLong() * 60 * 60 * 1000 - - dayAgo.any { date.contains(it) } -> - System.currentTimeMillis() - date.removeSuffixes(dayAgo).toLong() * 24 * 60 * 60 * 1000 - - weekAgo.any { date.contains(it) } -> - System.currentTimeMillis() - date.removeSuffixes(weekAgo).toLong() * 7 * 24 * 60 * 60 * 1000 - - monthAgo.any { date.contains(it) } -> - System.currentTimeMillis() - date.removeSuffixes(monthAgo).toLong() * 30 * 24 * 60 * 60 * 1000 - - yearAgo.any { date.contains(it) } -> - System.currentTimeMillis() - date.removeSuffixes(yearAgo).toLong() * 365 * 24 * 60 * 60 * 1000 - - else -> - SimpleDateFormat("dd/MM/yyyy", Locale.US).parseSafe(date) - } - } - - private fun String.removeSuffixes(suffixes: Set): String = - suffixes.fold(this) { text, suffix -> - text.replace(suffix, "") - }.trim() - - private fun getSortOrderQuery(order: SortOrder, hasTags: Boolean): String { - if (!hasTags) return "" - return when (order) { - SortOrder.UPDATED -> "m_orderby=latest" - SortOrder.POPULARITY -> "m_orderby=views" - SortOrder.ALPHABETICAL -> "m_orderby=alphabet" - SortOrder.RATING -> "m_orderby=rating" - else -> "m_orderby=latest" - } - } - - protected open val tagPath = "genre" - - private suspend fun fetchTags(): Set = webClient.httpGet("https://$domain/$tagPath").parseHtml() - .select("ul.page-genres li a") - .mapToSet { a -> - val title = a.ownText().toTitleCase(sourceLocale) - MangaTag( - key = a.attr("href").substringAfterLast('/'), - title = title, - source = source, - ) - } -} - diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/MangaFire.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/MangaFire.kt index 32b94f81..c9f84c20 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/MangaFire.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/MangaFire.kt @@ -8,6 +8,7 @@ import org.koitharu.kotatsu.parsers.MangaSourceParser import org.koitharu.kotatsu.parsers.config.ConfigKey import org.koitharu.kotatsu.parsers.core.PagedMangaParser import org.koitharu.kotatsu.parsers.model.ContentType +import org.koitharu.kotatsu.parsers.model.Demographic import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaListFilter @@ -18,13 +19,13 @@ 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.ContentRating import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.network.OkHttpWebClient import org.koitharu.kotatsu.parsers.util.generateUid import org.koitharu.kotatsu.parsers.util.parseJson -import org.koitharu.kotatsu.parsers.model.Demographic import java.util.EnumSet +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope internal abstract class MangaFireParser( context: MangaLoaderContext, @@ -35,12 +36,14 @@ internal abstract class MangaFireParser( override val configKeyDomain = ConfigKey.Domain("mangafire.to") override val availableSortOrders: Set = EnumSet.of( - SortOrder.UPDATED, - SortOrder.POPULARITY, - SortOrder.RATING, - SortOrder.NEWEST, - SortOrder.ALPHABETICAL, - SortOrder.RELEVANCE, + SortOrder.UPDATED, // chapter update + SortOrder.POPULARITY, // most views + SortOrder.RATING, // rating score + SortOrder.NEWEST, // created manga + SortOrder.ALPHABETICAL, // title asc + SortOrder.RELEVANCE, // relevance sc + SortOrder.POPULARITY_WEEK, + SortOrder.POPULARITY_MONTH, ) private val apiClient by lazy { @@ -67,27 +70,64 @@ internal abstract class MangaFireParser( isTagsExclusionSupported = true, isSearchSupported = true, isSearchWithFiltersSupported = true, - isYearSupported = false, // idk how to do the year support - isYearRangeSupported = false, + isYearRangeSupported = true, ) - // ---------- Tag ---------- companion object { val GENRE_MAP = mapOf( - "Action" to "1", "Adventure" to "78", "Avant Garde" to "3", - "Boys Love" to "4", "Comedy" to "5", "Demons" to "77", - "Drama" to "6", "Ecchi" to "7", "Fantasy" to "79", - "Girls Love" to "9", "Gourmet" to "10", "Harem" to "11", - "Horror" to "530", "Isekai" to "13", "Iyashikei" to "531", - "Josei" to "15", "Kids" to "532", "Magic" to "539", - "Mahou Shoujo" to "533", "Martial Arts" to "534", - "Mecha" to "19", "Military" to "535", "Music" to "21", - "Mystery" to "22", "Parody" to "23", "Psychological" to "536", - "Reverse Harem" to "25", "Romance" to "26", "School" to "73", - "Sci-Fi" to "28", "Seinen" to "537", "Shoujo" to "30", - "Shounen" to "31", "Slice of Life" to "538", "Space" to "33", - "Sports" to "34", "Super Power" to "75", "Supernatural" to "76", - "Suspense" to "37", "Thriller" to "38", "Vampire" to "39" + "Action" to "1", + "Adult" to "268929", + "Adventure" to "78", + "Avant Garde" to "3", + "Boys Love" to "4", + "Comedy" to "5", + "Crime" to "268921", + "Demons" to "77", + "Drama" to "6", + "Ecchi" to "7", + "Fantasy" to "79", + "Girls Love" to "9", + "Gourmet" to "10", + "Harem" to "11", + "Hentai" to "268930", + "Historical" to "268922", + "Horror" to "530", + "Isekai" to "13", + "Iyashikei" to "531", + "Josei" to "15", + "Kids" to "532", + "Magic" to "539", + "Magical Girls" to "268923", + "Mahou Shoujo" to "533", + "Martial Arts" to "534", + "Mature" to "268931", + "Mecha" to "19", + "Medical" to "268924", + "Military" to "535", + "Music" to "21", + "Mystery" to "22", + "Parody" to "23", + "Philosophical" to "268925", + "Psychological" to "536", + "Reverse Harem" to "25", + "Romance" to "26", + "School" to "73", + "Sci-Fi" to "28", + "Seinen" to "537", + "Shoujo" to "30", + "Shounen" to "31", + "Slice of Life" to "538", + "Smut" to "268932", + "Space" to "33", + "Sports" to "34", + "Super Power" to "75", + "Superhero" to "268926", + "Supernatural" to "76", + "Suspense" to "37", + "Thriller" to "38", + "Tragedy" to "268927", + "Vampire" to "39", + "Wuxia" to "268928" ) } @@ -106,20 +146,30 @@ internal abstract class MangaFireParser( availableContentTypes = EnumSet.of( ContentType.MANGA, ContentType.MANHWA, ContentType.MANHUA, ContentType.OTHER, ), - availableDemographics = EnumSet.of(Demographic.SHOUNEN, Demographic.SHOUJO, Demographic.SEINEN, Demographic.JOSEI, - ), + availableDemographics = EnumSet.of(Demographic.SHOUNEN, Demographic.SHOUJO, Demographic.SEINEN, Demographic.JOSEI), ) - // ---------- Listing ---------- override suspend fun getListPage( page: Int, order: SortOrder, filter: MangaListFilter, ): List { - val url = StringBuilder("https://$domain/api/titles?page=$page&limit=50") + val urlBuilder = okhttp3.HttpUrl.Builder() + .scheme("https") + .host(domain) + .addPathSegments("api/titles") + .addQueryParameter("page", page.toString()) + .addQueryParameter("limit", "50") if (!filter.query.isNullOrBlank()) { - url.append("&keyword=").append(filter.query) + urlBuilder.addQueryParameter("keyword", filter.query) + } + + if (filter.yearFrom > 0) { + urlBuilder.addQueryParameter("year_from", filter.yearFrom.toString()) + } + if (filter.yearTo > 0) { + urlBuilder.addQueryParameter("year_to", filter.yearTo.toString()) } filter.types.forEach { type -> @@ -130,7 +180,7 @@ internal abstract class MangaFireParser( ContentType.OTHER -> "other" else -> null } - value?.let { url.append("&types[]=$it") } + value?.let { urlBuilder.addQueryParameter("types[]", it) } } filter.demographics.forEach { demo -> @@ -141,40 +191,43 @@ internal abstract class MangaFireParser( Demographic.SHOUNEN -> "268918" else -> null } - id?.let { url.append("&demographics[]=$it") } + id?.let { urlBuilder.addQueryParameter("demographics[]", it) } } - filter.tags.forEach { url.append("&genres_in[]=").append(it.key) } - filter.tagsExclude.forEach { url.append("&genres_ex[]=").append(it.key) } - - // --- Statuses --- - filter.states.forEach { - url.append("&statuses[]=").append( - when (it) { - MangaState.ONGOING -> "releasing" - MangaState.FINISHED -> "finished" - MangaState.ABANDONED -> "discontinued" - MangaState.PAUSED -> "on_hiatus" - MangaState.UPCOMING -> "not_yet_released" - else -> "" - } - ) + filter.tags.forEach { urlBuilder.addQueryParameter("genres_in[]", it.key) } + filter.tagsExclude.forEach { urlBuilder.addQueryParameter("genres_ex[]", it.key) } + + filter.states.forEach { state -> + val apiState = when (state) { + MangaState.ONGOING -> "releasing" + MangaState.FINISHED -> "finished" + MangaState.ABANDONED -> "discontinued" + MangaState.PAUSED -> "on_hiatus" + MangaState.UPCOMING -> "not_yet_released" + else -> null + } + apiState?.let { urlBuilder.addQueryParameter("statuses[]", it) } } - // --- Sort order --- val sortParam = when (order) { - SortOrder.UPDATED -> "order[chapter_updated_at]=desc" - SortOrder.POPULARITY -> "order[views_total]=desc" - SortOrder.RATING -> "order[score]=desc" - SortOrder.NEWEST -> "order[created_at]=desc" - SortOrder.ALPHABETICAL -> "order[title]=asc" - SortOrder.RELEVANCE -> "order[relevance]=desc" - else -> "" + SortOrder.UPDATED -> "chapter_updated_at" to "desc" + SortOrder.POPULARITY -> "views_total" to "desc" + SortOrder.RATING -> "score" to "desc" + SortOrder.NEWEST -> "created_at" to "desc" + SortOrder.ALPHABETICAL -> "title" to "asc" + SortOrder.RELEVANCE -> "relevance" to "desc" + SortOrder.POPULARITY_WEEK -> "views_7d" to "desc" + SortOrder.POPULARITY_MONTH -> "views_30d" to "desc" + else -> null + } + + sortParam?.let { (field, dir) -> + urlBuilder.addQueryParameter("order[$field]", dir) } - if (sortParam.isNotEmpty()) url.append("&").append(sortParam) - // --- Execute request --- - val response = apiClient.httpGet(url.toString()).parseJson() + val url = urlBuilder.build().toString() + + val response = apiClient.httpGet(url).parseJson() val items = response.getJSONArray("items") val mangas = mutableListOf() for (i in 0 until items.length()) { @@ -208,102 +261,118 @@ internal abstract class MangaFireParser( return mangas } - // ---------- Details ---------- override suspend fun getDetails(manga: Manga): Manga { - val hid = manga.url.substringAfter("/title/").substringBefore("-").substringBefore("/") - val detailsJson = apiClient.httpGet("https://$domain/api/titles/$hid").parseJson() - val data = detailsJson.getJSONObject("data") - - val title = data.getString("title") - val poster = data.optJSONObject("poster") - val cover = poster?.optString("large") - ?: poster?.optString("medium") - ?: poster?.optString("small") - val synopsisHtml = data.optString("synopsisHtml", null) - val status = data.optString("status", null) - val type = data.optString("type", null) - val authors = data.optJSONArray("authors")?.let { arr -> - (0 until arr.length()).map { arr.getJSONObject(it).getString("title") } - }?.joinToString() - val genres = data.optJSONArray("genres")?.let { arr -> - (0 until arr.length()).map { arr.getJSONObject(it).getString("title") } - } - val themes = data.optJSONArray("themes")?.let { arr -> - (0 until arr.length()).map { arr.getJSONObject(it).getString("title") } - } - val altTitlesArray = data.optJSONArray("altTitles")?.let { arr -> - (0 until arr.length()).map { arr.getString(it) } - } ?: emptyList() + detailsCache[manga.url]?.let { return it } + + val result = coroutineScope { + val hid = extractHid(manga.url) + + val detailsJson = apiClient.httpGet("https://$domain/api/titles/$hid").parseJson() + val data = detailsJson.getJSONObject("data") + + val hasVolumes = data.optBoolean("hasVolumes", false) + + val chaptersDeferred = async { fetchChapters(hid, hasVolumes) } + + val title = data.getString("title") + val poster = data.optJSONObject("poster") + val cover = poster?.optString("large") + ?: poster?.optString("medium") + ?: poster?.optString("small") + val synopsisHtml = data.optString("synopsisHtml", null) + val status = data.optString("status", null) + val type = data.optString("type", null) + val authorsList = data.optJSONArray("authors")?.let { arr -> + (0 until arr.length()).map { arr.getJSONObject(it).getString("title") } + }.orEmpty() + val artistsList = data.optJSONArray("artists")?.let { arr -> + (0 until arr.length()).map { arr.getJSONObject(it).getString("title") } + }.orEmpty() + val genres = data.optJSONArray("genres")?.let { arr -> + (0 until arr.length()).map { arr.getJSONObject(it).getString("title") } + } + val themes = data.optJSONArray("themes")?.let { arr -> + (0 until arr.length()).map { arr.getJSONObject(it).getString("title") } + } + val altTitlesArray = data.optJSONArray("altTitles")?.let { arr -> + (0 until arr.length()).map { arr.getString(it) } + } ?: emptyList() + + val rawRating = data.optDouble("rating", -1.0) + val rating = if (rawRating >= 0.0) (rawRating / 10.0).toFloat() else RATING_UNKNOWN - val description = synopsisHtml?.let { Jsoup.parseBodyFragment(it).text() } + val synopsisText = synopsisHtml?.let { Jsoup.parseBodyFragment(it).text() } ?: "" - val genreList = buildList { - type?.let { add(it.replaceFirstChar { c -> c.uppercase() }) } - genres?.let { addAll(it) } - themes?.let { addAll(it) } + val genreList = buildList { + type?.let { add(it.replaceFirstChar { c -> c.uppercase() }) } + genres?.let { addAll(it) } + themes?.let { addAll(it) } + } + val genreTags = genreList.mapNotNull { name -> + tags.find { it.title == name } + }.toSet() + + val chapters = chaptersDeferred.await() + + manga.copy( + title = title, + coverUrl = cover ?: manga.coverUrl, + authors = (authorsList + artistsList).toSet(), + description = synopsisText.trim(), + rating = rating, + state = when (status?.lowercase()) { + "releasing" -> MangaState.ONGOING + "finished" -> MangaState.FINISHED + "discontinued" -> MangaState.ABANDONED + "on_hiatus" -> MangaState.PAUSED + "not_yet_released" -> MangaState.UPCOMING + else -> null + }, + tags = genreTags, + altTitles = altTitlesArray.toSet(), + chapters = chapters, + ) } - val genreTags = genreList.mapNotNull { name -> - tags.find { it.title == name } - }.toSet() + detailsCache.put(manga.url, result) + return result + } -// val ratingValue = if (data.has("rating")) { -// data.getDouble("rating").toFloat() -// } else { -// RATING_UNKNOWN -// } - // they use rating till 10 stars so idk - - val chapters = fetchChapters(hid) - - return manga.copy( - title = title, - coverUrl = cover ?: manga.coverUrl, - authors = setOfNotNull(authors), - description = description, - state = when (status) { - "releasing" -> MangaState.ONGOING - "finished" -> MangaState.FINISHED - "discontinued" -> MangaState.ABANDONED - "on_hiatus" -> MangaState.PAUSED - "not_yet_released" -> MangaState.UPCOMING - else -> null - }, - tags = genreTags, - altTitles = altTitlesArray.toSet(), - chapters = chapters, - ) + @get:Synchronized + private val detailsCache = object : LinkedHashMap(16, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean { + return size > 10 + } } - // ---------- Chapters ---------- - private suspend fun fetchChapters(hid: String): List { + private suspend fun fetchChapters(hid: String, hasVolumes: Boolean): List { val chapters = mutableListOf() + val base = "https://$domain/api/titles/$hid" + var page = 1 var lastPage: Int do { - val url = "https://$domain/api/titles/$hid/chapters?language=$siteLang&sort=number&order=desc&page=$page&limit=200" - val response = apiClient.httpGet(url).parseJson() - val items = response.getJSONArray("items") - val meta = response.optJSONObject("meta") + val json = apiClient.httpGet( + "$base/chapters?language=$siteLang&sort=number&order=desc&page=$page&limit=200" + ).parseJson() + val items = json.getJSONArray("items") + val meta = json.optJSONObject("meta") lastPage = meta?.optInt("lastPage", 1) ?: 1 for (i in 0 until items.length()) { - val chObj = items.getJSONObject(i) - - // Only chapters for the current language - if (chObj.getString("language") != siteLang) continue - - val id = chObj.getInt("id") - val number = chObj.getDouble("number").toFloat() - val name = chObj.optString("name", null) - val createdAt = chObj.optLong("createdAt", 0L) * 1000L + val ch = items.getJSONObject(i) + if (ch.getString("language") != siteLang) continue + + val id = ch.getInt("id") + val number = ch.getDouble("number").toFloat() + val name = ch.optString("name", null) + val createdAt = ch.optLong("createdAt", 0L) * 1000L + val type = ch.getString("type") val chapterUrl = "/title/$hid/$id" val displayName = buildString { append("Ch. ") append(number.toString().removeSuffix(".0")) - if (!name.isNullOrBlank()) { - append(" - $name") - } + if (!name.isNullOrBlank()) append(" - $name") } chapters.add( MangaChapter( @@ -314,7 +383,7 @@ internal abstract class MangaFireParser( url = chapterUrl, scanlator = null, uploadDate = createdAt, - branch = null, + branch = type, source = source, ) ) @@ -322,13 +391,62 @@ internal abstract class MangaFireParser( page++ } while (page <= lastPage) - return chapters.sortedBy { it.number } + if (hasVolumes) { + try { + val volJson = apiClient.httpGet("$base/volumes?language=$siteLang").parseJson() + val volItems = volJson.getJSONArray("items") + for (i in 0 until volItems.length()) { + val vol = volItems.getJSONObject(i) + if (vol.getString("language") != siteLang) continue + + val volId = vol.getInt("id") + val volNumber = vol.getDouble("number").toFloat() + val volName = vol.optString("name", "").takeIf { it.isNotBlank() } + val chapterCount = vol.optInt("chapterCount", 0) + + val title = buildString { + append("Vol. ") + append(volNumber.toString().removeSuffix(".0")) + if (volName != null) append(" - $volName") + } + val name = if (chapterCount > 0) "$chapterCount chapters" else "" + + chapters.add( + MangaChapter( + id = generateUid("/title/$hid/vol/$volId"), + title = title, + number = volNumber, + volume = 0, + url = "/title/$hid/vol/$volId", + scanlator = name, + uploadDate = 0L, + branch = "Volume", + source = source, + ) + ) + } + } catch (_: Exception) { + } + } + + val distinctBranches = chapters.map { it.branch }.distinct() + val useGroups = distinctBranches.size > 1 + + return chapters + .map { chapter -> + chapter.copy( + branch = if (useGroups) (chapter.branch ?: "").replaceFirstChar { it.uppercase() } else null + ) + } + .sortedBy { it.number } } - // ---------- Pages ---------- override suspend fun getPages(chapter: MangaChapter): List { - val chapterId = chapter.url.substringAfterLast("/") - val response = apiClient.httpGet("https://$domain/api/chapters/$chapterId").parseJson() + val chapterId = chapter.url.substringAfterLast("/") // numeric ID + val isVolume = chapter.url.contains("/vol/") + val endpoint = if (isVolume) "volumes" else "chapters" + + val response = apiClient.httpGet("https://$domain/api/$endpoint/$chapterId").parseJson() val pagesArray = response.getJSONObject("data").getJSONArray("pages") val pages = ArrayList(pagesArray.length()) for (i in 0 until pagesArray.length()) { @@ -352,6 +470,16 @@ internal abstract class MangaFireParser( override suspend fun isAuthorized(): Boolean = true override suspend fun getUsername(): String = "" + + private fun extractHid(url: String): String { + val lastPart = url.removeSuffix("/").substringAfterLast("/") + return when { + lastPart.contains(".") -> lastPart.substringAfterLast(".") + lastPart.contains("-") -> lastPart.substringBefore("-") + else -> lastPart + } + } + @MangaSourceParser("MANGAFIRE_EN", "MangaFire (English)", "en") class English(context: MangaLoaderContext) : MangaFireParser(context, MangaParserSource.MANGAFIRE_EN, "en") diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/MangaPlus.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/MangaPlus.kt new file mode 100644 index 00000000..bf7ea2fc --- /dev/null +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/MangaPlus.kt @@ -0,0 +1,293 @@ +package org.koitharu.kotatsu.parsers.site.kotatsu.all + +import okhttp3.Headers +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.Interceptor +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import org.json.JSONArray +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.asTypedList +import org.koitharu.kotatsu.parsers.util.json.getStringOrNull +import org.koitharu.kotatsu.parsers.util.json.mapJSON +import org.koitharu.kotatsu.parsers.util.json.mapJSONNotNull +import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy +import java.util.* + +internal abstract class MangaPlusParser( + context: MangaLoaderContext, + source: MangaParserSource, + private val sourceLang: String +): + SinglePageMangaParser(context, source), Interceptor { + + private val apiUrl = "https://jumpg-webapi.tokyo-cdn.com/api" + override val configKeyDomain = ConfigKey.Domain("mangaplus.shueisha.co.jp") + + override fun onCreateConfig(keys: MutableCollection>) { + super.onCreateConfig(keys) + keys.add(userAgentKey) + } + + override val availableSortOrders: Set = EnumSet.of( + SortOrder.POPULARITY, + SortOrder.UPDATED, + SortOrder.ALPHABETICAL, + ) + + override val filterCapabilities: MangaListFilterCapabilities + get() = MangaListFilterCapabilities( + isSearchSupported = true, + ) + + override suspend fun getFilterOptions() = MangaListFilterOptions() + + private val extraHeaders = Headers.headersOf("Session-Token", UUID.randomUUID().toString()) + + override suspend fun getList(order: SortOrder, filter: MangaListFilter): List { + return when { + filter.query.isNullOrEmpty() -> { + when (order) { + SortOrder.POPULARITY -> getPopularList() + SortOrder.UPDATED -> getLatestList() + else -> getAllTitleList() + } + } + else -> getAllTitleList(filter.query) + } + } + + private suspend fun getPopularList(): List { + val json = apiCall("/title_list/ranking") + + return json.getJSONObject("titleRankingView") + .getJSONArray("titles") + .asTypedList() + .toMangaList() + } + + private suspend fun getLatestList(): List { + val json = apiCall("/title_list/updated") + + return json.getJSONObject("titleUpdatedView") + .getJSONArray("latestTitle") + .mapJSON { it.getJSONObject("title") } + .toMangaList() + } + + private val allTitleCache = suspendLazy { + apiCall("/title_list/allV2") + .getJSONObject("allTitlesViewV2") + .getJSONArray("AllTitlesGroup") + .mapJSON { it.getJSONArray("titles").asTypedList() } + .flatten() + } + + private suspend fun getAllTitleList(query: String? = null): List { + return allTitleCache.get().toMangaList(query) + } + + private fun List.toMangaList(query: String? = null): List { + return mapNotNull { + val language = it.getStringOrNull("language") ?: "ENGLISH" + + if (language != sourceLang) { + return@mapNotNull null + } + + val name = it.getString("name") + val author = it.getString("author") + .split('/') + .joinToString(transform = String::trim) + + if (query != null && !(name.contains(query, true) || author.contains(query, true))) { + return@mapNotNull null + } + + val titleId = it.getInt("titleId").toString() + + Manga( + id = generateUid(titleId), + url = titleId, + publicUrl = "/titles/$titleId".toAbsoluteUrl(domain), + title = name, + coverUrl = it.getString("portraitImageUrl"), + altTitles = emptySet(), + authors = setOf(author), + contentRating = null, + rating = RATING_UNKNOWN, + state = null, + source = source, + tags = emptySet(), + ) + } + } + + override suspend fun getDetails(manga: Manga): Manga { + val json = apiCall("/title_detailV3?title_id=${manga.url}") + .getJSONObject("titleDetailView") + val title = json.getJSONObject("title") + + val completed = json.getJSONObject("titleLabels") + .getString("releaseSchedule").let { + it == "DISABLED" || it == "COMPLETED" + } + + val hiatus = json.getStringOrNull("nonAppearanceInfo")?.contains("on a hiatus") == true + val author = title.getString("author") + .split("/").joinToString(transform = String::trim) + + return manga.copy( + title = title.getString("name"), + publicUrl = "/titles/${title.getInt("titleId")}".toAbsoluteUrl(domain), + coverUrl = title.getString("portraitImageUrl"), + authors = setOf(author), + description = buildString { + json.getString("overview").let(::append) + json.getStringOrNull("viewingPeriodDescription") + ?.takeIf { !completed } + ?.let { append("

", it) } + }, + chapters = parseChapters( + json.getJSONArray("chapterListGroup"), + title.getStringOrNull("language") ?: "ENGLISH", + ), + state = when { + completed -> MangaState.FINISHED + hiatus -> MangaState.PAUSED + else -> MangaState.ONGOING + }, + ) + } + + private fun parseChapters(chapterListGroup: JSONArray, language: String): List { + val chapterList = chapterListGroup + .asTypedList() + .flatMap { + it.optJSONArray("firstChapterList")?.asTypedList().orEmpty() + + it.optJSONArray("lastChapterList")?.asTypedList().orEmpty() + } + + return chapterList.mapChapters { _, chapter -> + val chapterId = chapter.getInt("chapterId").toString() + val subtitle = chapter.getStringOrNull("subTitle") ?: return@mapChapters null + + MangaChapter( + id = generateUid(chapterId), + url = chapterId, + title = subtitle, + number = chapter.getString("name") + .substringAfter("#") + .toFloatOrNull() ?: -1f, + volume = 0, + uploadDate = chapter.getInt("startTimeStamp") * 1000L, + branch = when (language) { + "PORTUGUESE_BR" -> "Portuguese (Brazil)" + else -> language.lowercase().toTitleCase() + }, + scanlator = null, + source = source, + ) + } + } + + override suspend fun getPages(chapter: MangaChapter): List { + val pages = apiCall("/manga_viewer?chapter_id=${chapter.url}&split=yes&img_quality=super_high") + .getJSONObject("mangaViewer") + .getJSONArray("pages") + + return pages.mapJSONNotNull { + val mangaPage = it.optJSONObject("mangaPage") + ?: return@mapJSONNotNull null + val url = mangaPage.getString("imageUrl") + val encryptionKey = mangaPage.getStringOrNull("encryptionKey") + MangaPage( + id = generateUid(url), + url = url + if (encryptionKey == null) "" else "#$encryptionKey", + preview = null, + source = source, + ) + } + } + + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + val response = chain.proceed(request) + val encryptionKey = request.url.fragment + + if (encryptionKey.isNullOrEmpty()) { + return response + } + + return response.map { responseBody -> + val contentType = response.headers["Content-Type"] ?: "image/jpeg" + val image = responseBody.bytes().decodeXorCipher(encryptionKey) + image.toResponseBody(contentType.toMediaTypeOrNull()) + } + } + + private fun ByteArray.decodeXorCipher(key: String): ByteArray { + val keyStream = key.chunked(2) + .map { it.toInt(16) } + + return mapIndexed { i, byte -> byte.toInt() xor keyStream[i % keyStream.size] } + .map(Int::toByte) + .toByteArray() + } + + private suspend fun apiCall(url: String): JSONObject { + val newUrl = "$apiUrl$url".toHttpUrl().newBuilder() + .addQueryParameter("format", "json") + .build() + val response = webClient.httpGet(newUrl, extraHeaders).parseJson() + + val success = response.optJSONObject("success") + + return checkNotNull(success) { + val error = response.getJSONObject("error") + val reason = error.getJSONArray("popups") + .asTypedList() + .firstOrNull { it.getStringOrNull("language") == null } + + if (reason?.getStringOrNull("subject") == "Not Found" && url.contains("manga_viewer")) { + "This chapter has expired" + } else { + reason?.getStringOrNull("body") ?: "Unknown Error" + } + } + } + + @MangaSourceParser("MANGAPLUSPARSER_EN", "MANGA Plus (English)", "en") + class English(context: MangaLoaderContext) : MangaPlusParser(context, MangaParserSource.MANGAPLUSPARSER_EN, "ENGLISH") + + @MangaSourceParser("MANGAPLUSPARSER_ES", "MANGA Plus (Spanish)", "es") + class Spanish(context: MangaLoaderContext) : MangaPlusParser(context, MangaParserSource.MANGAPLUSPARSER_ES, "SPANISH") + + @MangaSourceParser("MANGAPLUSPARSER_FR", "MANGA Plus (French)", "fr") + class French(context: MangaLoaderContext) : MangaPlusParser(context, MangaParserSource.MANGAPLUSPARSER_FR, "FRENCH") + + @MangaSourceParser("MANGAPLUSPARSER_ID", "MANGA Plus (Indonesian)", "id") + class Indonesian(context: MangaLoaderContext) : MangaPlusParser(context, MangaParserSource.MANGAPLUSPARSER_ID, "INDONESIAN") + + @MangaSourceParser("MANGAPLUSPARSER_PTBR", "MANGA Plus Portuguese (Brazil)", "pt") + class Portuguese(context: MangaLoaderContext) : MangaPlusParser(context, MangaParserSource.MANGAPLUSPARSER_PTBR, "PORTUGUESE_BR") + + @MangaSourceParser("MANGAPLUSPARSER_RU", "MANGA Plus (Russian)", "ru") + class Russian(context: MangaLoaderContext) : MangaPlusParser(context, MangaParserSource.MANGAPLUSPARSER_RU, "RUSSIAN") + + @MangaSourceParser("MANGAPLUSPARSER_TH", "MANGA Plus (Thai)", "th") + class Thai(context: MangaLoaderContext) : MangaPlusParser(context, MangaParserSource.MANGAPLUSPARSER_TH, "THAI") + + @MangaSourceParser("MANGAPLUSPARSER_VI", "MANGA Plus (Vietnamese)", "vi") + class Vietnamese(context: MangaLoaderContext) : MangaPlusParser(context, MangaParserSource.MANGAPLUSPARSER_VI, "VIETNAMESE") + + @MangaSourceParser("MANGAPLUSPARSER_DE", "MANGA Plus (German)", "de") + class German(context: MangaLoaderContext) : MangaPlusParser(context, MangaParserSource.MANGAPLUSPARSER_DE, "GERMAN") +} diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/Mangaball.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/Mangaball.kt index 31abb075..34b940db 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/Mangaball.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/Mangaball.kt @@ -134,6 +134,8 @@ internal abstract class MangaBallParser( } override suspend fun getDetails(manga: Manga): Manga { + detailsCache[manga.url]?.let { return it } + ensureAdultCookie() val doc = client.httpGet(getMangaUrl(manga.url)).parseHtml() updateCsrf(doc) @@ -165,7 +167,7 @@ internal abstract class MangaBallParser( else -> manga.contentRating } - return manga.copy( + val result = manga.copy( title = title, altTitles = if (altTitles.isEmpty()) manga.altTitles else altTitles, publicUrl = getMangaUrl(manga.url), @@ -178,6 +180,8 @@ internal abstract class MangaBallParser( chapters = getChapterList(manga.url), contentRating = contentRating, ) + detailsCache[manga.url] = result + return result } override suspend fun getPages(chapter: MangaChapter): List { @@ -543,21 +547,32 @@ internal abstract class MangaBallParser( private fun getCsrf(): String { if (csrfToken.isNullOrEmpty()) { refreshCsrf() + if (csrfToken.isNullOrEmpty()) { + refreshCsrf() + } + if (csrfToken.isNullOrEmpty()) { + error("CSRF token not found after two attempts") + } } - return csrfToken ?: error("CSRF token not found") + return csrfToken!! } @Synchronized private fun refreshCsrf(document: Document? = null) { ensureAdultCookie() - updateCsrf( - document ?: rawHttpClient.newCall( - Request.Builder() - .url(BASE_URL) - .headers(getHeadersWithReferer()) - .build(), - ).execute().parseHtml(), - ) + if (document != null) { + updateCsrf(document) + if (!csrfToken.isNullOrEmpty()) return + } + + val request = Request.Builder() + .url(BASE_URL) + .headers(getHeadersWithReferer()) + .header("Cache-Control", "no-cache") + .build() + val response = rawHttpClient.newCall(request).execute() + updateCsrf(response.parseHtml()) + response.close() } @Synchronized @@ -567,6 +582,11 @@ internal abstract class MangaBallParser( } } + @get:Synchronized + private val detailsCache = object : LinkedHashMap(16, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean = size > 10 + } + private fun MutableList.addEncoded(key: String, value: String) { add("${key.urlEncoded()}=${value.urlEncoded()}") } @@ -889,4 +909,4 @@ internal abstract class MangaBallParser( MangaParserSource.MANGABALL_ZH, setOf("zh", "zh-cn", "zh-hk", "zh-mo", "zh-sg", "zh-tw"), ) -} \ No newline at end of file +} diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/ProjectSuki.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/ProjectSuki.kt index 3665034d..08fd7947 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/ProjectSuki.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/ProjectSuki.kt @@ -32,6 +32,8 @@ import java.util.EnumSet import java.util.LinkedHashMap import java.util.LinkedHashSet import java.util.Locale +import java.text.SimpleDateFormat +import java.util.Calendar @MangaSourceParser("PROJECTSUKI", "ProjectSuki") internal class ProjectSuki(context: MangaLoaderContext) : @@ -44,7 +46,6 @@ internal class ProjectSuki(context: MangaLoaderContext) : override val availableSortOrders: Set = EnumSet.of( SortOrder.UPDATED, SortOrder.POPULARITY, - SortOrder.RELEVANCE, ) override val filterCapabilities: MangaListFilterCapabilities @@ -178,6 +179,11 @@ internal class ProjectSuki(context: MangaLoaderContext) : val bookId = manga.url.toBookId() ?: document.location().toBookId() ?: manga.url.substringAfterLast('/') + val fallbackTimestamp = document.selectFirst("meta[property='og:updated_time']") + ?.attr("content") + ?.toLongOrNull() + ?.times(1000L) + val details = parseDetailsTable(document) val title = document.selectFirst("h2[itemprop=title]")?.text()?.nullIfEmpty() ?: document.selectFirst("h2")?.text()?.nullIfEmpty() @@ -191,7 +197,7 @@ internal class ProjectSuki(context: MangaLoaderContext) : val state = parseState(details["status"]) val genres = parseGenres(document) val altTitles = setOfNotNull(details["alt titles"] ?: details["alternative titles"]) - val chapters = parseChapters(document) + val chapters = parseChapters(document, fallbackTimestamp) return manga.copy( title = title, @@ -264,35 +270,115 @@ internal class ProjectSuki(context: MangaLoaderContext) : } } - private fun parseChapters(document: Document): List { + private fun parseChapters(document: Document, fallbackTimestamp: Long? = null): List { val chaptersMap = LinkedHashMap() - document.select("a[href*=/read/]").forEach { anchor -> - val parts = anchor.absUrl("href").toChapterParts() ?: return@forEach - val key = "${parts.bookId}/${parts.chapterId}" - if (key in chaptersMap) return@forEach - - val parentText = anchor.parent()?.text().orEmpty() - val title = anchor.text().trim().nullIfEmpty() - val chapterNumber = parseChapterNumber(title ?: parentText) - - val chapter = MangaChapter( - id = generateUid(key), - title = title, - number = chapterNumber, - volume = 0, - url = "/read/${parts.bookId}/${parts.chapterId}/1", - scanlator = null, - uploadDate = 0L, - branch = null, - source = source, - ) - chaptersMap[key] = chapter + val table = document.select("table").firstOrNull { table -> + table.select("tr").firstOrNull()?.let { row -> + row.select("td, th").any { cell -> + cell.text().trim().matches(Regex("added|date", RegexOption.IGNORE_CASE)) + } + } ?: false + } + + if (table != null) { + val headerRow = table.select("tr").first() + val headers = headerRow!!.select("td, th").map { it.text().trim().lowercase() } + + val dateCol = headers.indexOfFirst { it == "added" || it == "date" } + val chapterCol = headers.indexOfFirst { it.matches(Regex("chapter|ch\\.|name|#")) } + .takeIf { it >= 0 } ?: 0 + + val dataRows = table.select("tbody tr").ifEmpty { table.select("tr").drop(1) } + for (row in dataRows) { + val cells = row.select("td, th") + if (cells.size <= maxOf(chapterCol, dateCol)) continue + + val chapterCell = cells[chapterCol] + val link = chapterCell.selectFirst("a[href]") ?: continue + val parts = link.absUrl("href").toChapterParts() ?: continue + val key = "${parts.bookId}/${parts.chapterId}" + if (key in chaptersMap) continue + + val title = link.text().trim().nullIfEmpty() + ?: chapterCell.text().trim().nullIfEmpty() + val number = parseChapterNumber(title ?: "") + + val dateText = if (dateCol >= 0) cells[dateCol].text().trim() else "" + val uploadDate = parseChapterDate(dateText) ?: fallbackTimestamp ?: 0L + + chaptersMap[key] = MangaChapter( + id = generateUid(key), + title = title, + number = number, + volume = 0, + url = "/read/${parts.bookId}/${parts.chapterId}/1", + scanlator = null, + uploadDate = uploadDate, + branch = null, + source = source, + ) + } + } else { + document.select("a[href*=/read/]").forEach { anchor -> + val parts = anchor.absUrl("href").toChapterParts() ?: return@forEach + val key = "${parts.bookId}/${parts.chapterId}" + if (key in chaptersMap) return@forEach + + val title = anchor.text().trim().nullIfEmpty() + val number = parseChapterNumber(title ?: "") + chaptersMap[key] = MangaChapter( + id = generateUid(key), + title = title, + number = number, + volume = 0, + url = "/read/${parts.bookId}/${parts.chapterId}/1", + scanlator = null, + uploadDate = fallbackTimestamp ?: 0L, + branch = null, + source = source, + ) + } } return chaptersMap.values.sortedBy { it.number } } + private fun parseChapterDate(text: String): Long? { + if (text.isBlank()) return null + + val relativeRegex = Regex("""(\d+)\s+(years?|months?|weeks?|days?|hours?|mins?|minutes?|seconds?|sec)\s+ago""", RegexOption.IGNORE_CASE) + relativeRegex.matchEntire(text.trim())?.let { match -> + val number = match.groupValues[1].toInt() + val unit = match.groupValues[2].lowercase() + val cal = Calendar.getInstance() + when { + unit.startsWith("year") -> cal.add(Calendar.YEAR, -number) + unit.startsWith("month") -> cal.add(Calendar.MONTH, -number) + unit.startsWith("week") -> cal.add(Calendar.DAY_OF_MONTH, -number * 7) + unit.startsWith("day") -> cal.add(Calendar.DAY_OF_MONTH, -number) + unit.startsWith("hour") -> cal.add(Calendar.HOUR, -number) + unit.startsWith("min") -> cal.add(Calendar.MINUTE, -number) + unit.startsWith("sec") -> cal.add(Calendar.SECOND, -number) + } + return cal.timeInMillis + } + + val formats = listOf( + SimpleDateFormat("MMMM dd, yyyy", Locale.US), + SimpleDateFormat("MMM dd, yyyy", Locale.US), + SimpleDateFormat("MMM d, yyyy", Locale.US), + SimpleDateFormat("yyyy-MM-dd", Locale.US), + ) + for (fmt in formats) { + try { + return fmt.parse(text)?.time + } catch (_: Exception) {} + } + + return null + } + private fun parseChapterNumber(text: String): Float { val match = CHAPTER_NUMBER_REGEX.find(text) ?: return 0f val main = match.groupValues.getOrNull(1)?.toFloatOrNull() ?: return 0f diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/Webtoons.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/Webtoons.kt index eb1e87dd..500ae46c 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/Webtoons.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/Webtoons.kt @@ -3,6 +3,7 @@ package org.koitharu.kotatsu.parsers.site.kotatsu.all import androidx.collection.arraySetOf import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope +import okhttp3.HttpUrl.Companion.toHttpUrl import org.jsoup.nodes.Element import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaSourceParser @@ -14,6 +15,11 @@ import org.koitharu.kotatsu.parsers.util.* import org.koitharu.kotatsu.parsers.util.json.getStringOrNull import java.util.EnumSet +private const val ID_SEARCH_PREFIX = "id:" + +private const val webtoons = "webtoons" +private const val canvas = "canvas" + internal abstract class WebtoonsParser( context: MangaLoaderContext, source: MangaParserSource, @@ -24,6 +30,14 @@ internal abstract class WebtoonsParser( private val mobileApiDomain = "m.webtoons.com" private val staticDomain = "webtoon-phinf.pstatic.net" + private val contentKey = ConfigKey.PreferredImageServer( + presetValues = mapOf( + webtoons to "Webtoons", + canvas to "Canvas", + ), + defaultValue = webtoons, + ) + override val availableSortOrders: EnumSet = EnumSet.of( SortOrder.POPULARITY, SortOrder.RATING, @@ -35,8 +49,7 @@ internal abstract class WebtoonsParser( isSearchSupported = true, ) - override val userAgentKey = - ConfigKey.UserAgent("Mozilla/5.0 (Linux; Android 12; SM-G991B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.120 Mobile Safari/537.36") + override val userAgentKey = ConfigKey.UserAgent("Mozilla/5.0 (Linux; Android 12; SM-G991B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.120 Mobile Safari/537.36") override suspend fun getFilterOptions() = MangaListFilterOptions( availableTags = availableTags(), @@ -45,6 +58,7 @@ internal abstract class WebtoonsParser( override fun onCreateConfig(keys: MutableCollection>) { super.onCreateConfig(keys) keys.add(userAgentKey) + keys.add(contentKey) } override suspend fun getPageUrl(page: MangaPage): String { @@ -59,7 +73,12 @@ internal abstract class WebtoonsParser( } private suspend fun fetchEpisodes(titleNo: Long, type: String): List { - val url = "https://$mobileApiDomain/api/v1/$type/$titleNo/episodes?pageSize=99999" + val baseUrl = "https://$mobileApiDomain/api/v1/$type/$titleNo/episodes" + val url = if (type == "canvas") { + "$baseUrl?pageSize=99999&readingLanguageCode=$languageCode" + } else { + "$baseUrl?pageSize=99999" + } val json = webClient.httpGet(url).parseJson() val episodeList = json.optJSONObject("result")?.optJSONArray("episodeList") @@ -69,10 +88,15 @@ internal abstract class WebtoonsParser( val episodeTitle = jo.getStringOrNull("episodeTitle") ?: "" val episodeNo = jo.getInt("episodeNo") val viewerLink = jo.getString("viewerLink") + val hasBgm = jo.optBoolean("hasBgm", false) MangaChapter( id = generateUid("$titleNo-$episodeNo"), - title = episodeTitle, + title = buildString { + append(org.jsoup.parser.Parser.unescapeEntities(episodeTitle, false)) + append(" (ch. $episodeNo)") + if (hasBgm) append(" ♫") + }, number = episodeNo.toFloat(), volume = 0, url = viewerLink, @@ -81,8 +105,7 @@ internal abstract class WebtoonsParser( scanlator = null, source = source, ) - }.sortedBy(MangaChapter::number) - + }.sortedBy { it.number } } override suspend fun getDetails(manga: Manga): Manga = coroutineScope { @@ -108,10 +131,15 @@ internal abstract class WebtoonsParser( val author = listOf( doc.select("meta[property='com-linewebtoon:webtoon:author']").attr("content"), - doc.select(".detail_header .info .author").firstOrNull()?.text(), + doc.select(".detail_header .info .author:nth-of-type(1)").firstOrNull()?.ownText(), doc.select(".author_area").text(), ).firstOrNull { !it.isNullOrBlank() && it != "null" } + val artist = listOf( + doc.select(".detail_header .info .author:nth-of-type(2)").firstOrNull()?.ownText(), + doc.select(".author_area").text().takeIf { it != author }, + ).firstOrNull { !it.isNullOrBlank() } ?: author + val genreElements = doc.select(".detail_header .info .genre").ifEmpty { doc.select("h2.genre") } @@ -140,7 +168,7 @@ internal abstract class WebtoonsParser( coverUrl = coverUrl, largeCoverUrl = null, tags = genres.map { genre -> MangaTag(title = genre, key = genre.lowercase(), source = source) }.toSet(), - authors = setOfNotNull(author.takeIf { it != "null" }), + authors = setOfNotNull(author, artist).filter { it != "null" }.toSet(), description = description, state = state, chapters = chapters, @@ -182,39 +210,132 @@ internal abstract class WebtoonsParser( } override suspend fun getList(offset: Int, order: SortOrder, filter: MangaListFilter): List { - val query = filter.query - val document = when { - !query.isNullOrEmpty() -> { - val searchUrl = "https://$domain/$languageCode/search?keyword=${query.urlEncoded()}" - webClient.httpGet(searchUrl).parseHtml() + val query = filter.query.orEmpty() + val currentContent = config[contentKey] ?: webtoons + + var directTitleNo: Long? = null + var directType: String? = null + + when { + query.startsWith("https://") -> { + val url = query.toHttpUrl() + if (!url.host.endsWith("webtoons.com")) { + throw Exception("Unsupported domain: ${url.host}. Only webtoons.com and m.webtoons.com are allowed.") + } + directTitleNo = url.queryParameter("title_no")?.toLongOrNull() + ?: throw Exception("Missing 'title_no' parameter in URL") + val path = url.pathSegments.filter(String::isNotEmpty) + if (path.size < 2) throw Exception("Invalid URL path") + if (path[0] != languageCode) { + throw Exception("URL language (${path[0]}) does not match parser language ($languageCode)") + } + directType = if (path.getOrNull(1) == "canvas") canvas else webtoons + } + + query.startsWith(ID_SEARCH_PREFIX) -> { + val idPart = query.removePrefix(ID_SEARCH_PREFIX) + val parts = idPart.split(":") + val (type, lang, titleNo) = when (parts.size) { + 1 -> Triple(currentContent, languageCode, parts[0].toLongOrNull() + ?: throw Exception("Invalid title number: ${parts[0]}")) + 2 -> Triple(parts[0], languageCode, parts[1].toLongOrNull() + ?: throw Exception("Invalid title number: ${parts[1]}")) + 3 -> Triple(parts[0], parts[1], parts[2].toLongOrNull() + ?: throw Exception("Invalid title number: ${parts[2]}")) + else -> throw Exception("Invalid id search format. Use id:type:lang:titleNo, id:type:titleNo, or id:titleNo") + } + if (lang != languageCode) return emptyList() + if (type !in listOf(webtoons, canvas)) { + throw Exception("Unknown type '$type'. Use 'webtoons' or 'canvas'.") + } + directTitleNo = titleNo + directType = type } - filter.tags.isNotEmpty() -> { - val selectedGenre = filter.tags.first() - val genreUrlPath = genreUrlMap[selectedGenre.key] ?: selectedGenre.key - val sortParam = getSortOrderParam(order) - val genreUrl = "https://$domain/$languageCode/genres/$genreUrlPath?sortOrder=$sortParam" - webClient.httpGet(genreUrl).parseHtml() + query.toLongOrNull() != null -> { + directTitleNo = query.toLong() + directType = currentContent } + } + + if (directTitleNo != null && directType != null) { + val detailsUrl = "https://$domain/$languageCode/$directType/placeholder/list?title_no=$directTitleNo" + val manga = getDetails(Manga( + id = generateUid(directTitleNo), + title = "", + altTitles = emptySet(), + url = directTitleNo.toString(), + publicUrl = detailsUrl, + rating = RATING_UNKNOWN, + contentRating = null, + coverUrl = null, + tags = emptySet(), + state = null, + authors = emptySet(), + source = source, + )) + return listOf(manga) + } - else -> { - val rankingType = when (order) { - SortOrder.POPULARITY -> "popular" - SortOrder.RATING -> "trending" - SortOrder.UPDATED -> "originals" - else -> "popular" + val page = offset / 20 + 1 + val inPageOffset = offset % 20 + val selectedGenre = filter.tags.firstOrNull() + + val url = buildString { + append("https://$domain/$languageCode") + when { + query.isNotBlank() -> { + append("/search") + var cleanQuery = query + var searchType: String? = null + when { + cleanQuery.startsWith("type:originals ") -> { + searchType = "originals" + cleanQuery = cleanQuery.removePrefix("type:originals ").trim() + } + cleanQuery.startsWith("type:canvas ") -> { + searchType = "canvas" + cleanQuery = cleanQuery.removePrefix("type:canvas ").trim() + } + } + if (searchType == null) { + searchType = when (currentContent) { + webtoons -> "originals" + canvas -> "canvas" + else -> null + } + } + if (searchType != null) append("/$searchType") + append("?keyword=${cleanQuery.urlEncoded()}") + if (page > 1) append("&page=$page") + } + selectedGenre != null -> { + val genreUrlPath = genreUrlMap[selectedGenre.key] ?: selectedGenre.key + append("/genres/$genreUrlPath") + append("?sortOrder=${getSortOrderParam(order)}") + if (page > 1) append("&page=$page") + } + else -> { + val rankingType = when { + currentContent == canvas -> "canvas" + else -> when (order) { + SortOrder.POPULARITY -> "popular" + SortOrder.RATING -> "trending" + SortOrder.UPDATED -> "originals" + else -> "popular" + } + } + append("/ranking/$rankingType") + if (page > 1) append("?page=$page") } - val rankingUrl = "https://$domain/$languageCode/ranking/$rankingType" - webClient.httpGet(rankingUrl).parseHtml() } } - val selectedGenreForManga = if (filter.tags.isNotEmpty()) filter.tags.first() else null + val doc = webClient.httpGet(url).parseHtml() + val items = doc.select(".webtoon_list li a, .card_wrap .card_item a") + .map { element -> createMangaFromElement(element, source as MangaParserSource, selectedGenre) } - return document.select(".webtoon_list li a, .card_wrap .card_item a") - .map { element -> createMangaFromElement(element, source as MangaParserSource, selectedGenreForManga) } - .drop(offset) - .take(20) + return items.drop(inPageOffset).take(20) } private fun createMangaFromElement( @@ -236,11 +357,11 @@ internal abstract class WebtoonsParser( rating = RATING_UNKNOWN, contentRating = null, coverUrl = thumbnailUrl.toAbsoluteUrl(staticDomain), - largeCoverUrl = null, tags = selectedGenre?.let { setOf(it) } ?: emptySet(), + state = null, authors = emptySet(), + largeCoverUrl = null, description = null, - state = null, source = source, ) } @@ -258,44 +379,77 @@ internal abstract class WebtoonsParser( throw ParseException("Failed to get pages for chapter: ${chapter.title}", chapter.url, e) } - fun extractImages(selector: String, attr: String = "data-url"): List { - return doc.select(selector).mapIndexedNotNull { i, element -> - val url = element.attr(attr).takeIf { it.isNotBlank() } - ?: element.attr("src").takeIf { it.contains(staticDomain) } - ?: return@mapIndexedNotNull null - MangaPage( - id = generateUid("${chapter.id}-$i"), - url = url, - preview = null, - source = source, - ) + val images = doc.select("div#_imageList > img").mapIndexedNotNull { i, element -> + val rawUrl = element.attr("data-url").takeIf { it.isNotBlank() } + ?: element.attr("src").takeIf { it.contains(staticDomain) } + ?: return@mapIndexedNotNull null + + MangaPage( + id = generateUid("${chapter.id}-$i"), + url = rawUrl, + preview = null, + source = source, + ) + }.toMutableList() + + if (images.isEmpty()) { + val motionPages = fetchMotionToonPages(doc) + if (motionPages.isNotEmpty()) { + images.addAll(motionPages) } } - return extractImages("div#_imageList > img") - .ifEmpty { extractImages("canvas[data-url]") } - .ifEmpty { extractImages("img[src*='$staticDomain'], img[data-url*='$staticDomain']") } - .ifEmpty { throw ParseException("No images found in chapter.", chapter.url) } + if (images.isEmpty()) { + throw ParseException("No images found in chapter.", chapter.url) + } + return images + } + + private suspend fun fetchMotionToonPages(doc: org.jsoup.nodes.Document): List { + val docString = doc.toString() + val docUrlRegex = Regex("documentURL:.*?'(.*?)'") + val motionToonPathRegex = Regex("jpg:.*?'(.*?)\\{") + + val docUrl = docUrlRegex.find(docString)?.groupValues?.get(1) ?: return emptyList() + val motionToonPath = motionToonPathRegex.find(docString)?.groupValues?.get(1) ?: return emptyList() + + return try { + val motionToonResponse = webClient.httpGet(docUrl).parseJson() + val imagesMap = motionToonResponse.optJSONObject("assets")?.optJSONObject("images") + imagesMap?.let { map -> + map.keySet().filter { it.contains("layer") }.mapIndexedNotNull { i, key -> + val relPath = map.optString(key, null) ?: return@mapIndexedNotNull null + MangaPage( + id = generateUid("motion-$i"), + url = motionToonPath + relPath, + preview = null, + source = source, + ) + } + } ?: emptyList() + } catch (_: Exception) { + emptyList() + } } - @MangaSourceParser("WEBTOONS_EN", "Webtoons English", "en", type = ContentType.MANGA) + @MangaSourceParser("WEBTOONS_EN", "Webtoons (English)", "en", type = ContentType.MANGA) class English(context: MangaLoaderContext) : WebtoonsParser(context, MangaParserSource.WEBTOONS_EN) - @MangaSourceParser("WEBTOONS_ID", "Webtoons Indonesia", "id", type = ContentType.MANGA) + @MangaSourceParser("WEBTOONS_ID", "Webtoons (Indonesia)", "id", type = ContentType.MANGA) class Indonesian(context: MangaLoaderContext) : WebtoonsParser(context, MangaParserSource.WEBTOONS_ID) - @MangaSourceParser("WEBTOONS_ES", "Webtoons Spanish", "es", type = ContentType.MANGA) + @MangaSourceParser("WEBTOONS_ES", "Webtoons (Spanish)", "es", type = ContentType.MANGA) class Spanish(context: MangaLoaderContext) : WebtoonsParser(context, MangaParserSource.WEBTOONS_ES) - @MangaSourceParser("WEBTOONS_FR", "Webtoons French", "fr", type = ContentType.MANGA) + @MangaSourceParser("WEBTOONS_FR", "Webtoons (French)", "fr", type = ContentType.MANGA) class French(context: MangaLoaderContext) : WebtoonsParser(context, MangaParserSource.WEBTOONS_FR) - @MangaSourceParser("WEBTOONS_TH", "Webtoons Thai", "th", type = ContentType.MANGA) + @MangaSourceParser("WEBTOONS_TH", "Webtoons (Thai)", "th", type = ContentType.MANGA) class Thai(context: MangaLoaderContext) : WebtoonsParser(context, MangaParserSource.WEBTOONS_TH) - @MangaSourceParser("WEBTOONS_ZH", "Webtoons Chinese", "zh", type = ContentType.MANGA) + @MangaSourceParser("WEBTOONS_ZH", "Webtoons (Chinese)", "zh", type = ContentType.MANGA) class Chinese(context: MangaLoaderContext) : WebtoonsParser(context, MangaParserSource.WEBTOONS_ZH) - @MangaSourceParser("WEBTOONS_DE", "Webtoons German", "de", type = ContentType.MANGA) + @MangaSourceParser("WEBTOONS_DE", "Webtoons (German)", "de", type = ContentType.MANGA) class German(context: MangaLoaderContext) : WebtoonsParser(context, MangaParserSource.WEBTOONS_DE) -} \ No newline at end of file +} diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/hentais/NHentaiParser.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/hentais/NHentaiParser.kt index 10737e4d..f23e3df5 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/hentais/NHentaiParser.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/hentais/NHentaiParser.kt @@ -2,218 +2,1075 @@ package org.koitharu.kotatsu.parsers.site.kotatsu.all.hentais import org.json.JSONObject import org.koitharu.kotatsu.parsers.MangaLoaderContext +import org.koitharu.kotatsu.parsers.MangaParserAuthProvider 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.MangaPage -import org.koitharu.kotatsu.parsers.model.MangaParserSource -import org.koitharu.kotatsu.parsers.model.RATING_UNKNOWN -import org.koitharu.kotatsu.parsers.model.SortOrder -import org.koitharu.kotatsu.parsers.parsers.GalleryAdultsParser -import org.koitharu.kotatsu.parsers.util.generateUid -import org.koitharu.kotatsu.parsers.util.json.mapJSON -import org.koitharu.kotatsu.parsers.util.parseJson -import org.koitharu.kotatsu.parsers.util.urlBuilder -import org.koitharu.kotatsu.parsers.util.urlEncoded -import java.util.EnumSet -import java.util.Locale -import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions -import org.koitharu.kotatsu.parsers.model.MangaTag +import org.koitharu.kotatsu.parsers.config.ConfigKey +import org.koitharu.kotatsu.parsers.core.PagedMangaParser +import org.koitharu.kotatsu.parsers.model.* +import org.koitharu.kotatsu.parsers.util.* +import java.util.* + + +// removed first server, it has always problems +private const val TWO_IMG_SERVER = "2" +private const val THREE_IMG_SERVER = "3" +private const val FOUR_IMG_SERVER = "4" @MangaSourceParser("NHENTAI", "NHentai.net", type = ContentType.HENTAI) internal class NHentaiParser(context: MangaLoaderContext) : - GalleryAdultsParser(context, MangaParserSource.NHENTAI, "nhentai.net", 25) { + PagedMangaParser(context, MangaParserSource.NHENTAI, 25), + MangaParserAuthProvider { + + override val configKeyDomain = ConfigKey.Domain("nhentai.net") - private val apiSuffix = "api/v2" + private var imageServer: String = "" + private var thumbServer: String = "" - // Required overrides for base class - override val selectGallery = "" - override val selectGalleryLink = "" - override val selectGalleryTitle = "" - override val selectGalleryImg = "" - override val idImg = "none" + private var displayFullTitle: Boolean = true override val availableSortOrders: Set = EnumSet.of( - SortOrder.UPDATED, + SortOrder.UPDATED, // latest (default) + SortOrder.POPULARITY, // popular (all time) + SortOrder.POPULARITY_TODAY, + SortOrder.POPULARITY_WEEK, + SortOrder.POPULARITY_MONTH, ) - private fun JSONObject.extractTags(): Set { - return optJSONArray("tags") - ?.mapJSON { tag -> + override val filterCapabilities = MangaListFilterCapabilities( + isSearchSupported = true, + isSearchWithFiltersSupported = true, + ) - if (tag.optString("type") != "tag") { - return@mapJSON null - } + private val preferredServerKey = ConfigKey.PreferredImageServer( + presetValues = mapOf( + TWO_IMG_SERVER to "First server", + THREE_IMG_SERVER to "Second server", + FOUR_IMG_SERVER to "Third server", + ), + defaultValue = TWO_IMG_SERVER, + ) - MangaTag( - title = tag.optString("name") - .replace("-", " ") - .replaceFirstChar { c -> c.uppercase() }, - key = tag.optString("name"), - source = source - ) + override fun onCreateConfig(keys: MutableCollection>) { + super.onCreateConfig(keys) + keys.add(userAgentKey) + keys.add(preferredServerKey) + } + + private val selectedServer: String + get() = config[preferredServerKey].toString() + + companion object { + val LANG_MAP = mapOf( + "en" to "english", + "ja" to "japanese", + "zh" to "chinese", + ) + } + + private val nhTagMap: Map by lazy { + val raw = """ +2937=big breasts +35762=sole female +35763=sole male +8010=group +14283=anal +19440=lolicon +24201=stockings +10314=schoolgirl uniform +13720=nakadashi +29859=blowjob +8378=glasses +20905=full color +32341=shotacon +27553=rape +15658=bondage +23895=yaoi +27473=mosaic censorship +13989=ahegao +22942=incest +21712=males only +1207=milf +19018=dark skin +22945=double penetration +25614=paizuri +20035=x-ray +779=futanari +23237=tankoubon +21572=multi-work series +20525=defloration +14971=sex toys +8653=netorare +3735=swimsuit +19954=yuri +15348=ffm threesome +8368=full censorship +15408=femdom +29224=impregnation +29013=dilf +85295=twintails +31044=collar +85288=ponytail +24380=pantyhose +9260=cheating +28031=sister +16828=hairy +31880=bbm +30555=big penis +15782=crossdressing +31775=tentacles +27384=mind break +19175=bikini +8739=story arc +30473=muscle +24102=lactation +7752=schoolboy uniform +20617=mind control +9083=big ass +29023=tomgirl +81774=kemonomimi +1590=sweating +9162=masturbation +7256=mmf threesome +28550=teacher +190=maid +8693=uncensored +19899=exhibitionism +6343=pregnant +8050=females only +6817=unusual pupils +25871=lingerie +10988=anthology +20282=footjob +15853=mother +15785=harem +14072=huge breasts +30035=gender bender +1643=kissing +130025=anal intercourse +1033=handjob +12824=condom +31386=catgirl +10476=urination +3666=garter belt +26130=fingering +81707=beauty mark +22079=drugs +105833=gloves +4435=gag +25601=small breasts +5820=piercing +12695=prostitution +16228=demon girl +7155=cunnilingus +22950=tanlines +832=elf +31012=blindfold +17773=kimono +2820=scat +29182=blackmail +23132=bunny girl +32484=stomach deformation +2515=virginity +27063=filming +7142=bbw +21989=inflation +88846=horns +104227=tail +26953=bukkake +28800=bloomers +25050=gyaru +24676=rimjob +23632=big areolae +16533=sleeping +73750=bald +18567=monster +35972=sole dickgirl +18328=thigh high boots +5810=strap-on +29565=school swimsuit +32996=deepthroat +370=business suit +7550=monster girl +1067=inseki +50585=webtoon +12523=bestiality +27697=leotard +30645=dick growth +29631=inverted nipples +29366=tomboy +24412=bodysuit +15492=scanmark +9406=enema +35970=dickgirl on dickgirl +29399=daughter +18613=military +11941=replaced +6525=nurse +9661=cervix penetration +33129=slave +4573=corruption +5529=urethra insertion +10542=snuff +683=squirting +51399=crotch tattoo +122908=very long hair +7838=magical girl +24726=apron +23183=breast expansion +20074=latex +28426=hairy armpits +27217=guro +31285=fox girl +106119=no penetration +24764=drunk +9990=prostate massage +35968=dickgirl on male +2956=old man +32752=shibari +6900=miko +2153=wings +706=birth +10794=breast feeding +14069=ryona +25822=smell +5357=humiliation +5962=spanking +2531=transformation +21538=bike shorts +31101=incomplete +32745=chikan +16236=shemale +36957=bisexual +26952=tall girl +25663=oppai loli +7995=big nipples +32602=fisting +106733=hair buns +1088=bdsm +21283=masked face +15225=blowjob face +2633=leg lock +27378=artbook +35971=male on dickgirl +27112=tiara +107705=facial hair +24933=eyepatch +4549=torture +30206=tribadism +1037=oni +89056=hidden sex +13136=facesitting +3391=nun +25766=gokkun +5200=pegging +17531=cosplaying +28521=voyeurism +19479=nipple fuck +17349=tracksuit +22221=blood +50505=oyakodon +50486=tail plug +560=twins +23965=chloroform +15425=vore +25457=possession +129668=eye-covering bang +24984=orgasm denial +144644=extraneous ads +28589=hotpants +17752=foot licking +32282=piss drinking +19390=cousin +32589=feminization +11376=body modification +20362=gyaru-oh +28778=large insertions +27720=smegma +10811=double vaginal +3614=triple penetration +3455=chastity belt +2452=scar +31319=yandere +7354=amputee +28335=giantess +26848=waitress +28349=cbt +24967=sumata +104893=vtuber +8516=emotionless sex +26380=demon +17591=robot +17801=solo action +13640=frottage +25996=gaping +23035=aunt +23967=huge penis +31846=body writing +25744=cheerleader +24708=cowgirl +25085=swinging +18322=brother +101724=leash +10354=milking +97795=pixie cut +11089=body swap +32224=eggs +10606=pasties +3947=onahole +14573=tall man +10604=dog +14362=low lolicon +15242=lab coat +4935=farting +13468=shimapan +5620=double anal +14138=freckles +50390=josou seme +15119=dog girl +93324=fishnets +22025=prolapse +15471=asphyxiation +21774=human pet +31337=kunoichi +15712=eyemask +30126=big clit +92409=thick eyebrows +109360=cumflation +7208=catboy +31687=randoseru +24529=bride +19561=big balls +24450=chinese dress +121738=focus anal +22967=diaper +29347=miniguy +29001=parasite +25296=armpit licking +6220=orc +7546=witch +30895=sunglasses +7372=corset +28119=nose hook +8429=machine +7684=armpit sex +14516=wolf girl +15045=niece +13882=tutor +8391=public use +30811=christmas +104245=small penis +266=sundress +17501=phimosis +17800=tickling +25794=widow +7288=vomit +1215=unusual teeth +72471=dickgirls only +107503=soushuuhen +138044=exposed clothing +1352=slime +31986=age regression +23917=long tongue +24115=angel +114993=shimaidon +13722=moral degeneration +26898=age progression +27120=selfcest +7577=vampire +17676=ghost +88103=clothed female nude male +13515=coach +141098=nipple stimulation +9116=unbirth +5936=time stop +18420=all the way through +72139=clothed paizuri +27530=ball sucking +16518=coprophagia +28869=stuck in wall +2527=bandages +24621=insect +11399=metal armor +106006=large tattoo +3843=fundoshi +20120=multiple paizuri +8400=goblin +129321=mesuiki +124610=mouth mask +10693=dougi +31371=mecha girl +21450=minigirl +10685=double blowjob +118056=petplay +20789=policewoman +3031=underwater +31173=first person perspective +78262=shaved head +19064=pubic stubble +14280=bunny boy +25949=gothic lolita +23463=wrestling +16947=horse +11247=skinsuit +11073=living clothes +30786=watermarked +23073=assjob +52826=dark sclera +107478=drill hair +23225=non-h +109930=domination loss +20170=poor grammar +138200=gender change +16759=artistcg +80978=nudity only +15749=oil +30176=petrification +25848=human cattle +559=ttf threesome +14010=snake girl +11276=multiple penises +90671=original +18024=touhou project +1841=kantai collection +35605=fate grand order +20925=the idolmaster +972=granblue fantasy +78245=azur lane +17137=neon genesis evangelion +3185=love live +391=girls und panzer +11219=pokemon +15021=sailor moon +4505=mahou shoujo lyrical nanoha +128408=blue archive +10222=fate stay night +27431=to love-ru +13159=naruto +123503=genshin impact +3984=sword art online +3603=street fighter +22174=one piece +16285=puella magi madoka magica +91195=princess connect +12232=my hero academia +3163=king of fighters +26172=k-on +7259=touken ranbu +19080=code geass +37544=love live sunshine +17077=cardcaptor sakura +27547=the melancholy of haruhi suzumiya +13508=final fantasy vii +10954=shingeki no kyojin +25430=vocaloid +32687=free +4577=toheart2 +22146=dead or alive +20025=gochuumon wa usagi desu ka +8485=dragon ball z +5037=bleach +3218=bakemonogatari +12624=ore no imouto ga konna ni kawaii wake ga nai +37109=kono subarashii sekai ni syukufuku o +4369=monster hunter +127065=hololive +74788=girls frontline +24886=fate kaleid liner prisma illya +6999=toaru kagaku no railgun +22032=boku wa tomodachi ga sukunai +18350=ragnarok online +21674=dragon quest iii +14345=ojamajo doremi +7832=darkstalkers +24135=ah my goddess +32394=samurai spirits +1283=queens blade +16639=haikyuu +13924=yu-gi-oh +79467=kimetsu no yaiba +18238=danganronpa +26336=yu-gi-oh zexal +16984=persona 4 +18569=kuroko no basuke +1910=smile precure +30587=sakura taisen +16166=mahou sensei negima +12285=ranma 12 +8470=infinite stratos +32363=toaru majutsu no index +22708=saki +8708=to heart +108082=arknights +16707=detective conan +22210=guilty gear +947=gundam seed destiny +22677=tenchi muyo +23429=pretty cure +18512=strike witches +31027=lucky star +7408=league of legends +394=love hina +23201=kanon +27704=amagami +127052=nijisanji +70802=kemono friends +52098=persona 5 +22215=super robot wars +27567=hayate no gotoku +35251=osomatsu-san +7633=pripara +34823=ensemble stars +37914=re zero kara hajimeru isekai seikatsu +74918=bang dream +15041=martian successor nadesico +24783=dragon ball +120519=love live nijigasaki high school idol club +2803=love plus +5085=senki zesshou symphogear +28474=zero no tsukaima +15197=gundam build fighters +15427=dragon quest iv +1163=rozen maiden +23859=yu-gi-oh arc-v +75023=dragon quest xi +2112=dungeon ni deai o motomeru no wa machigatteiru darou ka +36418=voiceroid +28281=mitsudomoe +11624=the legend of zelda +14694=fullmetal alchemist +16847=dragon quest v +2497=urusei yatsura +5671=tengen toppa gurren lagann +22754=amagi brilliant park +20606=tsukihime +5165=gundam build fighters try +4114=macross frontier +20763=inazuma eleven +14550=sister princess +19083=jojos bizarre adventure +21052=fate hollow ataraxia +29922=teitoku +51810=gudao +16643=producer +13848=reimu hakurei +25125=asuka langley soryu +17279=sakuya izayoi +10496=patchouli knowledge +37739=shielder +3206=shinji ikari +38068=gran +4675=sanae kochiya +21779=rei ayanami +14040=fate testarossa +3870=flandre scarlet +23902=remilia scarlet +21688=atago +11373=marisa kirisame +35128=kashima +17154=sakura kinomoto +31462=satori komeiji +30080=kaga +10802=alice margatroid +17017=aya shameimaru +17862=yukari yakumo +5340=shimakaze +18935=nanoha takamachi +18896=shirou emiya +16555=rin tosaka +16130=rito yuuki +15890=reisen udongein inaba +7724=takao +27060=jeanne darc +78989=jeanne alter +7718=naruto uzumaki +5337=nami +22975=chun-li +17502=illyasviel von einzbern +20111=tifa lockhart +21131=youmu konpaku +18026=kazuto kirigaya +92923=shikikan +29856=saber +71442=minamoto no raikou +1843=asuna yuuki +51419=gudako +7488=mai shiranui +9835=koishi komeiji +16916=kasumi +30026=maki nishikino +143975=sensei +26906=izuku midoriya +37275=scathach +7696=momiji inubashiri +38039=astolfo +27794=mikoto misaka +20062=hamakaze +78285=artoria pendragon +34860=katsuki bakugou +3328=homura akemi +37687=djeeta +32200=suzuya +21108=rin shibuya +35964=nico yazawa +27494=levi ackerman +609=eren jaeger +11920=sakura haruno +20427=sailor mercury +24714=chino kafuu +31456=mikan yuuki +866=koyomi araragi +12149=kyousuke kousaka +277=haruka nanase +19926=haruna +3763=haruhi suzumiya +26427=mio akiyama +25439=hinata hyuga +17811=ran yakumo +14857=kongou +18548=kotori minami +32364=rider +15641=madoka kaname +2613=hong meiling +491=makoto tachibana +20702=koakuma +15315=tomoyo daidouji +10730=shigure +14265=touma kamijou +80311=bb +4203=mami tomoe +37706=kazuma satou +33070=umi sonoda +27172=yuyuko saigyouji +3353=yuuka kazami +2078=nagato +6311=arisu tachibana +647=belldandy +9274=maya +24889=sena kashiwazaki +15125=golden darkness +6555=sailor jupiter +25695=mika jougasaki +50929=shuten douji +33077=sailor mars +8293=minami nitta +7451=lelouch vi britannia +389=rika jougasaki +22469=prinz eugen +16108=azusa nakano +12812=tenryuu +7311=ami mizuno +6642=byakuren hijiri +7097=suwako moriya +19172=miki hoshii +9657=ayane +29433=c.c. +25220=sakura matou +14499=tsunade +10665=tenshi hinanai +16564=miku hatsune +29190=kallen stadtfeld +3312=kirino kousaka +1234=yuki nagato +26261=ranma saotome +19002=rin kaenbyou +12748=nico robin +32765=rin matsuoka +4241=fumika sagisawa +1729=tamaki kousaka +23997=ruri gokou +29684=sailor venus +19160=nitori kawashiro +27302=uzuki shimamura +23216=android 18 +8489=hibiki +7333=suguha kirigaya +1267=kodaka hasegawa +2345=morrigan aensland +9371=yamato +26087=inazuma +27532=archer +26587=miho nishizumi +12346=utsuho reiuji +37108=megumin +22407=takane shijou +15914=sasuke uchiha +2774=kyouko sakura +80930=abigail williams +81288=gudao | ritsuka fujimaru +73756=nightingale +6109=eri ayase +27492=akagi +17899=sakura kasugano +32137=cirno +11760=yui kotegawa +75029=eli ayase +11740=sailor moon +49158=narmaya +29693=ikazuchi +126586=aether +20918=iori minase +24832=misato katsuragi +2883=kasen ibara +6932=souji okita +28555=tamamo-no-mae +14428=kokoa hoto +26783=taihou +12763=rumia +401=nakoruru +72475=musashi miyamoto +23122=maho nishizumi +29188=eirin yagokoro +466=usagi tsukino +29638=kyon +15995=makoto kino +11744=amatsukaze +6175=cammy white +30331=ichika orimura +23473=mikuru asahina +28807=ruri hoshino +2572=hatate himekaidou +15291=chen +23386=fujiwara no mokou +9237=shoukaku +28763=tewi inaba +23851=gilgamesh +10672=aqua +9702=ro-500 +31074=keine kamishirasawa +32443=charlotte dunois +2196=sayaka miki +1645=zuikaku +5925=akatsuki +4196=hestia +33171=shiho nishizumi +19534=hayate yagami +79507=belfast +12433=kaede takagaki +12872=warrior +8170=len kagamine +50415=rem +14409=momoka sakurai +2211=mari illustrious makinami +99075=kokkoro +1907=rei hino +15651=miyu edelfelt +26169=musashi +8053=lum +50596=you watanabe +9883=kagami hiiragi +24509=darjeeling +11992=lala satalin deviluke +32683=hachiman hikigaya +31076=kuroko shirai +20836=red saber +12902=isuzu sento +10379=bianca whitaker +16181=nozomi toujou +27774=bismarck +28219=yui hirasawa +1271=momo velia deviluke +49852=subaru natsuki +5918=shinobu oshino +28056=link +25605=rangiku matsumoto +35313=cagliostro +18453=hero +75102=nozomi tojo +20722=mutsu +29170=yuma tsukumo +9486=nue houjuu +33049=ritsuko akizuki +23626=murakumo +20323=tsumugi kotobuki +16566=ritsu tainaka +14016=yuu narukami +11609=yoko ritona +107011=chloe von einzbern +52132=riko sakurauchi +32114=onpu segawa +11924=kagerou imaizumi + """.trimIndent() + raw.lineSequence() + .mapNotNull { line -> + val parts = line.split('=') + if (parts.size == 2 && parts[0].isNotBlank() && parts[1].isNotBlank()) { + parts[0] to parts[1] + } else null + }.toMap() + } + + override suspend fun getFilterOptions() = MangaListFilterOptions( + availableTags = nhTagMap.map { (id, name) -> + MangaTag( + title = name.replace("-", " ").replaceFirstChar { it.uppercase() }, + key = id, // numeric tag ID + source = source, + ) + }.toSet(), + availableLocales = setOf( + Locale.ENGLISH, + Locale.JAPANESE, + Locale.CHINESE, + ), + ) + + private suspend fun ensureNhConfig() { + if (imageServer.isBlank() || thumbServer.isBlank()) { + try { + val json = webClient.httpGet("https://$domain/api/v2/config").parseJson() + val imgServers = json.getJSONArray("image_servers") + .let { (0 until it.length()).map { i -> it.getString(i) } } + val thbServers = json.getJSONArray("thumb_servers") + .let { (0 until it.length()).map { i -> it.getString(i) } } + imageServer = imgServers[selectedServer.toInt() - 1] + thumbServer = thbServers[selectedServer.toInt() - 1] + } catch (_: Exception) { + imageServer = "https://i$selectedServer.nhentai.net" + thumbServer = "https://t$selectedServer.nhentai.net" } - ?.filterNotNull() - ?.toSet() - ?: emptySet() + } } - override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List { - - filter.query?.trim()?.let { query -> - if (query.all(Char::isDigit)) { - return try { - val obj = webClient - .httpGet("https://$domain/$apiSuffix/galleries/$query") - .parseJson() - - val title = obj.extractTitle() - - listOf( - Manga( - id = generateUid("/g/$query/"), - title = title.cleanupTitle().ifEmpty { title }, - altTitles = emptySet(), - url = "/g/$query/", - publicUrl = "https://$domain/g/$query/", - rating = RATING_UNKNOWN, - contentRating = ContentRating.ADULT, - coverUrl = "https://t.$domain/${obj.getThumbnailPath()}", - tags = obj.extractTags(), - state = null, - authors = emptySet(), - source = source - ) - ) - } catch (_: Exception) { - emptyList() - } + + override suspend fun getListPage( + page: Int, + order: SortOrder, + filter: MangaListFilter, + ): List { + ensureNhConfig() + + // Direct ID search + val directId = extractGalleryId(filter.query) + if (directId != null) { + return try { + listOf(fetchMangaById(directId)) + } catch (_: Exception) { + emptyList() } } - val url = urlBuilder().addPathSegments(apiSuffix) - val isDefaultHome = order == SortOrder.UPDATED - && filter.query.isNullOrEmpty() - - if (isDefaultHome) { - url.addPathSegment("galleries") - url.addQueryParameter("page", page.toString()) - } else { - val query = buildString { - filter.query?.trim()?.takeIf { it.isNotEmpty() }?.let { - append(it) - } + val selectedTag = filter.tags.firstOrNull() + if (selectedTag != null) { + val url = "https://$domain/api/v2/galleries/tagged?tag_id=${selectedTag.key}&page=$page" + val json = webClient.httpGet(url).parseJson() + val items = json.getJSONArray("result") + return (0 until items.length()).map { i -> items.getJSONObject(i).toManga() } + } + + val lang = filter.locale?.let { loc -> + LANG_MAP[loc.language] + } - if (isEmpty()) { - append("pages:>0") + val url = when { + // Search with language + lang != null -> { + val sort = when (order) { + SortOrder.POPULARITY -> "popular" + else -> "date" } + "https://$domain/api/v2/search?query=language:$lang&sort=$sort&page=$page" } - - val sort = when (order) { - else -> "date" + // Latest + order == SortOrder.UPDATED -> "https://$domain/api/v2/galleries?page=$page" + // Popular (all / today / week / month) + else -> { + val sort = when (order) { + SortOrder.POPULARITY_TODAY -> "popular-today" + SortOrder.POPULARITY_WEEK -> "popular-week" + SortOrder.POPULARITY_MONTH -> "popular-month" + SortOrder.POPULARITY -> "popular" + else -> "popular" + } + "https://$domain/api/v2/search?query=%22%22&sort=$sort&page=$page" } - - url.addPathSegment("search") - url.addQueryParameter("query", query.urlEncoded()) - url.addQueryParameter("sort", sort) - url.addQueryParameter("page", page.toString()) } - val json = webClient.httpGet(url.build()).parseJson() - return json.optJSONArray("result").mapJSON { - val id = it.getInt("id") - val title = it.extractTitle() - Manga( - id = generateUid("/g/$id/"), - title = title.cleanupTitle().ifEmpty { title }, - altTitles = emptySet(), - url = "/g/$id/", - publicUrl = "https://$domain/g/$id/", - rating = RATING_UNKNOWN, - contentRating = ContentRating.ADULT, - coverUrl = "https://t.$domain/${it.getThumbnailPath()}", - tags = it.extractTags(), - state = null, - authors = emptySet(), - source = source - ) + val json = webClient.httpGet(url).parseJson() + val items = json.getJSONArray("result") + return (0 until items.length()).map { i -> items.getJSONObject(i).toManga() } + } + + private fun extractGalleryId(query: String?): Int? { + if (query.isNullOrBlank()) return null + val trimmed = query.trim() + return when { + trimmed.startsWith("id:") -> trimmed.removePrefix("id:").toIntOrNull() + trimmed.all(Char::isDigit) -> trimmed.toIntOrNull() + else -> null } } - override suspend fun getFilterOptions(): MangaListFilterOptions { - return MangaListFilterOptions( - availableTags = emptySet(), - ) + private suspend fun fetchMangaById(id: Int): Manga { + ensureNhConfig() + val json = webClient.httpGet("https://$domain/api/v2/galleries/$id").parseJson() + return json.toMangaDetails() } override suspend fun getDetails(manga: Manga): Manga { - val id = manga.url.removeSurrounding("/g/", "/") - - val obj = webClient - .httpGet("https://$domain/$apiSuffix/galleries/$id") - .parseJson() - - return manga.copy( - tags = obj.extractTags(), - authors = emptySet(), - description = "Pages: ${obj.optInt("num_pages")}", - coverUrl = "https://t.$domain/${obj.getCoverPath()}", - chapters = listOf( - MangaChapter( - id = manga.id, - title = manga.title, - number = 1f, - volume = 0, - url = manga.url, - scanlator = null, - uploadDate = obj.optLong("upload_date") * 1000, - branch = null, - source = source - ) - ) + ensureNhConfig() + val id = manga.url.removeSurrounding("/g/", "/").toInt() + val json = webClient.httpGet("https://$domain/api/v2/galleries/$id").parseJson() + return json.toMangaDetails().copy( + coverUrl = manga.coverUrl?.ifBlank { + "${thumbServer}/${json.getJSONObject("cover").optString("path")}" + }, ) } + override suspend fun getPages(chapter: MangaChapter): List { - val id = chapter.url.removeSurrounding("/g/", "/") - val response = webClient.httpGet("https://$domain/$apiSuffix/galleries/$id").parseJson() - return response.getJSONArray("pages").mapJSON { page -> - val path = page.getString("path") + ensureNhConfig() + val id = chapter.url.removeSurrounding("/g/", "/").toInt() + val json = webClient.httpGet("https://$domain/api/v2/galleries/$id").parseJson() + val pagesArray = json.getJSONArray("pages") + return (0 until pagesArray.length()).map { i -> + val pageObj = pagesArray.getJSONObject(i) + val path = pageObj.getString("path") MangaPage( id = generateUid(path), - url = "https://i.$domain/$path", - preview = page.optString("thumbnail").takeIf { it.isNotBlank() } - ?.let { "https://t.$domain/$it" }, + url = "$imageServer/$path", + preview = null, source = source, ) } } override suspend fun getPageUrl(page: MangaPage): String = page.url + override suspend fun getRelatedManga(seed: Manga): List = emptyList() - private fun JSONObject.extractTitle(): String { + private fun JSONObject.toManga(): Manga { + val id = getInt("id") val titleObj = optJSONObject("title") - return listOfNotNull( - titleObj?.optString("english"), - titleObj?.optString("pretty"), - optString("english_title"), - optString("japanese_title") - ).firstOrNull { it.isNotBlank() } ?: "Gallery ${optInt("id")}" + val rawTitle = titleObj?.optString("english") + ?: titleObj?.optString("pretty") + ?: optString("english_title") + ?: optString("japanese_title") + ?: "Gallery $id" + + val title = if (displayFullTitle) rawTitle else rawTitle.shortenTitle() + val thumbPath = optJSONObject("thumbnail")?.optString("path") + ?: optString("thumbnail").takeIf { !it.isNullOrBlank() } + ?: "galleries/$id/thumb.webp" + + return Manga( + id = generateUid("/g/$id/"), + title = title, + altTitles = emptySet(), + url = "/g/$id/", + publicUrl = "https://$domain/g/$id/", + rating = RATING_UNKNOWN, + contentRating = ContentRating.ADULT, + coverUrl = "$thumbServer/$thumbPath", + tags = extractTags(), + state = null, + authors = emptySet(), + source = source, + ) } - private fun JSONObject.getThumbnailPath(): String = optJSONObject("thumbnail")?.optString("path") - ?: optString("thumbnail").takeIf { it.isNotBlank() } - ?: "galleries/${optString("media_id")}/thumb.webp" + private fun JSONObject.toMangaDetails(): Manga { + val id = getInt("id") + val titleObj = optJSONObject("title") + val rawTitle = titleObj?.optString("english") + ?: titleObj?.optString("japanese") + ?: titleObj?.optString("pretty") + ?: "Gallery $id" + val title = if (displayFullTitle) rawTitle else rawTitle.shortenTitle() + + val thumbPath = getJSONObject("thumbnail").getString("path") + val coverPath = getJSONObject("cover").optString("path", thumbPath) + val numPages = optInt("num_pages") + val uploadDate = optLong("upload_date") * 1000 + + val tags = extractTags() + val artists = getTagsOfType("artist") + val groups = getTagsOfType("group") + val author = artists.joinToString(", ").ifBlank { groups.joinToString(", ") } + + var langName: String? + var translated = false + val languageCandidates = mutableListOf() + + for (tag in tags) { + val parts = tag.key.split(":", limit = 2) + if (parts.size == 2 && parts[0] == "language") { + val name = parts[1] + when { + name.equals("translated", ignoreCase = true) -> translated = true + else -> languageCandidates.add(name) + } + } + } + + langName = languageCandidates.firstOrNull { it.equals("chinese", ignoreCase = true) } + ?: languageCandidates.firstOrNull { it.equals("japanese", ignoreCase = true) } + ?: languageCandidates.firstOrNull { it.equals("english", ignoreCase = true) } + ?: languageCandidates.firstOrNull() - private fun JSONObject.getCoverPath(): String = optJSONObject("cover")?.optString("path") ?: getThumbnailPath() + val description = buildString { + langName?.let { + val displayLang = it.replaceFirstChar { c -> c.uppercase() } + append(if (translated) "$displayLang (translated)" else displayLang) + append("\n") + } + append("Pages: $numPages") + } + return Manga( + id = generateUid("/g/$id/"), + title = title, + altTitles = listOfNotNull(titleObj?.optString("japanese")).toSet(), + url = "/g/$id/", + publicUrl = "https://$domain/g/$id/", + rating = RATING_UNKNOWN, + contentRating = ContentRating.ADULT, + coverUrl = "$thumbServer/$thumbPath", + largeCoverUrl = "$imageServer/$coverPath", + tags = tags, + state = null, + authors = setOfNotNull(author.ifBlank { null }), + description = description, + chapters = listOf( + MangaChapter( + id = generateUid("/g/$id/"), + title = title, + number = 1f, + volume = 0, + url = "/g/$id/", + scanlator = groups.joinToString(", ").ifBlank { null }, + uploadDate = uploadDate, + branch = null, + source = source, + ) + ), + source = source, + ) + } - override fun Locale.toLanguagePath(): String = when (this) { - Locale.ENGLISH -> "english" - Locale.JAPANESE -> "japanese" - Locale.CHINESE -> "chinese" - else -> language + private fun JSONObject.extractTags(): Set { + val tagsArray = optJSONArray("tags") ?: return emptySet() + return (0 until tagsArray.length()).map { i -> + val tag = tagsArray.getJSONObject(i) + val name = tag.getString("name") + MangaTag( + title = name.replace("-", " ").replaceFirstChar { it.uppercase() }, + key = "${tag.getString("type")}:$name", + source = source, + ) + }.toSet() } + + private fun JSONObject.getTagsOfType(type: String): List { + val tagsArray = optJSONArray("tags") ?: return emptyList() + return (0 until tagsArray.length()).mapNotNull { i -> + val tag = tagsArray.getJSONObject(i) + if (tag.getString("type") == type) tag.getString("name") else null + } + } + + private val shortenTitleRegex = Regex("""(\[[^]]*]|[({][^)}]*[)}])""") + private fun String.shortenTitle() = this.replace(shortenTitleRegex, "").trim() + + override val authUrl: String get() = "https://$domain" + override suspend fun isAuthorized(): Boolean = true + override suspend fun getUsername(): String = "" } diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/AquaManga.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/AquaManga.kt index 852e0959..079c25df 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/AquaManga.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/AquaManga.kt @@ -1,12 +1,13 @@ package org.koitharu.kotatsu.parsers.site.kotatsu.en -import okhttp3.Headers +import org.jsoup.nodes.Document import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaSourceParser -import org.koitharu.kotatsu.parsers.model.MangaParserSource +import org.koitharu.kotatsu.parsers.model.* import org.koitharu.kotatsu.parsers.parsers.MadaraParser -import java.util.Base64 -import kotlin.random.Random +import org.koitharu.kotatsu.parsers.util.* +import java.text.SimpleDateFormat +import java.util.* @MangaSourceParser("AQUAMANGA", "AquaManga", "en") internal class AquaManga(context: MangaLoaderContext) : @@ -15,38 +16,180 @@ internal class AquaManga(context: MangaLoaderContext) : override val withoutAjax = true override val stylePage = "" - override fun getRequestHeaders(): Headers = super.getRequestHeaders().newBuilder() - .set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8") - .set("Accept-Language", "en-US,en;q=0.5") - .set("Referer", "https://$domain/") - .set("Sec-Fetch-Dest", "document") - .set("Sec-Fetch-Mode", "navigate") - .set("Sec-Fetch-Site", "same-origin") - .set("Upgrade-Insecure-Requests", "1") - .set("X-Requested-With", randomValue) - .build() - - private fun getRandomSubstring(input: String, length: Int): String { - val startIndex = Random.nextInt(0, input.length - length + 1) - return input.substring(startIndex, startIndex + length) - } + override val selectChapter = ".aqua-ch-item" + + override val availableSortOrders: EnumSet = EnumSet.of( + SortOrder.UPDATED, + SortOrder.POPULARITY, + SortOrder.NEWEST, + SortOrder.ALPHABETICAL, + ) + + override suspend fun getFilterOptions() = MangaListFilterOptions( + availableTags = setOf( + MangaTag("Academy", "academy", source), + MangaTag("Action", "action", source), + MangaTag("Adaptation", "adaptation", source), + MangaTag("Adventure", "adventure", source), + MangaTag("Comedy", "comedy", source), + MangaTag("Cooking", "cooking", source), + MangaTag("Crime", "crime", source), + MangaTag("Cultivation", "cultivation", source), + MangaTag("Delinquents", "delinquents", source), + MangaTag("Demons", "demons", source), + MangaTag("Drama", "drama", source), + MangaTag("Dungeons", "dungeons", source), + MangaTag("Ecchi", "ecchi", source), + MangaTag("Fantasy", "fantasy", source), + MangaTag("Game", "game", source), + MangaTag("Gore", "gore", source), + MangaTag("Harem", "harem", source), + MangaTag("Historical", "historical", source), + MangaTag("Horror", "horror", source), + MangaTag("Isekai", "isekai", source), + MangaTag("Josei", "josei", source), + MangaTag("Magic", "magic", source), + MangaTag("Manga", "manga", source), + MangaTag("Manhua", "manhua", source), + MangaTag("Manhwa", "manhwa", source), + MangaTag("Martial Arts", "martial-arts", source), + MangaTag("Mecha", "mecha", source), + MangaTag("Medical", "medical", source), + MangaTag("Military", "military", source), + MangaTag("Monsters", "monsters", source), + MangaTag("Murim", "murim", source), + MangaTag("Music", "music", source), + MangaTag("Mystery", "mystery", source), + MangaTag("Necromancer", "necromancer", source), + MangaTag("Ninja", "ninja", source), + MangaTag("Office Workers", "office-workers", source), + MangaTag("OP-MC", "op-mc", source), + MangaTag("Overpowered", "overpowered", source), + MangaTag("Philosophical", "philosophical", source), + MangaTag("Post-Apocalyptic", "post-apocalyptic", source), + MangaTag("Psychological", "psychological", source), + MangaTag("Rebirth", "rebirth", source), + MangaTag("Regression", "regression", source), + MangaTag("Reincarnation", "reincarnation", source), + MangaTag("Returner", "returner", source), + MangaTag("Revenge", "revenge", source), + MangaTag("Romance", "romance", source), + MangaTag("School Life", "school-life", source), + MangaTag("Sci-fi", "sci-fi", source), + MangaTag("Seinen", "seinen", source), + MangaTag("Shounen", "shounen", source), + MangaTag("Slice-of-Life", "slice-of-life", source), + MangaTag("Sports", "sports", source), + MangaTag("Super Power", "super-power", source), + MangaTag("Superhero", "superhero", source), + MangaTag("Supernatural", "supernatural", source), + MangaTag("Survival", "survival", source), + MangaTag("System", "system", source), + MangaTag("Thriller", "thriller", source), + MangaTag("Time Travel", "time-travel", source), + MangaTag("Tower", "tower", source), + MangaTag("Tragedy", "tragedy", source), + MangaTag("Vampire", "vampire", source), + MangaTag("Video Games", "video-games", source), + MangaTag("Villainess", "villainess", source), + MangaTag("Virtual Reality", "virtual-reality", source), + MangaTag("Voilence", "voilence", source), + MangaTag("Webcomic", "webcomic", source), + MangaTag("Wuxia", "wuxia", source), + MangaTag("Zombies", "zombies", source), + ), + availableStates = EnumSet.of(MangaState.ONGOING, MangaState.FINISHED), + ) + + override suspend fun getDetails(manga: Manga): Manga { + val fullUrl = manga.url.toAbsoluteUrl(domain) + val doc = webClient.httpGet(fullUrl).parseHtml() - private val randomLength = Random.Default.nextInt(13, 21) + val title = doc.selectFirstOrThrow(".aqua-series-info__title").text() + val thumbnail = doc.selectFirstOrThrow(".aqua-series-cover__img").requireSrc() + val description = doc.selectFirst(".aqua-series-synopsis")?.html().orEmpty() + val status = doc.selectFirst(".aqua-series-meta__status")?.text() + val genres = doc.select(".aqua-series-genre-pill").map { it.text() }.toSet() + val author = doc.selectFirst(".aqua-series-info__creator-value a")?.ownText() + val artist = doc.selectFirst(".aqua-series-info__creator-value a")?.ownText() - private val decodedString = Base64.getDecoder() - .decode(LITTLE_BIT_CURSED_VALUE) - .toString(Charsets.UTF_8) - .trim() + val tags = genres.mapTo(mutableSetOf()) { MangaTag(title = it, key = it, source = source) } + val statusText = status?.lowercase().orEmpty() + val state = when (statusText) { + in ongoing -> MangaState.ONGOING + in finished -> MangaState.FINISHED + in abandoned -> MangaState.ABANDONED + in paused -> MangaState.PAUSED + else -> null + } - private val randomStringValue = getRandomSubstring(decodedString, randomLength) + val chapters = getChapters(manga, doc) + return manga.copy( + title = title, + coverUrl = thumbnail, + description = description, + tags = tags, + state = state, + authors = setOfNotNull(author, artist).filter { it.isNotBlank() }.toSet(), + chapters = chapters, + ) + } - private val randomValue = when { - Random.nextInt(1, 11) == 1 -> "org.chromium.chrome" - else -> randomStringValue + override suspend fun getChapters(manga: Manga, doc: Document): List { + return doc.select(selectChapter).mapChapters(reversed = true) { i, el -> + val a = el.selectFirstOrThrow("a") + val href = a.attrAsRelativeUrl("href") + val name = el.selectFirstOrThrow(".aqua-ch-item__name").text() + val dateText = el.selectFirst(".aqua-ch-item__time")?.text()?.trim() + MangaChapter( + id = generateUid(href), + url = href + stylePage, + title = name, + number = i + 1f, + volume = 0, + uploadDate = parseChapterDate(dateText), + source = source, + scanlator = null, + branch = null, + ) + } } - private companion object { - private const val LITTLE_BIT_CURSED_VALUE = - "ICAgSUFBZ0FFa0FRd0JCQUdjQVNRQkRBRUVBWndCUkFGVUFUZ0JDQUZFQVZRQnNBRUlBVVFCWEFHUUFRZ0JSQURBQVJnQkNBRk1BVlFCR0FFSUFXZ0F3QUVZQVJBQlJBRlVBUmdCVUFGVUFWUUJLQUVVQVVRQlZBRllBUmdCUkFGWUFjQUF6QUZFQWF3QndBRUlBVWdCVkFERUFRZ0JWQUZZQVJnQkRBR0lBYXdCR0FFWUFZUUF3QUVZQVVnQmtBREFBU2dCREFGRUFWUUJrQUdvQVVRQldBRTRBVWdCUkFHc0FVZ0JDQUZJQVZRQnNBRUlBVlFBeUFHUUFRd0JWQUdzQVJnQkhBRllBVlFCR0FGTUFXZ0F3QUVvQU1RQlJBRlVBV2dCR0FGRUFWZ0JhQUZJQVVRQnJBRGtBUWdCU0FGVUFiQUJDQUZZQVZnQkdBRU1BVmdBd0FFWUFSZ0JPQUVVQVJnQldBRm9BTUFCS0FGTUFVUUJWQUdRQWVnQlJBRllBVmdCdUFGRUFhd0JPQUVJQVVnQnJBR3dBUWdCV0FHd0FSZ0JEQUZZQU1BQkdBRVlBVXdCVkFFWUFWd0JrQURBQVNnQXhBRkVBVlFCa0FGSUFVUUJXQUVZQU13QlJBR3dBVWdCQ0FGSUFNd0JPQUVJQVZRQnRBR1FBUXdCU0FEQUFSZ0JIQUZVQVZRQkdBRmNBVlFCVkFFb0FTQUJSQUZVQVdnQktBRkVBVmdCYUFGSUFVUUJzQUZvQVFnQlNBRmNBT1FCQ0FGb0FSZ0JHQUVNQVZRQnJBRVlBUndCV0FGVUFSZ0JYQUZvQU1BQktBRFVBVVFCVkFGb0FSZ0JSQUZZQVdnQnVBRkVBYXdCa0FFSUFVZ0JGQURFQVFnQldBRllBUmdCREFHTUFhd0JHQUVZQVlnQXdBRVlBVWdCYUFEQUFTZ0JVQUZFQVZRQlNBRW9BVVFCV0FGSUFiZ0JSQUdzQVRnQkNBRklBYkFCc0FFSUFWQUJXQUVZQVF3QlNBREFBUmdCR0FGTUFWUUJHQUdFQVZRQlZBRW9BVndCUkFGVUFWZ0JhQUZFQVZnQktBRklBVVFCdEFHZ0FRZ0JTQUVVQVJnQkNBRlVBYlFCa0FFTUFZd0JyQUVZQVJ3QlNBRlVBUmdCWEFGVUFWUUJLQUV3QVVRQlZBRlVBTUFCUkFGWUFWZ0JTQUZFQWJBQmFBRUlBVWdBd0FERUFRZ0JhQUVnQVpBQkRBRlVBYXdCR0FFY0FWd0JWQUVZQVZBQmFBREFBU2dBeEFGRUFWUUJhQUVZQVVRQlhBRVlBYmdCUkFHc0FaQUJDQUZJQVZRQnNBRUlBVmdCWEFHUUFRd0JrQUVVQVJnQkZBR0VBTUFCR0FGSUFXZ0F3QUVvQVZ3QlJBRlVBVWdCQ0FGRUFWZ0JLQUc0QVVRQnJBRklBUWdCU0FHc0FNUUJDQUZRQVZRQkdBRU1BVWdBd0FFWUFSZ0JoQURBQVJnQlhBR1FBTUFCS0FGY0FVUUJWQUZZQVdnQlJBRllBWkFCdUFGRUFiQUJhQUVJQVVnQnNBRllBUWdCVkFESUFaQUJEQUZjQWF3QkdBRWNBVWdCVkFFWUFWd0JWQUZVQVNnQm9BRkVBVlFCV0FGb0FVUUJXQUZZQVVnQlJBR3dBYUFCQ0FGSUFhd0JzQUVJQVZnQlhBR1FBUXdCVkFHc0FSZ0JJQUdRQU1BQkdBR29BVVFCVkFFb0FSQUJSQUZVQVdnQktBRkVBVmdCS0FGSUFVUUJ1QUU0QVFnQlNBRlVBYkFCQ0FGWUFNUUJHQUVNQVZnQnJBRVlBUmdCWEFGVUFSZ0JTQUdRQU1BQktBRkFBVVFCVkFGb0FWZ0JSQUZZQVNnQnVBRkVBYXdCc0FFSUFVZ0JyQURFQVFnQldBR3dBUmdCREFGSUFNQUJHQUVZQVRnQkZBRVlBV0FCYUFEQUFSZ0F6QUZFQVZRQldBRm9BVVFCVkFEVUFRZ0JSQUd3QVNnQkNBRklBYkFCV0FFSUFWd0JyQUVZQVFnQmxBR3NBUmdCSEFGSUFWUUJHQUZjQVdnQXdBRW9BVUFCUkFGVUFXZ0JLQUZFQVZnQldBRklBVVFCdUFFb0FRZ0JTQUdzQWJBQkNBRlVBVndCa0FFTUFWUUF3QUVZQVJ3QldBRlVBUmdCWEFGb0FNQUJLQUVRQVVRQlZBRm9BYWdCUkFGY0FTZ0J1QUZFQWJRQjBBRUlBVWdCVkFERUFRZ0JXQUZnQVpBQkRBR01BYXdCR0FFWUFWd0JWQUVZQVV3QmFBREFBU2dCV0FGRUFWUUJhQUZZQVVRQldBRW9BYmdCUkFHd0FUZ0JDQUZJQWJBQldBRUlBVmdCc0FFWUFRd0JUQURBQVJnQkpBRllBVlFCR0FGWUFWUUJWQUVvQVZ3QlJBRlVBV2dCYUFGRUFWd0JPQUc0QVVRQnNBRW9BUWdCU0FHd0FiQUJDQUZVQWJRQmtBRUlBWlFCckFFWUFSd0JTQUZVQVJnQm9BR1FBTUFCS0FFd0FVUUJWQUZZQVNnQlJBRllBVmdCdUFGRUFXQUJzQUVJQVVnQlVBRklBUWdCVkFGY0FaQUJEQUZZQVJRQkdBRWNBVmdCVkFFWUFVd0JhQURBQVNnQkVBRkVBVlFCYUFIWUFVUUJWQURFQVFnQlJBR3NBWkFCQ0FGSUFWZ0JHQUVJQVZnQldBRVlBUXdCV0FHc0FSZ0JHQUZjQVZRQkdBRlFBV2dBd0FFb0FVd0JSQUZVQVdnQldBRkVBVmdCS0FHNEFVUUJ1QUZZQVFnQlNBR3NBVmdCQ0FGWUFiQUJHQUVNQVVnQnJBRVlBUlFCaEFEQUFSZ0JXQUZFQVZRQktBRlVBVVFCVkFGWUFSZ0JSQUZZQWNBQXpBRkVBYXdCd0FFSUFVZ0JWQURFQVFnQlZBRllBUmdCREFHSUFhd0JHQUVZQVlRQXdBRVlBVWdCa0FEQUFTZ0JEQUZFQVZRQmtBR29BVVFCV0FFNEFVZ0JSQUdzQVNnQkNBRklBUkFCQ0FFSUFWUUJHQUVZQVFnQmFBREFBUmdCRUFGRUFWUUJHQUVvQVVRQlZBRVlBYmdCUkFGVUFUZ0JDQUZFQVZRQnNBRUlBVVFCWEFHTUFad0JKQUVNQVFRQm5BRWtBUXdCQkFEMEFJQUFnQUNBQUlBQT0gICA=" + private fun parseChapterDate(text: String?): Long { + if (text.isNullOrBlank()) return 0L + + val relativeRegex = Regex( + """(\d+)\s+(years?|months?|weeks?|days?|hours?|mins?|minutes?|sec(?:onds?)?)\s+ago""", + RegexOption.IGNORE_CASE + ) + val match = relativeRegex.matchEntire(text) + if (match != null) { + val number = match.groupValues[1].toInt() + val unit = match.groupValues[2].lowercase() + val cal = Calendar.getInstance() + when { + unit.startsWith("year") -> cal.add(Calendar.YEAR, -number) + unit.startsWith("month") -> cal.add(Calendar.MONTH, -number) + unit.startsWith("week") -> cal.add(Calendar.DAY_OF_MONTH, -number * 7) + unit.startsWith("day") -> cal.add(Calendar.DAY_OF_MONTH, -number) + unit.startsWith("hour") -> cal.add(Calendar.HOUR, -number) + unit.startsWith("min") -> cal.add(Calendar.MINUTE, -number) + unit.startsWith("sec") -> cal.add(Calendar.SECOND, -number) + } + return cal.timeInMillis + } + + val formats = listOf( + SimpleDateFormat("MMM d, yyyy", Locale.ROOT).apply { timeZone = TimeZone.getTimeZone("UTC") }, + SimpleDateFormat("MMM dd, yyyy", Locale.ROOT).apply { timeZone = TimeZone.getTimeZone("UTC") }, + SimpleDateFormat("MMMM d, yyyy", Locale.ROOT).apply { timeZone = TimeZone.getTimeZone("UTC") }, + ) + for (fmt in formats) { + try { + return fmt.parse(text)?.time ?: 0L + } catch (_: Exception) {} + } + + return 0L } -} \ No newline at end of file +} diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Atsumaru.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Atsumaru.kt index 1c526f8d..a5aa24db 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Atsumaru.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Atsumaru.kt @@ -1,6 +1,8 @@ package org.koitharu.kotatsu.parsers.site.kotatsu.en +import okhttp3.Headers import okhttp3.HttpUrl.Companion.toHttpUrl +import org.json.JSONArray import org.json.JSONObject import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaSourceParser @@ -8,166 +10,428 @@ import org.koitharu.kotatsu.parsers.config.ConfigKey import org.koitharu.kotatsu.parsers.core.PagedMangaParser import org.koitharu.kotatsu.parsers.model.* import org.koitharu.kotatsu.parsers.util.* -import java.text.SimpleDateFormat import java.util.* import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.sync.Semaphore -import kotlinx.coroutines.sync.withPermit @MangaSourceParser("ATSUMARU", "Atsumaru", "en") internal class Atsumaru(context: MangaLoaderContext) : - PagedMangaParser(context, MangaParserSource.ATSUMARU, pageSize = 24) { + PagedMangaParser(context, MangaParserSource.ATSUMARU, pageSize = 40) { override val configKeyDomain = ConfigKey.Domain("atsu.moe") - private val apiUrl = "https://$domain/api/" + private val baseUrl = "https://atsu.moe" - private val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.ENGLISH).apply { - timeZone = TimeZone.getTimeZone("UTC") + private val apiHeaders: Headers by lazy { + Headers.Builder() + .add("Accept", "*/*") + .add("Referer", baseUrl) + .add("Content-Type", "application/json") + .build() } override val availableSortOrders: Set = EnumSet.of( - SortOrder.POPULARITY, - SortOrder.RATING, + SortOrder.POPULARITY, // trending (views) + SortOrder.UPDATED, // recently added (dateAdded) + SortOrder.ADDED, // recently new added mangas + SortOrder.RELEVANCE // chapters count ) - override val filterCapabilities: MangaListFilterCapabilities - get() = MangaListFilterCapabilities( - isSearchSupported = true - ) + override val filterCapabilities = MangaListFilterCapabilities( + isSearchSupported = true, + isMultipleTagsSupported = true, + isTagsExclusionSupported = true, + ) + + private val allGenres = listOf( + Genre("Action", "39"), Genre("Adult", "46"), Genre("Adventure", "37"), + Genre("Boys Love", "180"), Genre("Comedy", "6"), Genre("Drama", "31"), + Genre("Fantasy", "36"), Genre("Girls Love", "4"), Genre("Hentai", "10"), + Genre("Historical", "45"), Genre("Horror", "44"), Genre("Martial Arts", "29"), + Genre("Mystery", "32"), Genre("Psychological", "18"), Genre("Romance", "9"), + Genre("Sci-Fi", "1"), Genre("Slice of Life", "7"), Genre("Smut", "41"), + Genre("Supernatural", "22"), Genre("Thriller", "19"), Genre("Tragedy", "5") + ) + + private val allTags = listOf( + Tag("Blackmail", "285"), Tag("Cooking", "669"), Tag("Crimes", "288"), + Tag("Crossdressing", "167"), Tag("Murder", "250"), Tag("Prostitution", "366"), + Tag("Swordplay", "337"), Tag("Working", "248"), + Tag("Josei", "43"), Tag("Seinen", "8"), Tag("Shoujo", "40"), Tag("Shounen", "38"), + Tag("Otaku", "264"), Tag("Tsundere", "313"), Tag("Yandere", "315"), + Tag("Animal Characteristics", "274"), Tag("Beautiful Female Lead", "72"), + Tag("Big Breasts", "123"), Tag("Flat Chest", "320"), Tag("Glasses-Wearing Male Lead", "71"), + Tag("Handsome Male Lead", "68"), Tag("Kemonomimi", "279"), Tag("MILF", "339"), + Tag("Small Breasts", "124"), Tag("Young Male Lead", "787"), + Tag("Adult Cast", "159"), Tag("Bisexual", "382"), Tag("Ensemble Cast", "362"), + Tag("Female Lead", "59"), Tag("Male Lead", "58"), Tag("Non-Human Protagonist", "247"), + Tag("Primarily Adult Cast", "158"), Tag("Primarily Female Cast", "333"), + Tag("Primarily Male Cast", "335"), Tag("Primarily Teen Cast", "334"), + Tag("Strong Female Lead", "69"), Tag("Strong Male Lead", "67"), + Tag("Adapted to Anime", "166"), Tag("Based on a Light Novel", "76"), + Tag("Based on a Novel", "75"), Tag("Based on a Video Game", "77"), + Tag("Based on a Web Novel", "74"), Tag("College", "257"), Tag("Company", "1205"), + Tag("Countryside", "415"), Tag("Europe", "405"), Tag("Foreign", "336"), + Tag("High School", "162"), Tag("Hospital", "760"), Tag("Japan", "225"), + Tag("School", "107"), Tag("School Clubs", "356"), + Tag("Amnesia", "283"), Tag("Appearance Different from Personality", "651"), + Tag("Caught in the Act", "874"), Tag("Dead Family Member", "831"), + Tag("Family Drama", "848"), Tag("Flashbacks", "449"), Tag("Gender Bender", "12"), + Tag("Love Triangle", "125"), Tag("Male Lead Falls in Love First", "653"), + Tag("Misunderstandings", "647"), Tag("Past Plays a Big Role", "648"), + Tag("Reincarnation", "126"), Tag("Secret Identity", "260"), + Tag("Time Manipulation", "311"), Tag("Time Skip", "172"), Tag("Time Travel", "249"), + Tag("Tragic Past", "898"), Tag("Weak to Strong", "1064"), + Tag("Delinquents", "239"), Tag("Detectives", "240"), Tag("Idols", "281"), + Tag("Maids", "116"), Tag("Office Lady", "312"), Tag("Office Worker", "429"), + Tag("School Girl", "788"), Tag("Teachers", "175"), + Tag("Age Gap", "106"), Tag("Childhood Friends", "97"), Tag("Coworkers", "286"), + Tag("Female Harem", "163"), Tag("Friends to Lovers", "243"), Tag("Friendship", "242"), + Tag("Harem", "20"), Tag("Heterosexual", "108"), Tag("Incest", "174"), + Tag("Infidelity", "231"), Tag("Interspecies Relationship", "308"), + Tag("Love-Hate Relationship", "889"), Tag("Master-Servant Relationship", "406"), + Tag("Older Female Younger Male", "114"), Tag("Older Male Younger Female", "649"), + Tag("Older Uke Younger Seme", "880"), Tag("Siblings", "254"), + Tag("Student-Student Relationship", "573"), Tag("Student-Teacher Relationship", "177"), + Tag("Twins", "253"), + Tag("Chinese Ambience", "588"), Tag("European Ambience", "450"), + Tag("Fantasy World", "642"), Tag("Feudal Japan", "606"), Tag("Game Elements", "399"), + Tag("Game World", "641"), Tag("Isekai", "94"), Tag("Isekaied Into a Novel", "258"), + Tag("Mecha", "11"), Tag("Mythology", "259"), Tag("Urban", "338"), + Tag("Urban Fantasy", "261"), + Tag("Anal Intercourse", "100"), Tag("Bondage", "280"), Tag("Boobjob", "381"), + Tag("Borderline H", "448"), Tag("Cunnilingus", "171"), Tag("Defloration", "306"), + Tag("Dubious Consent", "985"), Tag("Ecchi", "21"), Tag("Erotica", "14"), + Tag("Exhibitionism", "287"), Tag("Group Intercourse", "373"), + Tag("Handjob", "303"), Tag("Lolicon", "28"), Tag("Masturbation", "161"), + Tag("Mature", "15"), Tag("Nakadashi", "169"), Tag("Netorare", "232"), + Tag("Nudity", "109"), Tag("Oral Intercourse", "99"), Tag("Outdoor Intercourse", "307"), + Tag("Public Intercourse", "103"), Tag("Rape", "95"), Tag("Sex Addict", "650"), + Tag("Sex Toys", "289"), Tag("Shotacon", "35"), Tag("Teens Love", "374"), + Tag("Threesome", "173"), Tag("Virginity", "369"), + Tag("Animals", "278"), Tag("Cats", "284"), Tag("Demons", "160"), + Tag("Ghosts", "229"), Tag("Gods", "176"), Tag("Monsters", "395"), + Tag("Non-human", "547"), Tag("Vampires", "252"), + Tag("21st century", "132"), Tag("Betrayal", "403"), Tag("Bullying", "235"), + Tag("Cohabitation", "228"), Tag("Coming of Age", "117"), Tag("Danmei", "305"), + Tag("Depression", "1090"), Tag("Family Life", "282"), Tag("Female Empowerment", "1816"), + Tag("Forbidden Love", "699"), Tag("Gore", "262"), Tag("Gourmet", "2"), + Tag("Harlequin", "304"), Tag("Jealousy", "881"), Tag("LGBTQ+", "326"), + Tag("Love Confession", "882"), Tag("Marriage", "360"), Tag("Mature Romance", "241"), + Tag("Medical", "350"), Tag("Military", "230"), Tag("Music", "27"), + Tag("Nobility", "127"), Tag("Obsessive Love", "893"), Tag("Orphans", "237"), + Tag("Religion", "498"), Tag("Reunion", "984"), Tag("Revenge", "227"), + Tag("Royalty", "128"), Tag("School Life", "42"), Tag("Shoujo Ai", "47"), + Tag("Shounen Ai", "23"), Tag("Special Ability", "883"), Tag("Sports", "30"), + Tag("Suicide", "309"), Tag("Super Powers", "236"), Tag("Unrequited Love", "226"), + Tag("Violence", "830"), Tag("War", "238"), Tag("Yaoi", "16"), Tag("Yuri", "33"), + Tag("4-Koma", "105"), Tag("Anthology", "113"), Tag("Chinese Novels", "1112"), + Tag("Collection of Stories", "111"), Tag("Doujinshi", "24"), Tag("Episodic", "115"), + Tag("Full color", "57"), Tag("Korean Novels", "1111"), Tag("Light Novel", "466"), + Tag("Longstrip", "93"), Tag("One Shot", "110"), Tag("Web Comic", "428"), + Tag("Web Novel", "427"), Tag("Magic", "121") + ) override suspend fun getFilterOptions() = MangaListFilterOptions( - availableTags = setOf( - MangaTag( - key = "Manga", - title = "Manga", - source = source - ), - MangaTag( - key = "Manwha", - title = "Manhwa", - source = source - ), - MangaTag( - key = "Manhua", - title = "Manhua", - source = source - ), - MangaTag( - key = "OEL", - title = "OEL", - source = source - ) - ), - availableStates = EnumSet.of( - MangaState.ONGOING, - MangaState.FINISHED, - MangaState.PAUSED, - MangaState.ABANDONED - ) + availableTags = (allGenres.map { (name, id) -> MangaTag(name, "genre:$id", source) } + + allTags.map { (name, id) -> MangaTag(name, "tag:$id", source) }).toSet(), + availableStates = EnumSet.of(MangaState.ONGOING, MangaState.FINISHED, MangaState.PAUSED, MangaState.ABANDONED), + availableContentTypes = EnumSet.of(ContentType.MANGA, ContentType.MANHWA, ContentType.MANHUA, ContentType.OTHER), + availableContentRating = EnumSet.of(ContentRating.SAFE, ContentRating.ADULT), ) override suspend fun getListPage( page: Int, order: SortOrder, - filter: MangaListFilter - ): List { + filter: MangaListFilter, + ): List = getSearchPage(page, order, filter) + + private suspend fun getSearchPage(page: Int, order: SortOrder, filter: MangaListFilter): List { + val query = filter.query ?: "*" + val url = "$baseUrl/collections/manga/documents/search".toHttpUrl().newBuilder().apply { + addQueryParameter("q", query) + if (query != "*") { + addQueryParameter("query_by", "title,englishTitle,otherNames,authors") + addQueryParameter("query_by_weights", "4,3,2,1") + addQueryParameter("num_typos", "4,3,2,1") + } + addQueryParameter("page", page.toString()) + addQueryParameter("per_page", pageSize.toString()) - val sort = when (order) { - SortOrder.POPULARITY -> "views:desc" - SortOrder.RATING -> "mbRating:desc" - else -> "views:desc" - } + val filterParts = mutableListOf("hidden:!=true", "views:>0") - val url = "https://$domain/collections/manga/documents/search" - .toHttpUrl() - .newBuilder() - .addQueryParameter("q", filter.query ?: "*") - .addQueryParameter( - "query_by", - "title,englishTitle,otherNames,authors" - ) - .addQueryParameter( - "include_fields", - "id,title,poster,type,status" - ) - .addQueryParameter("page", page.toString()) - .addQueryParameter("per_page", pageSize.toString()) - .addQueryParameter("sort_by", sort) - .addQueryParameter( - "filter_by", - buildFilter(filter) - ) - .build() + val ratings = filter.contentRating + when { + ratings.contains(ContentRating.ADULT) && !ratings.contains(ContentRating.SAFE) -> { + filterParts.add("isAdult:=true") + } + ratings.contains(ContentRating.SAFE) && !ratings.contains(ContentRating.ADULT) -> { + filterParts.add("isAdult:=false") + } + // both or none: no filter shows all + else -> { /* no isAdult filter */ } + } + filterParts.add("(mbContentRating:=[`Safe`,`Suggestive`,`Erotica`] || mbContentRating:!=*)") + + val types = filter.types.mapNotNull { ct -> + when (ct) { + ContentType.MANGA -> "Manga" + ContentType.MANHWA -> "Manwha" + ContentType.MANHUA -> "Manhua" + ContentType.OTHER -> "OEL" + else -> null + } + } + if (types.isNotEmpty()) { + filterParts.add("type:=[${types.joinToString(",") { "`$it`" }}]") + } + + val statusVals = filter.states.mapNotNull { st -> + when (st) { + MangaState.ONGOING -> "Ongoing" + MangaState.FINISHED -> "Completed" + MangaState.PAUSED -> "Hiatus" + MangaState.ABANDONED -> "Canceled" + else -> null + } + } + if (statusVals.isNotEmpty()) { + filterParts.add("status:=[${statusVals.joinToString(",") { "`$it`" }}]") + } + + val includedGenres = mutableListOf() + val excludedGenres = mutableListOf() + val includedTags = mutableListOf() + val excludedTags = mutableListOf() + for (tag in filter.tags) { + val key = tag.key + when { + key.startsWith("genre:") -> includedGenres.add(key.removePrefix("genre:")) + key.startsWith("tag:") -> includedTags.add(key.removePrefix("tag:")) + } + } + for (tag in filter.tagsExclude) { + val key = tag.key + when { + key.startsWith("genre:") -> excludedGenres.add(key.removePrefix("genre:")) + key.startsWith("tag:") -> excludedTags.add(key.removePrefix("tag:")) + } + } - val hits = webClient - .httpGet(url.toString()) - .parseJson() - .optJSONArray("hits") - ?: return emptyList() + if (includedGenres.isNotEmpty()) { + filterParts.add(includedGenres.joinToString(" && ") { "genreIds:=`$it`" }) + } + if (excludedGenres.isNotEmpty()) { + filterParts.add("genreIds:!=[${excludedGenres.joinToString(",") { "`$it`" }}]") + } + if (includedTags.isNotEmpty()) { + filterParts.add(includedTags.joinToString(" && ") { "tagIds:=`$it`" }) + } + if (excludedTags.isNotEmpty()) { + filterParts.add("tagIds:!=[${excludedTags.joinToString(",") { "`$it`" }}]") + } + addQueryParameter("filter_by", filterParts.joinToString(" && ")) - return List(hits.length()) { index -> - hits.getJSONObject(index) - .getJSONObject("document") - .let(::parseManga) + val sort = when (order) { + SortOrder.POPULARITY -> "trending:desc" + SortOrder.UPDATED -> "released:desc" + SortOrder.ADDED -> "dateAdded:desc" + SortOrder.RELEVANCE -> "chapterCount:desc" + else -> "released:desc" + } + addQueryParameter("sort_by", sort) + addQueryParameter("sort_by", sort) + }.build() + + val json = webClient.httpGet(url, apiHeaders).parseJson() + val hits = json.optJSONArray("hits") ?: return emptyList() + return (0 until hits.length()).map { i -> + hits.getJSONObject(i).getJSONObject("document").toManga() } } - private fun buildFilter(filter: MangaListFilter): String { + // limit tags to 20 + override suspend fun getDetails(manga: Manga): Manga { + detailsCache[manga.url]?.let { return it } - val filters = mutableListOf() + val result = coroutineScope { + val mangaId = manga.url.substringAfterLast("/") - filters += "views:>0" + val pageDeferred = async { + webClient.httpGet("$baseUrl/api/manga/page?id=$mangaId", apiHeaders).parseJson() + } + val chaptersDeferred = async { + webClient.httpGet("$baseUrl/api/manga/allChapters?mangaId=$mangaId", apiHeaders).parseJson() + } - // Manga type filter - val types = filter.tags.map { - "`${it.key}`" - } + val pageJson = pageDeferred.await() + val mangaPage = pageJson.optJSONObject("mangaPage") ?: return@coroutineScope manga - if (types.isNotEmpty()) { - filters += "type:=[${types.joinToString(",")}]" - } + val title = mangaPage.optString("title").ifEmpty { mangaPage.optString("englishTitle", manga.title) } + val description = mangaPage.optString("synopsis") ?: manga.description + + val rawRating = mangaPage.optDouble("avgRating", -1.0) + val rating = if (rawRating >= 0.0) (rawRating / 10.0).toFloat() else RATING_UNKNOWN + + val posterObj = mangaPage.optJSONObject("poster") + val posterImage = posterObj?.optString("mediumImage") + val coverUrl = if (!posterImage.isNullOrEmpty()) { + "https://$domain/static/$posterImage" + } else manga.coverUrl + + val authors = mangaPage.optJSONArray("authors")?.let { arr -> + (0 until arr.length()).mapNotNull { arr.optJSONObject(it)?.optString("name") }.toSet() + } ?: emptySet() + + val statusText = mangaPage.optString("status").orEmpty() + val state = when (statusText.lowercase()) { + "ongoing" -> MangaState.ONGOING + "completed" -> MangaState.FINISHED + "hiatus" -> MangaState.PAUSED + "canceled" -> MangaState.ABANDONED + else -> manga.state + } + + val tagSet = mutableSetOf() + mangaPage.optJSONArray("genres")?.let { arr -> + for (i in 0 until minOf(arr.length(), 10)) { + val obj = arr.optJSONObject(i) ?: continue + val name = obj.optString("name").takeIf { it.isNotEmpty() } ?: continue + tagSet.add(MangaTag(name, "genre:${obj.optString("id")}", source)) + } + } + mangaPage.optJSONArray("tags")?.let { arr -> + for (i in 0 until minOf(arr.length(), 10)) { + val obj = arr.optJSONObject(i) ?: continue + val name = obj.optString("name").takeIf { it.isNotEmpty() } ?: continue + tagSet.add(MangaTag(name, "tag:${obj.optString("id")}", source)) + } + } + + val chaptersJson = chaptersDeferred.await() + val chaptersArray = chaptersJson.optJSONArray("chapters") ?: JSONArray() + val scanlators = mutableMapOf() + mangaPage.optJSONArray("scanlators")?.let { scanArr -> + for (i in 0 until scanArr.length()) { + val sc = scanArr.optJSONObject(i) ?: continue + val id = sc.optString("id").takeIf { it.isNotEmpty() } ?: continue + val name = sc.optString("name").takeIf { it.isNotEmpty() } ?: continue + scanlators[id] = name + } + } + + val chapters = (0 until chaptersArray.length()).map { i -> + val ch = chaptersArray.getJSONObject(i) + val chId = ch.getString("id") + val number = ch.optDouble("number", 0.0).toFloat() + val chTitle = ch.optString("title") + val scanId = ch.optString("scanlationMangaId").nullIfEmpty() + val scanName = scanId?.let { scanlators[it] } + val createdAt = ch.optLong("createdAt", 0L) + + MangaChapter( + id = generateUid("$mangaId/$chId"), + title = chTitle.ifBlank { "Chapter $number" }, + number = number, + volume = 0, + url = "$mangaId/$chId", + uploadDate = createdAt, + source = source, + scanlator = scanName, + branch = scanName, + ) + }.reversed() - // Status filter - val statuses = filter.states.mapNotNull { - when (it) { - MangaState.ONGOING -> "`Ongoing`" - MangaState.FINISHED -> "`Completed`" - MangaState.PAUSED -> "`Hiatus`" - MangaState.ABANDONED -> "`Canceled`" - else -> null + val groupedChapters = if (chapters.map { it.branch }.distinct().size > 1) { + chapters.map { it.copy(branch = it.branch ?: "Unknown") } + } else { + chapters.map { it.copy(branch = null, scanlator = null) } } + + val altTitles = mangaPage.optJSONArray("otherNames")?.let { arr -> + (0 until arr.length()).mapNotNull { arr.optString(it) }.toSet() + } ?: emptySet() + + manga.copy( + title = title, + description = description, + coverUrl = coverUrl, + authors = authors, + state = state, + tags = tagSet, + rating = rating, + chapters = groupedChapters, + altTitles = altTitles, + ) } - if (statuses.isNotEmpty()) { - filters += "status:=[${statuses.joinToString(",")}]" + detailsCache.put(manga.url, result) + return result + } + + @get:Synchronized + private val detailsCache = object : LinkedHashMap(16, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean { + return size > 10 } + } + override suspend fun getRelatedManga(seed: Manga): List { + val mangaId = seed.url.substringAfterLast("/") + val pageJson = webClient.httpGet("$baseUrl/api/manga/page?id=$mangaId", apiHeaders).parseJson() + val mangaPage = pageJson.optJSONObject("mangaPage") ?: return emptyList() + val similar = mangaPage.optJSONArray("similarManga") ?: return emptyList() + return (0 until similar.length()).map { i -> + similar.getJSONObject(i).toManga() + } + } - return filters.joinToString(" && ") + override suspend fun getPages(chapter: MangaChapter): List { + val (mangaId, chapterId) = chapter.url.split("/") + val url = "$baseUrl/api/read/chapter".toHttpUrl().newBuilder() + .addQueryParameter("mangaId", mangaId) + .addQueryParameter("chapterId", chapterId) + .build() + val json = webClient.httpGet(url, apiHeaders).parseJson() + val pagesArray = json.getJSONObject("readChapter").getJSONArray("pages") + return (0 until pagesArray.length()).map { i -> + val page = pagesArray.getJSONObject(i) + val imagePath = page.getString("image") + val fullUrl = when { + imagePath.startsWith("http") -> imagePath + imagePath.startsWith("//") -> "https:$imagePath" + else -> "https://$domain/static/${imagePath.removePrefix("/").removePrefix("static/")}" + } + MangaPage(id = generateUid(fullUrl), url = fullUrl, preview = null, source = source) + } } + private fun JSONObject.toManga(): Manga { + val id = getString("id") + val title = optString("title").ifEmpty { optString("englishTitle", "Unknown") } - private fun parseManga(json: JSONObject): Manga { - val id = json.getString("id") - val title = json.optString("title").ifEmpty { - json.optString("englishTitle", "Unknown") + val imageRaw: Any? = when { + has("poster") -> get("poster") + has("image") -> getString("image") + else -> null } - // List results have "image", search results have "poster" - val image = json.optString("image") - val poster = json.optString("poster") - val imagePath = image.ifEmpty { poster } + val imagePath: String? = when (imageRaw) { + is String -> imageRaw.takeIf { it.isNotBlank() } + is JSONObject -> { + imageRaw.optString("image").nullIfEmpty() + ?: imageRaw.optString("mediumImage").nullIfEmpty() + ?: imageRaw.optString("smallImage").nullIfEmpty() + } + else -> null + }?.removePrefix("/")?.removePrefix("static/") - val coverUrl = if (imagePath.isNotEmpty()) { + val coverUrl = if (!imagePath.isNullOrBlank()) { when { imagePath.startsWith("http") -> imagePath - imagePath.startsWith("/static/") -> "https://$domain$imagePath" - imagePath.startsWith("/") -> "https://$domain$imagePath" + imagePath.startsWith("//") -> "https:$imagePath" else -> "https://$domain/static/$imagePath" } } else null @@ -177,245 +441,17 @@ internal class Atsumaru(context: MangaLoaderContext) : title = title, altTitles = emptySet(), url = "/manga/$id", - publicUrl = "https://$domain/manga/$id", + publicUrl = "$baseUrl/manga/$id", rating = RATING_UNKNOWN, contentRating = null, coverUrl = coverUrl, tags = emptySet(), state = null, authors = emptySet(), - source = source - ) - } - - override suspend fun getDetails(manga: Manga): Manga = coroutineScope { - val mangaId = manga.url.substringAfterLast("/") - val pageDeferred = async { - webClient - .httpGet("${apiUrl}manga/page?id=$mangaId") - .parseJson() - .optJSONObject("mangaPage") - } - val chaptersDeferred = async { - fetchAllChapters( - mangaId, - pageDeferred.await() - ) - } - val mangaPage = pageDeferred.await() - val title = mangaPage?.optString("title")?.ifEmpty { - mangaPage.optString("englishTitle", manga.title) - } ?: manga.title - val description = - mangaPage?.optString("synopsis") - ?: manga.description - val posterImage = - mangaPage - ?.optJSONObject("poster") - ?.optString("image") - val coverUrl = - if (!posterImage.isNullOrEmpty()) { - "https://$domain/static/$posterImage" - } else { - manga.coverUrl - } - val authors = - mangaPage - ?.optJSONArray("authors") - ?.let { array -> - (0 until array.length()) - .mapNotNull { - array.optJSONObject(it) - ?.optString("name") - } - .toSet() - } - ?: emptySet() - val status = mangaPage?.optString("status").orEmpty() - val state = when(status.lowercase()) { - "ongoing" -> MangaState.ONGOING - "completed" -> MangaState.FINISHED - "hiatus" -> MangaState.PAUSED - "cancelled" -> MangaState.ABANDONED - else -> manga.state - } - manga.copy( - title = title, - description = description, - coverUrl = coverUrl, - authors = authors, - state = state, - chapters = chaptersDeferred.await() - ) - } - - private suspend fun fetchAllChapters( - mangaId: String, - mangaPage: JSONObject? - ): List = coroutineScope { - - val scanlators = mangaPage.parseScanlators() - - // First request is needed to know total pages - val firstUrl = - "${apiUrl}manga/chapters?id=$mangaId&filter=all&sort=desc&page=0" - - val firstJson = webClient - .httpGet(firstUrl) - .parseJson() - - val totalPages = firstJson.optInt("pages", 1) - - val semaphore = Semaphore(8) - - val pages = buildList { - - // Add already fetched page 0 - add(firstJson) - - // Fetch remaining pages in parallel - addAll( - (1 until totalPages).map { page -> - - async { - semaphore.withPermit { - - val url = - "${apiUrl}manga/chapters?id=$mangaId&filter=all&sort=desc&page=$page" - - webClient - .httpGet(url) - .parseJson() - } - } - - }.awaitAll() - ) - } - - - pages.flatMap { json -> - - val array = json.optJSONArray("chapters") - ?: return@flatMap emptyList() - - (0 until array.length()) - .mapNotNull { i -> - array.optJSONObject(i) - } - .map { - parseChapter( - it, - mangaId, - scanlators - ) - } - } - .reversed() - .mapChapterBranches() - } - - private fun JSONObject?.parseScanlators(): Map { - val scanlatorsArray = this?.optJSONArray("scanlators") ?: return emptyMap() - return buildMap { - for (i in 0 until scanlatorsArray.length()) { - val scanlator = scanlatorsArray.optJSONObject(i) ?: continue - val id = scanlator.optString("id").takeIf { it.isNotEmpty() } ?: continue - val name = scanlator.optString("name").takeIf { it.isNotEmpty() } ?: continue - put(id, name) - } - } - } - - - private fun parseChapter( - json: JSONObject, - mangaId: String, - scanlators: Map, - ): MangaChapter { - val chapterId = json.getString("id") - val title = json.optString("title").takeIf { it.isNotEmpty() } - val number = json.optString("number").toFloatOrNull() - ?: json.optDouble("number", 0.0).toFloat() - val scanlator = scanlators[json.optString("scanlationMangaId")] - - val uploadDate = when (val createdAt = json.opt("createdAt")) { - is Number -> createdAt.toLong() - is String -> { - try { - dateFormat.parse(createdAt)?.time ?: 0L - } catch (_: Exception) { - 0L - } - } - else -> 0L - } - - return MangaChapter( - id = generateUid("$mangaId/$chapterId"), - title = title, - number = number, - volume = 0, - url = "$mangaId/$chapterId", - uploadDate = uploadDate, source = source, - scanlator = scanlator, - branch = scanlator ) } - private fun List.mapChapterBranches(): List { - val totalPerChapter = groupingBy { it.number } - .eachCount() - val counters = HashMap() - return map { chapter -> - val total = totalPerChapter[chapter.number] ?: 1 - - if (total == 1) { - return@map chapter.copy( - scanlator = null, - branch = null - ) - } - val count = counters.getOrDefault(chapter.number, 0) + 1 - counters[chapter.number] = count - val branch = if (count == 1) { - "Group 1" - } else { - "Group $count" - } - chapter.copy( - scanlator = branch, - branch = branch - ) - } - } - - override suspend fun getPages(chapter: MangaChapter): List { - val (mangaId, chapterId) = chapter.url.split("/") - val url = "${apiUrl}read/chapter".toHttpUrl().newBuilder() - .addQueryParameter("mangaId", mangaId) - .addQueryParameter("chapterId", chapterId) - .build() - - val json = webClient.httpGet(url.toString()).parseJson() - val readChapter = json.getJSONObject("readChapter") - val pages = readChapter.getJSONArray("pages") - - return (0 until pages.length()).map { i -> - val page = pages.getJSONObject(i) - val imagePath = page.getString("image") - val fullUrl = "https://$domain$imagePath" - - MangaPage( - id = generateUid(fullUrl), - url = fullUrl, - preview = null, - source = source - ) - } - } - override suspend fun getRelatedManga(seed: Manga): List { - return emptyList() - } + private data class Genre(val name: String, val id: String) + private data class Tag(val name: String, val id: String) } diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Batcave.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Batcave.kt new file mode 100644 index 00000000..7699d15d --- /dev/null +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Batcave.kt @@ -0,0 +1,251 @@ +package org.koitharu.kotatsu.parsers.site.kotatsu.en + +import okhttp3.FormBody +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +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.PagedMangaParser +import org.koitharu.kotatsu.parsers.exception.ParseException +import org.koitharu.kotatsu.parsers.model.* +import org.koitharu.kotatsu.parsers.network.OkHttpWebClient +import org.koitharu.kotatsu.parsers.util.* +import java.text.SimpleDateFormat +import java.util.* + +@MangaSourceParser("BATCAVE", "Batcave", "en", ContentType.COMICS) +internal class Batcave(context: MangaLoaderContext) : + PagedMangaParser(context, MangaParserSource.BATCAVE, 20) { + + override val configKeyDomain = ConfigKey.Domain("batcave.biz") + + private var publishers: List> = emptyList() + private var genres: List> = emptyList() + private var filterParseFailed = false + + private val dateFormat = SimpleDateFormat("dd.MM.yyyy", Locale.US) + + private val apiClient: OkHttpWebClient by lazy { + val httpClient = context.httpClient.newBuilder() + .addInterceptor(::refererInterceptor) + .addInterceptor(::dleGuardInterceptor) + .build() + OkHttpWebClient(httpClient, source) + } + + private val rawHttpClient: OkHttpClient by lazy { context.httpClient.newBuilder().build() } + + private fun refererInterceptor(chain: okhttp3.Interceptor.Chain): Response { + val request = chain.request() + val url = request.url.toString() + val referer = when { + url.contains("readcomicsonline.ru") -> "https://readcomicsonline.ru/" + else -> "https://$domain/" + } + return chain.proceed(request.newBuilder().header("Referer", referer).build()) + } + + private fun dleGuardInterceptor(chain: okhttp3.Interceptor.Chain): Response { + val originalRequest = chain.request() + val response = chain.proceed(originalRequest) + if (response.request.url.pathSegments.firstOrNull() != "_c") { + return response + } + response.close() + val url = if (originalRequest.method == "GET") { + originalRequest.url.toString() + } else { + "https://$domain/" + } + context.requestBrowserAction(this, url) + } + + override fun onCreateConfig(keys: MutableCollection>) { + super.onCreateConfig(keys) + keys.add(userAgentKey) + } + + override val availableSortOrders: Set = EnumSet.of(SortOrder.POPULARITY, SortOrder.UPDATED) + + override val filterCapabilities = MangaListFilterCapabilities( + isSearchSupported = true, + isMultipleTagsSupported = false, + ) + + override suspend fun getFilterOptions(): MangaListFilterOptions { + return MangaListFilterOptions( + availableTags = emptySet(), + ) + } + + private fun parseFilterValues(filterItems: JSONObject, key: String): List> { + val obj = filterItems.optJSONObject(key) ?: return emptyList() + val values = obj.optJSONArray("values") ?: return emptyList() + val result = mutableListOf>() + for (i in 0 until values.length()) { + val item = values.getJSONObject(i) + result.add(item.getString("value") to item.getInt("id")) + } + return result + } + + override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List { + val query = filter.query?.trim()?.takeIf { it.isNotEmpty() } + if (query != null) return searchManga(query, page) + + val urlBuilder = StringBuilder().apply { + append("https://$domain/ComicList/") + if (filter.yearFrom != YEAR_UNKNOWN) append("y[from]=${filter.yearFrom}/") + if (filter.yearTo != YEAR_UNKNOWN) append("y[to]=${filter.yearTo}/") + val pIds = filter.tags.filter { it.key.startsWith("p_") }.map { it.key.removePrefix("p_") } + val gIds = filter.tags.filter { it.key.startsWith("g_") }.map { it.key.removePrefix("g_") } + if (pIds.isNotEmpty()) append("p=${pIds.joinToString(",")}/") + if (gIds.isNotEmpty()) append("g=${gIds.joinToString(",")}/") + append("sort") + if (page > 1) append("/page/$page/") + } + val url = urlBuilder.toString() + + val sortPair = when (order) { + SortOrder.POPULARITY -> "rating" to "desc" + SortOrder.UPDATED -> "date" to "desc" + else -> "" to "" + } + + return if (sortPair.first.isEmpty()) { + parseMangaList(apiClient.httpGet(url).parseHtml()) + } else { + val formBody = FormBody.Builder() + .add("dlenewssortby", sortPair.first) + .add("dledirection", sortPair.second) + .add("set_new_sort", "dle_sort_xfilter") + .add("set_direction_sort", "dle_direction_xfilter") + .build() + val request = Request.Builder().url(url).post(formBody).build() + val response = rawHttpClient.newCall(request).execute() + parseMangaList(response.parseHtml()) + } + } + + private suspend fun searchManga(query: String, page: Int): List { + val encoded = query.urlEncoded() + val url = if (page == 1) "https://$domain/search/$encoded/" + else "https://$domain/search/$encoded/page/$page/" + return parseMangaList(apiClient.httpGet(url).parseHtml()) + } + + private fun parseMangaList(doc: org.jsoup.nodes.Document): List { + return doc.select("#dle-content > .readed").map { element -> + val a = element.selectFirst(".readed__title > a") ?: return@map null + val cover = element.selectFirst("img")?.attrAsAbsoluteUrl("data-src") + Manga( + id = generateUid(a.attrAsRelativeUrl("href")), + url = a.attrAsRelativeUrl("href"), + publicUrl = a.absUrl("href"), + title = a.ownText(), + altTitles = emptySet(), + rating = RATING_UNKNOWN, + contentRating = null, + coverUrl = cover, + tags = emptySet(), + state = null, + authors = emptySet(), + source = source, + ) + }.filterNotNull() + } + + @get:Synchronized + private val detailsCache = object : LinkedHashMap(16, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean = size > 10 + } + + override suspend fun getDetails(manga: Manga): Manga { + detailsCache[manga.url]?.let { return it } + + val doc = apiClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml() + + val title = doc.selectFirst("header.page__header h1")?.text() ?: manga.title + val cover = doc.selectFirst("div.page__poster img")?.absUrl("src") + val description = doc.selectFirst("div.page__text")?.text() + val author = doc.selectFirst(".page__list > li:has(> div:contains(Writer))")?.ownText() + val state = when (doc.selectFirst(".page__list > li:has(> div:contains(Release type))")?.ownText()?.trim()) { + "Ongoing" -> MangaState.ONGOING + else -> MangaState.FINISHED + } + + val script = doc.selectFirst("script:containsData(window.__DATA__)")?.data() + ?: throw ParseException("Chapter data script not found", manga.url) + val json = JSONObject( + script.substringAfter("window.__DATA__ = ").substringBeforeLast(";").trim() + ) + val newsId = json.getLong("news_id") + val chaptersArray = json.getJSONArray("chapters") + val chapters = (0 until chaptersArray.length()).map { i -> + val ch = chaptersArray.getJSONObject(i) + MangaChapter( + id = generateUid("/reader/$newsId/${ch.getInt("id")}"), + url = "/reader/$newsId/${ch.getInt("id")}", + title = ch.optString("title"), + number = ch.optDouble("posi", 0.0).toFloat(), + volume = 0, + scanlator = null, + uploadDate = dateFormat.parseSafe(ch.optString("date")), + branch = null, + source = source, + ) + }.reversed() + + val result = manga.copy( + title = title, + coverUrl = cover ?: manga.coverUrl, + description = description, + authors = setOfNotNull(author), + state = state, + tags = emptySet(), + chapters = chapters, + ) + detailsCache[manga.url] = result + return result + } + + override suspend fun getPages(chapter: MangaChapter): List { + val (newsId, rawId) = chapter.url.substringAfter("reader/").split("/", limit = 2) + val id = Regex("^\\d+").find(rawId)?.value ?: rawId + val jsonBody = JSONObject().apply { + put("news_id", newsId) + put("chapter_id", id) + }.toString() + + val request = Request.Builder() + .url("https://$domain/engine/ajax/controller.php?mod=api&action=reader/getChapterData") + .header("Referer", "https://$domain/") + .header("Content-Type", "application/json") + .post( + jsonBody + .toRequestBody("application/json; charset=utf-8".toMediaTypeOrNull()) + ) + .build() + + val response = rawHttpClient.newCall(request).execute() + val json = JSONObject(response.body?.string() ?: throw ParseException("Empty response", chapter.url)) + val data = json.getJSONObject("data") + val images = data.getJSONArray("images") + + return (0 until images.length()).map { i -> + var img = images.getString(i).trim() + if (!img.startsWith("http")) img = "https://$domain$img" + MangaPage(id = generateUid(img), url = img, preview = null, source = source) + } + } + + override suspend fun getPageUrl(page: MangaPage): String = page.url + + private fun SimpleDateFormat.parseSafe(date: String?): Long = + if (date.isNullOrBlank()) 0L else runCatching { parse(date)?.time ?: 0L }.getOrDefault(0L) +} diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/EnThunderScans.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/EnThunderScans.kt new file mode 100644 index 00000000..03153da3 --- /dev/null +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/EnThunderScans.kt @@ -0,0 +1,24 @@ +package org.koitharu.kotatsu.parsers.site.kotatsu.en + +import org.koitharu.kotatsu.parsers.MangaLoaderContext +import org.koitharu.kotatsu.parsers.MangaSourceParser +import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities +import org.koitharu.kotatsu.parsers.model.MangaParserSource +import org.koitharu.kotatsu.parsers.parsers.MangaReaderParser + +@MangaSourceParser("ENTHUNDERSCANS", "EnThunderScans", "en") +internal class EnThunderScans(context: MangaLoaderContext) : + MangaReaderParser( + context, + MangaParserSource.ENTHUNDERSCANS, + "en-thunderscans.com", + pageSize = 30, + searchPageSize = 10, + ) { + override val listUrl = "/comics" + + override val filterCapabilities: MangaListFilterCapabilities + get() = super.filterCapabilities.copy( + isTagsExclusionSupported = false, + ) +} diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/KingofShojo b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/KingofShojo.kt similarity index 62% rename from src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/KingofShojo rename to src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/KingofShojo.kt index a8cf1485..7e31556c 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/KingofShojo +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/KingofShojo.kt @@ -5,17 +5,6 @@ import org.koitharu.kotatsu.parsers.MangaSourceParser import org.koitharu.kotatsu.parsers.model.MangaParserSource import org.koitharu.kotatsu.parsers.parsers.MangaThemesia - -@MangaSourceParser("KINGOFSHOJO", "King of Shojo", "en",) +@MangaSourceParser("KINGOFSHOJO", "King of Shojo", "en") internal class KingofShojo(context: MangaLoaderContext) : - MangaThemesia(context, MangaParserSource.KINGOFSHOJO, "kingofshojo.com",) { - - override val mangaDirectory = - "manga" - - override val searchSelector = - ".listupd .bsx" - - override val pageSelector = - "#readerarea img" -} + MangaThemesia(context, MangaParserSource.KINGOFSHOJO, "kingofshojo.com") diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/MangaFox.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/MangaFox.kt index f86ca381..8af146a5 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/MangaFox.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/MangaFox.kt @@ -1,60 +1,89 @@ package org.koitharu.kotatsu.parsers.site.kotatsu.en +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.MangaSourceParser import org.koitharu.kotatsu.parsers.config.ConfigKey import org.koitharu.kotatsu.parsers.core.PagedMangaParser import org.koitharu.kotatsu.parsers.model.* +import org.koitharu.kotatsu.parsers.network.OkHttpWebClient import org.koitharu.kotatsu.parsers.util.* import java.text.SimpleDateFormat import java.util.* @MangaSourceParser("MANGAFOX", "MangaFox", "en") -internal class MangaFox(context: MangaLoaderContext): +internal class MangaFox(context: MangaLoaderContext) : PagedMangaParser(context, MangaParserSource.MANGAFOX, pageSize = 24) { private val baseUrl = "fanfox.net" private val mobileUrl = "https://m.fanfox.net" private val dateFormat = SimpleDateFormat("MMM dd, yyyy", Locale.ENGLISH) - override val configKeyDomain: ConfigKey.Domain = - ConfigKey.Domain(baseUrl) + override val configKeyDomain = ConfigKey.Domain(baseUrl) + + private val apiClient: OkHttpWebClient by lazy { + val httpClient = context.httpClient.newBuilder() + .addInterceptor { chain -> + val request = chain.request().newBuilder() + .header("Referer", "https://$baseUrl/") + .build() + chain.proceed(request) + } + .build() + OkHttpWebClient(httpClient, source) + } - override val availableSortOrders: Set = - EnumSet.of(SortOrder.POPULARITY, SortOrder.UPDATED) + override val availableSortOrders: Set = EnumSet.of(SortOrder.POPULARITY, SortOrder.UPDATED) - override val filterCapabilities = - MangaListFilterCapabilities(isSearchSupported = true) + override val filterCapabilities = MangaListFilterCapabilities( + isSearchSupported = true + ) - override suspend fun getListPage( - page: Int, - order: SortOrder, - filter: MangaListFilter, - ): List { + @Volatile + private var tagsCache: Set? = null + private val tagsMutex = Mutex() - val url = buildUrl(page, filter, order) - val doc = webClient.httpGet(url).parseHtml() + override suspend fun getFilterOptions(): MangaListFilterOptions { + return MangaListFilterOptions( + availableTags = getOrFetchTags() + ) + } + private suspend fun getOrFetchTags(): Set { + tagsCache?.let { return it } + return tagsMutex.withLock { + tagsCache ?: fetchTags().also { tagsCache = it } + } + } + + private fun fetchTags(): Set { + return listOf( + "action", "adventure", "comedy", "drama", "fantasy", + "harem", "romance", "school-life", "shounen", "shoujo", + "seinen", "sci-fi", "slice-of-life", "supernatural", + "ecchi", "mystery", "psychological", "historical", + "martial-arts", "mecha", "sports" + ).map { + MangaTag(key = it, title = it.replace("-", " "), source = source) + }.toSet() + } + + override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List { + val url = buildUrl(page, filter, order) + val doc = apiClient.httpGet(url).parseHtml() return parseMangaList(doc) } private fun buildUrl(page: Int, filter: MangaListFilter, order: SortOrder): String { - val query = filter.query?.trim().orEmpty() val tag = filter.tags.firstOrNull()?.key - return when { - - // TAG SEARCH (Fanfox mobile endpoint) !tag.isNullOrBlank() && query.isEmpty() -> "https://m.fanfox.net/search/cate/$tag" - - // TEXT SEARCH query.isNotEmpty() -> "https://m.fanfox.net/search?k=${query.urlEncoded()}" - - // DEFAULT DIRECTORY else -> buildString { append("https://$baseUrl/directory/") @@ -65,71 +94,77 @@ internal class MangaFox(context: MangaLoaderContext): } private fun parseMangaList(doc: Document): List { - - val nodes = when { - doc.select("ul.manga-list-1-list li").isNotEmpty() -> - doc.select("ul.manga-list-1-list li") - - doc.select("ul.manga-list-4-list li").isNotEmpty() -> - doc.select("ul.manga-list-4-list li") - - doc.select("ul.manga-list-2-list li").isNotEmpty() -> - doc.select("ul.manga-list-2-list li") - - else -> - doc.select("li") + val searchItems = doc.select(".post-list li") + if (searchItems.isNotEmpty()) { + return searchItems.mapNotNull { el -> + val a = el.selectFirst("a") ?: return@mapNotNull null + val href = a.attrAsRelativeUrl("href") + Manga( + id = generateUid(href), + title = a.attr("title").ifBlank { a.selectFirst(".cover-info .title")?.text() ?: a.text() }, + url = href, + publicUrl = a.absUrl("href"), + coverUrl = el.selectFirst("img")?.attr("abs:src").orEmpty(), + rating = RATING_UNKNOWN, + contentRating = null, + tags = emptySet(), + authors = emptySet(), + state = null, + altTitles = emptySet(), + largeCoverUrl = null, + description = null, + source = source + ) + } } - return nodes.mapNotNull { el -> - val a = el.selectFirst("a") ?: return@mapNotNull null - val href = a.attrAsRelativeUrl("href") - - Manga( - id = generateUid(href), - title = a.attr("title").ifBlank { a.text() }, - url = href, - publicUrl = a.absUrl("href"), - coverUrl = el.selectFirst("img")?.attr("abs:src"), - - rating = RATING_UNKNOWN, - contentRating = null, - - tags = emptySet(), - authors = emptySet(), - - state = null, - - altTitles = emptySet(), - largeCoverUrl = null, - description = null, - - source = source - ) + val dirSelectors = listOf( + "ul.manga-list-1-list li", + "ul.manga-list-4-list li", + "ul.manga-list-2-list li" + ) + for (sel in dirSelectors) { + val items = doc.select(sel) + if (items.isNotEmpty()) { + return items.mapNotNull { el -> + val a = el.selectFirst("a") ?: return@mapNotNull null + val href = a.attrAsRelativeUrl("href") + Manga( + id = generateUid(href), + title = a.attr("title").ifBlank { a.text() }, + url = href, + publicUrl = a.absUrl("href"), + coverUrl = el.selectFirst("img")?.attr("abs:src").orEmpty(), + rating = RATING_UNKNOWN, + contentRating = null, + tags = emptySet(), + authors = emptySet(), + state = null, + altTitles = emptySet(), + largeCoverUrl = null, + description = null, + source = source + ) + } + } } + return emptyList() } - override suspend fun getDetails(manga: Manga): Manga { - val doc = webClient.httpGet(manga.url.toAbsoluteUrl(baseUrl)).parseHtml() + val doc = apiClient.httpGet(manga.url.toAbsoluteUrl(baseUrl)).parseHtml() val info = doc.selectFirst(".detail-info-right")!! val chapters = doc.select("ul.detail-main-list li").mapIndexedNotNull { i, el -> - val a = el.selectFirst("a") ?: return@mapIndexedNotNull null val href = a.attrAsRelativeUrl("href") - - val name = a.selectFirst("p")?.text() - ?: a.ownText() - + val name = a.selectFirst("p")?.text() ?: a.ownText() val rawDate = el.text() .let { text -> Regex("""(Today|Yesterday|\w{3}\s\d{1,2},\s\d{4}|\d{4}-\d{2}-\d{2})""") - .find(text) - ?.value + .find(text)?.value } - val parsedDate = parseChapterDate(rawDate) - MangaChapter( id = generateUid(href), title = name, @@ -146,35 +181,21 @@ internal class MangaFox(context: MangaLoaderContext): return manga.copy( title = doc.selectFirst("h1")?.text() ?: manga.title, - - authors = info.select(".detail-info-right-say a") - .mapToSet { it.text() }, - - tags = info.select(".detail-info-right-tag-list a") - .mapToSet { - MangaTag( - key = it.text().lowercase(), - title = it.text(), - source = source - ) - }, - + authors = info.select(".detail-info-right-say a").mapToSet { it.text() }, + tags = info.select(".detail-info-right-tag-list a").mapToSet { + MangaTag(key = it.text().lowercase(), title = it.text(), source = source) + }, description = info.selectFirst("p.fullcontent")?.text(), - state = parseStatus(info.selectFirst(".detail-info-right-title-tip")?.text().orEmpty()), - - coverUrl = doc.selectFirst(".detail-info-cover-img")?.attr("abs:src"), - + coverUrl = doc.selectFirst(".detail-info-cover-img")?.attr("abs:src").orEmpty(), chapters = chapters ) } - private fun parseStatus(status: String): MangaState? { - return when { - status.contains("Ongoing", true) -> MangaState.ONGOING - status.contains("Completed", true) -> MangaState.FINISHED - else -> null - } + private fun parseStatus(status: String): MangaState? = when { + status.contains("Ongoing", true) -> MangaState.ONGOING + status.contains("Completed", true) -> MangaState.FINISHED + else -> null } private fun extractChapterNumber(name: String): Float { @@ -188,70 +209,28 @@ internal class MangaFox(context: MangaLoaderContext): private fun parseChapterDate(date: String?): Long { if (date.isNullOrBlank()) return 0L - val clean = date.trim() - return when { "Today" in clean -> Calendar.getInstance().timeInMillis - - "Yesterday" in clean -> Calendar.getInstance().apply { - add(Calendar.DATE, -1) - }.timeInMillis - + "Yesterday" in clean -> Calendar.getInstance().apply { add(Calendar.DATE, -1) }.timeInMillis clean.matches(Regex("""\d{4}-\d{2}-\d{2}""")) -> runCatching { val parts = clean.split("-") - Calendar.getInstance().apply { - set(parts[0].toInt(), parts[1].toInt() - 1, parts[2].toInt()) - }.timeInMillis - }.getOrDefault(0L) - - else -> runCatching { - dateFormat.parse(clean)?.time ?: 0L + Calendar.getInstance().apply { set(parts[0].toInt(), parts[1].toInt() - 1, parts[2].toInt()) }.timeInMillis }.getOrDefault(0L) + else -> runCatching { dateFormat.parse(clean)?.time ?: 0L }.getOrDefault(0L) } } - override suspend fun getFilterOptions(): MangaListFilterOptions { - return MangaListFilterOptions( - availableTags = getTags() - ) - } - - private fun getTags(): Set { - return listOf( - "action", "adventure", "comedy", "drama", "fantasy", - "harem", "romance", "school-life", "shounen", "shoujo", - "seinen", "sci-fi", "slice-of-life", "supernatural", - "ecchi", "mystery", "psychological", "historical", - "martial-arts", "mecha", "sports" - ).map { - MangaTag( - key = it, - title = it.replace("-", " "), - source = source - ) - }.toSet() - } - - override suspend fun getRelatedManga(seed: Manga): List = emptyList() - override suspend fun getPages(chapter: MangaChapter): List { val mobilePath = chapter.url.replace("/manga/", "/roll_manga/") val url = "$mobileUrl$mobilePath" - - val doc = webClient.httpGet(url).parseHtml() - + val doc = apiClient.httpGet(url).parseHtml() return doc.select("#viewer img").map { val imgUrl = it.attr("abs:data-original") - - MangaPage( - id = generateUid(imgUrl), - url = imgUrl, - preview = null, - source = source - ) + MangaPage(id = generateUid(imgUrl), url = imgUrl, preview = null, source = source) } } override suspend fun getPageUrl(page: MangaPage): String = page.url + override suspend fun getRelatedManga(seed: Manga): List = emptyList() } diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/MangaGeko.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/MangaGeko.kt new file mode 100644 index 00000000..c99da478 --- /dev/null +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/MangaGeko.kt @@ -0,0 +1,242 @@ +package org.koitharu.kotatsu.parsers.site.kotatsu.en + +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.json.JSONObject +import org.jsoup.Jsoup +import org.jsoup.nodes.Element +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.PagedMangaParser +import org.koitharu.kotatsu.parsers.model.* +import org.koitharu.kotatsu.parsers.util.* +import java.text.SimpleDateFormat +import java.util.* + +@MangaSourceParser("MANGAKEKO", "MangaGeko", "en") +internal class MangaGeko(context: MangaLoaderContext) : + PagedMangaParser(context, MangaParserSource.MANGAKEKO, 30) { + + override val configKeyDomain = ConfigKey.Domain("www.mgeko.cc") + private val baseUrl = "https://$domain" + + override val availableSortOrders: Set = EnumSet.of( + SortOrder.NEWEST, // recently_added + SortOrder.UPDATED, // latest + SortOrder.POPULARITY, // popular_all_time + SortOrder.RATING, // rating + SortOrder.ALPHABETICAL, // az + SortOrder.ALPHABETICAL_DESC, // za + ) + + override val filterCapabilities = MangaListFilterCapabilities( + isSearchSupported = true, + isMultipleTagsSupported = true, + isTagsExclusionSupported = true, + ) + + private val dateFormats = listOf( + SimpleDateFormat("MMMM dd, yyyy, h:mm a", Locale.ENGLISH), + SimpleDateFormat("MMMM dd, yyyy, h a", Locale.ENGLISH), + ) + + private val allGenres = setOf( + "Action", "Adventure", "Comedy", "Cooking", "Manga", "Drama", "Fantasy", + "Gender bender", "Harem", "Historical", "Horror", "Isekai", "Josei", + "Manhua", "Manhwa", "Martial arts", "Mature", "Mecha", "Medical", + "Mystery", "One shot", "Psychological", "Romance", "School life", + "Sci fi", "Seinen", "Shoujo", "Shounen", "Slice of life", "Sports", + "Supernatural", "Tragedy", "Webtoons", "Ladies", + ) + + override fun onCreateConfig(keys: MutableCollection>) { + super.onCreateConfig(keys) + keys.add(userAgentKey) + } + + override suspend fun getFilterOptions(): MangaListFilterOptions { + val tags = allGenres.map { MangaTag(it, it.lowercase().replace(" ", "_"), source) }.toSet() + return MangaListFilterOptions( + availableTags = tags, + availableStates = EnumSet.of(MangaState.ONGOING, MangaState.FINISHED, MangaState.PAUSED), + availableContentTypes = EnumSet.of(ContentType.MANGA, ContentType.MANHWA, ContentType.MANHUA, ContentType.COMICS), + availableContentRating = EnumSet.of(ContentRating.SAFE, ContentRating.ADULT), + ) + } + + override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List { + if (!filter.query.isNullOrEmpty()) { + return searchManga(page, filter.query!!) + } + return browseManga(page, order, filter) + } + + private suspend fun searchManga(page: Int, query: String): List { + val url = "$baseUrl/search/".toHttpUrl().newBuilder() + .addQueryParameter("search", query) + .addQueryParameter("results", page.toString()) + .build() + val doc = webClient.httpGet(url).parseHtml() + return doc.select(".novel-item").mapNotNull { it.toManga() } + } + + private suspend fun browseManga(page: Int, order: SortOrder, filter: MangaListFilter): List { + val sort = when (order) { + SortOrder.NEWEST -> "recently_added" + SortOrder.UPDATED -> "latest" + SortOrder.POPULARITY -> "popular_all_time" + SortOrder.RATING -> "rating" + SortOrder.ALPHABETICAL -> "az" + SortOrder.ALPHABETICAL_DESC -> "za" + else -> "latest" + } + + val url = "$baseUrl/browse-comics/data/".toHttpUrl().newBuilder().apply { + addQueryParameter("page", page.toString()) + addQueryParameter("sort", sort) + + val safeMode = if (ContentRating.ADULT !in filter.contentRating) "1" else "0" + addQueryParameter("safe_mode", safeMode) + + if (filter.tags.isNotEmpty()) { + addQueryParameter("include_genres", filter.tags.joinToString(",") { it.key.replace("_", " ") }) + } + if (filter.tagsExclude.isNotEmpty()) { + addQueryParameter("exclude_genres", filter.tagsExclude.joinToString(",") { it.key.replace("_", " ") }) + } + + if (filter.states.isNotEmpty()) { + val status = when (filter.states.first()) { + MangaState.ONGOING -> "Ongoing" + MangaState.FINISHED -> "Completed" + MangaState.PAUSED -> "Hiatus" + else -> null + } + status?.let { addQueryParameter("status", it) } + } + + if (filter.types.isNotEmpty()) { + val type = when (filter.types.first()) { + ContentType.MANGA -> "Manga" + ContentType.MANHWA -> "Manhwa" + ContentType.MANHUA -> "Manhua" + ContentType.COMICS -> "Webtoon" + else -> null + } + type?.let { addQueryParameter("type", it) } + } + }.build() + + val response = webClient.httpGet(url) + val json = JSONObject(response.body.string()) + val htmlFragment = json.getString("results_html") + val doc = Jsoup.parseBodyFragment(htmlFragment, baseUrl) + return doc.select(".comic-card").map { it.toManga() } + } + + override suspend fun getDetails(manga: Manga): Manga { + val doc = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml() + val header = doc.selectFirstOrThrow(".novel-header") + + val title = header.selectFirst(".novel-title")?.text()?.trim() ?: manga.title + val cover = header.selectFirst(".cover img")?.let { + it.attr("data-src").ifBlank { it.attr("src") } + } ?: manga.coverUrl + + val author = header.selectFirst(".author a")?.attr("title")?.trim()?.takeIf { + it.lowercase() != "updating" + } + + val state = when { + header.selectFirst("div.header-stats strong.completed") != null -> MangaState.FINISHED + header.selectFirst("div.header-stats strong.ongoing") != null -> MangaState.ONGOING + else -> null + } + + val tags = doc.select(".categories a[href*=genre]").mapToSet { a -> + val name = a.ownText().trim().split(" ").joinToString(" ") { word -> + word.lowercase().replaceFirstChar { it.uppercase() } + } + MangaTag(name, name.lowercase().replace(" ", "_"), source) + } + + val chapters = fetchChapters(manga.url) + + return manga.copy( + title = title, + altTitles = emptySet(), + coverUrl = cover, + largeCoverUrl = cover, + state = state, + authors = setOfNotNull(author), + description = null, + tags = tags, + chapters = chapters, + ) + } + + private suspend fun fetchChapters(mangaUrl: String): List { + val doc = webClient.httpGet(mangaUrl.toAbsoluteUrl(domain) + "all-chapters/").parseHtml() + return doc.select("ul.chapter-list > li").mapNotNull { li -> + val a = li.selectFirst("a") ?: return@mapNotNull null + val url = a.attrAsRelativeUrl("href") + val titleEl = li.selectFirst(".chapter-title, .chapter-number") ?: return@mapNotNull null + val chapterName = titleEl.ownText() + val dateStr = li.selectFirst(".chapter-update")?.attr("datetime") + ?.replace(".", "")?.replace("Sept", "Sep") ?: "" + val date = dateFormats.firstNotNullOfOrNull { fmt -> fmt.parseSafe(dateStr) } ?: 0L + MangaChapter( + id = generateUid(url), + title = "Chapter $chapterName", + number = 0f, + volume = 0, + url = url, + uploadDate = date, + source = source, + scanlator = null, + branch = null, + ) + }.reversed() + } + + override suspend fun getPages(chapter: MangaChapter): List { + val doc = webClient.httpGet(chapter.url.toAbsoluteUrl(domain)).parseHtml() + return doc.select("#chapter-reader img").mapNotNull { img -> + val src = img.absUrl("src") + if (src.isEmpty()) return@mapNotNull null + MangaPage( + id = generateUid(src), + url = src, + preview = null, + source = source, + ) + } + } + + private fun Element.toManga(): Manga { + val titleEl = selectFirst(".comic-card__title a") ?: selectFirst(".novel-title") + val title = titleEl?.text()?.trim() ?: "" + val href = selectFirst("a")?.attrAsRelativeUrl("href") ?: "" + val cover = selectFirst(".comic-card__cover img, .novel-cover img")?.let { + it.attr("data-src").ifBlank { it.attr("src") } + } ?: "" + return Manga( + id = generateUid(href), + url = href, + publicUrl = href.toAbsoluteUrl(domain), + title = title, + altTitles = emptySet(), + rating = RATING_UNKNOWN, + contentRating = null, + coverUrl = cover, + tags = emptySet(), + state = null, + authors = emptySet(), + source = source, + ) + } + + override suspend fun getRelatedManga(seed: Manga): List = emptyList() + + private fun SimpleDateFormat.parseSafe(date: String): Long? = runCatching { parse(date)?.time }.getOrNull() +} diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/MangaK.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/MangaK.kt index e5d71fa7..508a8099 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/MangaK.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/MangaK.kt @@ -6,7 +6,9 @@ import org.json.JSONObject import org.koitharu.kotatsu.parsers.config.ConfigKey import org.koitharu.kotatsu.parsers.core.PagedMangaParser 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.Demographic import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaListFilter @@ -32,6 +34,8 @@ import org.koitharu.kotatsu.parsers.util.urlEncoded import java.text.SimpleDateFormat import java.util.EnumSet import java.util.Locale +import okhttp3.Interceptor +import okhttp3.Response @MangaSourceParser("MANGAK", "MangaK", "en", ContentType.MANGA) internal class MangaK(context: MangaLoaderContext) : @@ -48,6 +52,18 @@ internal class MangaK(context: MangaLoaderContext) : keys.add(userAgentKey) } + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + val response = chain.proceed(request) + if (!response.isSuccessful && request.url.host.matches(IMAGE_FALLBACK_REGEX)) { + response.close() + val newUrl = request.url.newBuilder().host("rx.rzyn.net").build() + return chain.proceed(request.newBuilder().url(newUrl).build()) + } + return response + } + + override val availableSortOrders: Set = EnumSet.of( SortOrder.UPDATED, SortOrder.NEWEST, @@ -65,7 +81,28 @@ internal class MangaK(context: MangaLoaderContext) : override suspend fun getFilterOptions() = MangaListFilterOptions( availableTags = fetchTags(), - availableStates = EnumSet.of(MangaState.ONGOING, MangaState.FINISHED), + availableStates = EnumSet.of( + MangaState.ONGOING, + MangaState.FINISHED, +// MangaState.PAUSED, +// MangaState.ABANDONED, + ), + availableContentTypes = EnumSet.of( + ContentType.MANGA, + ContentType.MANHWA, + ContentType.MANHUA, + ), + availableDemographics = EnumSet.of( + Demographic.SHOUJO, + Demographic.SEINEN, + Demographic.SHOUNEN, + Demographic.JOSEI, + ), + availableContentRating = EnumSet.of( + ContentRating.SAFE, + ContentRating.SUGGESTIVE, + ContentRating.ADULT, + ), ) private suspend fun fetchTags(): Set { @@ -79,7 +116,12 @@ internal class MangaK(context: MangaLoaderContext) : } } - override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List { + override suspend fun getListPage( + page: Int, + order: SortOrder, + filter: MangaListFilter, + ): List { + val allExcludedTags = filter.tagsExclude.map { it.key }.toMutableSet() val query = filter.query val url = buildString { append(apiUrl) @@ -100,27 +142,70 @@ internal class MangaK(context: MangaLoaderContext) : if (!query.isNullOrEmpty()) { append("&q=") - append(query.urlEncoded()) + // api does not like commans and character limits to 60-70 + append(query.replace(",", "").urlEncoded()) } + if (filter.tags.isNotEmpty()) { append("&genres=") append(filter.tags.joinToString(",") { it.key }) } - if (filter.tagsExclude.isNotEmpty()) { + + if (allExcludedTags.isNotEmpty()) { append("&exclude=") - append(filter.tagsExclude.joinToString(",") { it.key }) + append(allExcludedTags.joinToString(",")) } + + filter.types.oneOrThrowIfMany()?.let { + append("&type=") + append( + when (it) { + ContentType.MANGA -> "manga" + ContentType.MANHWA -> "manhwa" + ContentType.MANHUA -> "manhua" + else -> null + } ?: return@let + ) + } + + filter.contentRating.oneOrThrowIfMany()?.let { + append("&content_rating=") + append( + when (it) { + ContentRating.SAFE -> "safe" + ContentRating.SUGGESTIVE -> "suggestive" + ContentRating.ADULT -> "pornographic" + } + ) + } + + filter.demographics.oneOrThrowIfMany()?.let { + append("&demographic=") + append( + when (it) { + Demographic.SHOUJO -> "shoujo" + Demographic.SEINEN -> "seinen" + Demographic.SHOUNEN -> "shounen" + Demographic.JOSEI -> "josei" + else -> null + } ?: return@let + ) + } + filter.states.oneOrThrowIfMany()?.let { append("&status=") append( when (it) { MangaState.ONGOING -> "ongoing" MangaState.FINISHED -> "completed" - else -> "all" - }, +// MangaState.PAUSED -> "hiatus" +// MangaState.ABANDONED -> "cancelled" + else -> null + } ?: return@let ) } } + val json = webClient.httpGet(url).parseJson() return json.getJSONObject("data").getJSONArray("items").mapJSON { item -> item.toManga() @@ -132,16 +217,13 @@ internal class MangaK(context: MangaLoaderContext) : return Manga( id = generateUid(getString("id")), title = getString("name"), - altTitles = emptySet(), + altTitles = parseAltTitles(), url = getString("id"), publicUrl = "https://$domain$relativeUrl", rating = optDouble("rating", 0.0).toRating(), - contentRating = null, + contentRating = optString("content_rating").nullIfEmpty().toContentRating(), coverUrl = optString("cover").nullIfEmpty(), - tags = optJSONArray("genres")?.mapJSONNotNullToSet { genre -> - val slug = genre.optString("slug").nullIfEmpty() ?: return@mapJSONNotNullToSet null - MangaTag(genre.getString("name").toTitleCase(sourceLocale), slug, source) - }.orEmpty(), + tags = parseTags(), state = optString("status").toMangaState(), authors = emptySet(), description = optString("summary").nullIfEmpty(), @@ -149,6 +231,34 @@ internal class MangaK(context: MangaLoaderContext) : ) } + + private fun String?.toContentRating() = when (this) { + "safe" -> ContentRating.SAFE + "suggestive" -> ContentRating.SUGGESTIVE + "erotica", "pornographic" -> ContentRating.ADULT + else -> null + } + + + private fun JSONObject.parseAltTitles(): Set { + val result = mutableSetOf() + optJSONArray("alt_names")?.let { arr -> + for (i in 0 until arr.length()) { + arr.optJSONObject(i)?.optString("name")?.nullIfEmpty()?.let { result.add(it) } + } + } + optString("alt_name").nullIfEmpty()?.let { result.add(it) } + return result + } + + + private fun JSONObject.parseTags(): Set = + optJSONArray("genres")?.mapJSONNotNullToSet { genre -> + val slug = genre.optString("slug").nullIfEmpty() ?: return@mapJSONNotNullToSet null + MangaTag(genre.getString("name").toTitleCase(sourceLocale), slug, source) + }.orEmpty() + + override suspend fun getDetails(manga: Manga): Manga { val json = webClient.httpGet("$apiUrl/titles/${manga.url}").parseJson() .getJSONObject("data").getJSONObject("title") @@ -159,18 +269,11 @@ internal class MangaK(context: MangaLoaderContext) : val artists = json.optJSONArray("artists")?.mapJSONNotNullToSet { it.optString("name").nullIfEmpty() }.orEmpty() - val tags = json.optJSONArray("genres")?.mapJSONNotNullToSet { genre -> - val slug = genre.optString("slug").nullIfEmpty() ?: return@mapJSONNotNullToSet null - MangaTag(genre.getString("name").toTitleCase(sourceLocale), slug, source) - }.orEmpty() - val altTitles = json.optString("alt_name").split(";") - .mapNotNull { it.trim().nullIfEmpty() } - .toSet() return manga.copy( title = json.optString("name").nullIfEmpty() ?: manga.title, - altTitles = altTitles, + altTitles = json.parseAltTitles().ifEmpty { manga.altTitles }, description = json.optString("summary").nullIfEmpty() ?: manga.description, - tags = tags.ifEmpty { manga.tags }, + tags = json.parseTags().ifEmpty { manga.tags }, authors = authors + artists, state = json.optString("status").toMangaState() ?: manga.state, rating = json.optDouble("rating", 0.0).toRating(), @@ -178,6 +281,7 @@ internal class MangaK(context: MangaLoaderContext) : ) } + private suspend fun fetchChapters(id: String, cv: Long): List { val json = webClient.httpGet("$apiUrl/titles/$id/chapters?cv=$cv").parseJson() val chapters = json.getJSONObject("data").getJSONArray("chapters").mapJSON { it } @@ -198,6 +302,7 @@ internal class MangaK(context: MangaLoaderContext) : }.reversed() } + override suspend fun getPages(chapter: MangaChapter): List { val doc = webClient.httpGet("https://$domain${chapter.url}").parseHtml() val raw = doc.selectFirst("script#__NEXT_DATA__")?.data() @@ -218,11 +323,18 @@ internal class MangaK(context: MangaLoaderContext) : } } - private fun Double.toRating(): Float = if (this > 0.0) (this / 5.0).toFloat() else RATING_UNKNOWN - private fun String?.toMangaState(): MangaState? = when (this?.lowercase(Locale.US)) { + private fun Double.toRating() = if (this > 0.0) (this / 5.0).toFloat() else RATING_UNKNOWN + + private fun String?.toMangaState() = when (this?.lowercase(Locale.US)) { "ongoing" -> MangaState.ONGOING "completed" -> MangaState.FINISHED + "hiatus" -> MangaState.PAUSED + "cancelled" -> MangaState.ABANDONED else -> null } -} \ No newline at end of file + + companion object { + private val IMAGE_FALLBACK_REGEX = Regex("rx\\.qvzr[a-z]\\.org") + } +} diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/MangaKatana.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/MangaKatana.kt index 9293c4c5..a4b9072b 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/MangaKatana.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/MangaKatana.kt @@ -8,7 +8,8 @@ import org.koitharu.kotatsu.parsers.core.PagedMangaParser import org.koitharu.kotatsu.parsers.model.* import org.koitharu.kotatsu.parsers.util.* import java.text.SimpleDateFormat -import java.util.* +import java.util.EnumSet +import java.util.Locale private const val two = "?sv=mk" private const val three = "?sv=3" @@ -107,15 +108,18 @@ internal class MangaKatana(context: MangaLoaderContext): order: SortOrder, filter: MangaListFilter, ): String { - val query = filter.query?.trim().orEmpty() val tags = filter.tags.map { it.key }.filter { it.isNotBlank() } - val status = filter.states.firstOrNull() return when { - - query.isNotEmpty() -> "$baseUrl/page/$page?search=${query.urlEncoded()}&search_by=m_name" + query.isNotEmpty() -> { + if (page == 1) { + "$baseUrl/?search=${query.urlEncoded()}&search_by=m_name" + } else { + "$baseUrl/page/$page?search=${query.urlEncoded()}&search_by=m_name" + } + } status != null -> { "$baseUrl/genres/?" + @@ -387,6 +391,6 @@ internal class MangaKatana(context: MangaLoaderContext): } .toList() - return if (pages.isEmpty()) null else pages + return pages.ifEmpty { null } } } diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/MangaTown.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/MangaTown.kt new file mode 100644 index 00000000..2ecac3ad --- /dev/null +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/MangaTown.kt @@ -0,0 +1,375 @@ +package org.koitharu.kotatsu.parsers.site.kotatsu.en + +import okhttp3.Headers +import okhttp3.HttpUrl.Companion.toHttpUrl +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.PagedMangaParser +import org.koitharu.kotatsu.parsers.model.* +import org.koitharu.kotatsu.parsers.util.* +import java.text.SimpleDateFormat +import java.util.* + +@MangaSourceParser("MANGATOWN", "MangaTown", "en") +internal class MangaTown(context: MangaLoaderContext) : + PagedMangaParser(context, MangaParserSource.MANGATOWN, 30) { + + override val configKeyDomain = ConfigKey.Domain("www.mangatown.com") + + override fun onCreateConfig(keys: MutableCollection>) { + super.onCreateConfig(keys) + keys.add(userAgentKey) + } + + override fun getRequestHeaders(): Headers = Headers.Builder() + .add("Referer", "https://$domain/") + .build() + + override val availableSortOrders: Set = EnumSet.of( + SortOrder.ALPHABETICAL, + SortOrder.RATING, + SortOrder.POPULARITY, + SortOrder.UPDATED, + ) + + override val filterCapabilities: MangaListFilterCapabilities + get() = MangaListFilterCapabilities( + isSearchSupported = true, + isSearchWithFiltersSupported = true, + isMultipleTagsSupported = true, + isTagsExclusionSupported = true, + isYearSupported = false, + ) + + + @Volatile + private var allTagsCache: List? = null + + private suspend fun getAllTags(): List { + if (allTagsCache == null) { + allTagsCache = fetchAvailableTags().toList() + } + return allTagsCache!! + } + + override suspend fun getFilterOptions() = MangaListFilterOptions( + availableTags = getAllTags().toSet(), + availableStates = EnumSet.of( + MangaState.ONGOING, + MangaState.FINISHED, + ), + availableDemographics = EnumSet.of( + Demographic.SHOUJO, + Demographic.SEINEN, + Demographic.SHOUNEN, + Demographic.JOSEI, + ), + availableContentTypes = EnumSet.of( + ContentType.MANGA, + ContentType.MANHUA, + ContentType.MANHWA, + ), + ) + + + override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List { + val query = filter.query?.trim() + val hasFilters = filter.types.isNotEmpty() || filter.demographics.isNotEmpty() || + filter.tags.isNotEmpty() || filter.tagsExclude.isNotEmpty() || + filter.states.isNotEmpty() + + val url = when { + hasFilters -> { + val builder = "https://$domain/search".toHttpUrl().newBuilder() + .addQueryParameter("name_method", "cw") + .addQueryParameter("name", query ?: "") + .addQueryParameter("author_method", "cw") + .addQueryParameter("author", "") + .addQueryParameter("artist_method", "cw") + .addQueryParameter("artist", "") + + builder.addQueryParameter("type", + if (filter.types.size == 1) when (filter.types.first()) { + ContentType.MANGA -> "manga" + ContentType.MANHWA -> "manhwa" + ContentType.MANHUA -> "manhua" + else -> "" + } else "" + ) + + builder.addQueryParameter("demographic", + if (filter.demographics.size == 1) when (filter.demographics.first()) { + Demographic.SEINEN -> "SEINEN" + Demographic.SHOUJO -> "SHOUJO" + Demographic.SHOUNEN -> "SHOUNEN" + Demographic.JOSEI -> "JOSEI" + else -> "" + } else "" + ) + + if (filter.states.size == 1) { + when (filter.states.first()) { + MangaState.FINISHED -> builder.addQueryParameter("is_completed", "1") + MangaState.ONGOING -> builder.addQueryParameter("is_completed", "0") + else -> builder.addQueryParameter("is_completed", "") + } + } else { + builder.addQueryParameter("is_completed", "") + } + + val allTags = getAllTags() + val includedKeys = filter.tags.map { it.key }.toSet() + val excludedKeys = filter.tagsExclude.map { it.key }.toSet() + for (tag in allTags) { + val value = when (tag.key) { + in includedKeys -> "1" + in excludedKeys -> "2" + else -> "0" + } + builder.addQueryParameter("genres[${tag.title}]", value) + } + + builder.addQueryParameter("released_method", "eq") + builder.addQueryParameter("released", "") + builder.addQueryParameter("rating_method", "eq") + builder.addQueryParameter("rating", "") + builder.addQueryParameter("advopts", "1") + builder.addQueryParameter("page", page.toString()) + + builder.build().toString() + } + !query.isNullOrEmpty() -> { + // Simple search + "https://$domain/search?page=$page&name=${query.urlEncoded()}" + } + order == SortOrder.UPDATED -> { + "https://$domain/latest/$page.htm" + } + else -> { + // Directory browse + buildString { + append("https://$domain/directory/") + append("0-") + append( + if (filter.tags.isNotEmpty()) filter.tags.oneOrThrowIfMany()?.key ?: "0" + else "0" + ) + append("-0-") + append( + if (filter.states.isNotEmpty()) { + filter.states.oneOrThrowIfMany()?.let { + when (it) { + MangaState.ONGOING -> "ongoing" + MangaState.FINISHED -> "completed" + else -> "0" + } + } ?: "0" + } else "0" + ) + append("-0-0/") + append(page).append(".htm") + append( + when (order) { + SortOrder.POPULARITY -> "" + SortOrder.ALPHABETICAL -> "?name.az" + SortOrder.RATING -> "?rating.za" + else -> "?last_chapter_time.za" + } + ) + } + } + } + + val doc = webClient.httpGet(url, getRequestHeaders()).parseHtml() + val root = doc.body().selectFirst("ul.manga_pic_list") ?: return emptyList() + val manga = root.select("li") + if (manga.isEmpty()) return emptyList() + + return manga.mapNotNull { li -> + val a = li.selectFirst("a.manga_cover") ?: return@mapNotNull null + val href = a.attrAsRelativeUrl("href") + val views = li.select("p.view") + val status = views.firstNotNullOfOrNull { it.ownText().takeIf { x -> x.startsWith("Status:") } } + ?.substringAfter(':')?.trim()?.lowercase(Locale.ROOT) + val author = views.firstNotNullOfOrNull { it.text().takeIf { x -> x.startsWith("Author:") } } + ?.substringAfter(':') + ?.trim() + Manga( + id = generateUid(href), + title = a.attr("title"), + coverUrl = a.selectFirst("img")?.attrAsAbsoluteUrlOrNull("src"), + source = source, + altTitles = emptySet(), + rating = li.selectFirst("p.score")?.selectFirst("b") + ?.ownText()?.toFloatOrNull()?.div(5f) ?: RATING_UNKNOWN, + authors = setOfNotNull(author), + state = when (status) { + "ongoing" -> MangaState.ONGOING + "completed" -> MangaState.FINISHED + else -> null + }, + tags = li.selectFirst("p.keyWord")?.select("a")?.mapToSet tags@{ x -> + MangaTag( + title = x.attr("title").toTitleCase(), + key = x.attr("href").substringAfter("/directory/0-").substringBefore("-0-"), + source = source, + ) + }.orEmpty(), + url = href, + contentRating = null, + publicUrl = href.toAbsoluteUrl(a.host ?: domain), + ) + } + } + + + override suspend fun getDetails(manga: Manga): Manga { + val doc = webClient.httpGet(manga.url.toAbsoluteUrl(domain), getRequestHeaders()).parseHtml() + val root = doc.body().selectFirst("section.main") + ?.selectFirst("div.article_content") ?: return manga + val info = root.selectFirst("div.detail_info")?.selectFirst("ul") + val chaptersList = root.selectFirst("div.chapter_content") + ?.selectFirst("ul.chapter_list")?.select("li")?.asReversed() + val dateFormat = SimpleDateFormat("MMM dd,yyyy", Locale.US) + + if (root.select("div.chapter_content:contains(has been licensed)").isNotEmpty()) { + return manga.copy(state = MangaState.ABANDONED, chapters = bypassLicensedChapters(manga)) + } + + return manga.copy( + tags = manga.tags + (info?.select("li")?.find { x -> + x.selectFirst("b")?.ownText() == "Genre(s):" + }?.select("a")?.mapNotNull { a -> + MangaTag( + title = a.attr("title").toTitleCase(), + key = a.attr("href").substringAfter("/directory/0-").substringBefore("-0-"), + source = source, + ) + }.orEmpty()), + description = info?.getElementById("show")?.ownText(), + chapters = chaptersList?.mapChapters { i, li -> + val linkEl = li.selectFirst("a") ?: return@mapChapters null + val href = linkEl.attrAsRelativeUrl("href") + val name = buildString { + append(linkEl.text()) + li.select("span").filter { span -> + !span.hasClass("time") && !span.hasClass("new") + }.forEach { span -> + if (isNotEmpty()) append(" ") + append(span.text()) + } + } + MangaChapter( + id = generateUid(href), + url = href, + source = source, + number = i + 1f, + volume = 0, + uploadDate = parseChapterDate( + dateFormat, + li.selectFirst("span.time")?.text(), + ), + title = name.nullIfEmpty(), + scanlator = null, + branch = null, + ) + } ?: emptyList(), + ) + } + + + private suspend fun bypassLicensedChapters(manga: Manga): List { + val subdomain = "m." + domain.removePrefix("www.") + val doc = webClient.httpGet(manga.url.toAbsoluteUrl(subdomain), getRequestHeaders()).parseHtml() + val list = doc.body().selectFirst("ul.detail-ch-list") ?: return emptyList() + val dateFormat = SimpleDateFormat("MMM dd,yyyy", Locale.US) + return list.select("li").asReversed().mapIndexedNotNull { i, li -> + val a = li.selectFirst("a") ?: return@mapIndexedNotNull null + val href = a.attrAsRelativeUrl("href") + val name = a.selectFirst("span.vol")?.text().orEmpty().ifEmpty { a.ownText() } + MangaChapter( + id = generateUid(href), + url = href, + source = source, + number = i + 1f, + volume = 0, + uploadDate = parseChapterDate( + dateFormat, + li.selectFirst("span.time")?.text(), + ), + title = name.nullIfEmpty(), + scanlator = null, + branch = null, + ) + } + } + + + private fun parseChapterDate(dateFormat: SimpleDateFormat, date: String?): Long { + return when { + date.isNullOrEmpty() -> 0L + date.contains("Today") -> Calendar.getInstance().timeInMillis + date.contains("Yesterday") -> Calendar.getInstance().apply { add(Calendar.DAY_OF_MONTH, -1) }.timeInMillis + else -> dateFormat.parseSafe(date) + } + } + + + private suspend fun fetchAvailableTags(): Set { + val doc = webClient.httpGet("/directory/".toAbsoluteUrl(domain), getRequestHeaders()).parseHtml() + val root = doc.body().selectFirst("aside.right") + ?.getElementsContainingOwnText("Genres") + ?.first() + ?.nextElementSibling() ?: doc.parseFailed("Root not found") + return root.select("li").mapNotNullToSet { li -> + val a = li.selectFirst("a") ?: return@mapNotNullToSet null + val key = a.attr("href").substringAfter("/directory/0-").substringBefore("-0-") + MangaTag( + source = source, + key = key, + title = a.text().toTitleCase(), + ) + } + } + + + override suspend fun getPages(chapter: MangaChapter): List { + val fullUrl = chapter.url.toAbsoluteUrl(domain) + val doc = webClient.httpGet(fullUrl, getRequestHeaders()).parseHtml() + val pageSelect = doc.body().selectFirst("div.page_select select") ?: return emptyList() + val options = pageSelect.select("option").filterNot { it.attr("value").endsWith("featured.html") } + if (options.isNotEmpty()) { + return options.mapIndexed { _, option -> + val href = option.attrAsRelativeUrl("value") + MangaPage( + id = generateUid(href), + url = href, + preview = null, + source = source, + ) + } + } + val imgElements = doc.select("div#viewer img") + return imgElements.map { img -> + val src = img.attrAsAbsoluteUrl("src") + MangaPage( + id = generateUid(src), + url = src, + preview = null, + source = source, + ) + } + } + + + override suspend fun getPageUrl(page: MangaPage): String { + if (page.url.startsWith("http")) return page.url + val doc = webClient.httpGet(page.url.toAbsoluteUrl(domain), getRequestHeaders()).parseHtml() + return doc.selectFirst("div#viewer img")?.attrAsAbsoluteUrl("src") + ?: throw Exception("Could not find image") + } + + + private fun String.nullIfEmpty(): String? = ifEmpty { null } +} diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Mangadotnet.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Mangadotnet.kt index 592dd4ce..1e74f249 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Mangadotnet.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Mangadotnet.kt @@ -17,77 +17,72 @@ internal class Mangadotnet(context: MangaLoaderContext) : PagedMangaParser(context, MangaParserSource.MANGADOTNET, 20) { override val configKeyDomain = ConfigKey.Domain("mangadot.net") - private val baseUrl = "https://mangadot.net" - - override val filterCapabilities: MangaListFilterCapabilities - get() = MangaListFilterCapabilities( - isSearchSupported = true, - ) - - override val availableSortOrders: Set = EnumSet.of( - SortOrder.UPDATED, - SortOrder.POPULARITY, - SortOrder.NEWEST, - SortOrder.ALPHABETICAL, - ) - - override suspend fun getFilterOptions() = MangaListFilterOptions( - availableTags = cachedGenres.mapTo(LinkedHashSet(cachedGenres.size)) { MangaTag(key = it, title = it, source = source) }, - ) - - private var cachedGenres: List = emptyList() - + private val apiBase = "$baseUrl/api" private val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.ROOT).apply { timeZone = TimeZone.getTimeZone("UTC") } - // region RSC decoder + private val showAdultKey = ConfigKey.ShowSuspiciousContent(false) + + private val genreNames = setOf( + "Josei", "Seinen", "Shoujo", "Shounen", + "Action", "Adventure", "Comedy", "Drama", "Fantasy", + "Historical", "Horror", "Mecha", "Mystery", "Psychological", + "Romance", "Sci-Fi", "Slice of Life", "Sports", "Supernatural", "Thriller", "Tragedy", + "Cooking", "Demons", "Ecchi", "Harem", "Isekai", "Magic", "Martial Arts", + "Medical", "Military", "Music", "School Life", "Webtoon", + "Academy", "Acting", "Adult", "Aliens", "Animals", "Anthology", "Apocalypse", + "Avant Garde", "Award Winning", "BDSM", "Boys Love", "Bully", "Business", + "Child Abuse", "Child Neglect", "Comic", "Crime", "crossdressing", "Crossdressing", + "Cultivation", "Delinquents", "Difficult Childhood", "dojinshi", "Doujinshi", + "Erotica", "Female Protagonist", "Femdom", "Fight", "futanari on male", "futunari", + "Gender Bender", "Genderswap", "Ghosts", "Girls Love", "Gore", "Gourmet", + "Gyaru", "Hentai", "Hunters", "Idol", "Incest", "Loli", "Lolicon", "Mafia", + "Magical Girls", "Mahou Shoujo", "Manga", "Manhua", "Manhwa", "Mature", + "Medieval Area", "Monster Girls", "Monsters", "Ninja", "Nobility", + "Office Romance", "Office Worker", "Office Workers", "One Shot", "Otome", + "Overpowered", "Philosophical", "playboy", "Police", "Post-Apocalyptic", + "Reincarnation", "Revenge", "Reverse Harem", "Royalty", "Samurai", "School", + "Seinin", "Shota", "Shotacon", "Shoujo Ai", "Shounen Ai", "Smut", "Superhero", + "Survival", "Suspense", "System", "Time Travel", "Traditional Games", "uncensored", + "Vampires", "Video Games", "Villainess", "Virtual Reality", "War", "Workplace", + "Wuxia", "Yaoi", "Yuri", "Zombies" + ) + + override fun onCreateConfig(keys: MutableCollection>) { + super.onCreateConfig(keys) + keys.add(userAgentKey) + keys.add(showAdultKey) + } private fun decodeRsc(flat: JSONArray): Any? { val cache = arrayOfNulls(flat.length()) - val nil = Object() - + val nil = Any() fun resolve(i: Int): Any? { if (i < 0 || i >= flat.length()) return null val cached = cache[i] if (cached != null) return if (cached === nil) null else cached - val result: Any? = when (val el = flat.opt(i)) { JSONObject.NULL -> null is String, is Number, is Boolean -> el - is JSONArray -> { - (0 until el.length()).mapTo(mutableListOf()) { j -> - resolve(el.optInt(j, -1)) - } - } + is JSONArray -> (0 until el.length()).mapTo(mutableListOf()) { j -> resolve(el.optInt(j, -1)) } is JSONObject -> { val map = mutableMapOf() for (key in el.keys()) { - val actualKey = if (key.startsWith("_")) { - flat.optString(key.substring(1).toInt(), key) - } else { - key - } + val actualKey = if (key.startsWith("_")) flat.optString(key.removePrefix("_").toInt(), key) else key map[actualKey] = resolve(el.optInt(key, -1)) } map } else -> null } - cache[i] = result ?: nil return result } - return resolve(0) } - /** - * Fetch an RSC-encoded URL and extract the given route. - * Route values are wrapped in `{"data": ...}` (Data). - * Returns the inner `data` value of the route. - */ @Suppress("UNCHECKED_CAST") private suspend fun fetchRscData(url: String, route: String): Map? { val flat = webClient.httpGet(url).parseJsonArray() @@ -96,101 +91,118 @@ internal class Mangadotnet(context: MangaLoaderContext) : return routeValue.asMap("data") } - // endregion + override val filterCapabilities = MangaListFilterCapabilities( + isSearchSupported = true, + isMultipleTagsSupported = true, + isTagsExclusionSupported = true, + isSearchWithFiltersSupported = true, + ) - // region Listing + override val availableSortOrders: Set = EnumSet.of( + SortOrder.UPDATED, // latest + SortOrder.POPULARITY, // most viewed + SortOrder.RATING, // top rated + SortOrder.ALPHABETICAL, // a-z + SortOrder.RELEVANCE, // most tracked + ) - override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List { - val query = filter.query - if (!query.isNullOrEmpty()) { - return getSearchPage(page, query, order) - } - return when (order) { - SortOrder.POPULARITY -> getPopularPage(page) - else -> getLatestPage(page) - } + override suspend fun getFilterOptions(): MangaListFilterOptions { + val tags = genreNames.map { name -> MangaTag(name, name, source) }.toSet() + return MangaListFilterOptions( + availableTags = tags, + availableStates = EnumSet.of(MangaState.ONGOING, MangaState.FINISHED, MangaState.PAUSED), + availableContentTypes = EnumSet.of(ContentType.MANGA, ContentType.MANHWA, ContentType.MANHUA, ContentType.OTHER), + availableDemographics = EnumSet.of(Demographic.JOSEI, Demographic.SEINEN, Demographic.SHOUJO, Demographic.SHOUNEN), + ) } - private suspend fun getPopularPage(page: Int): List { - val url = "$baseUrl/view-all/trending.data".toHttpUrl().newBuilder().apply { - if (page > 1) addQueryParameter("page", page.toString()) - addQueryParameter("_routes", "pages/ViewAllPage") - }.build().toString() - return parseViewAllPage(url) + override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List { + if (!filter.query.isNullOrBlank() || filter.tags.isNotEmpty() || filter.tagsExclude.isNotEmpty() || + filter.states.isNotEmpty() || filter.types.isNotEmpty() || filter.demographics.isNotEmpty()) { + return getSearchPage(page, order, filter) + } + return getBrowsePage(page, order) } - private suspend fun getLatestPage(page: Int): List { + private suspend fun getBrowsePage(page: Int, order: SortOrder): List { + val sortParam = when (order) { + SortOrder.POPULARITY -> "views" + SortOrder.RATING -> "rating" + SortOrder.ALPHABETICAL -> "alphabetical" + SortOrder.RELEVANCE -> "tracked" + else -> null + } + val adultParam = if (config[showAdultKey]) "both" else "0" val url = "$baseUrl/view-all/latest-updates.data".toHttpUrl().newBuilder().apply { + addQueryParameter("adult", adultParam) if (page > 1) addQueryParameter("page", page.toString()) + sortParam?.let { addQueryParameter("sortBy", it) } addQueryParameter("_routes", "pages/ViewAllPage") }.build().toString() return parseViewAllPage(url) } - /** - * ViewAllPage route structure: - * route_value = { "data": ViewAllData } - * ViewAllData = { "data": MangaList, "allGenres": [...] } - * MangaList = { "manga_list"|"results": [...], "pagination": {...} } - * - * After fetchRscData: we get ViewAllData. - */ - @Suppress("UNCHECKED_CAST") - private suspend fun parseViewAllPage(url: String): List { - val viewAllData = fetchRscData(url, "pages/ViewAllPage") ?: return emptyList() + private suspend fun getSearchPage(page: Int, order: SortOrder, filter: MangaListFilter): List { + val adultParam = if (config[showAdultKey]) "both" else "0" + val url = "$baseUrl/search.data".toHttpUrl().newBuilder().apply { + addQueryParameter("adult", adultParam) + if (!filter.query.isNullOrBlank()) addQueryParameter("search", filter.query!!) + addQueryParameter("page", page.toString()) + addQueryParameter("perPage", "56") + + val (sortBy, sortOrder) = when (order) { + SortOrder.UPDATED -> "latest" to "desc" + SortOrder.ALPHABETICAL -> "alphabetical" to "asc" + SortOrder.POPULARITY -> "views" to "desc" + SortOrder.RATING -> "rating" to "desc" + else -> "relevance" to "desc" + } + addQueryParameter("sortBy", sortBy) + addQueryParameter("sortOrder", sortOrder) - // Update genres - (viewAllData["allGenres"] as? List<*>)?.filterIsInstance()?.let { - if (it.isNotEmpty()) cachedGenres = it - } + if (filter.states.size == 1) { + addQueryParameter("status", filter.states.first().toApiStatus()) + } - // Navigate to MangaList: viewAllData.data - val mangaListMap = viewAllData.asMap("data") ?: return emptyList() - return parseMangaList(mangaListMap) - } + filter.types.forEach { ct -> + when (ct) { + ContentType.MANGA -> addQueryParameter("origin", "JP") + ContentType.MANHWA -> addQueryParameter("origin", "KR") + ContentType.MANHUA -> addQueryParameter("origin", "CN") + ContentType.OTHER -> addQueryParameter("origin", "ONESHOT") + else -> {} + } + } + + filter.demographics.forEach { demographic -> + addQueryParameter("genre", demographic.name.lowercase().replaceFirstChar { it.uppercase() }) + } + + filter.tags.forEach { tag -> addQueryParameter("genre", tag.key) } + filter.tagsExclude.forEach { tag -> addQueryParameter("genre", "-${tag.key}") } - private suspend fun getSearchPage(page: Int, query: String, order: SortOrder): List { - val url = "$baseUrl/search.data".toHttpUrl().newBuilder().apply { - addQueryParameter("search", query) - addQueryParameter("page", page.toString()) - addQueryParameter("sortBy", when (order) { - SortOrder.NEWEST -> "latest" - SortOrder.ALPHABETICAL -> "Alphabetical" - else -> "relevance" - }) addQueryParameter("_routes", "pages/SearchPage") }.build().toString() - /** - * SearchPage route structure: - * route_value = { "data": MangaList } - * MangaList = { "manga_list"|"results": [...], "pagination": {...}, "allGenres": [...] } - * - * After fetchRscData: we get MangaList directly. - */ - @Suppress("UNCHECKED_CAST") - val mangaListMap = fetchRscData(url, "pages/SearchPage") ?: return emptyList() - - (mangaListMap["allGenres"] as? List<*>)?.filterIsInstance()?.let { - if (it.isNotEmpty()) cachedGenres = it - } + val dataMap = fetchRscData(url, "pages/SearchPage") ?: return emptyList() + return parseMangaList(dataMap) + } + private suspend fun parseViewAllPage(url: String): List { + val viewAllData = fetchRscData(url, "pages/ViewAllPage") ?: return emptyList() + val mangaListMap = viewAllData.asMap("data") ?: return emptyList() return parseMangaList(mangaListMap) } - @Suppress("UNCHECKED_CAST") - private fun parseMangaList(mangaListMap: Map): List { - val mangaList = mangaListMap["manga_list"] as? List<*> - ?: mangaListMap["results"] as? List<*> - ?: return emptyList() - return mangaList.filterIsInstance>().map { parseMangaFromList(it) } + private fun parseMangaList(data: Map): List { + val list = data["manga_list"] as? List<*> ?: data["results"] as? List<*> ?: return emptyList() + return list.filterIsInstance>().map { parseMangaFromList(it) } } private fun parseMangaFromList(data: Map): Manga { val id = (data["id"] as? Number)?.toInt()?.toString() ?: "" val title = data["title"] as? String ?: "" val photo = data["photo"] as? String - val coverUrl = photo?.let { when { it.startsWith("/") -> "$baseUrl$it" @@ -198,7 +210,6 @@ internal class Mangadotnet(context: MangaLoaderContext) : else -> null } } - return Manga( id = generateUid(id), url = id, @@ -215,26 +226,19 @@ internal class Mangadotnet(context: MangaLoaderContext) : ) } - // endregion - - // region Details + @get:Synchronized + private val detailsCache = object : LinkedHashMap(16, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean = size > 10 + } - /** - * MangaDetailPage route structure: - * route_value = { "data": MangaData } - * MangaData = { "mangaData": { "manga": Manga } } - * - * After fetchRscData: we get MangaData. - */ @Suppress("UNCHECKED_CAST") override suspend fun getDetails(manga: Manga): Manga { + detailsCache[manga.url]?.let { return it } + val url = "$baseUrl/manga/${manga.url}.data?_routes=pages/MangaDetailPage" val mangaData = fetchRscData(url, "pages/MangaDetailPage") ?: return manga - val mangaInfo = mangaData - .asMap("mangaData")?.asMap("manga") - ?: return manga - + val mangaInfo = mangaData.asMap("mangaData")?.asMap("manga") ?: return manga val title = mangaInfo["title"] as? String ?: manga.title val description = mangaInfo["description"] as? String val photo = mangaInfo["photo"] as? String @@ -245,174 +249,182 @@ internal class Mangadotnet(context: MangaLoaderContext) : else -> null } } ?: manga.coverUrl - val hiatus = mangaInfo["hiatus"] as? String - val status = mangaInfo["status"] as? String - val genres = (mangaInfo["genres"] as? List<*>)?.filterIsInstance() - ?.mapNotNull { it.trim().ifBlank { null } }.orEmpty() - val altTitles = (mangaInfo["alt_titles"] as? List<*>)?.filterIsInstance() - ?.mapNotNull { it.trim().ifBlank { null } }?.toSet().orEmpty() + val bannerImage = mangaInfo["banner_image"] as? String + val largeCoverUrl = bannerImage?.let { + when { + it.startsWith("/") -> "$baseUrl$it" + it.startsWith("http") -> it + else -> null + } + } + + val genres = (mangaInfo["genres"] as? List<*>)?.filterIsInstance()?.mapNotNull { it.trim().ifBlank { null } }.orEmpty() + val altTitles = (mangaInfo["alt_titles"] as? List<*>)?.filterIsInstance()?.mapNotNull { it.trim().ifBlank { null } }?.toSet().orEmpty() val origin = mangaInfo["country_of_origin"] as? String - val ratingValue = (mangaInfo["avg_rating"] as? Number)?.toFloat() - val anilistId = (mangaInfo["anilist_id"] as? Number)?.toLong() - val sourceUrl = mangaInfo["source_url"] as? String - val author = mangaInfo["author"] as? String + val authorRaw = mangaInfo["authors"] as? String + val author = parseJsonArrayString(authorRaw)?.joinToString(", ") ?: authorRaw + val statusText = mangaInfo["status_text"] as? String + val status = mangaInfo["status"] as? String val state = when { - "One Shot" in genres -> MangaState.FINISHED - hiatus.equals("Yes", ignoreCase = true) -> MangaState.PAUSED - else -> when (status?.lowercase()) { + !statusText.isNullOrBlank() -> when (statusText.lowercase()) { "ongoing" -> MangaState.ONGOING "completed" -> MangaState.FINISHED + "hiatus" -> MangaState.PAUSED + else -> null + } + !status.isNullOrBlank() -> when (status.lowercase()) { + "ongoing" -> MangaState.ONGOING + "completed" -> MangaState.FINISHED + "hiatus" -> MangaState.PAUSED else -> null } - } - - val tags = genres.mapTo(LinkedHashSet(genres.size)) { MangaTag(key = it, title = it, source = source) } - val contentType = when (origin) { - "JP" -> "Manga" - "KR" -> "Manhwa" - "CN" -> "Manhua" else -> null } - val contentTag = contentType?.let { MangaTag(key = it, title = it, source = source) } - val allTags = if (contentTag != null && contentTag !in tags) tags + contentTag else tags + val tagSet = genres.mapTo(LinkedHashSet(genres.size)) { MangaTag(key = it, title = it, source = source) } + when (origin) { + "JP" -> tagSet.add(MangaTag("Manga", "Manga", source)) + "KR" -> tagSet.add(MangaTag("Manhwa", "Manhwa", source)) + "CN" -> tagSet.add(MangaTag("Manhua", "Manhua", source)) + } - val richDescription = buildString { - ratingValue?.let { rating -> - val stars = (rating / 2).toInt().coerceIn(0, 5) - append("${"★".repeat(stars)}${"☆".repeat(5 - stars)} $rating\n\n") - } - description?.let { - append( - it.replace("\r\n", "\n") - .replace(Regex("\n{3,}"), "\n\n") - .trim(), - "\n\n", - ) - } - val links = buildList { - anilistId?.let { add("[AniList](https://anilist.co/manga/$it)") } - sourceUrl?.let { add("[Source]($it)") } - } - if (links.isNotEmpty()) { - append("\nLinks:\n") - links.forEach { append("- ", it, "\n") } - } - if (altTitles.isNotEmpty()) { - append("\nAlternative Names:\n") - altTitles.forEach { append("- ", it, "\n") } - } - }.trim() + val tagsArray = mangaInfo["tags"] as? List> + tagsArray?.flatMap { category -> + (category["tags"] as? List> ?: emptyList()) + .mapNotNull { it["name"] as? String } + }?.distinct()?.take(20)?.forEach { tagSet.add(MangaTag(it, it, source)) } + + val rawRating = (mangaInfo["avg_rating"] as? Number)?.toDouble() ?: -1.0 + val rating = if (rawRating >= 0.0) (rawRating / 10.0).toFloat() else RATING_UNKNOWN val chapters = fetchChapters(manga.url) - return manga.copy( + val result = manga.copy( title = title, coverUrl = coverUrl, - description = richDescription, + largeCoverUrl = largeCoverUrl, + rating = rating, + description = description, altTitles = altTitles, - tags = allTags, + tags = tagSet, state = state, authors = setOfNotNull(author).filterTo(mutableSetOf()) { it.isNotBlank() }, chapters = chapters, ) - } - // endregion + detailsCache[manga.url] = result + return result + } - // region Chapters + private fun parseJsonArrayString(raw: String?): List? { + if (raw.isNullOrBlank()) return null + return try { + val arr = JSONArray(raw) + (0 until arr.length()).mapNotNull { arr.optString(it) } + } catch (_: Exception) { null } + } - @Suppress("UNCHECKED_CAST") private suspend fun fetchChapters(mangaId: String): List { - val response = webClient.httpGet("$baseUrl/api/manga/$mangaId/chapters/list?lang=en").parseJsonArray() - - val allChapters = (0 until response.length()).map { i -> - response.getJSONObject(i) - } + val response = webClient.httpGet("$apiBase/manga/$mangaId/chapters/list?lang=en").parseJsonArray() + val allChapters = (0 until response.length()).map { response.getJSONObject(it) } val chaptersByTeam = mutableMapOf>() - for (chapter in allChapters) { - val group = chapter.optString("group_name", "").nullIfEmpty() - val scanlator = chapter.optString("scanlator_name", "").nullIfEmpty() - val teamName = group ?: scanlator ?: "Unknown" - chaptersByTeam.getOrPut(teamName) { mutableListOf() }.add(chapter) + for (ch in allChapters) { + val team = listOf( + ch.optString("group_name", ""), + ch.optString("scanlator_name", "") + ).firstOrNull { it.isNotBlank() && !it.equals("null", ignoreCase = true) } + ?: "Unknown" + chaptersByTeam.getOrPut(team) { mutableListOf() }.add(ch) } - val allChapterNumbers = allChapters.map { it.optDouble("chapter_number", 0.0).toFloat() }.toSet().sorted() - - val chaptersBuilder = ChaptersListBuilder(allChapters.size * chaptersByTeam.size) - - for ((teamName, teamChapters) in chaptersByTeam) { - val teamChapterMap = teamChapters.associateBy { it.optDouble("chapter_number", 0.0).toFloat() } - - for (chapterNumber in allChapterNumbers) { - val chapterData = teamChapterMap[chapterNumber] - ?: allChapters.find { it.optDouble("chapter_number", 0.0).toFloat() == chapterNumber } - ?: continue - - val chapterId = chapterData.getString("id") - val chapterSource = chapterData.optString("source", "scraper") - val number = chapterData.optDouble("chapter_number", 0.0).toFloat() - val name = chapterData.optString("chapter_title", "").nullIfEmpty() - val group = chapterData.optString("group_name", "").nullIfEmpty() - val scanlator = chapterData.optString("scanlator_name", "").nullIfEmpty() - val actualTeamName = group ?: scanlator ?: "Unknown" - val date = chapterData.optString("date_added", "").nullIfEmpty() - + val allNumbers = allChapters.map { it.optDouble("chapter_number", 0.0).toFloat() }.distinct().sorted() + val builder = ChaptersListBuilder(allChapters.size * chaptersByTeam.size) + + for ((team, teamChapters) in chaptersByTeam) { + val mapByNum = teamChapters.associateBy { it.optDouble("chapter_number", 0.0).toFloat() } + for (num in allNumbers) { + val chapter = mapByNum[num] ?: continue + val chId = chapter.getString("id") + val src = chapter.optString("source", "user") + val name = chapter.optString("chapter_title", "").nullIfEmpty() + val date = chapter.optString("date_added", "").nullIfEmpty() val title = buildString { - val numStr = number.toString().substringBefore(".0") - if (name != null && !name.contains(numStr)) { - append("Chapter $numStr: ") - } else if (name == null) { - append("Chapter $numStr") + val numStr = num.toString().removeSuffix(".0") + append("Chapter $numStr") + val cleanName = name?.trim()?.takeIf { it.isNotEmpty() && !it.equals("null", ignoreCase = true) } + if (cleanName != null && !cleanName.contains(numStr)) { + append(": ").append(cleanName) } - name?.let { append(it.trim()) } } - - val chapter = MangaChapter( - id = generateUid("$teamName-$chapterId"), - title = title, - number = number, - volume = 0, - url = JSONObject().apply { - put("id", chapterId) - put("source", chapterSource) - }.toString(), - uploadDate = date?.let { dateFormat.parseSafe(it) } ?: 0L, - source = source, - scanlator = actualTeamName, - branch = teamName, + val chapterUrl = JSONObject().apply { + put("id", chId) + put("source", src) + }.toString() + builder.add( + MangaChapter( + id = generateUid("$team-$chId"), + title = title, + number = num, + volume = 0, + url = chapterUrl, + uploadDate = date?.let { dateFormat.parseSafe(it) } ?: 0L, + source = source, + scanlator = team, + branch = team, + ) ) + } + } - chaptersBuilder.add(chapter) + val volumesResponse = runCatching { + webClient.httpGet("$apiBase/manga/$mangaId/volumes?lang=en").parseJsonArray() + }.getOrNull() + + if (volumesResponse != null) { + val volumes = (0 until volumesResponse.length()).map { volumesResponse.getJSONObject(it) } + .sortedBy { it.optDouble("volume_number", 0.0).toFloat() } + + for (vol in volumes) { + if (vol.optString("language", "en") != "en") continue + val volId = vol.getInt("id") + val volNum = vol.optDouble("volume_number", 0.0).toFloat() + val volDate = vol.optString("date_added", "").nullIfEmpty() + val volSource = vol.optString("source", "user") + val volGroup = vol.optString("group_name", "").nullIfEmpty() + ?: vol.optString("scanlator_name", "").nullIfEmpty() + ?: "Unknown" + val volumeUrl = JSONObject().apply { + put("id", volId) + put("source", volSource) + }.toString() + builder.add( + MangaChapter( + id = generateUid("vol-$volId"), + title = "Volume ${volNum.toString().removeSuffix(".0")}", + number = volNum, + volume = 0, + url = volumeUrl, + uploadDate = volDate?.let { dateFormat.parseSafe(it) } ?: 0L, + source = source, + scanlator = volGroup, + branch = "Volume", + ) + ) } } - return chaptersBuilder.toList() + return builder.toList() } - // endregion - - // region Pages - - override suspend fun getRelatedManga(seed: Manga): List = emptyList() - override suspend fun getPages(chapter: MangaChapter): List { val chapterData = JSONObject(chapter.url) val chapterId = chapterData.getString("id") - val chapterSource = chapterData.optString("source", "scraper") - - val segment = if (chapterSource == "user") "uploads" else "chapters" + val source = chapterData.optString("source", "user") + val segment = if (source == "user") "uploads" else "chapters" val response = webClient.httpGet("$baseUrl/api/$segment/$chapterId/images").parseJson() - - // Response can be wrapped in {"data": {"manga": {"id": ...}, "images": [...]}} or flat {"images": [...]} val data = response.optJSONObject("data") - val imagesArray = if (data != null && data.has("images")) { - data.getJSONArray("images") - } else { - response.getJSONArray("images") - } - + val imagesArray = if (data != null && data.has("images")) data.getJSONArray("images") else response.getJSONArray("images") return (0 until imagesArray.length()).mapNotNull { i -> val img = imagesArray.getJSONObject(i) val imgUrl = img.optString("url", "").nullIfEmpty() ?: return@mapNotNull null @@ -425,17 +437,34 @@ internal class Mangadotnet(context: MangaLoaderContext) : id = generateUid(fullUrl), url = fullUrl, preview = null, - source = source, + source = this.source, ) } } - // endregion - // region Helpers + @Suppress("UNCHECKED_CAST") + override suspend fun getRelatedManga(seed: Manga): List { + val url = "$baseUrl/manga/${seed.url}.data?_routes=pages/MangaDetailPage" + val data = fetchRscData(url, "pages/MangaDetailPage") ?: return emptyList() + + val related = mutableListOf() + (data["suggestions"] as? List<*>)?.filterIsInstance>()?.mapTo(related) { parseMangaFromList(it) } + val relationsData = data.asMap("relationsData") + val relations = relationsData?.asMap("relations") as? Map>> + relations?.values?.forEach { list -> list.mapTo(related) { parseMangaFromList(it) } } + + return related + } + + private fun MangaState.toApiStatus() = when (this) { + MangaState.ONGOING -> "Ongoing" + MangaState.FINISHED -> "Completed" + MangaState.PAUSED -> "Hiatus" + else -> "" + } @Suppress("UNCHECKED_CAST") private fun Map.asMap(key: String): Map? = this[key] as? Map - - // endregion -} \ No newline at end of file + private fun String?.nullIfEmpty(): String? = if (this.isNullOrEmpty()) null else this +} diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Mangataro.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Mangataro.kt index 4b6b97ba..205ae6ea 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Mangataro.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Mangataro.kt @@ -10,9 +10,6 @@ import org.koitharu.kotatsu.parsers.MangaSourceParser import org.koitharu.kotatsu.parsers.config.ConfigKey import org.koitharu.kotatsu.parsers.core.PagedMangaParser import org.koitharu.kotatsu.parsers.exception.ParseException -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.coroutineScope import org.koitharu.kotatsu.parsers.model.ContentType import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaChapter @@ -38,6 +35,7 @@ import java.security.MessageDigest import java.time.ZoneOffset import java.time.ZonedDateTime import java.time.format.DateTimeFormatter +import java.util.ArrayList import java.util.Calendar import java.util.EnumSet import java.util.HashSet @@ -54,47 +52,6 @@ internal class Mangataro(context: MangaLoaderContext) : private val tokenFormatter = DateTimeFormatter.ofPattern("yyyyMMddHH", Locale.US) - private val genreTags by lazy { - setOf( - tag("7", "Action"), - tag("19", "Adventure"), - tag("41", "Award Winning"), - tag("49", "Boys Love"), - tag("62", "Comedy"), - tag("79", "Drama"), - tag("83", "Ecchi"), - tag("87", "Erotica"), - tag("91", "Fantasy"), - tag("93", "Full Color"), - tag("100", "Girls Love"), - tag("104", "Harem"), - tag("108", "Historical"), - tag("109", "Horror"), - tag("114", "Isekai"), - tag("116", "Josei"), - tag("123", "Long Strip"), - tag("134", "Manga"), - tag("136", "Manhua"), - tag("137", "Manhwa"), - tag("138", "Martial Arts"), - tag("152", "Mystery"), - tag("159", "One-shot"), - tag("170", "Psychological"), - tag("173", "Reincarnation"), - tag("175", "Romance"), - tag("178", "School"), - tag("180", "Sci-Fi"), - tag("181", "Seinen"), - tag("184", "Shoujo"), - tag("186", "Shounen"), - tag("190", "Slice of Life"), - tag("194", "Sports"), - tag("198", "Supernatural"), - tag("206", "Time Travel"), - tag("220", "Web Comic"), - ) - } - override fun onCreateConfig(keys: MutableCollection>) { super.onCreateConfig(keys) keys.add(userAgentKey) @@ -112,12 +69,16 @@ internal class Mangataro(context: MangaLoaderContext) : isSearchSupported = true, isSearchWithFiltersSupported = true, isMultipleTagsSupported = true, + isYearSupported = true, ) override suspend fun getFilterOptions() = MangaListFilterOptions( availableTags = genreTags, + availableStates = EnumSet.of(MangaState.ONGOING, MangaState.FINISHED), + availableContentTypes = EnumSet.of(ContentType.MANGA, ContentType.MANHWA, ContentType.MANHUA, ContentType.OTHER), ) + override suspend fun getListPage( page: Int, order: SortOrder, @@ -126,10 +87,41 @@ internal class Mangataro(context: MangaLoaderContext) : val payload = JSONObject().apply { put("page", page) put("search", filter.query.orEmpty()) - put("years", "[]") + + // Year + val yearArray = JSONArray() + if (filter.year > 0) yearArray.put(filter.year.toString()) + put("years", yearArray.toString()) + + // Genres (tags) put("genres", filter.tags.toGenrePayload()) - put("types", "[]") - put("statuses", "[]") + + // Types + val typeArray = JSONArray() + filter.types.forEach { ct -> + val apiType = when (ct) { + ContentType.MANGA -> "Manga" + ContentType.MANHWA -> "Manhwa" + ContentType.MANHUA -> "Manhua" + ContentType.OTHER -> "Novel" + else -> null + } + if (apiType != null) typeArray.put(apiType) + } + put("types", typeArray.toString()) + + // Statuses + val statusArray = JSONArray() + filter.states.forEach { state -> + val apiStatus = when (state) { + MangaState.ONGOING -> "Ongoing" + MangaState.FINISHED -> "Completed" + else -> null + } + if (apiStatus != null) statusArray.put(apiStatus) + } + put("statuses", statusArray.toString()) + put("sort", order.toApiSort()) put("genreMatchMode", "any") } @@ -157,64 +149,53 @@ internal class Mangataro(context: MangaLoaderContext) : val chapters = loadChapters(mangaId, detailUrl) return manga.copy( description = description, - tags = if (tags.isEmpty()) manga.tags else tags, - authors = if (authors.isEmpty()) manga.authors else authors, - altTitles = if (altTitles.isEmpty()) manga.altTitles else altTitles, + tags = tags.ifEmpty { manga.tags }, + authors = authors.ifEmpty { manga.authors }, + altTitles = altTitles.ifEmpty { manga.altTitles }, state = state, rating = rating, chapters = chapters, ) } - private suspend fun loadChapters( - mangaId: String, - referer: String, - ): List = coroutineScope { - + private suspend fun loadChapters(mangaId: String, referer: String): List { val headers = Headers.headersOf("Referer", referer) - - val offsets = listOf(0, 100, 200, 300) - - val chapters = offsets.map { offset -> - async { - val (token, timestamp) = generateChapterToken() - - val url = buildChapterUrl( - mangaId, - offset, - 100, - token, - timestamp, - ) - - val raw = webClient.httpGet(url, headers) - .parseRaw() - .trim() - - val json = raw.toJsonObjectOrNull() - ?: return@async emptyList() - - if (!json.optBoolean("success")) { - return@async emptyList() - } - - val array = json.optJSONArray("chapters") - ?: return@async emptyList() - - List(array.length()) { i -> - array.getJSONObject(i) - .toMangaChapter( - (offset + i + 1).toFloat() - ) + val result = ArrayList() + val seenIds = HashSet() + var offset = 0 + val limit = 500 + var expectedTotal = Int.MAX_VALUE + while (offset < expectedTotal) { + val (token, timestamp) = generateChapterToken() + val requestUrl = buildChapterUrl(mangaId, offset, limit, token, timestamp) + val raw = webClient.httpGet(requestUrl, headers).parseRaw().trim() + if (raw.isEmpty()) { + break + } + val json = runCatching { JSONObject(raw) }.getOrNull() ?: break + if (!json.optBoolean("success", false)) { + break + } + expectedTotal = json.optInt("total", expectedTotal) + val chaptersArray = json.optJSONArray("chapters") ?: break + if (chaptersArray.length() == 0) { + break + } + for (i in 0 until chaptersArray.length()) { + val item = chaptersArray.getJSONObject(i) + val key = item.optString("id").ifEmpty { item.optString("url") } + if (!seenIds.add(key)) { + continue } + val fallbackNumber = (offset + i + 1).toFloat() + result.add(item.toMangaChapter(fallbackNumber)) + } + offset += chaptersArray.length() + if (!json.optBoolean("has_more", false)) { + break } } - - chapters - .awaitAll() - .flatten() - .distinctBy { it.id } - .sortedBy { it.number } + return result } private fun buildChapterUrl( @@ -239,19 +220,16 @@ internal class Mangataro(context: MangaLoaderContext) : .toString() } - private val md5 = MessageDigest.getInstance("MD5") - - private fun generateChapterToken(): Pair { - val timestamp = (System.currentTimeMillis()/1000L).toString() - val hourKey = ZonedDateTime.now(ZoneOffset.UTC) - .format(tokenFormatter) - - val input = timestamp + "mng_ch_$hourKey" - - val token = md5.digest(input.toByteArray()) + private fun generateChapterToken(): Pair { + val timestamp = (System.currentTimeMillis() / 1000L).toString() + val hourKey = ZonedDateTime.now(ZoneOffset.UTC).format(tokenFormatter) + val secret = "mng_ch_$hourKey" + val hashInput = timestamp + secret + val token = MessageDigest + .getInstance("MD5") + .digest(hashInput.toByteArray()) .toHex() - .substring(0,16) - + .substring(0, 16) return token to timestamp } @@ -626,10 +604,10 @@ internal class Mangataro(context: MangaLoaderContext) : } val ids = LinkedHashSet(size) for (tag in this) { - val numericKey = tag.key?.toIntOrNull()?.let { it.toString() } + val numericKey = tag.key.toIntOrNull()?.toString() ?: genreTags.firstOrNull { ref -> - ref.title.equals(tag.title, ignoreCase = true) - }?.key?.toIntOrNull()?.let { it.toString() } + ref.title.equals(tag.title, ignoreCase = true) + }?.key?.toIntOrNull()?.toString() if (numericKey != null) { ids.add(numericKey) } @@ -673,4 +651,211 @@ internal class Mangataro(context: MangaLoaderContext) : ?: attrAsAbsoluteUrlOrNull("data-lazy-src") ?: attrAsAbsoluteUrlOrNull("src") } -} \ No newline at end of file + + private val genreTags by lazy { + setOf( + tag("7", "Action"), + tag("12", "Adventure"), + tag("17", "Award Winning"), + tag("80", "Boys Love"), + tag("20", "Comedy"), + tag("8", "Drama"), + tag("40", "Ecchi"), + tag("71", "Erotica"), + tag("2", "Fantasy"), + tag("34", "Full Color"), + tag("49", "Girls Love"), + tag("64", "Harem"), + tag("18", "Historical"), + tag("44", "Horror"), + tag("3", "Isekai"), + tag("43", "Josei"), + tag("1172", "Long Strip"), + tag("14", "Manga"), + tag("33", "Manhua"), + tag("6", "Manhwa"), + tag("4", "Martial Arts"), + tag("24", "Mystery"), + tag("48", "One-shot"), + tag("26", "Psychological"), + tag("5", "Reincarnation"), + tag("29", "Romance"), + tag("10", "School"), + tag("15", "Sci-Fi"), + tag("19", "Seinen"), + tag("42", "Shoujo"), + tag("13", "Shounen"), + tag("47", "Slice of Life"), + tag("25", "Sports"), + tag("9", "Supernatural"), + tag("32", "Time Travel"), + tag("39", "Webtoon"), + tag("2094", "4-Koma"), + tag("1050", "Abandoned Children"), + tag("2386", "Ability Steal"), + tag("2397", "Absent Parents"), + tag("4012", "Academy"), + tag("999", "Accelerated Growth"), + tag("1351", "Adaptation"), + tag("1000", "Adapted to Anime"), + tag("5072", "Adapted to Drama CD"), + tag("1001", "Adapted to Game"), + tag("5069", "Adapted to Manga"), + tag("963", "Adapted to Manhua"), + tag("1002", "Adapted to Manhwa"), + tag("5070", "Adapted to Visual Novel"), + tag("1058", "Adopted Children"), + tag("88", "Adult"), + tag("16", "Adult Cast"), + tag("2861", "Age Regression"), + tag("964", "Alchemy"), + tag("965", "Aliens"), + tag("966", "Alternate World"), + tag("2862", "Ancient Times"), + tag("1135", "Animals"), + tag("2096", "Anthology"), + tag("93", "Anthropomorphic"), + tag("5071", "Appearance Different from Actual Age"), + tag("1037", "Aristocracy"), + tag("2387", "Army"), + tag("1005", "Army Building"), + tag("1047", "Arranged Marriage"), + tag("2401", "Arrogant Characters"), + tag("4551", "Artbook"), + tag("4004", "Artifacts"), + tag("2863", "Assassins"), + tag("98", "Avant Garde"), + tag("5279", "Battle Academy"), + tag("2388", "Battle Competition"), + tag("1137", "BD"), + tag("968", "Beast Companions"), + tag("969", "Beautiful Female Lead"), + tag("2864", "Betrayal"), + tag("971", "Blacksmith"), + tag("4237", "Boys' Love"), + tag("1038", "Brotherhood"), + tag("2398", "Business Management"), + tag("1006", "Calm Protagonist"), + tag("2392", "Cautious Protagonist"), + tag("94", "CGDCT"), + tag("4929", "Character growth"), + tag("51", "Childcare"), + tag("4818", "Children's"), + tag("2393", "Cold Love Interests"), + tag("2394", "Cold Protagonist"), + tag("92", "Combat Sports"), + tag("2118", "Cooking"), + tag("1753", "Crime"), + tag("81", "Crossdressing"), + tag("2395", "Cruel Characters"), + tag("1039", "Cunning Protagonist"), + tag("4817", "Cute Stuffs"), + tag("4930", "Dark"), + tag("1044", "Death of Loved Ones"), + tag("52", "Delinquents"), + tag("1376", "Demons"), + tag("975", "Dense Protagonist"), + tag("58", "Detective"), + tag("4008", "Devoted love interests"), + tag("79", "Doujinshi"), + tag("1040", "Dragons"), + tag("1048", "Drugs"), + tag("4795", "Dungeon"), + tag("1011", "Dungeons"), + tag("66", "Educational"), + tag("976", "Elemental Magic"), + tag("4010", "Elves"), + tag("4005", "Family"), + tag("59", "Gag Humor"), + tag("36", "Game"), + tag("979", "Game Elements"), + tag("106", "Gender Bender"), + tag("2093", "Genderswap"), + tag("1134", "Ghosts"), + tag("4739", "Gyaru"), + tag("76", "High Stakes Game"), + tag("82", "Hentai"), + tag("4932", "Human nonhuman relationship"), + tag("95", "Idols (Female)"), + tag("101", "Idols (Male)"), + tag("109", "Indonesian"), + tag("91", "Iyashikei"), + tag("107", "Kids"), + tag("4006", "Kingdoms"), + tag("1020", "Level System"), + tag("55", "Light Novel"), + tag("4207", "Loli"), + tag("89", "Lolicon"), + tag("1173", "Web Comic"), + tag("41", "Love Polygon"), + tag("100", "Love Status Quo"), + tag("2061", "Mafia"), + tag("35", "Magic"), + tag("2095", "Magical Girls"), + tag("68", "Magical Sex Shift"), + tag("97", "Mahou Shoujo"), + tag("4933", "Male"), + tag("4792", "Mangataro Exclusive"), + tag("1414", "Monster Girls"), + tag("37", "Monsters"), + tag("1024", "Multiple POV"), + tag("38", "Murim"), + tag("83", "Music"), + tag("53", "Mythology"), + tag("4601", "Ninja"), + tag("103", "OEL"), + tag("2062", "Office Workers"), + tag("4834", "Official Colored"), + tag("4210", "Oneshot"), + tag("50", "Organized Crime"), + tag("70", "Otaku Culture"), + tag("21", "Parody"), + tag("72", "Performing Arts"), + tag("105", "Pets"), + tag("63", "Philosophical"), + tag("1136", "Police"), + tag("1557", "Post-Apocalyptic"), + tag("102", "Racing"), + tag("4014", "Reincarnated in another world"), + tag("61", "Reverse Harem"), + tag("28", "Romantic Subtext"), + tag("60", "Samurai"), + tag("73", "School Life"), + tag("1413", "Self-Published"), + tag("4116", "Sexual Violence"), + tag("90", "Shoujo Ai"), + tag("104", "Shounen Ai"), + tag("54", "Showbiz"), + tag("4009", "Skill books"), + tag("85", "Smut"), + tag("84", "Space"), + tag("77", "Strategy Game"), + tag("22", "Super Power"), + tag("4098", "Superhero"), + tag("56", "Survival"), + tag("75", "Suspense"), + tag("995", "Sword And Magic"), + tag("27", "Team Sports"), + tag("591", "Thai"), + tag("1341", "Thriller"), + tag("4742", "Traditional Games"), + tag("65", "Tragedy"), + tag("4011", "Transported to another world"), + tag("110", "Urban Fantasy"), + tag("45", "Vampire"), + tag("2102", "Vampires"), + tag("31", "Video Game"), + tag("1366", "Video Games"), + tag("62", "Villainess"), + tag("4485", "Virtual Reality"), + tag("57", "Visual Arts"), + tag("69", "Workplace"), + tag("1350", "Wuxia"), + tag("2391", "Xianxia"), + tag("962", "Xuanhuan"), + tag("86", "Yaoi"), + tag("99", "Yuri"), + tag("1342", "Zombies"), + ) + } +} diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Manhuaus.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Manhuaus.kt index 146673f0..ffb883a6 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Manhuaus.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Manhuaus.kt @@ -13,7 +13,7 @@ import org.koitharu.kotatsu.parsers.parsers.MadaraParser internal class Manhuaus(context: MangaLoaderContext): MadaraParser(context, MangaParserSource.MANHUAUS, "manhuaus.com") { - override val postReq = true + override val postReq = false override val filterCapabilities = MangaListFilterCapabilities( isMultipleTagsSupported = true, diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/ManhwaNex.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/ManhwaNex.kt new file mode 100644 index 00000000..ffc91246 --- /dev/null +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/ManhwaNex.kt @@ -0,0 +1,14 @@ +package org.koitharu.kotatsu.parsers.site.kotatsu.en + +import org.koitharu.kotatsu.parsers.MangaLoaderContext +import org.koitharu.kotatsu.parsers.MangaSourceParser +import org.koitharu.kotatsu.parsers.model.MangaParserSource +import org.koitharu.kotatsu.parsers.parsers.MadaraParser + +@MangaSourceParser("MANHWANEX", "ManhwaNex", "en") +internal class ManhwaNex(context: MangaLoaderContext): + MadaraParser(context, MangaParserSource.MANHWANEX, "manhwanex.com") { + + override val postReq = false + +} diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/ManhwaTop.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/ManhwaTop.kt index c5612a97..b264ee08 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/ManhwaTop.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/ManhwaTop.kt @@ -1,54 +1,10 @@ package org.koitharu.kotatsu.parsers.site.kotatsu.en -import org.jsoup.nodes.Document import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaSourceParser -import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaParserSource import org.koitharu.kotatsu.parsers.parsers.MadaraParser -import org.koitharu.kotatsu.parsers.util.* -import java.text.SimpleDateFormat @MangaSourceParser("MANHWATOP", "ManhwaTop", "en") internal class ManhwaTop(context: MangaLoaderContext) : - MadaraParser(context, MangaParserSource.MANHWATOP, "manhwatop.com") { - - override val postReq = true - - override suspend fun loadChapters(mangaUrl: String, document: Document): List { - - val mangaId = document.select("div#manga-chapters-holder").attr("data-id") - val url = "https://$domain/wp-admin/admin-ajax.php" - val postData = "action=manga_get_chapters&manga=$mangaId" - val doc = webClient.httpPost(url, postData).parseHtml() - - val dateFormat = SimpleDateFormat(datePattern, sourceLocale) - - return doc.select(selectChapter).mapChapters(reversed = true) { i, li -> - val a = li.selectFirst("a") - val href = a?.attrAsRelativeUrlOrNull("href") ?: li.parseFailed("Link is missing") - val link = href + stylePage - val dateText = li.selectFirst("a.c-new-tag")?.attr("title") ?: li.selectFirst(selectDate)?.text() - val name = a.selectFirst("p")?.text() ?: a.ownText() - val dateText2 = if (dateText != "Complete") { - dateText - } else { - null - } - MangaChapter( - id = generateUid(href), - url = link, - title = name, - number = i + 1f, - volume = 0, - branch = null, - uploadDate = parseChapterDate( - dateFormat, - dateText2, - ), - scanlator = null, - source = source, - ) - } - } -} \ No newline at end of file + MadaraParser(context, MangaParserSource.MANHWATOP, "manhwatop.com") diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Mgread.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Mgread.kt new file mode 100644 index 00000000..3a7f8f4c --- /dev/null +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Mgread.kt @@ -0,0 +1,295 @@ +package org.koitharu.kotatsu.parsers.site.kotatsu.en + +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.json.JSONArray +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.config.ConfigKey +import org.koitharu.kotatsu.parsers.core.PagedMangaParser +import org.koitharu.kotatsu.parsers.network.UserAgents +import org.koitharu.kotatsu.parsers.model.* +import org.koitharu.kotatsu.parsers.util.* +import java.text.SimpleDateFormat +import java.util.* +import okhttp3.Interceptor +import okhttp3.Response + +// TODO +// add filter options + +@MangaSourceParser("MGREADIO", "Mgread.io", "en") +internal class MgreadIo(context: MangaLoaderContext) : + PagedMangaParser(context, MangaParserSource.MGREADIO, 24) { + + override val configKeyDomain = ConfigKey.Domain("mgread.io") + override val userAgentKey = ConfigKey.UserAgent(UserAgents.KOTATSU) + + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request().newBuilder() + .header("Referer", "https://$domain/") + .header("Origin", "https://$domain") + .build() + return chain.proceed(request) + } + + override fun onCreateConfig(keys: MutableCollection>) { + super.onCreateConfig(keys) + keys.add(userAgentKey) + } + + override val availableSortOrders: Set = EnumSet.of( + SortOrder.UPDATED, + SortOrder.POPULARITY, + ) + + override val filterCapabilities = MangaListFilterCapabilities( + isSearchSupported = true, + ) + + private val chapterDateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.US) + private val restChapterDateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).apply { + timeZone = TimeZone.getTimeZone("GMT+7") + } + + override suspend fun getFilterOptions() = MangaListFilterOptions() + + override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List { + val query = filter.query?.trim()?.takeIf { it.isNotEmpty() } + + if (query != null) { + val url = "https://$domain/wp-json/initlise/v1/search".toHttpUrl().newBuilder() + .addQueryParameter("term", query) + .addQueryParameter("page", page.toString()) + .build() + val jsonArray = JSONArray(webClient.httpGet(url).parseRaw()) + return (0 until jsonArray.length()).mapNotNull { i -> + val obj = jsonArray.getJSONObject(i) + val title = obj.getString("title").let { org.jsoup.Jsoup.parse(it).text() } + val mangaUrl = obj.getString("url").trim().takeIf(String::isNotEmpty) ?: return@mapNotNull null + val thumb = obj.optString("thumb", null) + Manga( + id = generateUid(mangaUrl), + title = title, + url = mangaUrl, + publicUrl = mangaUrl.toAbsoluteUrl(domain), + coverUrl = thumb, + source = source, + altTitles = emptySet(), + rating = RATING_UNKNOWN, + contentRating = null, + tags = emptySet(), + state = null, + authors = emptySet(), + ) + }.filterNot { it.isAnime() } + } + + val slug = if (order == SortOrder.POPULARITY) "manga-ranking" else "recently-updated" + val url = if (page == 1) "https://$domain/$slug/" else "https://$domain/$slug/page/$page/" + val doc = webClient.httpGet(url).parseHtml() + return parseMangaGrid(doc).filterNot { it.isAnime() } + } + + private fun parseMangaGrid(doc: Document): List { + return doc.select(".manga-item-grid").map { element -> + val titleElement = element.selectFirst("h2 a[href*='/manga/']") + ?: element.selectFirst("a[href*='/manga/']:not([href*='/chapter-'])") + ?: return@map null + val href = titleElement.absUrl("href") + Manga( + id = generateUid(href), + title = titleElement.text(), + url = href.toHttpUrl().encodedPath, + publicUrl = href, + coverUrl = element.selectFirst("img")?.imageUrl(), + source = source, + altTitles = emptySet(), + rating = RATING_UNKNOWN, + contentRating = null, + tags = emptySet(), + state = null, + authors = emptySet(), + ) + }.filterNotNull() + } + + private fun Manga.isAnime(): Boolean { + val t = title.lowercase() + return t.startsWith("anime -") || t.startsWith("anime –") || + url.substringAfter("/manga/", "").startsWith("anime-") + } + + override suspend fun getDetails(manga: Manga): Manga { + val doc = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml() + val title = doc.selectFirst("#manga-title")?.ownText() + ?: doc.selectFirst("meta[property=og:title]")?.attr("content")?.substringBefore(" [Ch.")?.trim() + ?: manga.title + + val cover = doc.selectFirst(".story-cover img")?.imageUrl() + ?: doc.selectFirst("meta[property=og:image]")?.attr("content") + ?: manga.coverUrl + + val descElement = doc.selectFirst("#manga-description") + val descriptionText = descElement?.wholeText()?.trim() + ?: doc.selectFirst("meta[name=description]")?.attr("content")?.trim() + + val genre = doc.select("#genre-tags a[href*='/genre/']") + .joinToString { it.ownText().ifEmpty { it.text() } } + + val statusText = doc.selectFirst("#manga-status")?.text()?.lowercase(Locale.US)?.trim() + val state = when (statusText) { + "ongoing" -> MangaState.ONGOING + "completed" -> MangaState.FINISHED + "season end", "source hiatus", "caught up" -> MangaState.PAUSED + "dropped" -> MangaState.ABANDONED + else -> null + } + + val metaRow = doc.selectFirst("#manga-title + div") + val metadata = buildList { + metaRow?.ownText()?.substringBefore("Chapters")?.trim()?.takeIf(String::isNotEmpty)?.let { + add("Chapters: $it") + } + doc.selectFirst("#comic-othername")?.text()?.takeIf(String::isNotEmpty)?.let { + add("Alternative title: $it") + } + doc.selectFirst(".init-review-info")?.text()?.takeIf(String::isNotEmpty)?.let { + add("Rating: $it") + } + metaRow?.selectFirst(".init-plugin-suite-view-count-number")?.text()?.takeIf(String::isNotEmpty)?.let { + add("Views: $it") + } + doc.selectFirst("#last-updated")?.text()?.takeIf(String::isNotEmpty)?.let { + add("Last updated: $it") + } + } + + val description = buildString { + if (descriptionText != null) append(descriptionText) + if (metadata.isNotEmpty()) { + if (isNotEmpty()) append("\n\n") + metadata.joinTo(this, separator = "\n") + } + }.trim().takeIf(String::isNotEmpty) + + val tags = genre.split(",").mapNotNull { it.trim().takeIf(String::isNotEmpty) }.map { name -> + MangaTag(name, name.lowercase(), source) + }.toSet() + + val mangaId = doc.selectFirst("#manga-title[data-id], #chapter-search-input[data-manga-id]") + ?.attr("data-id")?.ifEmpty { doc.selectFirst("#chapter-search-input[data-manga-id]")?.attr("data-manga-id") } + ?.toIntOrNull() + + val chapters = if (mangaId != null) { + fetchChaptersApi(mangaId, manga.url) + } else { + doc.select(".chapter-list .chapter-item").mapNotNull { chapterFromElement(it) } + } + + return manga.copy( + title = title, + coverUrl = cover, + description = description, + tags = tags, + state = state, + chapters = chapters, + ) + } + + private suspend fun fetchChaptersApi(mangaId: Int, mangaPath: String): List { + val result = mutableListOf() + var page = 1 + do { + val url = "https://$domain/wp-json/initmanga/v1/chapters".toHttpUrl().newBuilder() + .addQueryParameter("manga_id", mangaId.toString()) + .addQueryParameter("paged", page.toString()) + .addQueryParameter("per_page", "50") + .build() + val json = webClient.httpGet(url).parseJson() + val items = json.optJSONArray("items") ?: break + val totalPages = json.optInt("total_pages", 1) + for (i in 0 until items.length()) { + val item = items.getJSONObject(i) + val number = item.optDouble("number", -1.0).toFloat() + val title = item.optString("title", "") + val slug = item.optString("slug", "") + val createdAt = item.optString("created_at", "") + val chapterName = if (title.isNotEmpty()) "Chapter ${number.toInt()} - $title" else "Chapter ${number.toInt()}" + val cleanPath = mangaPath.substringBefore("/chapter/").trimEnd('/') + result.add( + MangaChapter( + id = generateUid("$cleanPath/$slug/"), + title = chapterName, + number = number, + url = "$cleanPath/$slug/", + uploadDate = restChapterDateFormat.parseSafe(createdAt), + source = source, + volume = 0, + scanlator = null, + branch = null, + ) + ) + } + page++ + } while (page <= totalPages) + return result.sortedBy { it.number } + } + + private fun chapterFromElement(element: Element): MangaChapter? { + val urlElement = element.selectFirst("a[href*='/chapter-']") ?: return null + val chapterUrl = urlElement.absUrl("href") + val name = element.selectFirst("h3")?.text() + ?: chapterUrl.substringBeforeLast('/').substringAfterLast('/').replace('-', ' ').replaceFirstChar { it.uppercase() } + val number = Regex("""/chapter-(\d+(?:\.\d+)?)/""").find(chapterUrl)?.groupValues?.get(1)?.toFloatOrNull() ?: -1f + val dateStr = element.selectFirst("time[datetime]")?.attr("datetime") + val date = dateStr?.parseChapterDate() ?: 0L + return MangaChapter( + id = generateUid(chapterUrl.toHttpUrl().encodedPath), + title = name, + number = number, + url = chapterUrl.toHttpUrl().encodedPath, + uploadDate = date, + source = source, + volume = 0, + scanlator = null, + branch = null, + ) + } + + override suspend fun getPages(chapter: MangaChapter): List { + val doc = webClient.httpGet(chapter.url.toAbsoluteUrl(domain)).parseHtml() + return doc.select("#chapter-content img[src]").mapIndexed { _, img -> + val src = img.absUrl("src") + MangaPage( + id = generateUid(src), + url = src, + preview = null, + source = source, + ) + } + } + + private fun String?.parseChapterDate(): Long { + if (isNullOrBlank()) return 0L + val normalized = this + .replace(Regex("""([+-]\d{2}):(\d{2})$"""), "$1$2") + .replace(Regex("""Z$"""), "+0000") + return chapterDateFormat.parseSafe(normalized) + } + + private fun Element.imageUrl(): String? { + return when (normalName()) { + "meta" -> attr("content") + else -> attr("abs:data-src").ifEmpty { + attr("abs:data-lazy-src").ifEmpty { attr("abs:src") } + } + }.takeIf(String::isNotEmpty) + } + + override suspend fun getRelatedManga(seed: Manga): List = emptyList() + + private fun SimpleDateFormat.parseSafe(date: String): Long = + runCatching { parse(date)?.time ?: 0L }.getOrDefault(0L) +} diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Utoon.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Utoon.kt deleted file mode 100644 index 18f0fe38..00000000 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Utoon.kt +++ /dev/null @@ -1,270 +0,0 @@ -package org.koitharu.kotatsu.parsers.site.kotatsu.en - -import okhttp3.HttpUrl.Companion.toHttpUrl -import org.json.JSONArray -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.config.ConfigKey -import org.koitharu.kotatsu.parsers.core.PagedMangaParser -import org.koitharu.kotatsu.parsers.model.* -import org.koitharu.kotatsu.parsers.util.* -import java.util.Calendar -import java.util.EnumSet -import java.util.Locale - -@MangaSourceParser("UTOON", "UToon", "en") -internal class UToon(context: MangaLoaderContext) : - PagedMangaParser(context, MangaParserSource.UTOON, pageSize = 28) { - - override val configKeyDomain = ConfigKey.Domain("utoon.net") - - override val availableSortOrders: Set = EnumSet.of( - SortOrder.POPULARITY, - SortOrder.UPDATED, - SortOrder.NEWEST, - SortOrder.ALPHABETICAL, - ) - - override val filterCapabilities = MangaListFilterCapabilities( - isSearchSupported = true, - isSearchWithFiltersSupported = true, - ) - - override suspend fun getFilterOptions() = MangaListFilterOptions( - availableTags = genreTags, - availableStates = EnumSet.of(MangaState.ONGOING, MangaState.FINISHED), - ) - - override suspend fun getListPage( - page: Int, - order: SortOrder, - filter: MangaListFilter, - ): List { - - val url = buildUrl(page, order, filter) - val doc = webClient.httpGet(url).parseHtml() - - return doc.select(".agrid .acard").map { it.toManga() } - } - - private fun buildUrl( - page: Int, - order: SortOrder, - filter: MangaListFilter - ): String { - return buildString { - append("https://") - append(domain) - append("/manga/") - if (page > 1) { - append("page/") - append(page) - append("/") - } - }.toHttpUrl().newBuilder().apply { - - filter.query?.takeIf { it.isNotBlank() }?.let { - addQueryParameter("q", it) - } - - when (order) { - SortOrder.POPULARITY -> addQueryParameter("orderby", "popular") - SortOrder.NEWEST -> addQueryParameter("orderby", "new") - SortOrder.ALPHABETICAL -> addQueryParameter("orderby", "alphabet") - else -> Unit - } - - filter.states.firstOrNull()?.let { state -> - val value = when (state) { - MangaState.ONGOING -> "ongoing" - MangaState.FINISHED -> "completed" - else -> null - } - if (value != null) addQueryParameter("status", value) - } - - filter.tags.firstOrNull()?.key?.let { - addQueryParameter("genre", it) - } - - }.build().toString() - } - - private fun Element.toManga(): Manga { - val href = attrAsRelativeUrl("href") - - return Manga( - id = generateUid(href), - title = selectFirst(".ac-t")?.text().orEmpty(), - altTitles = emptySet(), - url = href, - publicUrl = href.toAbsoluteUrl(domain), - rating = selectFirst(".ac-rate") - ?.text() - ?.toFloatOrNull() - ?.div(5f) ?: RATING_UNKNOWN, - contentRating = null, - coverUrl = selectFirst("img")?.src(), - tags = emptySet(), - state = parseState(selectFirst(".ac-status")?.text()), - authors = emptySet(), - source = source, - ) - } - - override suspend fun getDetails(manga: Manga): Manga { - val doc = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml() - - var author: String? = null - var artist: String? = null - var state: MangaState? = null - - doc.select(".sinfo-grid .sir").forEach { row -> - val label = row.selectFirst(".l")?.text()?.trim().orEmpty() - val value = row.selectFirst(".v")?.text()?.trim().orEmpty() - - when (label.lowercase(Locale.ROOT)) { - "author" -> author = value.nullIfEmpty() - "artist" -> artist = value.nullIfEmpty() - "status" -> state = parseState(value) - } - } - - if (state == null) { - val fallback = doc.select(".hinfo .hi") - .joinToString(" ") { it.text() } - state = parseState(fallback) - } - - val base = manga.copy( - title = doc.selectFirst(".htitle")?.text() ?: manga.title, - coverUrl = doc.selectFirst(".poster img")?.src() ?: manga.coverUrl, - description = doc.selectFirst(".syn")?.text(), - tags = doc.select(".genres .genre").mapNotNullToSet { el -> - val title = el.text().trim().nullIfEmpty() ?: return@mapNotNullToSet null - val key = el.attr("href") - .trimEnd('/') - .substringAfterLast('/') - .nullIfEmpty() - ?: return@mapNotNullToSet null - - MangaTag(key = key, title = title, source = source) - }, - authors = setOfNotNull(author ?: artist), - state = state, - ) - - val chapters = parseChapters(doc) - - return base.copy(chapters = chapters) - } - - private fun parseChapters(doc: Document): List { - val script = doc.selectFirst("script:containsData(var CH=)")?.data() - ?: return emptyList() - - val json = script.substringAfter("var CH=").substringBefore(";").trim() - val array = runCatching { JSONArray(json) }.getOrNull() - ?: return emptyList() - - return (0 until array.length()).mapNotNull { i -> - val obj = array.optJSONObject(i) ?: return@mapNotNull null - if (obj.optBoolean("locked", false)) return@mapNotNull null - - val url = obj.optString("url").nullIfEmpty() - ?.toRelativeUrl(domain) ?: return@mapNotNull null - - val label = obj.optString("label").nullIfEmpty() - ?: return@mapNotNull null - - MangaChapter( - id = generateUid(url), - title = label, - number = obj.optDouble("num", 0.0).toFloat(), - volume = 0, - url = url, - scanlator = null, - uploadDate = parseChapterDate(obj.optString("ago")), - branch = null, - source = source, - ) - }.reversed() - } - - override suspend fun getPages(chapter: MangaChapter): List { - val doc = webClient.httpGet(chapter.url.toAbsoluteUrl(domain)).parseHtml() - - if (doc.selectFirst(".zx-locked__card") != null) { - throw IllegalStateException("Locked premium chapter") - } - - return doc.select("div.reading-content img").mapNotNull { img -> - val src = img.src() ?: return@mapNotNull null - MangaPage( - id = generateUid(src), - url = src, - preview = null, - source = source, - ) - } - } - - override suspend fun getPageUrl(page: MangaPage): String = page.url - - private fun parseState(status: String?): MangaState? = when { - status.isNullOrBlank() -> null - status.contains("ongoing", true) -> MangaState.ONGOING - status.contains("completed", true) -> MangaState.FINISHED - status.contains("hiatus", true) || status.contains("on hold", true) -> MangaState.PAUSED - status.contains("cancel", true) -> MangaState.ABANDONED - else -> null - } - - private fun parseChapterDate(value: String?): Long { - val v = value?.lowercase(Locale.ROOT)?.nullIfEmpty() ?: return 0L - if (!v.contains("ago")) return 0L - - val amount = Regex("\\d+").find(v)?.value?.toIntOrNull() ?: return 0L - val cal = Calendar.getInstance() - - when { - "second" in v -> cal.add(Calendar.SECOND, -amount) - "minute" in v -> cal.add(Calendar.MINUTE, -amount) - "hour" in v -> cal.add(Calendar.HOUR_OF_DAY, -amount) - "day" in v -> cal.add(Calendar.DAY_OF_YEAR, -amount) - "week" in v -> cal.add(Calendar.WEEK_OF_YEAR, -amount) - "month" in v -> cal.add(Calendar.MONTH, -amount) - "year" in v -> cal.add(Calendar.YEAR, -amount) - else -> return 0L - } - - return cal.timeInMillis - } - - private val genreTags: Set - get() = setOf( - "fantasy" to "Fantasy", - "drama" to "Drama", - "adventure" to "Adventure", - "action" to "Action", - "comedy" to "Comedy", - "shounen" to "Shounen", - "comic" to "Comic", - "manhwa" to "Manhwa", - "fight" to "Fight", - "magic" to "Magic", - "supernatural" to "Supernatural", - "manga" to "Manga", - "romance" to "Romance", - "martial-arts" to "Martial Arts", - "crime" to "Crime", - "mystery" to "Mystery", - "isekai" to "Isekai", - "historical" to "Historical", - "reincarnation" to "Reincarnation", - "slice-of-life" to "Slice of Life", - "manhua" to "Manhua", - ).mapToSet { (k, t) -> MangaTag(k, t, source) } -} diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/ZinManga.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/ZinManga.kt index ad7f3c66..98e8a4e3 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/ZinManga.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/ZinManga.kt @@ -17,19 +17,12 @@ internal class Zinmanga(context: MangaLoaderContext) : override val datePattern = "yyyy-MM-dd" override val withoutAjax = true - // The reader page carries a ubiquitous `