mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 13:40:27 +02:00
feat: Enhance CalibreOPDSClient with improved search scoring and OpenSearch template fetching
This commit is contained in:
@@ -4,12 +4,12 @@ import dataclasses
|
|||||||
import html
|
import html
|
||||||
import re
|
import re
|
||||||
import unicodedata
|
import unicodedata
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import PurePosixPath
|
from pathlib import PurePosixPath
|
||||||
from typing import Any, Deque, Dict, Iterable, Iterator, List, Mapping, Optional, Set, Tuple
|
from typing import Any, Deque, Dict, Iterable, Iterator, List, Mapping, Optional, Set, Tuple, Union
|
||||||
from urllib.parse import quote, urljoin, urlparse
|
from urllib.parse import quote, urljoin, urlparse
|
||||||
from xml.etree import ElementTree as ET
|
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
@@ -43,6 +43,12 @@ _EPUB_MIME_TYPES = {
|
|||||||
}
|
}
|
||||||
_SUPPORTED_DOWNLOAD_MIME_TYPES = set(_EPUB_MIME_TYPES) | {"application/pdf"}
|
_SUPPORTED_DOWNLOAD_MIME_TYPES = set(_EPUB_MIME_TYPES) | {"application/pdf"}
|
||||||
_SUPPORTED_DOWNLOAD_EXTENSIONS = {".epub", ".pdf"}
|
_SUPPORTED_DOWNLOAD_EXTENSIONS = {".epub", ".pdf"}
|
||||||
|
_STOP_WORDS = {
|
||||||
|
"a", "an", "the", "and", "or", "but", "if", "then", "else", "when",
|
||||||
|
"at", "by", "for", "from", "in", "into", "of", "off", "on", "onto",
|
||||||
|
"to", "with", "is", "are", "was", "were", "be", "been", "being",
|
||||||
|
"that", "this", "these", "those", "it", "its"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class CalibreOPDSError(RuntimeError):
|
class CalibreOPDSError(RuntimeError):
|
||||||
@@ -199,6 +205,59 @@ class CalibreOPDSClient:
|
|||||||
|
|
||||||
return self._parse_feed(response.text, base_url=target)
|
return self._parse_feed(response.text, base_url=target)
|
||||||
|
|
||||||
|
def _fetch_opensearch_template(self, href: str) -> Optional[str]:
|
||||||
|
target = self._make_url(href)
|
||||||
|
try:
|
||||||
|
with self._open_client() as client:
|
||||||
|
response = client.get(target, follow_redirects=True)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
# Simple XML parsing to find the Url template
|
||||||
|
# We avoid full namespace handling for robustness
|
||||||
|
root = ET.fromstring(response.text)
|
||||||
|
for node in root.iter():
|
||||||
|
if node.tag.endswith("Url"):
|
||||||
|
template = node.attrib.get("template")
|
||||||
|
if template and "{searchTerms}" in template:
|
||||||
|
mime = node.attrib.get("type", "")
|
||||||
|
# Prefer atom/xml feeds
|
||||||
|
if "atom" in mime or "xml" in mime:
|
||||||
|
return template
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _find_best_seed_feed(self, root_feed: OPDSFeed) -> OPDSFeed:
|
||||||
|
# If the root feed already has books, use it
|
||||||
|
for entry in root_feed.entries:
|
||||||
|
if any("acquisition" in (link.rel or "") for link in entry.links):
|
||||||
|
return root_feed
|
||||||
|
|
||||||
|
# Otherwise, look for a "By Title" or "All" navigation entry
|
||||||
|
candidates = ["title", "all", "books", "catalog"]
|
||||||
|
best_href = None
|
||||||
|
|
||||||
|
for entry in root_feed.entries:
|
||||||
|
title_lower = (entry.title or "").lower()
|
||||||
|
if any(c in title_lower for c in candidates):
|
||||||
|
# Check if it has a navigation link
|
||||||
|
for link in entry.links:
|
||||||
|
if self._is_navigation_link(link):
|
||||||
|
best_href = link.href
|
||||||
|
# Prefer "By Title" explicitly
|
||||||
|
if "title" in title_lower:
|
||||||
|
break
|
||||||
|
if best_href and "title" in title_lower:
|
||||||
|
break
|
||||||
|
|
||||||
|
if best_href:
|
||||||
|
try:
|
||||||
|
return self.fetch_feed(best_href)
|
||||||
|
except CalibreOPDSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return root_feed
|
||||||
|
|
||||||
def search(self, query: str, start_href: Optional[str] = None) -> OPDSFeed:
|
def search(self, query: str, start_href: Optional[str] = None) -> OPDSFeed:
|
||||||
cleaned = (query or "").strip()
|
cleaned = (query or "").strip()
|
||||||
if not cleaned:
|
if not cleaned:
|
||||||
@@ -210,37 +269,62 @@ class CalibreOPDSClient:
|
|||||||
except CalibreOPDSError:
|
except CalibreOPDSError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
candidates: List[Tuple[Optional[str], Optional[Mapping[str, Any]]]] = []
|
# 1. Try explicit search link from feed
|
||||||
if base_feed:
|
if base_feed:
|
||||||
|
# Check for OpenSearch description first
|
||||||
|
search_link = self._resolve_link(base_feed.links, "search")
|
||||||
|
if search_link and search_link.type == "application/opensearchdescription+xml":
|
||||||
|
template = self._fetch_opensearch_template(search_link.href)
|
||||||
|
if template:
|
||||||
|
search_url = template.replace("{searchTerms}", quote(cleaned))
|
||||||
|
try:
|
||||||
|
feed = self.fetch_feed(search_url)
|
||||||
|
if feed.entries:
|
||||||
|
filtered = self._filter_feed_entries(feed, cleaned)
|
||||||
|
if filtered.entries:
|
||||||
|
return filtered
|
||||||
|
except CalibreOPDSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Check for direct template
|
||||||
search_url = self._resolve_search_url(base_feed, cleaned)
|
search_url = self._resolve_search_url(base_feed, cleaned)
|
||||||
if search_url:
|
if search_url:
|
||||||
candidates.append((search_url, None))
|
try:
|
||||||
|
feed = self.fetch_feed(search_url)
|
||||||
|
if feed.entries:
|
||||||
|
filtered = self._filter_feed_entries(feed, cleaned)
|
||||||
|
if filtered.entries:
|
||||||
|
return filtered
|
||||||
|
except CalibreOPDSError:
|
||||||
|
pass
|
||||||
|
|
||||||
candidates.extend([
|
# 2. Try common guesses if explicit link failed
|
||||||
|
candidates: List[Tuple[Optional[str], Optional[Mapping[str, Any]]]] = [
|
||||||
("search", {"query": cleaned}),
|
("search", {"query": cleaned}),
|
||||||
("search", {"q": cleaned}),
|
("search", {"q": cleaned}),
|
||||||
(None, {"search": cleaned}),
|
(None, {"search": cleaned}),
|
||||||
])
|
]
|
||||||
|
|
||||||
last_error: Optional[Exception] = None
|
last_error: Optional[Exception] = None
|
||||||
base_combined: Optional[OPDSFeed] = None
|
|
||||||
|
|
||||||
# Try server-side search first (global search)
|
|
||||||
for path, params in candidates:
|
for path, params in candidates:
|
||||||
try:
|
try:
|
||||||
feed = self.fetch_feed(path, params=params)
|
feed = self.fetch_feed(path, params=params)
|
||||||
|
if feed.entries:
|
||||||
|
# Check if the server ignored the query and returned the default feed
|
||||||
|
if base_feed and feed.title == base_feed.title:
|
||||||
|
# Compare first entry ID to see if it's the same feed
|
||||||
|
if feed.entries[0].id == base_feed.entries[0].id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
filtered = self._filter_feed_entries(feed, cleaned)
|
||||||
|
if filtered.entries:
|
||||||
|
return filtered
|
||||||
except CalibreOPDSError as exc:
|
except CalibreOPDSError as exc:
|
||||||
last_error = exc
|
last_error = exc
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# If we got results from server search, use them
|
# 3. Fallback to local search (crawling)
|
||||||
if feed.entries:
|
|
||||||
return feed
|
|
||||||
|
|
||||||
# Fallback to local search (crawling)
|
|
||||||
# If start_href is provided, we search from there (contextual search)
|
|
||||||
# Otherwise we search from root (which might be empty if root has no books)
|
|
||||||
|
|
||||||
seed_feed: Optional[OPDSFeed] = None
|
seed_feed: Optional[OPDSFeed] = None
|
||||||
if start_href:
|
if start_href:
|
||||||
try:
|
try:
|
||||||
@@ -249,7 +333,8 @@ class CalibreOPDSClient:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
if not seed_feed and base_feed:
|
if not seed_feed and base_feed:
|
||||||
seed_feed = base_feed
|
# If we are falling back to base_feed (Root), try to find a better seed
|
||||||
|
seed_feed = self._find_best_seed_feed(base_feed)
|
||||||
|
|
||||||
if not seed_feed:
|
if not seed_feed:
|
||||||
try:
|
try:
|
||||||
@@ -259,7 +344,22 @@ class CalibreOPDSClient:
|
|||||||
raise last_error
|
raise last_error
|
||||||
raise exc
|
raise exc
|
||||||
|
|
||||||
return self._local_search(cleaned, seed_feed=seed_feed)
|
# Heuristic: If the seed feed has acquisition links, use linear scan.
|
||||||
|
# Otherwise, use BFS to find content.
|
||||||
|
has_books = False
|
||||||
|
if seed_feed and seed_feed.entries:
|
||||||
|
for entry in seed_feed.entries[:5]:
|
||||||
|
for link in entry.links:
|
||||||
|
if "acquisition" in (link.rel or ""):
|
||||||
|
has_books = True
|
||||||
|
break
|
||||||
|
if has_books:
|
||||||
|
break
|
||||||
|
|
||||||
|
if has_books:
|
||||||
|
return self._collect_search_results(seed_feed, cleaned)
|
||||||
|
else:
|
||||||
|
return self._local_search(cleaned, seed_feed=seed_feed)
|
||||||
|
|
||||||
def _collect_search_results(
|
def _collect_search_results(
|
||||||
self,
|
self,
|
||||||
@@ -484,7 +584,8 @@ class CalibreOPDSClient:
|
|||||||
|
|
||||||
feed_id = root.findtext("atom:id", default=None, namespaces=NS)
|
feed_id = root.findtext("atom:id", default=None, namespaces=NS)
|
||||||
feed_title = root.findtext("atom:title", default=None, namespaces=NS)
|
feed_title = root.findtext("atom:title", default=None, namespaces=NS)
|
||||||
links = self._parse_links(root.findall("atom:link", NS), base_url)
|
links_list = self._extract_links(root.findall("atom:link", NS), base_url)
|
||||||
|
links = self._links_to_dict(links_list)
|
||||||
parsed_entries = [self._parse_entry(node, base_url) for node in root.findall("atom:entry", NS)]
|
parsed_entries = [self._parse_entry(node, base_url) for node in root.findall("atom:entry", NS)]
|
||||||
entries: List[OPDSEntry] = []
|
entries: List[OPDSEntry] = []
|
||||||
for entry in parsed_entries:
|
for entry in parsed_entries:
|
||||||
@@ -531,10 +632,11 @@ class CalibreOPDSClient:
|
|||||||
authors.append(value)
|
authors.append(value)
|
||||||
|
|
||||||
links = node.findall("atom:link", NS)
|
links = node.findall("atom:link", NS)
|
||||||
parsed_links = self._parse_links(links, base_url)
|
all_links = self._extract_links(links, base_url)
|
||||||
download_link = self._select_download_link(parsed_links.values())
|
link_dict = self._links_to_dict(all_links)
|
||||||
alternate_link = parsed_links.get("alternate")
|
download_link = self._select_download_link(all_links)
|
||||||
thumb_link = parsed_links.get("http://opds-spec.org/image/thumbnail") or parsed_links.get(
|
alternate_link = link_dict.get("alternate")
|
||||||
|
thumb_link = link_dict.get("http://opds-spec.org/image/thumbnail") or link_dict.get(
|
||||||
"thumbnail"
|
"thumbnail"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -595,7 +697,7 @@ class CalibreOPDSClient:
|
|||||||
download=download_link,
|
download=download_link,
|
||||||
alternate=alternate_link,
|
alternate=alternate_link,
|
||||||
thumbnail=thumb_link,
|
thumbnail=thumb_link,
|
||||||
links=list(parsed_links.values()),
|
links=all_links,
|
||||||
series=series_name,
|
series=series_name,
|
||||||
series_index=series_index,
|
series_index=series_index,
|
||||||
tags=tags,
|
tags=tags,
|
||||||
@@ -782,8 +884,8 @@ class CalibreOPDSClient:
|
|||||||
continue
|
continue
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _parse_links(self, link_nodes: List[ET.Element], base_url: str) -> Dict[str, OPDSLink]:
|
def _extract_links(self, link_nodes: List[ET.Element], base_url: str) -> List[OPDSLink]:
|
||||||
results: Dict[str, OPDSLink] = {}
|
links: List[OPDSLink] = []
|
||||||
for link in link_nodes:
|
for link in link_nodes:
|
||||||
href = link.attrib.get("href")
|
href = link.attrib.get("href")
|
||||||
if not href:
|
if not href:
|
||||||
@@ -793,15 +895,22 @@ class CalibreOPDSClient:
|
|||||||
title = link.attrib.get("title")
|
title = link.attrib.get("title")
|
||||||
base_for_join = base_url or self._base_url
|
base_for_join = base_url or self._base_url
|
||||||
absolute_href = urljoin(base_for_join, href)
|
absolute_href = urljoin(base_for_join, href)
|
||||||
entry = OPDSLink(href=absolute_href, rel=rel, type=link_type, title=title)
|
links.append(OPDSLink(href=absolute_href, rel=rel, type=link_type, title=title))
|
||||||
key = rel or absolute_href
|
return links
|
||||||
|
|
||||||
|
def _links_to_dict(self, links: List[OPDSLink]) -> Dict[str, OPDSLink]:
|
||||||
|
results: Dict[str, OPDSLink] = {}
|
||||||
|
for entry in links:
|
||||||
|
key = entry.rel or entry.href
|
||||||
|
if not key:
|
||||||
|
continue
|
||||||
|
|
||||||
# Prioritize search links with template parameters
|
# Prioritize search links with template parameters
|
||||||
if key == "search" and key in results:
|
if key == "search" and key in results:
|
||||||
existing = results[key]
|
existing = results[key]
|
||||||
if "{searchTerms}" in (existing.href or ""):
|
if "{searchTerms}" in (existing.href or ""):
|
||||||
continue
|
continue
|
||||||
if "{searchTerms}" in absolute_href:
|
if "{searchTerms}" in (entry.href or ""):
|
||||||
results[key] = entry
|
results[key] = entry
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -988,7 +1097,18 @@ class CalibreOPDSClient:
|
|||||||
tokens = [token for token in re.split(r"\s+", normalized_query) if token]
|
tokens = [token for token in re.split(r"\s+", normalized_query) if token]
|
||||||
if not tokens:
|
if not tokens:
|
||||||
return feed
|
return feed
|
||||||
filtered = [entry for entry in feed.entries if self._entry_matches_query(entry, tokens)]
|
|
||||||
|
scored_entries = []
|
||||||
|
for entry in feed.entries:
|
||||||
|
score = self._calculate_match_score(entry, tokens)
|
||||||
|
# Require a minimum score to avoid weak matches (e.g. single word in summary)
|
||||||
|
if score >= 10:
|
||||||
|
scored_entries.append((score, entry))
|
||||||
|
|
||||||
|
# Sort by score descending
|
||||||
|
scored_entries.sort(key=lambda x: x[0], reverse=True)
|
||||||
|
|
||||||
|
filtered = [e for s, e in scored_entries]
|
||||||
return dataclasses.replace(feed, entries=filtered)
|
return dataclasses.replace(feed, entries=filtered)
|
||||||
|
|
||||||
def browse_letter(
|
def browse_letter(
|
||||||
@@ -1094,8 +1214,89 @@ class CalibreOPDSClient:
|
|||||||
|
|
||||||
return href
|
return href
|
||||||
|
|
||||||
|
def _calculate_match_score(self, entry: OPDSEntry, tokens: List[str]) -> int:
|
||||||
|
if not tokens:
|
||||||
|
return 0
|
||||||
|
|
||||||
def feed_to_dict(feed: OPDSFeed) -> Dict[str, Any]:
|
score = 0
|
||||||
"""Helper used by APIs to convert a feed into JSON-serialisable payloads."""
|
|
||||||
|
|
||||||
return feed.to_dict()
|
# Prepare normalized text
|
||||||
|
title = self._normalize_text(entry.title)
|
||||||
|
authors = [self._normalize_text(a) for a in entry.authors]
|
||||||
|
series = self._normalize_text(entry.series) if entry.series else ""
|
||||||
|
summary = self._normalize_text(entry.summary) if entry.summary else ""
|
||||||
|
tags = [self._normalize_text(t) for t in entry.tags]
|
||||||
|
|
||||||
|
# 1. Exact/Phrase matches
|
||||||
|
query_phrase = " ".join(tokens)
|
||||||
|
if query_phrase == title:
|
||||||
|
score += 1000
|
||||||
|
elif query_phrase in title:
|
||||||
|
score += 500
|
||||||
|
|
||||||
|
for author in authors:
|
||||||
|
if query_phrase in author:
|
||||||
|
score += 300
|
||||||
|
|
||||||
|
if query_phrase in series:
|
||||||
|
score += 200
|
||||||
|
|
||||||
|
for tag in tags:
|
||||||
|
if query_phrase == tag:
|
||||||
|
score += 100
|
||||||
|
elif query_phrase in tag:
|
||||||
|
score += 50
|
||||||
|
|
||||||
|
# 2. Token matches
|
||||||
|
# Filter out stop words unless the query is only stop words
|
||||||
|
significant_tokens = [t for t in tokens if t not in _STOP_WORDS]
|
||||||
|
if not significant_tokens:
|
||||||
|
significant_tokens = tokens
|
||||||
|
|
||||||
|
for token in significant_tokens:
|
||||||
|
token_score = 0
|
||||||
|
# Use regex for word boundary matching
|
||||||
|
# Escape token to handle special chars
|
||||||
|
token_regex = r"\b" + re.escape(token) + r"\b"
|
||||||
|
|
||||||
|
# Title: High weight
|
||||||
|
if re.search(token_regex, title):
|
||||||
|
token_score = max(token_score, 50)
|
||||||
|
elif token in title:
|
||||||
|
token_score = max(token_score, 5)
|
||||||
|
|
||||||
|
# Author: Medium-High weight
|
||||||
|
for author in authors:
|
||||||
|
if re.search(token_regex, author):
|
||||||
|
token_score = max(token_score, 40)
|
||||||
|
elif token in author:
|
||||||
|
token_score = max(token_score, 5)
|
||||||
|
|
||||||
|
# Series: Medium weight
|
||||||
|
if token in series:
|
||||||
|
if re.search(token_regex, series):
|
||||||
|
token_score = max(token_score, 30)
|
||||||
|
else:
|
||||||
|
token_score = max(token_score, 5)
|
||||||
|
|
||||||
|
# Tags: Medium weight
|
||||||
|
for tag in tags:
|
||||||
|
if re.search(token_regex, tag):
|
||||||
|
token_score = max(token_score, 30)
|
||||||
|
elif token in tag:
|
||||||
|
token_score = max(token_score, 5)
|
||||||
|
|
||||||
|
# Summary: Low weight
|
||||||
|
if token in summary:
|
||||||
|
if re.search(token_regex, summary):
|
||||||
|
# Only add if not found elsewhere? Or just add small amount?
|
||||||
|
if token_score == 0:
|
||||||
|
token_score = 15
|
||||||
|
else:
|
||||||
|
token_score += 5 # Small boost if also in description
|
||||||
|
elif token_score == 0:
|
||||||
|
token_score = 2 # Very low for substring in summary
|
||||||
|
|
||||||
|
score += token_score
|
||||||
|
|
||||||
|
return score
|
||||||
|
|||||||
Reference in New Issue
Block a user