From ab82d0eba0ed66cf144cfd0214145fc664f974aa Mon Sep 17 00:00:00 2001 From: AwkwardPeak7 Date: Wed, 13 Dec 2023 21:18:57 +0500 Subject: [PATCH] MangaPlus --- .../parsers/site/all/MangaPlusParser.kt | 271 ++++++++++++++++++ 1 file changed, 271 insertions(+) create mode 100644 src/main/kotlin/org/koitharu/kotatsu/parsers/site/all/MangaPlusParser.kt diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/all/MangaPlusParser.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/all/MangaPlusParser.kt new file mode 100644 index 00000000..9e710a3d --- /dev/null +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/all/MangaPlusParser.kt @@ -0,0 +1,271 @@ +package org.koitharu.kotatsu.parsers.site.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.JSONObject +import org.koitharu.kotatsu.parsers.MangaLoaderContext +import org.koitharu.kotatsu.parsers.MangaParser +import org.koitharu.kotatsu.parsers.MangaSourceParser +import org.koitharu.kotatsu.parsers.config.ConfigKey +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.MangaSource +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.util.domain +import org.koitharu.kotatsu.parsers.util.generateUid +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.json.toJSONList +import org.koitharu.kotatsu.parsers.util.parseJson +import org.koitharu.kotatsu.parsers.util.toAbsoluteUrl +import java.util.Locale +import java.util.UUID + +@MangaSourceParser("MANGAPLUSPARSER", "MangaPlus", type = ContentType.HENTAI) +class MangaPlusParser(context: MangaLoaderContext) : MangaParser(context, MangaSource.MANGAPLUSPARSER), Interceptor { + + override val configKeyDomain = ConfigKey.Domain("mangaplus.shueisha.co.jp") + + override val availableSortOrders = setOf( + SortOrder.POPULARITY, + SortOrder.UPDATED, + SortOrder.ALPHABETICAL + ) + + override suspend fun getAvailableLocales()= setOf( + Locale.ENGLISH, + Locale("es"), + Locale.FRENCH, + Locale("id"), + Locale("pt_br"), + Locale("ru"), + Locale("th"), + Locale("vi") + ) + + private val extraHeaders = Headers.headersOf("Session-Token", UUID.randomUUID().toString()) + + override suspend fun getList(offset: Int, filter: MangaListFilter?): List { + if (offset > 0) { + return emptyList() + } + + return when (filter) { + is MangaListFilter.Advanced -> { + when (filter.sortOrder) { + SortOrder.POPULARITY -> getPopularList(filter.locale) + SortOrder.UPDATED -> getLatestList(filter.locale) + else -> getAllTitleList(filter.locale) + } + } + is MangaListFilter.Search -> getAllTitleList(query = filter.query) + else -> getAllTitleList() + } + } + + private suspend fun getPopularList(locale: Locale?): List { + val json = apiCall("/title_list/ranking") + + return json.getJSONObject("titleRankingView") + .getJSONArray("titles") + .toJSONList() + .toMangaList(locale.toSiteLocale()) + } + + private suspend fun getLatestList(locale: Locale?): List { + val json = apiCall("/title_list/updated") + + return json.getJSONObject("titleUpdatedView") + .getJSONArray("latestTitle") + .mapJSON { it.getJSONObject("title") } + .toMangaList(locale.toSiteLocale()) + } + + private suspend fun getAllTitleList(locale: Locale? = null, query: String? = null): List { + val json = apiCall("/title_list/allV2") + + return json.getJSONObject("allTitlesViewV2") + .getJSONArray("AllTitlesGroup") + .mapJSON { it.getJSONArray("titles").toJSONList() } + .flatten() + .toMangaList(locale.toSiteLocale(), query) + + } + + private fun Collection.toMangaList(langToFilter: String?, query: String? = null): List { + return mapNotNull { + val language = it.getStringOrNull("language") ?: "ENGLISH" + + // filter out any other language other than langToFilter + if (langToFilter != null && language != langToFilter) + return@mapNotNull null + + val name = it.getString("name") + val author = it.getString("author").replace(" / ", ", ") + + // filter out any other title or author which doesn't match search input + 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"), + altTitle = null, + author = author, + isNsfw = false, + 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 chapterList = json.getJSONArray("chapterListGroup") + .toJSONList() + .flatMap { + it.optJSONArray("firstChapterList")?.toJSONList().orEmpty() + + it.optJSONArray("lastChapterList")?.toJSONList().orEmpty() + } + val language = title.getStringOrNull("language") ?: "ENGLISH" + + return manga.copy( + title = title.getString("name"), + publicUrl = "/titles/${title.getInt("titleId")}".toAbsoluteUrl(domain), + coverUrl = title.getString("portraitImageUrl"), + author = title.getString("author").replace(" / ", ", "), + description = json.getString("overview"), + chapters = chapterList.mapNotNull { chapter -> + val chapterId = chapter.getInt("chapterId").toString() + val name = chapter.getString("name") + val subtitle = chapter.getStringOrNull("subTitle") + ?: return@mapNotNull null + + MangaChapter( + id = generateUid(chapterId), + url = chapterId, + name = "$name - $subtitle", + number = name.substringAfter("#").toIntOrNull() ?: -1, + uploadDate = chapter.getInt("startTimeStamp") * 1000L, + branch = language, + 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 + } + + val contentType = response.headers["Content-Type"] ?: "image/jpeg" + + val image = requireNotNull(response.body).bytes().decodeXorCipher(encryptionKey) + val body = image.toResponseBody(contentType.toMediaTypeOrNull()) + + return response.newBuilder() + .body(body) + .build() + } + + 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") + .toJSONList() + .firstOrNull { it.getStringOrNull("language") == null } + ?.getStringOrNull("body") + + reason ?: "Unknown Error" + } + } + + private fun Locale?.toSiteLocale(): String? { + if (this == null) return null + + return when { + equals(Locale.ENGLISH) -> "ENGLISH" + equals(Locale("es")) -> "SPANISH" + equals(Locale.FRENCH) -> "FRENCH" + equals(Locale("id")) -> "INDONESIAN" + equals(Locale("pt-BR")) -> "PORTUGUESE_BR" + equals(Locale("ru")) -> "RUSSIAN" + equals(Locale("th")) -> "THAI" + equals(Locale("vi")) -> "VIETNAMESE" + else -> null + } + } + + override suspend fun getAvailableTags(): Set { + return emptySet() + } + + override suspend fun getRelatedManga(seed: Manga): List { + return emptyList() + } + + companion object { + private const val apiUrl = "https://jumpg-webapi.tokyo-cdn.com/api" + } +} +