diff --git a/build.gradle.kts b/build.gradle.kts index e1e68be..86beb8a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -5,6 +5,7 @@ plugins { `maven-publish` alias(libs.plugins.kotlin.jvm) alias(libs.plugins.ksp) + id("com.google.protobuf") version "0.9.4" } group = "org.dokiteam" @@ -35,6 +36,11 @@ kotlin { sourceSets["main"].kotlin.srcDirs("build/generated/ksp/main/kotlin") } +protobuf { + protoc { + artifact = "com.google.protobuf:protoc:4.33.5" + } +} publishing { publications { create("mavenJava") { @@ -50,6 +56,7 @@ dependencies { implementation(libs.json) implementation(libs.androidx.collection) api(libs.jsoup) + api(libs.protobuf.java) ksp(project(":doki-ksp")) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7912cdf..8db16a9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,34 +1,37 @@ -[versions] -kotlin = "2.2.10" -ksp = "2.2.10-2.0.2" -coroutines = "1.10.2" -junit = "5.10.1" -okhttp = "5.1.0" -okio = "3.16.0" -json = "20240303" -androidx-collection = "1.5.0" -jsoup = "1.21.2" -quickjs = "1.1.0" -korte = "4.0.10" +[versions] +kotlin = "2.2.10" +ksp = "2.2.10-2.0.2" +coroutines = "1.10.2" +junit = "5.10.1" +okhttp = "5.1.0" +okio = "3.16.0" +json = "20240303" +androidx-collection = "1.5.0" +jsoup = "1.21.2" +quickjs = "1.1.0" +korte = "4.0.10" simplexml = "2.7.1" - +protobuf = "4.33.5" + [plugins] kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } - -[libraries] -ksp-symbol-processing-api = { module = "com.google.devtools.ksp:symbol-processing-api", version.ref = "ksp" } -kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" } -kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" } -junit-launcher = { group = "org.junit.platform", name = "junit-platform-launcher" } -junit-api = { group = "org.junit.jupiter", name = "junit-jupiter-api", version.ref = "junit" } -junit-engine = { group = "org.junit.jupiter", name = "junit-jupiter-engine", version.ref = "junit" } -junit-params = { group = "org.junit.jupiter", name = "junit-jupiter-params", version.ref = "junit" } -okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } -okio = { module = "com.squareup.okio:okio", version.ref = "okio" } -json = { module = "org.json:json", version.ref = "json" } -androidx-collection = { module = "androidx.collection:collection", version.ref = "androidx-collection" } -jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" } -quickjs = { module = "io.webfolder:quickjs", version.ref = "quickjs" } -korte = { module = "com.soywiz.korlibs.korte:korte-jvm", version.ref = "korte" } +protobuf = { id = "com.google.protobuf", version = "0.9.4" } + +[libraries] +ksp-symbol-processing-api = { module = "com.google.devtools.ksp:symbol-processing-api", version.ref = "ksp" } +kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" } +kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" } +junit-launcher = { group = "org.junit.platform", name = "junit-platform-launcher" } +junit-api = { group = "org.junit.jupiter", name = "junit-jupiter-api", version.ref = "junit" } +junit-engine = { group = "org.junit.jupiter", name = "junit-jupiter-engine", version.ref = "junit" } +junit-params = { group = "org.junit.jupiter", name = "junit-jupiter-params", version.ref = "junit" } +okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } +okio = { module = "com.squareup.okio:okio", version.ref = "okio" } +json = { module = "org.json:json", version.ref = "json" } +androidx-collection = { module = "androidx.collection:collection", version.ref = "androidx-collection" } +jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" } +quickjs = { module = "io.webfolder:quickjs", version.ref = "quickjs" } +korte = { module = "com.soywiz.korlibs.korte:korte-jvm", version.ref = "korte" } simplexml = { module = "org.simpleframework:simple-xml", version.ref = "simplexml" } +protobuf-java = { module = "com.google.protobuf:protobuf-java", version.ref = "protobuf" } diff --git a/src/main/kotlin/org/dokiteam/doki/parsers/site/all/MangaPlusParser.kt b/src/main/kotlin/org/dokiteam/doki/parsers/site/all/MangaPlusParser.kt index 406411f..b1893e7 100644 --- a/src/main/kotlin/org/dokiteam/doki/parsers/site/all/MangaPlusParser.kt +++ b/src/main/kotlin/org/dokiteam/doki/parsers/site/all/MangaPlusParser.kt @@ -6,7 +6,13 @@ import okhttp3.Interceptor import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.Response import okhttp3.ResponseBody.Companion.toResponseBody -import org.json.JSONArray +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import com.google.protobuf.ByteString +import com.google.protobuf.CodedInputStream +import com.google.protobuf.UnknownFieldSet +import com.google.protobuf.WireFormat +import jp.co.comic.jump.proto.MangaplusApi import org.json.JSONObject import org.dokiteam.doki.parsers.MangaLoaderContext import org.dokiteam.doki.parsers.MangaSourceParser @@ -28,6 +34,10 @@ internal abstract class MangaPlusParser( ) : SinglePageMangaParser(context, source), Interceptor { private val apiUrl = "https://jumpg-webapi.tokyo-cdn.com/api" + private val appApiUrl = "https://jumpg-api.tokyo-cdn.com/api" + private val appVersion = "235" + private val os = "android" + private val osVersion = "29" override val configKeyDomain = ConfigKey.Domain("mangaplus.shueisha.co.jp") override fun onCreateConfig(keys: MutableCollection>) { @@ -49,6 +59,12 @@ internal abstract class MangaPlusParser( override suspend fun getFilterOptions() = MangaListFilterOptions() private val extraHeaders = Headers.headersOf("Session-Token", UUID.randomUUID().toString()) + private val appHeaders = Headers.headersOf( + "User-Agent", "okhttp/4.12.0", + "Connection", "Keep-Alive", + "Host", "jumpg-api.tokyo-cdn.com", + ) + private val appSecret = suspendLazy { registerAppDevice() } override suspend fun getList(order: SortOrder, filter: MangaListFilter): List { return when { @@ -145,6 +161,14 @@ internal abstract class MangaPlusParser( val hiatus = json.getStringOrNull("nonAppearanceInfo")?.contains("on a hiatus") == true val author = title.getString("author") .split("/").joinToString(transform = String::trim) + val chapters = runCatching { + parseAppChapters(title.getInt("titleId")) + }.getOrElse { + parseChapters( + json, + title.getStringOrNull("language") ?: "ENGLISH", + ) + } return manga.copy( title = title.getString("name"), @@ -157,10 +181,7 @@ internal abstract class MangaPlusParser( ?.takeIf { !completed } ?.let { append("

", it) } }, - chapters = parseChapters( - json.getJSONArray("chapterListGroup"), - title.getStringOrNull("language") ?: "ENGLISH", - ), + chapters = chapters, state = when { completed -> MangaState.FINISHED hiatus -> MangaState.PAUSED @@ -169,13 +190,22 @@ internal abstract class MangaPlusParser( ) } - private fun parseChapters(chapterListGroup: JSONArray, language: String): List { - val chapterList = chapterListGroup - .asTypedList() - .flatMap { + private fun parseChapters(titleDetailView: JSONObject, language: String): List { + val chapterListFromGroups = titleDetailView + .optJSONArray("chapterListGroup") + ?.asTypedList() + ?.flatMap { it.optJSONArray("firstChapterList")?.asTypedList().orEmpty() + + it.optJSONArray("midChapterList")?.asTypedList().orEmpty() + it.optJSONArray("lastChapterList")?.asTypedList().orEmpty() } + .orEmpty() + val chapterListFallback = titleDetailView.optJSONArray("firstChapterList") + ?.asTypedList() + .orEmpty() + titleDetailView.optJSONArray("lastChapterList") + ?.asTypedList() + .orEmpty() + val chapterList = chapterListFromGroups + chapterListFallback return chapterList.mapChapters { _, chapter -> val chapterId = chapter.getInt("chapterId").toString() @@ -200,10 +230,133 @@ internal abstract class MangaPlusParser( } } + private suspend fun parseAppChapters(titleId: Int): List { + val responseBytes = appApiCallBytes( + "/title_detailV3?title_id=$titleId&lang=${contentLangCode(sourceLang)}&clang=${contentLangCode(sourceLang)}", + ) + val chapterList = findAppChapterEntries(UnknownFieldSet.parseFrom(responseBytes)) + .distinctBy { it.chapterId } + + check(chapterList.isNotEmpty()) { "No chapters found in app response" } + + return chapterList.mapChapters { _, chapter -> + val subtitle = chapter.subTitle.takeIf { it.isNotBlank() } ?: return@mapChapters null + val chapterId = chapter.chapterId.toString() + MangaChapter( + id = generateUid(chapterId), + url = chapterId, + title = subtitle, + number = chapter.name.substringAfter("#").toFloatOrNull() ?: -1f, + volume = 0, + uploadDate = chapter.startTimeStamp * 1000L, + branch = when (sourceLang) { + "PORTUGUESE_BR" -> "Portuguese (Brazil)" + else -> sourceLang.lowercase().toTitleCase() + }, + scanlator = null, + source = source, + ) + } + } + + private data class AppChapterEntry( + val chapterId: Int, + val name: String, + val subTitle: String, + val startTimeStamp: Long, + ) + + private fun parseAppChapterEntry(group: UnknownFieldSet): AppChapterEntry? { + fun varint(number: Int): Long = group.asMap()[number]?.varintList?.firstOrNull() ?: 0L + fun str(number: Int): String = group.asMap()[number]?.lengthDelimitedList?.firstOrNull()?.toUtf8().orEmpty() + val chapterId = varint(2).toInt() + val name = str(3) + val subTitle = str(4) + val startTimeStamp = varint(6) + if (chapterId == 0 || subTitle.isBlank()) { + return null + } + return AppChapterEntry( + chapterId = chapterId, + name = name, + subTitle = subTitle, + startTimeStamp = startTimeStamp, + ) + } + + private fun findAppChapterEntries(root: UnknownFieldSet): List { + val result = mutableListOf() + + fun walk(set: UnknownFieldSet) { + for ((fieldNumber, field) in set.asMap()) { + if (fieldNumber == 38) { + field.groupList.mapNotNullTo(result, ::parseAppChapterEntry) + field.lengthDelimitedList + .mapNotNull { bytes -> runCatching { parseAppChapterEntry(bytes.toByteArray()) }.getOrNull() } + .mapNotNullTo(result) { it } + } + field.groupList.forEach(::walk) + field.lengthDelimitedList.forEach { bytes -> + runCatching { UnknownFieldSet.parseFrom(bytes) } + .onSuccess(::walk) + } + } + } + + walk(root) + return result + } + + private fun parseAppChapterEntry(bytes: ByteArray): AppChapterEntry? { + var chapterId = 0 + var name = "" + var subTitle = "" + var startTimeStamp = 0L + val input = CodedInputStream.newInstance(bytes) + + while (!input.isAtEnd) { + val tag = input.readTag() + if (tag == 0) break + when (WireFormat.getTagFieldNumber(tag)) { + 2 -> chapterId = input.readInt32() + 3 -> name = input.readStringRequireUtf8() + 4 -> subTitle = input.readStringRequireUtf8() + 6 -> startTimeStamp = input.readInt64() + else -> input.skipField(tag) + } + } + if (chapterId == 0 || subTitle.isBlank()) { + return null + } + return AppChapterEntry( + chapterId = chapterId, + name = name, + subTitle = subTitle, + startTimeStamp = startTimeStamp, + ) + } + + private fun ByteString.toUtf8(): String = toStringUtf8() + 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") + val pages = runCatching { + apiCall("/manga_viewer?chapter_id=${chapter.url}&split=yes&img_quality=super_high") + }.getOrElse { primaryError -> + // Fallback to app-like flags used by MangaPlus API clients when default viewer call is denied. + val fallbackQueries = arrayOf( + "/manga_viewer?chapter_id=${chapter.url}&split=yes&img_quality=super_high&free_reading=yes&viewer_mode=vertical&clang=${contentLangCode(sourceLang)}", + "/manga_viewer?chapter_id=${chapter.url}&split=yes&img_quality=super_high&subscription_reading=yes&viewer_mode=vertical&clang=${contentLangCode(sourceLang)}", + "/manga_viewer?chapter_id=${chapter.url}&split=yes&img_quality=super_high&ticket_reading=yes&viewer_mode=vertical&clang=${contentLangCode(sourceLang)}", + ) + var lastError = primaryError + for (query in fallbackQueries) { + val result = runCatching { apiCall(query) } + result.onSuccess { return@getOrElse it } + lastError = result.exceptionOrNull() ?: lastError + } + runCatching { return@getOrElse appViewerApiCall(chapter.url.toInt()) } + throw lastError + }.getJSONObject("mangaViewer").getJSONArray("pages") return pages.mapJSONNotNull { val mangaPage = it.optJSONObject("mangaPage") @@ -245,9 +398,97 @@ internal abstract class MangaPlusParser( .toByteArray() } + private suspend fun appViewerApiCall(chapterId: Int): JSONObject { + val success = appApiCall( + "/manga_viewer?chapter_id=$chapterId&split=yes&img_quality=super_high&viewer_mode=vertical&clang=${contentLangCode(sourceLang)}", + ) + val pages = success.mangaViewer.pagesList + return JSONObject().put( + "mangaViewer", + JSONObject().put( + "pages", + org.json.JSONArray( + pages.mapNotNull { page -> + if (!page.hasMangaPage()) { + return@mapNotNull null + } + val mangaPage = page.mangaPage + JSONObject().put( + "mangaPage", + JSONObject() + .put("imageUrl", mangaPage.imageUrl) + .put("encryptionKey", mangaPage.encryptionKey), + ) + }, + ), + ), + ) + } + + private suspend fun registerAppDevice(): String { + val deviceToken = UUID.randomUUID().toString().md5() + val securityKey = (deviceToken + "4Kin9vGg").md5() + val success = appApiCall( + "/register?device_token=$deviceToken&security_key=$securityKey", + method = "PUT", + withSecret = false, + ) + val secret = success.registerationData.deviceSecret + check(secret.isNotBlank()) { "Cannot obtain app secret" } + return secret + } + + private suspend fun appApiCall( + url: String, + method: String = "GET", + withSecret: Boolean = true, + ): MangaplusApi.SuccessResult { + val response = MangaplusApi.Response.parseFrom(appApiCallBytes(url, method, withSecret)) + + return if (response.hasSuccess()) { + response.success + } else { + val error = response.error + val message = error.englishPopup.body.takeIf { it.isNotBlank() } + ?: error.debugInfo.takeIf { it.isNotBlank() } + ?: "Unknown Error" + error(message) + } + } + + private suspend fun appApiCallBytes( + url: String, + method: String = "GET", + withSecret: Boolean = true, + ): ByteArray { + val newUrl = "$appApiUrl$url".toHttpUrl().newBuilder() + .addQueryParameter("os", os) + .addQueryParameter("os_ver", osVersion) + .addQueryParameter("app_ver", appVersion) + .apply { + if (withSecret) { + addQueryParameter("secret", appSecret.get()) + } + } + .build() + val requestBuilder = Request.Builder() + .url(newUrl) + .headers(appHeaders) + if (method == "PUT") { + requestBuilder.put("".toRequestBody()) + } else { + requestBuilder.get() + } + + return context.httpClient.newCall(requestBuilder.build()).await().use { it.body.bytes() } + } + private suspend fun apiCall(url: String): JSONObject { val newUrl = "$apiUrl$url".toHttpUrl().newBuilder() .addQueryParameter("format", "json") + .addQueryParameter("os", os) + .addQueryParameter("os_ver", osVersion) + .addQueryParameter("app_ver", appVersion) .build() val response = webClient.httpGet(newUrl, extraHeaders).parseJson() @@ -267,6 +508,19 @@ internal abstract class MangaPlusParser( } } + private fun contentLangCode(language: String): String = when (language) { + "ENGLISH" -> "eng" + "SPANISH" -> "esp" + "FRENCH" -> "fra" + "INDONESIAN" -> "ind" + "PORTUGUESE_BR" -> "ptb" + "RUSSIAN" -> "rus" + "THAI" -> "tha" + "GERMAN" -> "deu" + "VIETNAMESE" -> "vie" + else -> "eng" + } + @MangaSourceParser("MANGAPLUSPARSER_EN", "MANGA Plus English", "en") class English(context: MangaLoaderContext) : MangaPlusParser( context, diff --git a/src/main/proto/mangaplus_api.proto b/src/main/proto/mangaplus_api.proto new file mode 100644 index 0000000..383a663 --- /dev/null +++ b/src/main/proto/mangaplus_api.proto @@ -0,0 +1,1122 @@ +syntax = "proto3"; + +package mangaplus_api; + +option java_package = "jp.co.comic.jump.proto"; + +message AdNetworkList { + repeated AdNetwork adNetworks = 1; +} + +message AdRewardNetworkList { + repeated AdNetwork adNetworks = 1; + string token = 2; + int32 rewardViewCount = 3; +} + +message AdNetwork { + + enum AdType { + STILL_IMAGE = 0; + MOVIE = 1; + NATIVE_MANUAL = 2; + NATIVE_MEDIUM = 3; + MREC = 4; + REWARD = 5; + } + + message Facebook { + string placementID = 1; + } + + message Admob { + string unitID = 1; + } + + message Adsense { + string unitID = 1; + string location = 2; + } + + message Applovin { + string unitID = 1; + } + + message Mopub { + string unitID = 1; + } + + message ApplovinMax { + string unitID = 1; + int32 type = 2; + string amazonAppId = 3; + string amazonAdUnitId = 4; + } + + Facebook facebook = 1; + Admob admob = 2; + Mopub mopub = 3; + Adsense adsense = 4; + Applovin applovin = 5; + ApplovinMax applovinMax = 6; + + enum NetworkCase { + NETWORK_NOT_SET = 0; + FACEBOOK = 1; + ADMOB = 2; + MOPUB = 3; + ADSENSE = 4; + APPLOVIN = 5; + APPLOVINMAX = 6; + } +} + +message AllFreeTitlesView { + repeated FreeTitle freeTitles = 1; +} + +message AllTicketTitlesView { + repeated TicketTitles ticketTitle = 1; +} + +message AllTitlesView { + repeated Title titles = 1; +} + +message AllTitlesViewV2 { + repeated AllTitlesGroup allTitlesGroup = 1; +} + +message Banner { + string imageUrl = 1; + TransitionAction action = 2; + int32 id = 3; + int32 width = 4; + int32 height = 5; + bool isPr = 6; +} + +message Chapter { + int32 titleId = 1; + int32 chapterId = 2; + string name = 3; + string subTitle = 4; + string thumbnailUrl = 5; + int64 startTimeStamp = 6; + int64 endTimeStamp = 7; + bool alreadyViewed = 8; + bool isVerticalOnly = 9; + int32 chapterTicketEndtime = 10; + bool viewedForFree = 11; + bool isHorizontalOnly = 12; + int32 viewCount = 13; + int32 commentCount = 14; +} + +message CommentIcon { + int32 id = 1; + string imageUrl = 2; +} + +message CommentListView { + message TitleDetailComment { + int32 titleId = 1; + string titleName = 2; + string chapterName = 3; + string credit = 4; + } + + repeated Comment comments = 1; + bool ifSetUserName = 2; + TitleDetailComment titleDetailComment = 3; +} + +message Comment { + int32 id = 1; + int32 index = 2; + string userName = 3; + string iconUrl = 4; + bool isMyComment = 6; + bool alreadyLiked = 7; + int32 numberOfLikes = 9; + string body = 10; + int32 created = 11; +} + +enum FirstTimeFreePlatform { + DISABLED = 0; + WEB = 1; + APP = 2; +} + +message FreeViewDialogue { + FirstTimeFreePlatform platform = 1; + string dialogueUrl = 2; + Banner publisherBanner = 3; +} + +message DownloadableImagesView { + repeated DownloadableImageGroup downloadableImages = 1; +} + +message DownloadableImageGroup { + int32 id = 1; + string imageUrl = 2; + string imageTitle = 3; + int32 width = 4; + int32 height = 5; + string type = 6; +} + +message ErrorResult { + enum Action { + DEFAULT = 0; + UNAUTHORIZED = 1; + MAINTENANCE = 2; + GEOIP_BLOCKING = 3; + } + + Action action = 1; + Popup.OSDefault englishPopup = 2; + Popup.OSDefault spanishPopup = 3; + string debugInfo = 4; + repeated Popup.OSDefault popups = 5; +} + +message FavoriteTitlesView { + repeated AvailableLanguages availableLanguages = 1; + repeated FavoriteTitleGroup favoriteTitles = 2; +} + +message FavoriteTitleGroup { + Language language = 1; + repeated Title titles = 2; +} + +message FeaturedTitlesView { + + message Contents { + enum DataCase { + DATA_NOT_SET = 0; + BANNER = 1; + TITLE_LIST = 2; + } + + DataCase data = 1; + TitleList titleList = 2; + } + + Banner mainBanner = 1; + Banner subBanner1 = 2; + Banner subBanner2 = 3; + repeated Contents contents = 4; +} + +message FeaturedTitlesViewV2 { + + message Contents { + Banner banner = 1; + TitleList titleList = 2; + repeated TitleRankingGroup rankedTitles = 3; + } + + repeated Banner topSearchBanners = 1; + repeated Contents contents = 2; +} + +message Feedback { + enum ResponseType { + QUESTION = 0; + ANSWER = 1; + } + + int64 timeStamp = 1; + string body = 2; + ResponseType responseType = 3; +} + +message FeedbackView { + repeated Feedback feedbackList = 1; +} + +message HistoryView { + repeated Banner historyBanners = 1; + repeated Title viewHistory = 2; +} + +message HomeViewV3 { + repeated Banner topBanners = 1; + repeated UpdatedTitleV2Group groups = 2; + Popup popup = 9; + bool displayTrackingPopup = 10; + Subscription userSubscription = 11; + repeated ServiceAnnouncement serviceAnnouncements = 12; +} + +message HomeViewV4 { + + message Sections { + enum SectionCase { + SECTION_NOT_SET = 0; + WEEKLY_SECTION = 1; + RANKING_SECTION = 2; + PREVIEW_SECTION = 3; + TITLE_LIST_SECTION = 4; + BANNER_SECTION = 5; + } + + WeeklySection weeklySection = 1; + RankingSection rankingSection = 2; + PreviewSection previewSection = 3; + TitleListSection titleListSection = 4; + BannerSection bannerSection = 5; + } + + message WeeklySection { + message WeeklyContent { + bool isUpdated = 1; + int64 updatedTimeStamp = 2; + repeated ContentItem contentItems = 3; + } + + message ContentItem { + enum ContentCase { + CONTENT_NOT_SET = 0; + PR_BANNER = 1; + MV_BANNER = 2; + TITLE_GROUP = 3; + CAROUSEL_BANNERS = 4; + MINOR_LANGUAGE_BANNER = 5; + } + + ContentCase content = 1; + MVBanner mVBanner = 2; + TitleGroup titleGroup = 3; + CarouselBanner carouselBanners = 4; + MinorLanguageBanner minorLanguageBanner = 5; + } + + message MVBanner { + string imageUrl = 1; + OriginalTitleGroup titleGroups = 2; + } + + message TitleGroup { + repeated OriginalTitleGroup titleGroups = 1; + } + + message CarouselBanner { + repeated Banner banners = 1; + } + + message MinorLanguageBanner { + repeated OriginalTitleGroup titleGroups = 1; + } + + repeated WeeklyContent contents = 1; + } + + message RankingSection { + message RankingTab { + RankingTabType tabType = 1; + repeated TitleRankingGroup rankedTitles = 2; + } + + repeated RankingTab rankingTabs = 1; + } + + message PreviewSection { + message PreviewTab { + RankingTabType tabType = 1; + ChapterPageList chapterPagesList = 2; + } + + repeated PreviewTab previewTabs = 1; + } + + message TitleListSection { + TitleList titleList = 1; + } + + message BannerSection { + repeated Banner banners = 1; + } + + repeated Sections sections = 1; + Popup popup = 2; + bool displayTrackingPopup = 3; + Subscription userSubscription = 4; + repeated ServiceAnnouncement serviceAnnouncements = 5; +} + +message InitialViewV2 { + bool gdprAgreementRequired = 1; + repeated AvailableLanguages availableLanguages = 2; +} + +message IntroduceSubscription { + string subscription = 1; + string titleCountText = 2; + string chapterCountText = 3; +} + +message LabeledView { + Label label = 1; + repeated LabeledTitlesGroup labeledTitlesGroup = 2; +} + +enum LabelCodes { + WJ = 0; + SQ = 1; + VJ = 2; + YJ = 3; + J_PLUS = 4; + REVIVAL = 5; + CREATORS = 6; + MEE = 7; + TYJ = 8; + OTHERS = 9; + SKJ = 10; + GIGA = 11; + UJ = 12; + DX = 13; +} + +message Label { + LabelCodes label = 1; + string description = 2; +} + +enum Language { + ENGLISH = 0; + SPANISH = 1; + FRENCH = 2; + INDONESIAN = 3; + PORTUGUESE_BR = 4; + RUSSIAN = 5; + THAI = 6; + GERMAN = 7; + ITALIAN = 8; + VIETNAMESE = 9; +} + +message Languages { + Language defaultUiLanguage = 1; + Language defaultContentLanguageOne = 2; + Language defaultContentLanguageTwo = 3; + Language defaultContentLanguageThree = 4; + repeated AvailableLanguages availableLanguages = 5; +} + +message AvailableLanguages { + Language language = 1; + int32 titlesCount = 2; +} + +message MangaViewer { + + message TitleAvailableLanguages { + int32 titleId = 1; + Language language = 2; + bool isNew = 3; + } + + repeated Page pages = 1; + int32 chapterId = 2; + repeated Chapter chapters = 3; + Sns sns = 4; + string titleName = 5; + string chapterName = 6; + int32 numberOfComments = 7; + bool isVerticalOnly = 8; + int32 titleId = 9; + bool startFromRight = 10; + string regionCode = 11; + bool isHorizontalOnly = 12; + Subscription userSubscription = 13; + string planType = 14; + string titleLanguage = 15; + Popup ratingPopup = 16; + repeated TitleAvailableLanguages titleAvailableLanguages = 17; + Title title = 18; +} + +message MetaInfo { + string metaTitle = 1; + string metaDescription = 2; +} + +message MpcStatusView { + bool isMpcMaintenance = 1; + string maintenanceMessage = 2; +} + +message Page { + enum ChapterType { + LATEST = 0; + SEQUENCE = 1; + NOSEQUENCE = 2; + } + + enum PageType { + SINGLE = 0; + LEFT = 1; + RIGHT = 2; + DOUBLE = 3; + } + + message MangaPage { + string imageUrl = 1; + int32 width = 2; + int32 height = 3; + PageType type = 4; + string encryptionKey = 5; + } + + message LastPage { + Chapter currentChapter = 1; + Chapter nextChapter = 2; + repeated Comment topComments = 3; + bool isSubscribed = 4; + int64 nextTimeStamp = 5; + ChapterType chapterType = 6; + AdNetworkList advertisement = 7; + Popup movieReward = 8; + repeated Banner banners = 9; + repeated Title ticketTitleList = 10; + Banner publisherBanner = 11; + UserTickets userTickets = 12; + bool isNextChapterReadByTicket = 13; + bool isNextChapterOneTimeFree = 14; + FreeViewDialogue freeViewDialogue = 15; + bool isNextChapterSubscription = 16; + AdRewardNetworkList advertisementReward = 17; + AdNetworkList viewerEndAdvertisement = 18; + } + + message BannerList { + string bannerTitle = 1; + repeated Banner banners = 2; + } + + enum DataCase { + DATA_NOT_SET = 0; + MANGA_PAGE = 1; + BANNER_LIST = 2; + LAST_PAGE = 3; + ADVERTISEMENT = 4; + INSERT_BANNER_LIST = 5; + } + + MangaPage mangaPage = 1; + BannerList bannerList = 2; + LastPage lastPage = 3; + AdRewardNetworkList advertisement = 4; + BannerList insertBannerList = 5; +} + +message Popup { + message OSDefault { + string subject = 1; + string body = 2; + Button okButton = 3; + Button neutralButton = 4; + Button cancelButton = 5; + Language language = 6; + } + + message AppDefault { + string subject = 1; + string body = 2; + TransitionAction action = 3; + string imageUrl = 4; + } + + message OneImage { + TransitionAction action = 1; + string imageUrl = 2; + } + + message Button { + string text = 1; + TransitionAction action = 2; + } + + enum PopupCase { + POPUP_NOT_SET = 0; + OS_DEFAULT = 1; + APP_DEFAULT = 2; + MOVIE_REWARD = 3; + ONE_IMAGE = 4; + } + + OSDefault oSDefault = 1; + AppDefault appDefault = 2; + MovieReward movieReward = 3; + OneImage oneImage = 4; + int32 popupId = 5; +} + +message MovieReward { + string imageUrl = 1; + AdNetworkList advertisement = 2; +} + +message ProfileSettingsView { + repeated CommentIcon iconList = 1; + string userName = 2; + CommentIcon myIcon = 3; +} + +message PublisherNewsListView { + int32 publisherId = 1; + string publisherName = 2; + Banner banner = 3; + repeated PublisherNews newsList = 4; +} + +message PublisherNews { + int32 id = 1; + int32 publisherId = 2; + string publisherName = 3; + string subject = 4; + string body = 5; + int64 publishedTimeStamp = 6; + TransitionAction action = 7; +} + +message PushTokenView { + string token = 1; + int64 tokenTimeStamp = 2; +} + +message Questionnaire { + string description = 1; + repeated string selection = 2; + int32 numberOfChoices = 3; + bool hideFreeform = 4; + string freeForm = 5; + bool canSkip = 6; +} + +message QuestionnaireView { + bool isAnswered = 1; + string subject = 2; + repeated Questionnaire questions = 3; + Language language = 4; +} + +enum RankingTabType { + HOTTEST = 0; + TRENDING = 1; + COMPLETED = 2; +} + +message RegistrationData { + string deviceSecret = 1; +} + +message Response { + enum ResultCase { + RESULT_NOT_SET = 0; + SUCCESS = 1; + ERROR = 2; + } + + SuccessResult success = 1; + ErrorResult error = 2; +} + +message SearchView { + message Contents { + Banner banner = 1; + TitleList titleList = 2; + repeated TitleRankingGroup rankedTitles = 3; + repeated Label allLabels = 4; + ChapterPageList chapterPagesList = 5; + } + + repeated Banner topSearchBanners = 1; + repeated Tag allTags = 2; + repeated AllTitlesGroup allTitlesGroup = 3; + repeated Contents contents = 5; +} + +message ServiceAnnouncement { + string title = 1; + string body = 2; + int64 date = 3; + int32 id = 4; +} + +message ServiceAnnouncementsView { + repeated ServiceAnnouncement serviceAnnouncements = 1; +} + +message SettingsView { + CommentIcon myIcon = 1; + string userName = 2; + bool noticeOfNewsAndEvents = 3; + bool noticeOfUpdatesOfSubscribedTitles = 4; + int32 englishTitlesCount = 5; + int32 spanishTitlesCount = 6; +} + +message SettingsViewV2 { + CommentIcon myIcon = 1; + string userName = 2; + bool noticeOfNewsAndEvents = 3; + bool noticeOfUpdatesOfSubscribedTitles = 4; + repeated AvailableLanguages availableLanguages = 5; + Subscription userSubscription = 6; + Banner banner = 7; + repeated CustomCodeDialogue availableCustomCodes = 8; + repeated TitleRankingGroup favoriteTitles = 9; +} + +message CustomCodeDialogue { + string planType = 1; +} + +message Sns { + string body = 1; + string url = 2; +} + +message SubscribedTitlesView { + repeated Title titles = 1; + repeated Banner historyBanners = 2; + repeated Title viewHistory = 3; +} + +message IosSubscriptionOffer { + enum OfferType { + NO_OFFER = 0; + INTRODUCTORY = 1; + PROMOTIONAL = 2; + } + + OfferType offerType = 1; + string signature = 2; + string appleKey = 3; + string nonce = 4; + string timestamp = 5; + string identifier = 6; +} + +message AndroidSubscriptionOfferTags { + string tag = 1; +} + +message Subscription { + string planType = 1; + int32 nextPaymentDate = 2; + bool isFreeTrial = 3; + bool isPendingDowngrade = 4; + bool isFirstTimeUser = 5; +} + +message PlanType { + string plan = 1; + string description = 2; + string productId = 3; + IosSubscriptionOffer subscriptionOffer = 4; + repeated AndroidSubscriptionOfferTags androidOfferTags = 5; +} + +message SubscriptionView { + Subscription userSubscription = 1; + repeated PlanType planTypes = 2; + repeated TitleSubscriptionGroup subscriptionTitles = 3; + bool userHasUsedTrial = 4; + Banner banner = 5; + SubscriptionPlanBoxDescription planBoxDescription = 6; + SubscriptionNote note = 7; +} + +message SubscriptionPlanBoxDescription { + string title = 1; + string text = 2; +} + +message SubscriptionNote { + string title = 1; + string text = 2; +} + +message SuccessResult { + enum DataCase { + DATA_NOT_SET = 0; + REGISTERATION_DATA = 2; + FEATURED_TITLES_VIEW = 4; + ALL_TITLES_VIEW = 5; + TITLE_RANKING_VIEW = 6; + SUBSCRIBED_TITLES_VIEW = 7; + TITLE_DETAIL_VIEW = 8; + COMMENT_LIST_VIEW = 9; + MANGA_VIEWER = 10; + PROFILE_SETTINGS_VIEW = 13; + UPDATE_PROFILE_RESULT_VIEW = 14; + SERVICE_ANNOUNCEMENTS_VIEW = 15; + FEEDBACK_VIEW = 17; + PUBLISHER_NEWS_LIST_VIEW = 18; + QUESTIONNAIRE_VIEW = 19; + TITLE_UPDATED_VIEW = 20; + UPDATED_TITLE_LIST_VIEW = 22; + ALL_TICKET_TITLES_VIEW = 23; + HOME_VIEW_V3 = 24; + ALL_TITLES_VIEW_V2 = 25; + SETTINGS_VIEW_V2 = 26; + TITLE_LIST_VIEW_V2 = 27; + INITIAL_VIEW_V2 = 28; + PUSH_TOKEN_VIEW = 32; + ALL_FREE_TITLES_VIEW = 33; + LABELED_VIEW = 34; + SEARCH_VIEW = 35; + SUBSCRIPTION_VIEW = 36; + TITLE_RANKING_VIEW_V2 = 37; + WEB_HOME_VIEW_V4 = 38; + FEATURED_TITLES_VIEW_V2 = 39; + HISTORY_VIEW = 40; + DOWNLOADABLE_IMAGES_VIEW = 41; + INTRODUCE_SUBSCRIPTION = 42; + FAVORITE_TITLES_VIEW = 43; + MPC_STATUS_VIEW = 44; + HOME_VIEW_V4 = 45; + } + + bool isFeaturedUpdated = 1; + RegistrationData registerationData = 2; + FeaturedTitlesView featuredTitlesView = 4; + AllTitlesView allTitlesView = 5; + TitleRankingView titleRankingView = 6; + SubscribedTitlesView subscribedTitlesView = 7; + TitleDetailView titleDetailView = 8; + CommentListView commentListView = 9; + MangaViewer mangaViewer = 10; + ProfileSettingsView profileSettingsView = 13; + UpdateProfileResultView updateProfileResultView = 14; + ServiceAnnouncementsView serviceAnnouncementsView = 15; + FeedbackView feedbackView = 17; + PublisherNewsListView publisherNewsListView = 18; + QuestionnaireView questionnaireView = 19; + TitleUpdatedView titleUpdatedView = 20; + UpdatedTitleListView updatedTitleListView = 22; + AllTicketTitlesView allTicketTitlesView = 23; + HomeViewV3 homeViewV3 = 24; + AllTitlesViewV2 allTitlesViewV2 = 25; + SettingsViewV2 settingsViewV2 = 26; + TitleUpdatedViewV2 titleListViewV2 = 27; + InitialViewV2 initialViewV2 = 28; + Languages languages = 29; + PushTokenView pushTokenView = 32; + AllFreeTitlesView allFreeTitlesView = 33; + LabeledView labeledView = 34; + SearchView searchView = 35; + SubscriptionView subscriptionView = 36; + TitleRankingViewV2 titleRankingViewV2 = 37; + WebHomeViewV4 webHomeViewV4 = 38; + FeaturedTitlesViewV2 featuredTitlesViewV2 = 39; + HistoryView historyView = 40; + DownloadableImagesView downloadableImagesView = 41; + IntroduceSubscription introduceSubscription = 42; + FavoriteTitlesView favoriteTitlesView = 43; + MpcStatusView mpcStatusView = 44; + HomeViewV4 homeViewV4 = 45; +} + +message Tag { + string tag = 1; + string slug = 2; +} + +message TitleDetailView { + enum ReleaseSchedule { + DISABLED = 0; + EVERYDAY = 1; + WEEKLY = 2; + BIWEEKLY = 3; + MONTHLY = 4; + BIMONTHLY = 5; + TRIMONTHLY = 6; + OTHER = 7; + COMPLETED = 8; + } + + enum UpdateTiming { + NOT_REGULARLY = 0; + MONDAY = 1; + TUESDAY = 2; + WEDNESDAY = 3; + THURSDAY = 4; + FRIDAY = 5; + SATURDAY = 6; + SUNDAY = 7; + DAY = 8; + } + + enum Rating { + ALLAGE = 0; + TEEN = 1; + TEENPLUS = 2; + MATURE = 3; + } + + message TitleLabels { + int32 releaseSchedule = 1; + bool isSimulpub = 2; + string planType = 3; + } + + message PublisherItem { + Banner banner = 1; + PublisherNews publisherNews = 2; + } + + message TitleLanguages { + int32 titleId = 1; + Language language = 2; + } + + message ChapterGroup { + string chapterNumbers = 1; + repeated Chapter firstChapterList = 2; + repeated Chapter midChapterList = 3; + repeated Chapter lastChapterList = 4; + } + + Title title = 1; + string titleImageUrl = 2; + string overview = 3; + string backgroundImageUrl = 4; + int64 nextTimeStamp = 5; + int32 updateTiming = 6; + string viewingPeriodDescription = 7; + string nonAppearanceInfo = 8; + repeated Chapter firstChapterList = 9; + repeated Chapter lastChapterList = 10; + repeated Banner banners = 11; + repeated Title recommendedTitleList = 12; + Sns sns = 13; + bool isSimulReleased = 14; + bool isSubscribed = 15; + Rating rating = 16; + bool chaptersDescending = 17; + int32 numberOfViews = 18; + repeated PublisherItem publisherItems = 19; + repeated Banner titleBanners = 20; + UserTickets userTickets = 21; + repeated Chapter ticketChapterList = 22; + repeated Title ticketTitleList = 23; + bool hasChaptersBetween = 24; + Banner publisherBanner = 25; + AdNetworkList advertisement = 26; + repeated TitleLanguages titleLanguages = 27; + repeated ChapterGroup chapterListGroup = 28; + FreeViewDialogue freeViewDialogue = 29; + string regionCode = 30; + repeated Tag tags = 31; + TitleLabels titleLabels = 32; + Subscription userSubscription = 33; + Label label = 34; + bool isFirstTimeFree = 35; + MetaInfo metaInfo = 36; + Popup ratingPopup = 37; +} + +message AllTitlesGroup { + string theTitle = 1; + repeated Title titles = 2; + repeated Tag tags = 3; + Label label = 4; + int64 nextChapterStartTimestamp = 5; +} + +message TitleRankingGroup { + int32 originalTitleId = 1; + repeated Title titles = 2; + int32 score = 3; +} + +message TitleUpdatedGroup { + int64 updatedTitleTimestamp = 1; + repeated AllTitlesGroup latestTitle = 2; +} + +message LabeledTitlesGroup { + string theTitle = 1; + repeated Title titles = 2; +} + +message TitleSubscriptionGroup { + string plan = 1; + repeated Title titles = 2; + string titleCountText = 3; +} + +message TitleList { + string listName = 1; + repeated Title featuredTitles = 2; + int32 containerId = 3; +} + +message ChapterPageList { + string listName = 1; + repeated ChapterPages chapterPages = 2; +} + +message ChapterPages { + int32 containerId = 1; + int32 chapterId = 2; + int32 titleId = 3; + string name = 4; + string author = 5; + string favoriteImageUrl = 6; + repeated Page pages = 7; +} + +enum TitleUpdateStatus { + NONE = 0; + NEW = 1; + UP = 2; + REEDITION = 3; + CREATORS_STATUS = 4; + OUR_PICKS = 5; +} + +message Title { + int32 titleId = 1; + string name = 2; + string author = 3; + string portraitImageUrl = 4; + string landscapeImageUrl = 5; + int32 viewCount = 6; + Language language = 7; + TitleUpdateStatus titleUpdateStatus = 8; + string favoriteImageUrl = 9; +} + +message UpdatedTitle { + Title title = 1; + int32 chapterId = 2; + string chapterName = 3; + string chapterSubTitle = 4; + bool isLatest = 5; + bool isVerticalOnly = 6; + bool isHorizontalOnly = 7; +} + +message TitleUpdated { + Title title = 1; + string updatedTitleTimestamp = 2; +} + +message FreeTitle { + Title title = 1; + string updatedTitleTimestamp = 2; +} + +message TicketTitles { + Title title = 1; + int32 firstTicketChapter = 2; + int32 lastTicketChapter = 3; +} + +message SubscribableTitle { + Title title = 1; + bool isLatest = 2; + bool isSubscribed = 3; +} + +message ComingSoonTitle { + Title title = 1; + string nextChapterName = 2; + int64 nextChapterStartTimestamp = 3; +} + +message TitleHighlight { + Title title = 1; + int32 chapterId = 2; + repeated string pageUrlList = 3; + int32 pageHight = 4; + int32 pageWidth = 5; + bool isVerticalOnly = 6; + bool isHorizontalOnly = 7; +} + +message TitleRankingView { + repeated Title titles = 1; +} + +message TitleRankingViewV2 { + repeated Banner rankingBanners = 1; + int64 updatedTimeStamp = 2; + repeated TitleRankingGroup rankedTitles = 3; +} + +message TitleUpdatedView { + repeated TitleUpdated latestTitle = 1; +} + +message TitleUpdatedViewV2 { + repeated TitleUpdatedGroup titleUpdatedGroup = 1; +} + +message TransitionAction { + enum PresentationMethod { + PUSH = 0; + MODAL = 1; + EXTERNAL = 2; + } + + PresentationMethod method = 1; + string url = 2; +} + +message UpdatedTitleGroup { + string groupName = 1; + repeated UpdatedTitle titles = 2; +} + +message UpdatedTitleV2Group { + string groupName = 1; + repeated OriginalTitleGroup titleGroups = 2; + int32 groupNameDays = 3; +} + +message OriginalTitleGroup { + string theTitle = 1; + string chapterNumber = 2; + repeated UpdatedTitle titles = 3; + int32 viewCount = 4; + TitleUpdateStatus titleUpdateStatus = 5; + int32 chapterStartTime = 6; +} + +message UpdatedTitleListView { + repeated UpdatedTitleGroup groups = 1; +} + +message UpdateProfileResultView { + enum Result { + SUCCESS = 0; + DUPLICATED = 1; + NG_WORD = 2; + } + + Result result = 1; +} + +message UserTickets { + int32 currentTickets = 1; + int64 nextTicketTimestamp = 2; +} + +message WebHomeViewV4 { + repeated Banner topBanners = 1; + repeated UpdatedTitleV2Group groups = 2; + repeated TitleRankingGroup rankedTitles = 3; + Popup popup = 4; + repeated TitleList featuredTitleLists = 5; + repeated ServiceAnnouncement serviceAnnouncements = 6; +} diff --git a/src/test/kotlin/org/dokiteam/doki/parsers/site/all/MangaPlusParserTest.kt b/src/test/kotlin/org/dokiteam/doki/parsers/site/all/MangaPlusParserTest.kt new file mode 100644 index 0000000..109b596 --- /dev/null +++ b/src/test/kotlin/org/dokiteam/doki/parsers/site/all/MangaPlusParserTest.kt @@ -0,0 +1,94 @@ +package org.dokiteam.doki.parsers.site.all + +import kotlinx.coroutines.test.runTest +import org.json.JSONObject +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.dokiteam.doki.parsers.MangaLoaderContextMock +import org.dokiteam.doki.parsers.MangaParser +import org.dokiteam.doki.parsers.model.MangaChapter +import org.dokiteam.doki.parsers.model.MangaParserSource +import org.dokiteam.doki.parsers.model.RATING_UNKNOWN +import org.dokiteam.doki.parsers.util.json.asTypedList +import kotlin.time.Duration.Companion.minutes + +internal class MangaPlusParserTest { + + private val context = MangaLoaderContextMock + private val timeout = 2.minutes + private val source = MangaParserSource.MANGAPLUSPARSER_EN + private val titleId = "100280" + + @Test + fun details_include_mid_chapters_and_load_pages() = runTest(timeout = timeout) { + val parser = context.newParserInstance(source) + val titleDetailView = getTitleDetailView(titleId) + val chapterGroups = titleDetailView.getJSONArray("chapterListGroup").asTypedList() + val legacyChapterIds = chapterGroups.flatMap { group -> + group.optJSONArray("firstChapterList")?.asTypedList().orEmpty() + + group.optJSONArray("lastChapterList")?.asTypedList().orEmpty() + }.map { it.getInt("chapterId").toString() }.toSet() + val midChapterIds = chapterGroups.flatMap { group -> + group.optJSONArray("midChapterList")?.asTypedList().orEmpty() + }.map { it.getInt("chapterId").toString() }.toSet() + + assertTrue(legacyChapterIds.isNotEmpty(), "Legacy chapter lists are empty") + assertTrue(midChapterIds.isNotEmpty(), "No chapters found in midChapterList for title $titleId") + + val manga = org.dokiteam.doki.parsers.model.Manga( + id = 100280L, + url = titleId, + publicUrl = "https://mangaplus.shueisha.co.jp/titles/$titleId", + title = "Hope You're Happy, Lemon", + coverUrl = "", + altTitles = emptySet(), + authors = emptySet(), + contentRating = null, + rating = RATING_UNKNOWN, + state = null, + source = source, + tags = emptySet(), + ) + val chapters = checkNotNull(parser.getDetails(manga).chapters) { "Chapters are null for title $titleId" } + val parsedChapterIds = chapters.map { it.url }.toSet() + assertTrue(chapters.size == 116, "Expected 116 chapters, got ${chapters.size}") + + assertTrue( + parsedChapterIds.containsAll(legacyChapterIds), + "Parser missed chapters from first/last lists", + ) + assertTrue( + parsedChapterIds.containsAll(midChapterIds), + "Parser missed chapters from midChapterList: ${midChapterIds - parsedChapterIds}", + ) + + val loadedChapter = loadFirstReadableChapter(parser, chapters, preferredIds = legacyChapterIds) + assertNotNull(loadedChapter, "No readable chapter could be loaded for title $titleId") + } + + private suspend fun getTitleDetailView(titleId: String): JSONObject { + val url = "https://jumpg-webapi.tokyo-cdn.com/api/title_detailV3?title_id=$titleId&format=json" + return context.doRequest(url, source).use { response -> + assertTrue(response.isSuccessful, "Title detail request failed: ${response.code} ${response.message}") + JSONObject(checkNotNull(response.body).string()) + .getJSONObject("success") + .getJSONObject("titleDetailView") + } + } + + private suspend fun loadFirstReadableChapter( + parser: MangaParser, + chapters: List, + preferredIds: Set, + ): MangaChapter? { + val candidates = chapters.filter { it.url in preferredIds } + chapters.filterNot { it.url in preferredIds } + for (chapter in candidates) { + val pages = runCatching { parser.getPages(chapter) }.getOrNull().orEmpty() + if (pages.isNotEmpty()) { + return chapter + } + } + return null + } +}