diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/MangaParser.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/MangaParser.kt index 26dc59ed..89c2799d 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/MangaParser.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/MangaParser.kt @@ -8,6 +8,7 @@ import org.koitharu.kotatsu.parsers.network.OkHttpWebClient import org.koitharu.kotatsu.parsers.network.UserAgents import org.koitharu.kotatsu.parsers.network.WebClient import org.koitharu.kotatsu.parsers.util.FaviconParser +import org.koitharu.kotatsu.parsers.util.RelatedMangaFinder import org.koitharu.kotatsu.parsers.util.domain import org.koitharu.kotatsu.parsers.util.toAbsoluteUrl import java.util.* @@ -127,4 +128,14 @@ abstract class MangaParser @InternalParsersApi constructor( open fun onCreateConfig(keys: MutableCollection>) { keys.add(configKeyDomain) } + + open suspend fun getRelatedManga(seed: Manga): List { + return RelatedMangaFinder(listOf(this)).invoke(seed) + } + + protected fun getParser(source: MangaSource) = if (this.source == source) { + this + } else { + context.newParserInstance(source) + } } diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/grouple/GroupleParser.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/grouple/GroupleParser.kt index bf4cfa70..1955e597 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/grouple/GroupleParser.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/grouple/GroupleParser.kt @@ -76,6 +76,7 @@ internal abstract class GroupleParser( mapOf( "q" to query.urlEncoded(), "offset" to (offset upBy PAGE_SIZE_SEARCH).toString(), + "fast-filter" to "CREATION", ), ) @@ -321,6 +322,15 @@ internal abstract class GroupleParser( keys.add(userAgentKey) } + override suspend fun getRelatedManga(seed: Manga): List { + val parsers = listOf( + getParser(MangaSource.READMANGA_RU), + getParser(MangaSource.MINTMANGA), + getParser(MangaSource.SELFMANGA), + ) + return RelatedMangaFinder(parsers).invoke(seed) + } + private fun getSortKey(sortOrder: SortOrder) = when (sortOrder) { SortOrder.ALPHABETICAL -> "name" diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/madara/pt/Atlantisscan.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/madara/pt/Atlantisscan.kt index a6735892..ab1a103a 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/madara/pt/Atlantisscan.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/madara/pt/Atlantisscan.kt @@ -8,7 +8,7 @@ import org.koitharu.kotatsu.parsers.site.madara.MadaraParser @MangaSourceParser("ATLANTISSCAN", "Atlantisscan", "pt") internal class Atlantisscan(context: MangaLoaderContext) : - MadaraParser(context, MangaSource.ATLANTISSCAN, "atlantisscan.com") { + MadaraParser(context, MangaSource.ATLANTISSCAN, "br.atlantisscan.com", pageSize = 10) { override val datePattern = "dd/MM/yyyy" diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/util/RelatedMangaFinder.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/util/RelatedMangaFinder.kt new file mode 100644 index 00000000..516a5b9c --- /dev/null +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/util/RelatedMangaFinder.kt @@ -0,0 +1,47 @@ +package org.koitharu.kotatsu.parsers.util + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import org.koitharu.kotatsu.parsers.MangaParser +import org.koitharu.kotatsu.parsers.model.Manga + +class RelatedMangaFinder( + private val parsers: Collection, +) { + + private val regexWhitespace = Regex("\\s+") + + suspend operator fun invoke(seed: Manga): List = coroutineScope { + parsers.singleOrNull()?.let { parser -> + findRelatedImpl(this, parser, seed) + } ?: parsers.map { parser -> + async { + findRelatedImpl(this, parser, seed) + } + }.awaitAll().flatten() + } + + private suspend fun findRelatedImpl(scope: CoroutineScope, parser: MangaParser, seed: Manga): List { + val words = HashSet() + words += seed.title.split(regexWhitespace) + seed.altTitle?.let { + words += it.split(regexWhitespace) + } + if (words.isEmpty()) { + return emptyList() + } + val results = words.map { keyword -> + scope.async { + val result = parser.getList(0, keyword) + result.filter { it.id != seed.id && it.containKeyword(keyword) } + } + }.awaitAll() + return results.minBy { if (it.isEmpty()) Int.MAX_VALUE else it.size } + } + + private fun Manga.containKeyword(keyword: String): Boolean { + return title.contains(keyword, ignoreCase = true) || altTitle?.contains(keyword, ignoreCase = true) == true + } +} diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/util/String.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/util/String.kt index 6de74f3f..c18c3fdb 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/util/String.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/util/String.kt @@ -2,6 +2,7 @@ package org.koitharu.kotatsu.parsers.util +import androidx.annotation.FloatRange import androidx.collection.arraySetOf import java.math.BigInteger import java.net.URLEncoder @@ -209,6 +210,18 @@ fun String.levenshteinDistance(other: String): Int { return cost[lhsLength - 1] } +/** + * @param threshold 0 = exact match + */ +fun String.almostEquals(other: String, @FloatRange(from = 0.0) threshold: Float): Boolean { + if (threshold <= 0f) { + return equals(other, ignoreCase = true) + } + val diff = lowercase().levenshteinDistance(other.lowercase()) / ((length + other.length) / 2f) + return diff < threshold +} + + inline fun Appendable.appendAll( items: Iterable, separator: CharSequence, @@ -223,4 +236,4 @@ inline fun Appendable.appendAll( } append(transform(item)) } -} \ No newline at end of file +} diff --git a/src/test/kotlin/org/koitharu/kotatsu/parsers/MangaParserTest.kt b/src/test/kotlin/org/koitharu/kotatsu/parsers/MangaParserTest.kt index 2e9c5ef1..872997dd 100644 --- a/src/test/kotlin/org/koitharu/kotatsu/parsers/MangaParserTest.kt +++ b/src/test/kotlin/org/koitharu/kotatsu/parsers/MangaParserTest.kt @@ -3,6 +3,7 @@ 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.api.extension.ExtendWith import org.junit.jupiter.params.ParameterizedTest import org.koitharu.kotatsu.parsers.model.Manga @@ -22,6 +23,14 @@ internal class MangaParserTest { private val context = MangaLoaderContextMock + @Test + fun related() = runTest { + val parser = context.newParserInstance(MangaSource.READMANGA_RU) + val seed = parser.getList(0, "emanon").first() + val related = parser.getRelatedManga(seed) + assert(related.isNotEmpty() && seed !in related) + } + @ParameterizedTest(name = "{index}|list|{0}") @MangaSources fun list(source: MangaSource) = runTest {