diff --git a/src/en/bookwalker/build.gradle b/src/en/bookwalker/build.gradle index 6050cc24f..d70d14645 100644 --- a/src/en/bookwalker/build.gradle +++ b/src/en/bookwalker/build.gradle @@ -1,7 +1,7 @@ ext { extName = 'BookWalker Global' extClass = '.BookWalker' - extVersionCode = 1 + extVersionCode = 2 isNsfw = true } diff --git a/src/en/bookwalker/src/eu/kanade/tachiyomi/extension/en/bookwalker/BookWalker.kt b/src/en/bookwalker/src/eu/kanade/tachiyomi/extension/en/bookwalker/BookWalker.kt index 9e894f667..9c49f810d 100644 --- a/src/en/bookwalker/src/eu/kanade/tachiyomi/extension/en/bookwalker/BookWalker.kt +++ b/src/en/bookwalker/src/eu/kanade/tachiyomi/extension/en/bookwalker/BookWalker.kt @@ -8,6 +8,7 @@ import androidx.preference.EditTextPreference import androidx.preference.ListPreference import androidx.preference.PreferenceScreen import androidx.preference.SwitchPreferenceCompat +import eu.kanade.tachiyomi.extension.en.bookwalker.dto.BookUpdateDto import eu.kanade.tachiyomi.extension.en.bookwalker.dto.HoldBookEntityDto import eu.kanade.tachiyomi.extension.en.bookwalker.dto.HoldBooksInfoDto import eu.kanade.tachiyomi.extension.en.bookwalker.dto.SeriesDto @@ -31,6 +32,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.serialization.json.Json import kotlinx.serialization.modules.SerializersModule import kotlinx.serialization.modules.plus @@ -52,13 +54,18 @@ import java.util.regex.PatternSyntaxException class BookWalker : ConfigurableSource, ParsedHttpSource(), BookWalkerPreferences { override val name = "BookWalker Global" + private val domain = "bookwalker.jp" - override val baseUrl = "https://global.bookwalker.jp" + override val baseUrl = "https://global.$domain" override val lang = "en" override val supportsLatest = true + private val rimgUrl = "https://rimg.$domain" + private val cUrl = "https://c.$domain" + private val memberApiUrl = "https://member-app.$domain/api" + override val client = network.client.newBuilder() .addInterceptor(BookWalkerImageRequestInterceptor(this)) .build() @@ -318,22 +325,31 @@ class BookWalker : ConfigurableSource, ParsedHttpSource(), BookWalkerPreferences override fun popularMangaParse(response: Response): MangasPage { if (showLibraryInPopular) { - val manga = response.parseAs().holdBookList.entities - .map { - when (it) { - is SeriesDto -> SManga.create().apply { - url = "/series/${it.seriesId}/" - title = it.seriesName.cleanTitle() - thumbnail_url = it.imageUrl - } - is SingleDto -> SManga.create().apply { - url = it.detailUrl.substring(baseUrl.length) - title = it.title.cleanTitle() - thumbnail_url = it.imageUrl - author = it.authors.joinToString { a -> a.authorName } + val manga = runBlocking(Dispatchers.IO) { + response.parseAs().holdBookList.entities + .mapNotNull { entity -> + when (entity) { + is SeriesDto -> { + SManga.create().apply { + url = "/series/${entity.seriesId}/" + title = entity.seriesName.cleanTitle() + thumbnail_url = getHiResCoverFromLegacyUrl(entity.imageUrl) + } + } + is SingleDto -> { + val bookUpdate = fetchBookUpdate(entity.uuid) + bookUpdate?.let { bookUpdate -> + SManga.create().apply { + url = "/de${entity.uuid}/" + title = bookUpdate.seriesName?.cleanTitle() ?: bookUpdate.productName.cleanTitle() + thumbnail_url = bookUpdate.coverImageUrl + author = bookUpdate.authors.joinToString { it.authorName } + } + } + } } } - } + } return MangasPage(manga, false) } return super.popularMangaParse(response) @@ -351,8 +367,9 @@ class BookWalker : ConfigurableSource, ParsedHttpSource(), BookWalkerPreferences return SManga.create().apply { url = titleElt.attr("href").substring(baseUrl.length) title = titleElt.attr("title").cleanTitle() - thumbnail_url = element.select(".a-tile-thumb-img > img") - .attr("data-srcset").getHighestQualitySrcset() + thumbnail_url = getHiResCoverFromLegacyUrl( + element.selectFirst(".a-tile-thumb-img > img")?.attr("data-srcset")?.getHighestQualitySrcset(), + ) } } @@ -417,43 +434,48 @@ class BookWalker : ConfigurableSource, ParsedHttpSource(), BookWalkerPreferences .asJsoup() .let { validateLogin(it) } - val mangaDetails = SManga.create().apply { - updateDetailsFromSeriesPage(seriesPage) - } - // It generally doesn't matter which chapter we take the description from, but for // series that release in volumes we want the earliest one, which will _usually_ be the // last one on the page. With that said, it's not worth it to paginate in order to find // the earliest volume, and volume releases don't usually have 60+ volumes anyways. - val chapterUrl = seriesPage - .select(".o-tile .a-tile-ttl a").last() - ?.attr("href") - ?: return@rxSingle mangaDetails + val firstItem = seriesPage.selectFirst(".o-tile:not(:has(.a-ribbon-pre-order)) .a-tile-ttl a") + val uuid = firstItem!!.absUrl("href").substringAfter("/de").substringBefore("/") + val bookUpdate = fetchBookUpdate(uuid) - val chapterPage = client.newCall(GET(chapterUrl, callHeaders)).await().asJsoup() - - mangaDetails.apply { - description = getDescriptionFromChapterPage(chapterPage) + SManga.create().apply { + title = bookUpdate!!.seriesName?.cleanTitle() ?: bookUpdate.productName.cleanTitle() + author = bookUpdate.authors.joinToString { it.authorName } + description = listOfNotNull(bookUpdate.productExplanationShort, bookUpdate.productExplanationDetails) + .joinToString("\n\n") + .trim() + thumbnail_url = bookUpdate.coverImageUrl + genre = getAvailableFilterNames(seriesPage, "side-genre").joinToString() + val statusIndicators = seriesPage.select("ul.side-others > li > a").map { it.ownText() } + status = parseStatus(statusIndicators) } }.toObservable() } private fun fetchSingleMangaDetails(manga: SManga): Observable { return rxSingle { - val document = client.newCall(GET(baseUrl + manga.url, callHeaders)) - .awaitSuccess() - .asJsoup() + val uuid = manga.url.substringAfter("/de").substringBefore("/") + val bookUpdate = fetchBookUpdate(uuid) SManga.create().apply { - title = getTitleFromChapterPage(document)?.cleanTitle().orEmpty() + title = bookUpdate!!.seriesName?.cleanTitle() ?: bookUpdate.productName.cleanTitle() + author = bookUpdate.authors.joinToString { it.authorName } + description = listOfNotNull(bookUpdate.productExplanationShort, bookUpdate.productExplanationDetails) + .joinToString("\n\n") + .trim() + thumbnail_url = bookUpdate.coverImageUrl - description = getDescriptionFromChapterPage(document) // From the browse pages we can't distinguish between a true one-shot and a // serial manga with only one chapter, but we can detect if there's a series // reference in the chapter page. If there is, we should let the user know that // they may need to take some action in the future to correct the error. + val document = client.newCall(GET(baseUrl + manga.url, callHeaders)).awaitSuccess().asJsoup() if (document.selectFirst(".product-detail th:contains(Series Title)") != null) { - description = ( + this.description = ( "WARNING: This entry is being treated as a one-shot but appears to " + "have an associated series. If another chapter is released in " + "the future, you will likely need to migrate this to itself." + @@ -464,44 +486,22 @@ class BookWalker : ConfigurableSource, ParsedHttpSource(), BookWalkerPreferences }.toObservable() } - private fun SManga.updateDetailsFromSeriesPage(document: Document) = run { - // Take the thumbnail from the first chapter that is not on pre-order. - // Pre-order chapters often just have a gray rectangle with "NOW PRINTING" as their - // thumbnail, which doesn't look very pretty for the catalog. - thumbnail_url = document - .select(".o-tile:not(:has(.a-ribbon-pre-order)) .a-tile-thumb-img > img") - .attr("data-srcset") - .getHighestQualitySrcset() - title = document.selectFirst(".title-main-inner")!!.ownText().cleanTitle() - author = getAvailableFilterNames(document, "side-author").joinToString() - genre = getAvailableFilterNames(document, "side-genre").joinToString() - - val statusIndicators = document.select("ul.side-others > li > a").map { it.ownText() } - - status = - if (statusIndicators.any { it.startsWith("Completed") }) { - if (statusIndicators.any { it.startsWith("Pre-Order") }) { - SManga.PUBLISHING_FINISHED - } else { - SManga.COMPLETED - } + private fun parseStatus(statusIndicators: List): Int { + return if (statusIndicators.any { it.startsWith("Completed") }) { + if (statusIndicators.any { it.startsWith("Pre-Order") }) { + SManga.PUBLISHING_FINISHED } else { - SManga.ONGOING + SManga.COMPLETED } + } else { + SManga.ONGOING + } } private fun getTitleFromChapterPage(document: Document): String? { return document.selectFirst(".detail-book-title-box h1[itemprop='name']")?.ownText() } - private fun getDescriptionFromChapterPage(document: Document): String { - return buildString { - append(document.select(".synopsis-lead").text()) - append("\n\n") - append(document.select(".synopsis-text").text()) - }.trim() - } - override fun fetchChapterList(manga: SManga): Observable> { return rxSingle { if (!manga.url.startsWith("/series/")) { @@ -750,6 +750,41 @@ class BookWalker : ConfigurableSource, ParsedHttpSource(), BookWalkerPreferences return srcsetPairs.maxByOrNull { it.first ?: 0 }?.second } + // This function works if the cover is from before mid-dec'23 (non-hexadecimal). + // If it's a newer cover, it will fall back to the low-res version. + private fun getHiResCoverFromLegacyUrl(url: String?): String? { + if (url.isNullOrEmpty()) return url + return try { + val extension = url.substringAfterLast(".") + val numericId = when { + url.startsWith(rimgUrl) -> { + val id = url.substringAfter("$rimgUrl/").substringBefore('/') + id.reversed().toLongOrNull() + } + // For legacy covers of "series" from the user's library. + url.contains("thumbnailImage_") -> { + val id = url.substringAfter("thumbnailImage_").substringBefore(".$extension") + id.toLongOrNull() + } + else -> null + } + numericId?.let { "$cUrl/coverImage_${it - 1}.$extension" } ?: url + } catch (e: Exception) { + url + } + } + + // Fetch manga details form api. + private suspend fun fetchBookUpdate(uuid: String): BookUpdateDto? { + val apiUrl = "$memberApiUrl/books/updates".toHttpUrl().newBuilder() + .addQueryParameter("fileType", "EPUB") + .addQueryParameter(uuid, "0") + .build() + + return client.newCall(GET(apiUrl, headers)).awaitSuccess() + .parseAs>().firstOrNull() + } + private fun String.parseChapterNumber(): Pair? { for (pattern in CHAPTER_NUMBER_PATTERNS) { val match = pattern.find(this) diff --git a/src/en/bookwalker/src/eu/kanade/tachiyomi/extension/en/bookwalker/dto/DetailsDto.kt b/src/en/bookwalker/src/eu/kanade/tachiyomi/extension/en/bookwalker/dto/DetailsDto.kt new file mode 100644 index 000000000..158f85115 --- /dev/null +++ b/src/en/bookwalker/src/eu/kanade/tachiyomi/extension/en/bookwalker/dto/DetailsDto.kt @@ -0,0 +1,18 @@ +package eu.kanade.tachiyomi.extension.en.bookwalker.dto + +import kotlinx.serialization.Serializable + +@Serializable +class BookUpdateDto( + val productName: String, + val seriesName: String?, + val productExplanationShort: String?, + val productExplanationDetails: String, + val coverImageUrl: String, + val authors: List, +) + +@Serializable +class AuthorUpdateDto( + val authorName: String, +) diff --git a/src/en/bookwalker/src/eu/kanade/tachiyomi/extension/en/bookwalker/dto/SingleDto.kt b/src/en/bookwalker/src/eu/kanade/tachiyomi/extension/en/bookwalker/dto/SingleDto.kt index 97cb428a1..df7e7cf8e 100644 --- a/src/en/bookwalker/src/eu/kanade/tachiyomi/extension/en/bookwalker/dto/SingleDto.kt +++ b/src/en/bookwalker/src/eu/kanade/tachiyomi/extension/en/bookwalker/dto/SingleDto.kt @@ -6,13 +6,5 @@ import kotlinx.serialization.Serializable @Serializable @SerialName("normal") class SingleDto( - val detailUrl: String, - val title: String, - val imageUrl: String, - val authors: List, + val uuid: String, ) : HoldBookEntityDto() - -@Serializable -class AuthorDto( - val authorName: String, -)