fix: search books by author and series
This commit is contained in:
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user