fix: search books by author and series
This commit is contained in:
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "com.aletheia.app"
|
||||
minSdk = 24
|
||||
targetSdk = 36
|
||||
versionCode = 44
|
||||
versionName = "2.33"
|
||||
versionCode = 45
|
||||
versionName = "2.34"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
|
||||
@@ -9,6 +9,11 @@ import org.w3c.dom.Document
|
||||
import org.w3c.dom.Element
|
||||
import org.xml.sax.InputSource
|
||||
|
||||
data class OpdsNavigationEntry(
|
||||
val title: String,
|
||||
val url: String
|
||||
)
|
||||
|
||||
object OpdsCatalogParser {
|
||||
fun isOpdsFeed(xml: String): Boolean = runCatching {
|
||||
val root = parseDocument(xml).documentElement
|
||||
@@ -18,6 +23,30 @@ object OpdsCatalogParser {
|
||||
fun parseBooks(xml: String, resolveUrl: (String) -> String): List<QBooksBook> =
|
||||
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 {
|
||||
val document = parseDocument(xml)
|
||||
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_MEDIA_TYPE = "application/atom+xml"
|
||||
private const val DC_TERMS_NAMESPACE = "http://purl.org/dc/terms/"
|
||||
private val COVER_RELS = setOf(
|
||||
"http://opds-spec.org/image",
|
||||
|
||||
@@ -375,8 +375,25 @@ class QBooksService(
|
||||
pageSize: Int,
|
||||
pageUrl: String?
|
||||
): 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 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) {
|
||||
error(qBooksHttpError(connection.responseCode))
|
||||
}
|
||||
@@ -386,19 +403,143 @@ class QBooksService(
|
||||
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 {
|
||||
val root = requireBaseUrl().trimEnd('/')
|
||||
val uri = URI(root)
|
||||
val isFlibusta = uri.host.equals(FLIBUSTA_HOST, ignoreCase = true) &&
|
||||
uri.path.trimEnd('/').equals("/opds", ignoreCase = true)
|
||||
val encodedQuery = URLEncoder.encode(query, Charsets.UTF_8.name())
|
||||
val isPublicCatalog = isPublicOpdsRoot(root)
|
||||
val encodedQuery = encodeQueryParameter(query)
|
||||
|
||||
return when {
|
||||
isFlibusta && query.isBlank() -> "$root/new/$page/new"
|
||||
isFlibusta -> "$root/search?searchType=books&searchTerm=$encodedQuery&pageNumber=$page"
|
||||
isPublicCatalog && query.isBlank() -> "$root/new/$page/new"
|
||||
isPublicCatalog -> "$root/search?searchType=books&searchTerm=$encodedQuery&pageNumber=$page"
|
||||
query.isBlank() -> root
|
||||
else -> "$root/search?searchTerm=$encodedQuery"
|
||||
}
|
||||
@@ -476,6 +617,7 @@ class QBooksService(
|
||||
|
||||
private fun catalogCacheKey(searchQuery: String, page: Int, pageUrl: String?): String =
|
||||
listOf(
|
||||
if (searchQuery.isBlank()) CATALOG_CACHE_SCHEMA else SEARCH_CACHE_SCHEMA,
|
||||
requireBaseUrl(),
|
||||
username.orEmpty(),
|
||||
searchQuery.trim().lowercase(Locale.ROOT),
|
||||
@@ -607,6 +749,11 @@ class QBooksService(
|
||||
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 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 val REDIRECT_RESPONSE_CODES = setOf(301, 302, 303, 307, 308)
|
||||
private val UNKNOWN_AUTHORS = setOf("", "unknown", "unknown author", "неизвестный автор")
|
||||
|
||||
@@ -44,6 +44,8 @@
|
||||
android:layout_height="match_parent"
|
||||
app:boxBackgroundMode="none"
|
||||
app:endIconMode="clear_text"
|
||||
app:endIconTint="@color/ink_soft_color"
|
||||
app:hintEnabled="false"
|
||||
app:startIconDrawable="@drawable/ic_nav_search"
|
||||
app:startIconTint="@color/ink_soft_color">
|
||||
|
||||
@@ -52,8 +54,11 @@
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@android:color/transparent"
|
||||
android:gravity="center_vertical"
|
||||
android:hint="@string/label_search_qbooks"
|
||||
android:imeOptions="actionSearch"
|
||||
android:paddingTop="0dp"
|
||||
android:paddingBottom="0dp"
|
||||
android:singleLine="true"
|
||||
android:textColor="@color/ink_color"
|
||||
android:textColorHint="@color/ink_soft_color"
|
||||
|
||||
@@ -31,6 +31,17 @@ class OpdsCatalogParserTest {
|
||||
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
|
||||
fun returnsResolvedNextPageEvenWhenSomeEntriesAreFilteredOut() {
|
||||
val page = OpdsCatalogParser.parsePage(PAGINATED_FEED) { href -> "https://books.example$href" }
|
||||
@@ -113,5 +124,19 @@ class OpdsCatalogParserTest {
|
||||
</entry>
|
||||
</feed>
|
||||
""".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()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user