diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d7519c3..6edf5e4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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 { diff --git a/app/src/main/java/com/aletheia/app/data/OpdsCatalogParser.kt b/app/src/main/java/com/aletheia/app/data/OpdsCatalogParser.kt index 370b64a..03e95ff 100644 --- a/app/src/main/java/com/aletheia/app/data/OpdsCatalogParser.kt +++ b/app/src/main/java/com/aletheia/app/data/OpdsCatalogParser.kt @@ -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 = parsePage(xml, resolveUrl).books + fun parseNavigationEntries(xml: String, resolveUrl: (String) -> String): List { + 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", diff --git a/app/src/main/java/com/aletheia/app/data/QBooksService.kt b/app/src/main/java/com/aletheia/app/data/QBooksService.kt index 4450f16..754eca6 100644 --- a/app/src/main/java/com/aletheia/app/data/QBooksService.kt +++ b/app/src/main/java/com/aletheia/app/data/QBooksService.kt @@ -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 = + 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 { + 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 { + 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, + query: String + ): List { + val normalizedQuery = query.normalizedMetadataText() + val queryTokens = normalizedQuery.split(' ').filter(String::isNotBlank).toSet() + return entries.withIndex() + .sortedWith( + compareByDescending> { 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::value) + } + + private fun mergeSearchResults( + sources: List>, + pageSize: Int + ): List { + val result = mutableListOf() + val seen = mutableSetOf() + 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", "неизвестный автор") diff --git a/app/src/main/res/layout/fragment_qbooks_library.xml b/app/src/main/res/layout/fragment_qbooks_library.xml index 2733e38..8986036 100644 --- a/app/src/main/res/layout/fragment_qbooks_library.xml +++ b/app/src/main/res/layout/fragment_qbooks_library.xml @@ -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" diff --git a/app/src/test/java/com/aletheia/app/data/OpdsCatalogParserTest.kt b/app/src/test/java/com/aletheia/app/data/OpdsCatalogParserTest.kt index 1501e9e..edaa075 100644 --- a/app/src/test/java/com/aletheia/app/data/OpdsCatalogParserTest.kt +++ b/app/src/test/java/com/aletheia/app/data/OpdsCatalogParserTest.kt @@ -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 { """.trimIndent() + + private val AUTHOR_NAVIGATION_FEED = """ + + + + tag:author:656 + Азимов Айзек + + + + + """.trimIndent() } }