komikku/app/src/main/java/exh/smartsearch/SmartSearchEngine.kt

196 lines
7 KiB
Kotlin
Raw Normal View History

package exh.smartsearch
2019-07-31 09:39:51 +02:00
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.SManga
2020-04-17 21:08:21 +02:00
import exh.util.await
2019-07-31 09:39:51 +02:00
import info.debatty.java.stringsimilarity.NormalizedLevenshtein
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.async
import kotlinx.coroutines.supervisorScope
2019-07-31 09:39:51 +02:00
import rx.schedulers.Schedulers
import uy.kohesive.injekt.injectLazy
class SmartSearchEngine(
2020-08-05 01:32:36 +02:00
private val extraSearchParams: String? = null
2020-08-24 23:25:10 +02:00
) {
2019-07-31 09:39:51 +02:00
private val db: DatabaseHelper by injectLazy()
2019-08-11 02:19:59 +02:00
private val normalizedLevenshtein = NormalizedLevenshtein()
@OptIn(ExperimentalCoroutinesApi::class)
2019-07-31 09:39:51 +02:00
suspend fun smartSearch(source: CatalogueSource, title: String): SManga? {
val cleanedTitle = cleanSmartSearchTitle(title)
val queries = getSmartSearchQueries(cleanedTitle)
val eligibleManga = supervisorScope {
queries.map { query ->
async(Dispatchers.Default) {
val builtQuery = if (extraSearchParams != null) {
2019-08-03 08:23:21 +02:00
"$query ${extraSearchParams.trim()}"
} else query
2019-08-03 08:23:21 +02:00
val searchResults = source.fetchSearchManga(1, builtQuery, FilterList())
.toSingle().await(Schedulers.io())
2019-07-31 09:39:51 +02:00
searchResults.mangas.map {
val cleanedMangaTitle = cleanSmartSearchTitle(it.originalTitle)
2019-08-11 02:19:59 +02:00
val normalizedDistance = normalizedLevenshtein.similarity(cleanedTitle, cleanedMangaTitle)
SearchEntry(it, normalizedDistance)
2019-07-31 09:39:51 +02:00
}.filter { (_, normalizedDistance) ->
normalizedDistance >= MIN_SMART_ELIGIBLE_THRESHOLD
}
}
}.flatMap { it.await() }
}
2020-08-24 23:25:10 +02:00
return eligibleManga.maxByOrNull { it.dist }?.manga
2019-07-31 09:39:51 +02:00
}
@OptIn(ExperimentalCoroutinesApi::class)
2019-07-31 09:39:51 +02:00
suspend fun normalSearch(source: CatalogueSource, title: String): SManga? {
val eligibleManga = supervisorScope {
val searchQuery = if (extraSearchParams != null) {
2019-08-03 08:23:21 +02:00
"$title ${extraSearchParams.trim()}"
} else title
val searchResults = source.fetchSearchManga(1, searchQuery, FilterList()).toSingle().await(Schedulers.io())
2019-07-31 09:39:51 +02:00
2020-05-02 06:46:24 +02:00
if (searchResults.mangas.size == 1) {
return@supervisorScope listOf(SearchEntry(searchResults.mangas.first(), 0.0))
2020-05-02 06:46:24 +02:00
}
2019-07-31 09:39:51 +02:00
searchResults.mangas.map {
val normalizedDistance = normalizedLevenshtein.similarity(title, it.originalTitle)
SearchEntry(it, normalizedDistance)
2019-07-31 09:39:51 +02:00
}.filter { (_, normalizedDistance) ->
normalizedDistance >= MIN_NORMAL_ELIGIBLE_THRESHOLD
}
}
2020-08-24 23:25:10 +02:00
return eligibleManga.maxByOrNull { it.dist }?.manga
2019-07-31 09:39:51 +02:00
}
private fun getSmartSearchQueries(cleanedTitle: String): List<String> {
val splitCleanedTitle = cleanedTitle.split(" ")
val splitSortedByLargest = splitCleanedTitle.sortedByDescending { it.length }
if (splitCleanedTitle.isEmpty()) {
2019-07-31 09:39:51 +02:00
return emptyList()
}
// Search cleaned title
// Search two largest words
// Search largest word
// Search first two words
// Search first word
val searchQueries = listOf(
listOf(cleanedTitle),
splitSortedByLargest.take(2),
splitSortedByLargest.take(1),
splitCleanedTitle.take(2),
splitCleanedTitle.take(1)
2019-07-31 09:39:51 +02:00
)
return searchQueries.map {
2019-08-03 08:23:21 +02:00
it.joinToString(" ").trim()
2019-07-31 09:39:51 +02:00
}.distinct()
}
private fun cleanSmartSearchTitle(title: String): String {
val preTitle = title.toLowerCase()
// Remove text in brackets
var cleanedTitle = removeTextInBrackets(preTitle, true)
if (cleanedTitle.length <= 5) { // Title is suspiciously short, try parsing it backwards
2019-07-31 09:39:51 +02:00
cleanedTitle = removeTextInBrackets(preTitle, false)
}
// Strip non-special characters
cleanedTitle = cleanedTitle.replace(titleRegex, " ")
// Strip splitters and consecutive spaces
cleanedTitle = cleanedTitle.trim().replace(" - ", " ").replace(consecutiveSpacesRegex, " ").trim()
return cleanedTitle
}
private fun removeTextInBrackets(text: String, readForward: Boolean): String {
val bracketPairs = listOf(
'(' to ')',
'[' to ']',
'<' to '>',
'{' to '}'
2019-07-31 09:39:51 +02:00
)
var openingBracketPairs = bracketPairs.mapIndexed { index, (opening, _) ->
opening to index
}.toMap()
var closingBracketPairs = bracketPairs.mapIndexed { index, (_, closing) ->
closing to index
}.toMap()
// Reverse pairs if reading backwards
if (!readForward) {
2019-07-31 09:39:51 +02:00
val tmp = openingBracketPairs
openingBracketPairs = closingBracketPairs
closingBracketPairs = tmp
}
val depthPairs = bracketPairs.map { 0 }.toMutableList()
val result = StringBuilder()
for (c in if (readForward) text else text.reversed()) {
2019-07-31 09:39:51 +02:00
val openingBracketDepthIndex = openingBracketPairs[c]
if (openingBracketDepthIndex != null) {
2019-07-31 09:39:51 +02:00
depthPairs[openingBracketDepthIndex]++
} else {
val closingBracketDepthIndex = closingBracketPairs[c]
if (closingBracketDepthIndex != null) {
2019-07-31 09:39:51 +02:00
depthPairs[closingBracketDepthIndex]--
} else {
if (depthPairs.all { it <= 0 }) {
2019-07-31 09:39:51 +02:00
result.append(c)
} else {
// In brackets, do not append to result
}
}
}
}
return result.toString()
}
/**
* Returns a manga from the database for the given manga from network. It creates a new entry
* if the manga is not yet in the database.
*
* @param sManga the manga from the source.
* @return a manga from the database.
*/
suspend fun networkToLocalManga(sManga: SManga, sourceId: Long): Manga {
var localManga = db.getManga(sManga.url, sourceId).executeAsBlocking()
2019-07-31 09:39:51 +02:00
if (localManga == null) {
val newManga = Manga.create(sManga.url, sManga.title, sourceId)
newManga.copyFrom(sManga)
val result = db.insertManga(newManga).executeAsBlocking()
2019-07-31 09:39:51 +02:00
newManga.id = result.insertedId()
localManga = newManga
}
return localManga
}
companion object {
2019-08-03 08:23:21 +02:00
const val MIN_SMART_ELIGIBLE_THRESHOLD = 0.4
const val MIN_NORMAL_ELIGIBLE_THRESHOLD = 0.4
2019-07-31 09:39:51 +02:00
private val titleRegex = Regex("[^a-zA-Z0-9- ]")
private val consecutiveSpacesRegex = Regex(" +")
}
}
data class SearchEntry(val manga: SManga, val dist: Double)