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..dcde9fc5 --- /dev/null +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/all/MangaPlusParser.kt @@ -0,0 +1,336 @@ +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.JSONArray +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.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.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.util.SuspendLazy +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 org.koitharu.kotatsu.parsers.util.toTitleCase +import java.util.UUID + +internal abstract class MangaPlusParser( + context: MangaLoaderContext, + source: MangaSource, + private val sourceLang: String +) : MangaParser(context, source), Interceptor { + + override val configKeyDomain = ConfigKey.Domain("mangaplus.shueisha.co.jp") + + override val availableSortOrders = setOf( + SortOrder.POPULARITY, + SortOrder.UPDATED, + SortOrder.ALPHABETICAL + ) + + private val extraHeaders = Headers.headersOf("Session-Token", UUID.randomUUID().toString()) + + // no tags or tag search available + override suspend fun getAvailableTags(): Set = emptySet() + + 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() + SortOrder.UPDATED -> getLatestList() + else -> getAllTitleList() + } + } + is MangaListFilter.Search -> getAllTitleList(filter.query) + else -> getAllTitleList() + } + } + + private suspend fun getPopularList(): List { + val json = apiCall("/title_list/ranking") + + return json.getJSONObject("titleRankingView") + .getJSONArray("titles") + .toJSONList() + .toMangaList() + } + + private suspend fun getLatestList(): List { + val json = apiCall("/title_list/updated") + + return json.getJSONObject("titleUpdatedView") + .getJSONArray("latestTitle") + .mapJSON { it.getJSONObject("title") } + .toMangaList() + } + + // since search is local, save network calls on related manga call + private val allTitleCache = SuspendLazy { + apiCall("/title_list/allV2") + .getJSONObject("allTitlesViewV2") + .getJSONArray("AllTitlesGroup") + .mapJSON { it.getJSONArray("titles").toJSONList() } + .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) + + // 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 completed = json.getJSONObject("titleLabels") + .getString("releaseSchedule").let { + it == "DISABLED" || it == "COMPLETED" + } + + val hiatus = json.getStringOrNull("nonAppearanceInfo")?.contains("on a hiatus") == true + + return manga.copy( + title = title.getString("name"), + publicUrl = "/titles/${title.getInt("titleId")}".toAbsoluteUrl(domain), + coverUrl = title.getString("portraitImageUrl"), + author = title.getString("author") + .split("/").joinToString(transform = String::trim), + 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 = if (completed) { + MangaState.FINISHED + } else if (hiatus) { + MangaState.PAUSED + } else { + MangaState.ONGOING + } + ) + } + + private fun parseChapters(chapterListGroup: JSONArray, language: String): List { + val chapterList = chapterListGroup + .toJSONList() + .flatMap { + it.optJSONArray("firstChapterList")?.toJSONList().orEmpty() + + it.optJSONArray("lastChapterList")?.toJSONList().orEmpty() + } + + return chapterList.mapNotNull { chapter -> + val chapterId = chapter.getInt("chapterId").toString() + val subtitle = chapter.getStringOrNull("subTitle") + ?: return@mapNotNull null + + MangaChapter( + id = generateUid(chapterId), + url = chapterId, + name = subtitle, + number = chapter.getString("name") + .substringAfter("#") + .toIntOrNull() ?: -1, + 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 + ) + } + } + + // image descrambling + 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 } + + if (reason?.getStringOrNull("subject") == "Not Found" && url.contains("manga_viewer")) { + "This chapter has expired" + } else { + reason?.getStringOrNull("body") ?: "Unknown Error" + } + } + } + + companion object { + private const val apiUrl = "https://jumpg-webapi.tokyo-cdn.com/api" + } + + @MangaSourceParser("MANGAPLUSPARSER_EN", "MANGA Plus English", "en") + class English(context: MangaLoaderContext) : MangaPlusParser( + context, + MangaSource.MANGAPLUSPARSER_EN, + "ENGLISH" + ) + + @MangaSourceParser("MANGAPLUSPARSER_ES", "MANGA Plus Spanish", "es") + class Spanish(context: MangaLoaderContext) : MangaPlusParser( + context, + MangaSource.MANGAPLUSPARSER_ES, + "SPANISH" + ) + + @MangaSourceParser("MANGAPLUSPARSER_FR", "MANGA Plus French", "fr") + class French(context: MangaLoaderContext) : MangaPlusParser( + context, + MangaSource.MANGAPLUSPARSER_FR, + "FRENCH" + ) + + @MangaSourceParser("MANGAPLUSPARSER_ID", "MANGA Plus Indonesian", "id") + class Indonesian(context: MangaLoaderContext) : MangaPlusParser( + context, + MangaSource.MANGAPLUSPARSER_ID, + "INDONESIAN" + ) + + @MangaSourceParser("MANGAPLUSPARSER_PTBR", "MANGA Plus Portuguese (Brazil)", "pt") + class Portuguese(context: MangaLoaderContext) : MangaPlusParser( + context, + MangaSource.MANGAPLUSPARSER_PTBR, + "PORTUGUESE_BR" + ) + + @MangaSourceParser("MANGAPLUSPARSER_RU", "MANGA Plus Russian", "ru") + class Russian(context: MangaLoaderContext) : MangaPlusParser( + context, + MangaSource.MANGAPLUSPARSER_RU, + "RUSSIAN" + ) + + @MangaSourceParser("MANGAPLUSPARSER_TH", "MANGA Plus Thai", "th") + class Thai(context: MangaLoaderContext) : MangaPlusParser( + context, + MangaSource.MANGAPLUSPARSER_TH, + "THAI" + ) + + @MangaSourceParser("MANGAPLUSPARSER_VI", "MANGA Plus Vietnamese", "vi") + class Vietnamese(context: MangaLoaderContext) : MangaPlusParser( + context, + MangaSource.MANGAPLUSPARSER_VI, + "VIETNAMESE" + ) +}