fix: search books by author and series

This commit is contained in:
Курнат Андрей
2026-07-17 23:09:20 +03:00
parent ded46771bb
commit 28312e3fbc
5 changed files with 217 additions and 10 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "com.aletheia.app" applicationId = "com.aletheia.app"
minSdk = 24 minSdk = 24
targetSdk = 36 targetSdk = 36
versionCode = 44 versionCode = 45
versionName = "2.33" versionName = "2.34"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { vectorDrawables {
@@ -9,6 +9,11 @@ import org.w3c.dom.Document
import org.w3c.dom.Element import org.w3c.dom.Element
import org.xml.sax.InputSource import org.xml.sax.InputSource
data class OpdsNavigationEntry(
val title: String,
val url: String
)
object OpdsCatalogParser { object OpdsCatalogParser {
fun isOpdsFeed(xml: String): Boolean = runCatching { fun isOpdsFeed(xml: String): Boolean = runCatching {
val root = parseDocument(xml).documentElement val root = parseDocument(xml).documentElement
@@ -18,6 +23,30 @@ object OpdsCatalogParser {
fun parseBooks(xml: String, resolveUrl: (String) -> String): List<QBooksBook> = fun parseBooks(xml: String, resolveUrl: (String) -> String): List<QBooksBook> =
parsePage(xml, resolveUrl).books parsePage(xml, resolveUrl).books
fun parseNavigationEntries(xml: String, resolveUrl: (String) -> String): List<OpdsNavigationEntry> {
val document = parseDocument(xml)
val root = document.documentElement
require(root.localName == "feed" && root.namespaceURI == ATOM_NAMESPACE) {
"Сервер вернул документ, который не является OPDS-каталогом."
}
val entries = document.getElementsByTagNameNS(ATOM_NAMESPACE, "entry")
return buildList {
for (index in 0 until entries.length) {
val entry = entries.item(index) as? Element ?: continue
val title = entry.firstText(ATOM_NAMESPACE, "title").trim()
if (title.isBlank()) continue
val catalogLink = entry.childElements("link").firstOrNull { link ->
val type = link.getAttribute("type").substringBefore(';').trim()
val rel = link.getAttribute("rel").trim()
type.equals(ATOM_MEDIA_TYPE, ignoreCase = true) && rel.isBlank()
} ?: continue
val href = catalogLink.getAttribute("href").trim()
if (href.isNotBlank()) add(OpdsNavigationEntry(title, resolveUrl(href)))
}
}
}
fun parsePage(xml: String, resolveUrl: (String) -> String): CatalogPage { fun parsePage(xml: String, resolveUrl: (String) -> String): CatalogPage {
val document = parseDocument(xml) val document = parseDocument(xml)
val root = document.documentElement val root = document.documentElement
@@ -177,6 +206,7 @@ object OpdsCatalogParser {
) )
private const val ATOM_NAMESPACE = "http://www.w3.org/2005/Atom" private const val ATOM_NAMESPACE = "http://www.w3.org/2005/Atom"
private const val ATOM_MEDIA_TYPE = "application/atom+xml"
private const val DC_TERMS_NAMESPACE = "http://purl.org/dc/terms/" private const val DC_TERMS_NAMESPACE = "http://purl.org/dc/terms/"
private val COVER_RELS = setOf( private val COVER_RELS = setOf(
"http://opds-spec.org/image", "http://opds-spec.org/image",
@@ -375,8 +375,25 @@ class QBooksService(
pageSize: Int, pageSize: Int,
pageUrl: String? pageUrl: String?
): CatalogPage { ): CatalogPage {
val root = requireBaseUrl().trimEnd('/')
val combinedSearchPage = pageUrl
?.takeIf { it.startsWith(COMBINED_SEARCH_PAGE_PREFIX) }
?.removePrefix(COMBINED_SEARCH_PAGE_PREFIX)
?.toIntOrNull()
if (isPublicOpdsRoot(root) && query.isNotBlank()) {
return searchPublicOpdsCatalog(
query = query,
page = (combinedSearchPage ?: page).coerceAtLeast(0),
pageSize = pageSize.coerceAtLeast(1)
)
}
val url = pageUrl?.takeIf(String::isNotBlank) ?: buildOpdsBooksUrl(query, page.coerceAtLeast(0)) val url = pageUrl?.takeIf(String::isNotBlank) ?: buildOpdsBooksUrl(query, page.coerceAtLeast(0))
val parsed = openConnection(url, accept = OPDS_ACCEPT).use { connection -> return fetchOpdsPage(url)
}
private fun fetchOpdsPage(url: String): CatalogPage =
openConnection(url, accept = OPDS_ACCEPT).use { connection ->
if (connection.responseCode !in 200..299) { if (connection.responseCode !in 200..299) {
error(qBooksHttpError(connection.responseCode)) error(qBooksHttpError(connection.responseCode))
} }
@@ -386,19 +403,143 @@ class QBooksService(
resolveCatalogLink(url, pathOrUrl) resolveCatalogLink(url, pathOrUrl)
} }
} }
return parsed
private fun fetchOpdsNavigation(url: String): List<OpdsNavigationEntry> =
openConnection(url, accept = OPDS_ACCEPT).use { connection ->
if (connection.responseCode !in 200..299) {
error(qBooksHttpError(connection.responseCode))
}
val payload = connection.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() }
OpdsCatalogParser.parseNavigationEntries(payload) { pathOrUrl ->
resolveCatalogLink(url, pathOrUrl)
}
}
private fun searchPublicOpdsCatalog(query: String, page: Int, pageSize: Int): CatalogPage {
val root = requireBaseUrl().trimEnd('/')
val directPage = fetchOpdsPage(buildPublicSearchUrl(root, "books", query, page))
val authorEntries = findAuthorEntries(root, query)
val rankedAuthors = rankNavigationEntries(authorEntries, query).take(MAX_AUTHOR_SEARCH_FEEDS)
val authorPages = rankedAuthors.mapNotNull { entry ->
val url = entry.url.trimEnd('/') + "/alphabet" + pageSuffix(page)
runCatching { fetchOpdsPage(url) }.getOrNull()
}
val sequenceEntries = runCatching {
fetchOpdsNavigation("$root/sequences/${encodePathSegment(query)}")
}.getOrDefault(emptyList())
val sequencePages = rankNavigationEntries(sequenceEntries, query)
.take(MAX_SEQUENCE_SEARCH_FEEDS)
.mapNotNull { entry ->
runCatching { fetchOpdsPage(entry.url.trimEnd('/') + pageSuffix(page)) }.getOrNull()
}
val pages = buildList {
add(directPage)
addAll(authorPages)
addAll(sequencePages)
}
val books = mergeSearchResults(pages.map(CatalogPage::books), pageSize)
val hasNextPage = pages.any { !it.nextPageUrl.isNullOrBlank() }
return CatalogPage(
books = books,
nextPageUrl = if (hasNextPage) "$COMBINED_SEARCH_PAGE_PREFIX${page + 1}" else null
)
}
private fun findAuthorEntries(root: String, query: String): List<OpdsNavigationEntry> {
for (term in authorSearchTerms(query)) {
val entries = runCatching {
fetchOpdsNavigation(buildPublicSearchUrl(root, "authors", term, page = 0))
}.getOrDefault(emptyList())
if (entries.isNotEmpty()) return entries
}
return emptyList()
}
private fun authorSearchTerms(query: String): List<String> {
val terms = query.trim().split(METADATA_WHITESPACE).filter { it.length > 1 }
return buildList {
add(query.trim())
terms.sortedByDescending(String::length).forEach(::add)
}.filter(String::isNotBlank).distinctBy { it.lowercase(Locale.ROOT) }
}
private fun rankNavigationEntries(
entries: List<OpdsNavigationEntry>,
query: String
): List<OpdsNavigationEntry> {
val normalizedQuery = query.normalizedMetadataText()
val queryTokens = normalizedQuery.split(' ').filter(String::isNotBlank).toSet()
return entries.withIndex()
.sortedWith(
compareByDescending<IndexedValue<OpdsNavigationEntry>> { indexed ->
val title = indexed.value.title.normalizedMetadataText()
val titleTokens = title.split(' ').filter(String::isNotBlank).toSet()
when {
title == normalizedQuery -> 10_000
titleTokens == queryTokens -> 9_000
queryTokens.isNotEmpty() && titleTokens.containsAll(queryTokens) -> 7_000
title.startsWith(normalizedQuery) -> 5_000
title.contains(normalizedQuery) -> 4_000
else -> titleTokens.intersect(queryTokens).size * 100
}
}.thenBy { it.index }
)
.map(IndexedValue<OpdsNavigationEntry>::value)
}
private fun mergeSearchResults(
sources: List<List<QBooksBook>>,
pageSize: Int
): List<QBooksBook> {
val result = mutableListOf<QBooksBook>()
val seen = mutableSetOf<String>()
val positions = IntArray(sources.size)
while (result.size < pageSize) {
var addedThisRound = false
sources.forEachIndexed { sourceIndex, books ->
while (positions[sourceIndex] < books.size) {
val book = books[positions[sourceIndex]++]
val key = book.id.ifBlank { book.downloadUrl }
if (seen.add(key)) {
result += book
addedThisRound = true
break
}
}
if (result.size >= pageSize) return result
}
if (!addedThisRound) break
}
return result
}
private fun buildPublicSearchUrl(root: String, type: String, query: String, page: Int): String =
"$root/search?searchType=$type&searchTerm=${encodeQueryParameter(query)}&pageNumber=$page"
private fun encodeQueryParameter(value: String): String =
URLEncoder.encode(value, Charsets.UTF_8.name())
private fun encodePathSegment(value: String): String =
encodeQueryParameter(value).replace("+", "%20")
private fun pageSuffix(page: Int): String = if (page <= 0) "" else "/$page"
private fun isPublicOpdsRoot(root: String): Boolean {
val uri = URI(root)
return uri.host.equals(FLIBUSTA_HOST, ignoreCase = true) &&
uri.path.trimEnd('/').equals("/opds", ignoreCase = true)
} }
private fun buildOpdsBooksUrl(query: String, page: Int): String { private fun buildOpdsBooksUrl(query: String, page: Int): String {
val root = requireBaseUrl().trimEnd('/') val root = requireBaseUrl().trimEnd('/')
val uri = URI(root) val isPublicCatalog = isPublicOpdsRoot(root)
val isFlibusta = uri.host.equals(FLIBUSTA_HOST, ignoreCase = true) && val encodedQuery = encodeQueryParameter(query)
uri.path.trimEnd('/').equals("/opds", ignoreCase = true)
val encodedQuery = URLEncoder.encode(query, Charsets.UTF_8.name())
return when { return when {
isFlibusta && query.isBlank() -> "$root/new/$page/new" isPublicCatalog && query.isBlank() -> "$root/new/$page/new"
isFlibusta -> "$root/search?searchType=books&searchTerm=$encodedQuery&pageNumber=$page" isPublicCatalog -> "$root/search?searchType=books&searchTerm=$encodedQuery&pageNumber=$page"
query.isBlank() -> root query.isBlank() -> root
else -> "$root/search?searchTerm=$encodedQuery" else -> "$root/search?searchTerm=$encodedQuery"
} }
@@ -476,6 +617,7 @@ class QBooksService(
private fun catalogCacheKey(searchQuery: String, page: Int, pageUrl: String?): String = private fun catalogCacheKey(searchQuery: String, page: Int, pageUrl: String?): String =
listOf( listOf(
if (searchQuery.isBlank()) CATALOG_CACHE_SCHEMA else SEARCH_CACHE_SCHEMA,
requireBaseUrl(), requireBaseUrl(),
username.orEmpty(), username.orEmpty(),
searchQuery.trim().lowercase(Locale.ROOT), searchQuery.trim().lowercase(Locale.ROOT),
@@ -607,6 +749,11 @@ class QBooksService(
private const val USER_AGENT = "Aletheia/2.28" private const val USER_AGENT = "Aletheia/2.28"
private const val OPDS_ACCEPT = "application/atom+xml, application/xml;q=0.9, */*;q=0.8" private const val OPDS_ACCEPT = "application/atom+xml, application/xml;q=0.9, */*;q=0.8"
private const val FLIBUSTA_HOST = "m.flibusta.is" private const val FLIBUSTA_HOST = "m.flibusta.is"
private const val COMBINED_SEARCH_PAGE_PREFIX = "aletheia-opds-search-page:"
private const val CATALOG_CACHE_SCHEMA = "catalog-v1"
private const val SEARCH_CACHE_SCHEMA = "search-v2"
private const val MAX_AUTHOR_SEARCH_FEEDS = 2
private const val MAX_SEQUENCE_SEARCH_FEEDS = 1
private const val MAX_DOWNLOAD_REDIRECTS = 5 private const val MAX_DOWNLOAD_REDIRECTS = 5
private val REDIRECT_RESPONSE_CODES = setOf(301, 302, 303, 307, 308) private val REDIRECT_RESPONSE_CODES = setOf(301, 302, 303, 307, 308)
private val UNKNOWN_AUTHORS = setOf("", "unknown", "unknown author", "неизвестный автор") private val UNKNOWN_AUTHORS = setOf("", "unknown", "unknown author", "неизвестный автор")
@@ -44,6 +44,8 @@
android:layout_height="match_parent" android:layout_height="match_parent"
app:boxBackgroundMode="none" app:boxBackgroundMode="none"
app:endIconMode="clear_text" app:endIconMode="clear_text"
app:endIconTint="@color/ink_soft_color"
app:hintEnabled="false"
app:startIconDrawable="@drawable/ic_nav_search" app:startIconDrawable="@drawable/ic_nav_search"
app:startIconTint="@color/ink_soft_color"> app:startIconTint="@color/ink_soft_color">
@@ -52,8 +54,11 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:background="@android:color/transparent" android:background="@android:color/transparent"
android:gravity="center_vertical"
android:hint="@string/label_search_qbooks" android:hint="@string/label_search_qbooks"
android:imeOptions="actionSearch" android:imeOptions="actionSearch"
android:paddingTop="0dp"
android:paddingBottom="0dp"
android:singleLine="true" android:singleLine="true"
android:textColor="@color/ink_color" android:textColor="@color/ink_color"
android:textColorHint="@color/ink_soft_color" android:textColorHint="@color/ink_soft_color"
@@ -31,6 +31,17 @@ class OpdsCatalogParserTest {
assertTrue(books.isEmpty()) assertTrue(books.isEmpty())
} }
@Test
fun parsesOnlyPrimaryNavigationLinks() {
val entries = OpdsCatalogParser.parseNavigationEntries(AUTHOR_NAVIGATION_FEED) { href ->
"https://books.example$href"
}
assertEquals(1, entries.size)
assertEquals("Азимов Айзек", entries.single().title)
assertEquals("https://books.example/opds/author/656", entries.single().url)
}
@Test @Test
fun returnsResolvedNextPageEvenWhenSomeEntriesAreFilteredOut() { fun returnsResolvedNextPageEvenWhenSomeEntriesAreFilteredOut() {
val page = OpdsCatalogParser.parsePage(PAGINATED_FEED) { href -> "https://books.example$href" } val page = OpdsCatalogParser.parsePage(PAGINATED_FEED) { href -> "https://books.example$href" }
@@ -113,5 +124,19 @@ class OpdsCatalogParserTest {
</entry> </entry>
</feed> </feed>
""".trimIndent() """.trimIndent()
private val AUTHOR_NAVIGATION_FEED = """
<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<entry>
<id>tag:author:656</id>
<title>Азимов Айзек</title>
<link type="application/atom+xml;profile=opds-catalog" href="/opds/author/656" />
<link rel="http://www.feedbooks.com/opds/facet"
type="application/atom+xml;profile=opds-catalog"
href="/opds/authorsequences/656" />
</entry>
</feed>
""".trimIndent()
} }
} }