diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/ru/grouple/GroupleParser.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/ru/grouple/GroupleParser.kt index a5c636a0..18976adf 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/ru/grouple/GroupleParser.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/ru/grouple/GroupleParser.kt @@ -4,6 +4,7 @@ import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import okhttp3.Headers +import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.Interceptor import okhttp3.Response @@ -18,6 +19,7 @@ import org.koitharu.kotatsu.parsers.exception.AuthRequiredException import org.koitharu.kotatsu.parsers.exception.ParseException import org.koitharu.kotatsu.parsers.model.* import org.koitharu.kotatsu.parsers.util.* +import org.koitharu.kotatsu.parsers.util.json.getStringOrNull import org.koitharu.kotatsu.parsers.util.json.mapJSON import java.text.SimpleDateFormat import java.util.* @@ -113,6 +115,11 @@ internal abstract class GroupleParser( val root = doc.body().requireElementById("mangaBox").selectFirstOrThrow("div.leftContent") val dateFormat = SimpleDateFormat("dd.MM.yy", Locale.US) val coverImg = root.selectFirst("div.subject-cover")?.selectFirst("img") + val translations = root.selectFirst("div.translator-selection") + ?.select(".translator-selection-item") + ?.associate { + it.id().removePrefix("tr-").toLong() to it.selectFirst(".translator-selection-name")?.textOrNull() + } return manga.copy( description = root.selectFirst("div.manga-description")?.html(), largeCoverUrl = coverImg?.attr("data-full"), @@ -128,24 +135,48 @@ internal abstract class GroupleParser( author = root.selectFirst("a.person-link")?.text() ?: manga.author, isNsfw = manga.isNsfw || root.select(".alert-warning").any { it.ownText().contains(NSFW_ALERT) }, chapters = root.requireElementById("chapters-list").select("a.chapter-link") - .mapChapters(reversed = true) { i, a -> - val tr = a.selectFirstParent("tr") ?: return@mapChapters null + .flatMapChapters(reversed = true) { a -> + val tr = a.selectFirstParent("tr") ?: return@flatMapChapters emptyList() val href = a.attrAsRelativeUrl("href") - var translators = "" - val translatorElement = a.attr("title") - if (!translatorElement.isNullOrBlank()) { - translators = translatorElement.replace("(Переводчик),", "&").removeSuffix(" (Переводчик)") + val number = tr.attr("data-num").toFloatOrNull()?.div(10f) ?: 0f + val volume = tr.attr("data-vol").toIntOrNull() ?: 0 + if (translations.isNullOrEmpty() || a.attr("data-translations").isEmpty()) { + var translators = "" + val translatorElement = a.attr("title") + if (!translatorElement.isNullOrBlank()) { + translators = translatorElement.replace("(Переводчик),", "&").removeSuffix(" (Переводчик)") + } + listOf( + MangaChapter( + id = generateUid(href), + name = a.text().removePrefix(manga.title).trim(), + number = number, + volume = volume, + url = href, + uploadDate = dateFormat.tryParse(tr.selectFirst("td.date")?.text()), + scanlator = translators, + source = source, + branch = null, + ), + ) + } else { + val translationData = JSONArray(a.attr("data-translations")) + translationData.mapJSON { jo -> + val personId = jo.getLong("personId") + val link = href.setQueryParam("tran", personId.toString()) + MangaChapter( + id = generateUid(link), + name = a.text().removePrefix(manga.title).trim(), + number = number, + volume = volume, + url = link, + uploadDate = dateFormat.tryParse(jo.getStringOrNull("dateCreated")), + scanlator = null, + source = source, + branch = translations[personId], + ) + } } - MangaChapter( - id = generateUid(href), - name = a.text().removePrefix(manga.title).trim(), - number = i + 1, - url = href, - uploadDate = dateFormat.tryParse(tr.selectFirst("td.date")?.text()), - scanlator = translators, - source = source, - branch = null, - ) }, ) } @@ -402,4 +433,14 @@ internal abstract class GroupleParser( ) } } + + private fun String.setQueryParam(name: String, value: String): String { + return toAbsoluteUrl(domain) + .toHttpUrl() + .newBuilder() + .setQueryParameter(name, value) + .build() + .toString() + .toRelativeUrl(domain) + } } diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/util/Chapters.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/util/Chapters.kt index 9ebbb718..4211ad09 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/util/Chapters.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/util/Chapters.kt @@ -19,6 +19,19 @@ inline fun List.mapChapters( return builder.toList() } +@InternalParsersApi +inline fun List.flatMapChapters( + reversed: Boolean = false, + transform: (T) -> Iterable, +): List { + val builder = ChaptersListBuilder(collectionSize()) + val elements = if (reversed) this.asReversed() else this + for (item in elements) { + builder.addAll(transform(item)) + } + return builder.toList() +} + @PublishedApi internal fun Iterable.collectionSize(): Int { return if (this is Collection<*>) this.size else 10 diff --git a/src/test/kotlin/org/koitharu/kotatsu/parsers/MangaParserTest.kt b/src/test/kotlin/org/koitharu/kotatsu/parsers/MangaParserTest.kt index c09b099a..e5d47a1c 100644 --- a/src/test/kotlin/org/koitharu/kotatsu/parsers/MangaParserTest.kt +++ b/src/test/kotlin/org/koitharu/kotatsu/parsers/MangaParserTest.kt @@ -3,11 +3,9 @@ package org.koitharu.kotatsu.parsers import kotlinx.coroutines.test.runTest import okhttp3.HttpUrl import org.junit.jupiter.api.Disabled +import org.junit.jupiter.api.Test import org.junit.jupiter.params.ParameterizedTest -import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.model.MangaListFilter -import org.koitharu.kotatsu.parsers.model.MangaSource -import org.koitharu.kotatsu.parsers.model.SortOrder +import org.koitharu.kotatsu.parsers.model.* import org.koitharu.kotatsu.parsers.util.domain import org.koitharu.kotatsu.parsers.util.medianOrNull import org.koitharu.kotatsu.parsers.util.mimeType @@ -21,6 +19,14 @@ internal class MangaParserTest { private val context = MangaLoaderContextMock private val timeout = 2.minutes + @Test + fun singleTest() = runTest { + val manga = mangaOf(MangaSource.READMANGA_RU, "https://readmanga.live/podniatie_urovnia_v_odinochku__A5e4e") + val parser = context.newParserInstance(manga.source) + val details = parser.getDetails(manga) + assert(!details.chapters.isNullOrEmpty()) + } + @ParameterizedTest(name = "{index}|list|{0}") @MangaSources fun list(source: MangaSource) = runTest(timeout = timeout) { @@ -162,8 +168,9 @@ internal class MangaParserTest { assert(c.isDistinctBy { it.id }) { "Chapters are not distinct by id: ${c.maxDuplicates { it.id }} for $publicUrl" } - assert(c.isDistinctByNotNull { if (it.number > 0f) it.number to it.branch else null }) { - "Chapters are not distinct by number: ${c.maxDuplicates { it.number to it.branch }} for $publicUrl" + assert(c.isDistinctByNotNull { it.key() }) { + val dup = c.mapNotNull { it.key() }.maxDuplicates { it } + "Chapters are not distinct by branch/volume/number: $dup for $publicUrl" } assert(c.all { it.source == source }) checkImageRequest(coverUrl, source) @@ -266,4 +273,10 @@ internal class MangaParserTest { private fun String.isCapitalized(): Boolean { return !first().isLowerCase() } + + private fun MangaChapter.key(): Any? = when { + number > 0f && volume > 0 -> Triple(branch, volume, number) + number > 0f -> Pair(branch, number) + else -> null + } }