bookcli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bookcli/__init__.py +3 -0
- bookcli/cache.py +103 -0
- bookcli/cli.py +752 -0
- bookcli/config.py +72 -0
- bookcli/database/__init__.py +32 -0
- bookcli/database/migrations.py +94 -0
- bookcli/downloader.py +130 -0
- bookcli/exceptions.py +34 -0
- bookcli/models.py +23 -0
- bookcli/opener.py +32 -0
- bookcli/providers/__init__.py +1 -0
- bookcli/providers/base.py +61 -0
- bookcli/providers/google_books.py +202 -0
- bookcli/providers/gutenberg.py +211 -0
- bookcli/providers/internet_archive.py +266 -0
- bookcli/providers/openlibrary.py +256 -0
- bookcli/services/__init__.py +1 -0
- bookcli/services/history.py +69 -0
- bookcli/services/ranking.py +169 -0
- bookcli/services/search.py +96 -0
- bookcli/settings.py +28 -0
- bookcli/utils.py +30 -0
- bookcli-0.1.0.dist-info/METADATA +198 -0
- bookcli-0.1.0.dist-info/RECORD +27 -0
- bookcli-0.1.0.dist-info/WHEEL +5 -0
- bookcli-0.1.0.dist-info/entry_points.txt +2 -0
- bookcli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""Open Library API provider implementation."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import List, Optional
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from bookcli.exceptions import ProviderError, DownloadError
|
|
8
|
+
from bookcli.models import BookMetadata
|
|
9
|
+
from bookcli.providers.base import BaseProvider
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class OpenLibraryProvider(BaseProvider):
|
|
15
|
+
"""Open Library API provider."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, timeout_seconds: int = 15):
|
|
18
|
+
super().__init__(name="openlibrary", timeout_seconds=timeout_seconds)
|
|
19
|
+
self.search_url = "https://openlibrary.org/search.json"
|
|
20
|
+
|
|
21
|
+
def _map_doc_to_metadata(self, doc: dict) -> BookMetadata:
|
|
22
|
+
"""Maps Open Library search document to BookMetadata."""
|
|
23
|
+
work_key = doc.get("key", "")
|
|
24
|
+
# Standardize ID: openlibrary:OL123W
|
|
25
|
+
book_id = f"openlibrary:{work_key.split('/')[-1]}"
|
|
26
|
+
|
|
27
|
+
title = doc.get("title", "Unknown Title")
|
|
28
|
+
subtitle = doc.get("subtitle")
|
|
29
|
+
authors = doc.get("author_name", [])
|
|
30
|
+
|
|
31
|
+
# Publishers (Open Library returns a list)
|
|
32
|
+
publishers = doc.get("publisher", [])
|
|
33
|
+
publisher = publishers[0] if publishers else None
|
|
34
|
+
|
|
35
|
+
published_year = doc.get("first_publish_year")
|
|
36
|
+
pages = doc.get("number_of_pages_median")
|
|
37
|
+
|
|
38
|
+
# Languages (Open Library returns ISO codes in list, e.g. ['eng'])
|
|
39
|
+
languages = doc.get("language", [])
|
|
40
|
+
language = languages[0] if languages else None
|
|
41
|
+
|
|
42
|
+
# ISBN list
|
|
43
|
+
isbns = doc.get("isbn", [])
|
|
44
|
+
isbn = isbns[0] if isbns else None
|
|
45
|
+
|
|
46
|
+
# Cover Image URL
|
|
47
|
+
cover_i = doc.get("cover_i")
|
|
48
|
+
cover_url = f"https://covers.openlibrary.org/b/id/{cover_i}-M.jpg" if cover_i else None
|
|
49
|
+
|
|
50
|
+
# Internet Archive IDs (ia) for downloads
|
|
51
|
+
ia_list = doc.get("ia", [])
|
|
52
|
+
download_available = False
|
|
53
|
+
download_url = None
|
|
54
|
+
file_format = None
|
|
55
|
+
|
|
56
|
+
if ia_list:
|
|
57
|
+
# We filter out items that don't look like standard IA IDs (e.g. starting with markers like 'lending')
|
|
58
|
+
valid_ia = [ia for ia in ia_list if not ia.startswith("lending:")]
|
|
59
|
+
if valid_ia:
|
|
60
|
+
ia_id = valid_ia[0]
|
|
61
|
+
# Default to epub for IA download
|
|
62
|
+
download_url = f"https://archive.org/download/{ia_id}/{ia_id}.epub"
|
|
63
|
+
download_available = True
|
|
64
|
+
file_format = "epub"
|
|
65
|
+
|
|
66
|
+
return BookMetadata(
|
|
67
|
+
id=book_id,
|
|
68
|
+
title=title,
|
|
69
|
+
subtitle=subtitle,
|
|
70
|
+
authors=authors,
|
|
71
|
+
description=None, # Search endpoint doesn't return full description; get_book will fetch it
|
|
72
|
+
language=language,
|
|
73
|
+
publisher=publisher,
|
|
74
|
+
published_year=published_year,
|
|
75
|
+
pages=pages,
|
|
76
|
+
isbn=isbn,
|
|
77
|
+
cover_url=cover_url,
|
|
78
|
+
download_url=download_url,
|
|
79
|
+
source=self.name,
|
|
80
|
+
download_availability=download_available,
|
|
81
|
+
file_format=file_format
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
async def search(
|
|
85
|
+
self,
|
|
86
|
+
query: str,
|
|
87
|
+
author: Optional[str] = None,
|
|
88
|
+
isbn: Optional[str] = None,
|
|
89
|
+
publisher: Optional[str] = None,
|
|
90
|
+
subject: Optional[str] = None
|
|
91
|
+
) -> List[BookMetadata]:
|
|
92
|
+
"""Searches Open Library."""
|
|
93
|
+
params = {}
|
|
94
|
+
if query.strip():
|
|
95
|
+
params["q"] = query.strip()
|
|
96
|
+
if author:
|
|
97
|
+
params["author"] = author
|
|
98
|
+
if isbn:
|
|
99
|
+
params["isbn"] = isbn
|
|
100
|
+
if publisher:
|
|
101
|
+
params["publisher"] = publisher
|
|
102
|
+
if subject:
|
|
103
|
+
params["subject"] = subject
|
|
104
|
+
|
|
105
|
+
if not params:
|
|
106
|
+
return []
|
|
107
|
+
|
|
108
|
+
params["limit"] = 20
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
async with httpx.AsyncClient(timeout=self.timeout_seconds) as client:
|
|
112
|
+
response = await client.get(self.search_url, params=params)
|
|
113
|
+
if response.status_code != 200:
|
|
114
|
+
raise ProviderError(f"Open Library API returned status {response.status_code}")
|
|
115
|
+
|
|
116
|
+
data = response.json()
|
|
117
|
+
docs = data.get("docs", [])
|
|
118
|
+
|
|
119
|
+
results = []
|
|
120
|
+
for doc in docs:
|
|
121
|
+
try:
|
|
122
|
+
results.append(self._map_doc_to_metadata(doc))
|
|
123
|
+
except Exception as e:
|
|
124
|
+
logger.warning("Error parsing Open Library doc: %s", e)
|
|
125
|
+
return results
|
|
126
|
+
|
|
127
|
+
except httpx.RequestError as e:
|
|
128
|
+
logger.error("HTTP error searching Open Library: %s", e)
|
|
129
|
+
raise ProviderError(f"Network error querying Open Library API: {e}")
|
|
130
|
+
except Exception as e:
|
|
131
|
+
logger.error("Error searching Open Library: %s", e)
|
|
132
|
+
raise ProviderError(f"Open Library search failed: {e}")
|
|
133
|
+
|
|
134
|
+
async def get_book(self, book_id: str) -> Optional[BookMetadata]:
|
|
135
|
+
"""Fetches details for a single work from Open Library."""
|
|
136
|
+
actual_id = book_id.split(":", 1)[-1]
|
|
137
|
+
|
|
138
|
+
# Work URL (fetch the work JSON, e.g. /works/OL123W.json)
|
|
139
|
+
work_url = f"https://openlibrary.org/works/{actual_id}.json"
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
async with httpx.AsyncClient(timeout=self.timeout_seconds) as client:
|
|
143
|
+
# Fetch detailed Work JSON
|
|
144
|
+
work_response = await client.get(work_url)
|
|
145
|
+
if work_response.status_code == 404:
|
|
146
|
+
return None
|
|
147
|
+
if work_response.status_code != 200:
|
|
148
|
+
raise ProviderError(f"Open Library work endpoint returned {work_response.status_code}")
|
|
149
|
+
|
|
150
|
+
work_data = work_response.json()
|
|
151
|
+
|
|
152
|
+
# Get description
|
|
153
|
+
description = None
|
|
154
|
+
desc_field = work_data.get("description")
|
|
155
|
+
if isinstance(desc_field, dict):
|
|
156
|
+
description = desc_field.get("value")
|
|
157
|
+
elif isinstance(desc_field, str):
|
|
158
|
+
description = desc_field
|
|
159
|
+
|
|
160
|
+
# Get authors (works only contain references to author keys, e.g. {"author": {"key": "/authors/OL123A"}})
|
|
161
|
+
authors = []
|
|
162
|
+
for auth_entry in work_data.get("authors", []):
|
|
163
|
+
auth_key = auth_entry.get("author", {}).get("key")
|
|
164
|
+
if auth_key:
|
|
165
|
+
# Fetch author detail to get name
|
|
166
|
+
auth_response = await client.get(f"https://openlibrary.org{auth_key}.json")
|
|
167
|
+
if auth_response.status_code == 200:
|
|
168
|
+
authors.append(auth_response.json().get("name", ""))
|
|
169
|
+
|
|
170
|
+
# Fetch editions to find pages, publisher, ISBN, cover, and downloads
|
|
171
|
+
# /works/OL123W/editions.json
|
|
172
|
+
editions_url = f"https://openlibrary.org/works/{actual_id}/editions.json"
|
|
173
|
+
editions_response = await client.get(editions_url)
|
|
174
|
+
|
|
175
|
+
publisher = None
|
|
176
|
+
published_year = None
|
|
177
|
+
pages = None
|
|
178
|
+
isbn = None
|
|
179
|
+
cover_url = None
|
|
180
|
+
download_available = False
|
|
181
|
+
download_url = None
|
|
182
|
+
file_format = None
|
|
183
|
+
|
|
184
|
+
if editions_response.status_code == 200:
|
|
185
|
+
editions_data = editions_response.json()
|
|
186
|
+
entries = editions_data.get("entries", [])
|
|
187
|
+
|
|
188
|
+
if entries:
|
|
189
|
+
# Check the first edition with details
|
|
190
|
+
first_ed = entries[0]
|
|
191
|
+
|
|
192
|
+
publishers = first_ed.get("publishers", [])
|
|
193
|
+
publisher = publishers[0] if publishers else None
|
|
194
|
+
|
|
195
|
+
pub_date = first_ed.get("publish_date")
|
|
196
|
+
if pub_date:
|
|
197
|
+
# Try parsing year
|
|
198
|
+
try:
|
|
199
|
+
published_year = int(pub_date.split()[-1])
|
|
200
|
+
except ValueError:
|
|
201
|
+
pass
|
|
202
|
+
|
|
203
|
+
pages = first_ed.get("number_of_pages")
|
|
204
|
+
|
|
205
|
+
isbns = first_ed.get("isbn_13", []) or first_ed.get("isbn_10", [])
|
|
206
|
+
isbn = isbns[0] if isbns else None
|
|
207
|
+
|
|
208
|
+
# Cover URL if present
|
|
209
|
+
covers = first_ed.get("covers", [])
|
|
210
|
+
if covers:
|
|
211
|
+
cover_url = f"https://covers.openlibrary.org/b/id/{covers[0]}-M.jpg"
|
|
212
|
+
|
|
213
|
+
# Internet Archive IDs
|
|
214
|
+
ia_list = first_ed.get("ocaid") or first_ed.get("ia", [])
|
|
215
|
+
if isinstance(ia_list, str):
|
|
216
|
+
ia_list = [ia_list]
|
|
217
|
+
|
|
218
|
+
if ia_list:
|
|
219
|
+
valid_ia = [ia for ia in ia_list if not ia.startswith("lending:")]
|
|
220
|
+
if valid_ia:
|
|
221
|
+
ia_id = valid_ia[0]
|
|
222
|
+
download_url = f"https://archive.org/download/{ia_id}/{ia_id}.epub"
|
|
223
|
+
download_available = True
|
|
224
|
+
file_format = "epub"
|
|
225
|
+
|
|
226
|
+
return BookMetadata(
|
|
227
|
+
id=book_id,
|
|
228
|
+
title=work_data.get("title", "Unknown Title"),
|
|
229
|
+
subtitle=work_data.get("subtitle"),
|
|
230
|
+
authors=authors,
|
|
231
|
+
description=description,
|
|
232
|
+
language=None,
|
|
233
|
+
publisher=publisher,
|
|
234
|
+
published_year=published_year,
|
|
235
|
+
pages=pages,
|
|
236
|
+
isbn=isbn,
|
|
237
|
+
cover_url=cover_url,
|
|
238
|
+
download_url=download_url,
|
|
239
|
+
source=self.name,
|
|
240
|
+
download_availability=download_available,
|
|
241
|
+
file_format=file_format
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
except httpx.RequestError as e:
|
|
245
|
+
raise ProviderError(f"Network error getting Open Library Work: {e}")
|
|
246
|
+
except Exception as e:
|
|
247
|
+
raise ProviderError(f"Open Library Work retrieval failed: {e}")
|
|
248
|
+
|
|
249
|
+
async def download(self, book: BookMetadata, dest_path: str) -> None:
|
|
250
|
+
"""Downloads the Open Library Book. Delegates to central downloader."""
|
|
251
|
+
if not book.download_availability or not book.download_url:
|
|
252
|
+
raise DownloadError("No legal download URL available for this Open Library book.")
|
|
253
|
+
|
|
254
|
+
# Import dynamically to avoid circular dependencies
|
|
255
|
+
from bookcli.downloader import download_file
|
|
256
|
+
await download_file(book.download_url, dest_path, self.timeout_seconds)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Services for BookCLI."""
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Service for managing search history in the SQLite database."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import List, Dict, Any
|
|
5
|
+
from bookcli.database import DatabaseManager
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
async def add_search_history(
|
|
11
|
+
db_manager: DatabaseManager, query: str, results_count: int
|
|
12
|
+
) -> None:
|
|
13
|
+
"""Adds a search record to the search history table."""
|
|
14
|
+
insert_sql = """
|
|
15
|
+
INSERT INTO search_history (query, results_count)
|
|
16
|
+
VALUES (?, ?)
|
|
17
|
+
"""
|
|
18
|
+
async with db_manager.connection() as conn:
|
|
19
|
+
try:
|
|
20
|
+
await conn.execute(insert_sql, (query, results_count))
|
|
21
|
+
await conn.commit()
|
|
22
|
+
logger.debug("Logged search query '%s' with %d results to history", query, results_count)
|
|
23
|
+
except Exception as e:
|
|
24
|
+
logger.error("Failed to write to search history: %s", e)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def get_search_history(
|
|
28
|
+
db_manager: DatabaseManager, limit: int = 50
|
|
29
|
+
) -> List[Dict[str, Any]]:
|
|
30
|
+
"""Retrieves list of search history entries, sorted by most recent first."""
|
|
31
|
+
select_sql = """
|
|
32
|
+
SELECT id, query, timestamp, results_count
|
|
33
|
+
FROM search_history
|
|
34
|
+
ORDER BY timestamp DESC, id DESC
|
|
35
|
+
LIMIT ?
|
|
36
|
+
"""
|
|
37
|
+
async with db_manager.connection() as conn:
|
|
38
|
+
try:
|
|
39
|
+
async with conn.execute(select_sql, (limit,)) as cursor:
|
|
40
|
+
rows = await cursor.fetchall()
|
|
41
|
+
return [
|
|
42
|
+
{
|
|
43
|
+
"id": row["id"],
|
|
44
|
+
"query": row["query"],
|
|
45
|
+
"timestamp": row["timestamp"],
|
|
46
|
+
"results_count": row["results_count"]
|
|
47
|
+
}
|
|
48
|
+
for row in rows
|
|
49
|
+
]
|
|
50
|
+
except Exception as e:
|
|
51
|
+
logger.error("Failed to fetch search history: %s", e)
|
|
52
|
+
return []
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
async def clear_search_history(db_manager: DatabaseManager) -> int:
|
|
56
|
+
"""Clears all search history records. Returns the number of deleted records."""
|
|
57
|
+
async with db_manager.connection() as conn:
|
|
58
|
+
try:
|
|
59
|
+
async with conn.execute("SELECT COUNT(*) FROM search_history") as cursor:
|
|
60
|
+
row = await cursor.fetchone()
|
|
61
|
+
count = row[0] if row else 0
|
|
62
|
+
|
|
63
|
+
await conn.execute("DELETE FROM search_history")
|
|
64
|
+
await conn.commit()
|
|
65
|
+
logger.info("Cleared search history database (%d items).", count)
|
|
66
|
+
return count
|
|
67
|
+
except Exception as e:
|
|
68
|
+
logger.error("Failed to clear search history: %s", e)
|
|
69
|
+
return 0
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Service for ranking and deduplicating book search results."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import List, Optional
|
|
5
|
+
from rapidfuzz import fuzz
|
|
6
|
+
|
|
7
|
+
from bookcli.models import BookMetadata
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def normalize_isbn(isbn: Optional[str]) -> Optional[str]:
|
|
11
|
+
"""Strips dashes, spaces, and lowercase 'x' to normalize ISBN."""
|
|
12
|
+
if not isbn:
|
|
13
|
+
return None
|
|
14
|
+
cleaned = re.sub(r"[- \s]", "", isbn).strip()
|
|
15
|
+
return cleaned.upper() if cleaned else None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def is_duplicate(book1: BookMetadata, book2: BookMetadata) -> bool:
|
|
19
|
+
"""Determines if two books are duplicates based on ISBN or Title+Author similarity."""
|
|
20
|
+
# Check ISBN match
|
|
21
|
+
isbn1 = normalize_isbn(book1.isbn)
|
|
22
|
+
isbn2 = normalize_isbn(book2.isbn)
|
|
23
|
+
if isbn1 and isbn2 and isbn1 == isbn2:
|
|
24
|
+
return True
|
|
25
|
+
|
|
26
|
+
# Check Title + Author similarity
|
|
27
|
+
# We use fuzz.token_set_ratio for comparison to support subtitle variations
|
|
28
|
+
title_sim = fuzz.token_set_ratio(book1.title.lower(), book2.title.lower())
|
|
29
|
+
|
|
30
|
+
# Compare authors
|
|
31
|
+
authors1 = " ".join(book1.authors).lower()
|
|
32
|
+
authors2 = " ".join(book2.authors).lower()
|
|
33
|
+
|
|
34
|
+
if authors1 and authors2:
|
|
35
|
+
author_sim = fuzz.token_sort_ratio(authors1, authors2)
|
|
36
|
+
elif not authors1 and not authors2:
|
|
37
|
+
author_sim = 100.0 # both have no authors
|
|
38
|
+
else:
|
|
39
|
+
author_sim = 0.0
|
|
40
|
+
|
|
41
|
+
# If title similarity is very high and author matches reasonably well
|
|
42
|
+
if title_sim >= 85 and author_sim >= 80:
|
|
43
|
+
return True
|
|
44
|
+
|
|
45
|
+
return False
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def merge_books(book1: BookMetadata, book2: BookMetadata) -> BookMetadata:
|
|
49
|
+
"""Merges two duplicate books, prioritizing the one with download availability and richer metadata."""
|
|
50
|
+
# Determine primary book (prefer download availability, then longer description, then presence of cover)
|
|
51
|
+
if book1.download_availability and not book2.download_availability:
|
|
52
|
+
primary, secondary = book1, book2
|
|
53
|
+
elif book2.download_availability and not book1.download_availability:
|
|
54
|
+
primary, secondary = book2, book1
|
|
55
|
+
else:
|
|
56
|
+
desc1_len = len(book1.description or "")
|
|
57
|
+
desc2_len = len(book2.description or "")
|
|
58
|
+
if desc1_len >= desc2_len:
|
|
59
|
+
primary, secondary = book1, book2
|
|
60
|
+
else:
|
|
61
|
+
primary, secondary = book2, book1
|
|
62
|
+
|
|
63
|
+
# Merge fields
|
|
64
|
+
merged_authors = list(primary.authors)
|
|
65
|
+
for auth in secondary.authors:
|
|
66
|
+
if auth not in merged_authors:
|
|
67
|
+
merged_authors.append(auth)
|
|
68
|
+
|
|
69
|
+
return BookMetadata(
|
|
70
|
+
id=primary.id,
|
|
71
|
+
title=primary.title,
|
|
72
|
+
subtitle=primary.subtitle or secondary.subtitle,
|
|
73
|
+
authors=merged_authors,
|
|
74
|
+
description=primary.description or secondary.description,
|
|
75
|
+
language=primary.language or secondary.language,
|
|
76
|
+
publisher=primary.publisher or secondary.publisher,
|
|
77
|
+
published_year=primary.published_year or secondary.published_year,
|
|
78
|
+
pages=primary.pages or secondary.pages,
|
|
79
|
+
isbn=primary.isbn or secondary.isbn,
|
|
80
|
+
cover_url=primary.cover_url or secondary.cover_url,
|
|
81
|
+
download_url=primary.download_url or secondary.download_url,
|
|
82
|
+
source=f"{primary.source}, {secondary.source}" if primary.source != secondary.source else primary.source,
|
|
83
|
+
download_availability=primary.download_availability or secondary.download_availability,
|
|
84
|
+
file_format=primary.file_format or secondary.file_format
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def calculate_score(
|
|
89
|
+
book: BookMetadata,
|
|
90
|
+
query: str,
|
|
91
|
+
author: Optional[str] = None,
|
|
92
|
+
isbn: Optional[str] = None
|
|
93
|
+
) -> float:
|
|
94
|
+
"""Calculates a relevance score for a book based on search query and filters."""
|
|
95
|
+
score = 0.0
|
|
96
|
+
|
|
97
|
+
# 1. Exact ISBN Match (Max weight)
|
|
98
|
+
if isbn:
|
|
99
|
+
norm_query_isbn = normalize_isbn(isbn)
|
|
100
|
+
norm_book_isbn = normalize_isbn(book.isbn)
|
|
101
|
+
if norm_query_isbn and norm_book_isbn and norm_query_isbn == norm_book_isbn:
|
|
102
|
+
score += 200.0
|
|
103
|
+
|
|
104
|
+
# 2. Fuzzy Title Match
|
|
105
|
+
if query.strip():
|
|
106
|
+
# Title token sort ratio
|
|
107
|
+
title_ratio = fuzz.token_sort_ratio(query.lower(), book.title.lower())
|
|
108
|
+
# Title partial ratio (helps when query is a substring)
|
|
109
|
+
title_partial = fuzz.partial_ratio(query.lower(), book.title.lower())
|
|
110
|
+
score += (title_ratio * 0.6) + (title_partial * 0.4)
|
|
111
|
+
|
|
112
|
+
# 3. Fuzzy Author Match
|
|
113
|
+
book_authors_str = " ".join(book.authors).lower()
|
|
114
|
+
if author and book_authors_str:
|
|
115
|
+
author_ratio = fuzz.token_sort_ratio(author.lower(), book_authors_str)
|
|
116
|
+
score += author_ratio * 0.8
|
|
117
|
+
elif query.strip() and book_authors_str:
|
|
118
|
+
# Check if the query itself matches the author name
|
|
119
|
+
author_query_ratio = fuzz.token_sort_ratio(query.lower(), book_authors_str)
|
|
120
|
+
# If query matches author, add a moderate weight
|
|
121
|
+
if author_query_ratio > 70:
|
|
122
|
+
score += author_query_ratio * 0.4
|
|
123
|
+
|
|
124
|
+
# 4. Download Availability Bonus
|
|
125
|
+
if book.download_availability:
|
|
126
|
+
score += 15.0
|
|
127
|
+
|
|
128
|
+
# 5. Publisher Match (bonus if publisher was in query terms)
|
|
129
|
+
if query.strip() and book.publisher:
|
|
130
|
+
pub_ratio = fuzz.partial_ratio(query.lower(), book.publisher.lower())
|
|
131
|
+
if pub_ratio > 80:
|
|
132
|
+
score += 5.0
|
|
133
|
+
|
|
134
|
+
return score
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def rank_and_deduplicate(
|
|
138
|
+
books: List[BookMetadata],
|
|
139
|
+
query: str,
|
|
140
|
+
author: Optional[str] = None,
|
|
141
|
+
isbn: Optional[str] = None
|
|
142
|
+
) -> List[BookMetadata]:
|
|
143
|
+
"""Deduplicates and ranks a list of book metadata objects."""
|
|
144
|
+
# Deduplicate in-memory
|
|
145
|
+
unique_books: List[BookMetadata] = []
|
|
146
|
+
|
|
147
|
+
for book in books:
|
|
148
|
+
found_dup_idx = -1
|
|
149
|
+
for idx, u_book in enumerate(unique_books):
|
|
150
|
+
if is_duplicate(book, u_book):
|
|
151
|
+
found_dup_idx = idx
|
|
152
|
+
break
|
|
153
|
+
|
|
154
|
+
if found_dup_idx >= 0:
|
|
155
|
+
# Merge duplicate
|
|
156
|
+
unique_books[found_dup_idx] = merge_books(unique_books[found_dup_idx], book)
|
|
157
|
+
else:
|
|
158
|
+
unique_books.append(book)
|
|
159
|
+
|
|
160
|
+
# Calculate score and sort
|
|
161
|
+
# We sort descending by score
|
|
162
|
+
scored_books = [
|
|
163
|
+
(calculate_score(book, query, author, isbn), book)
|
|
164
|
+
for book in unique_books
|
|
165
|
+
]
|
|
166
|
+
|
|
167
|
+
scored_books.sort(key=lambda x: x[0], reverse=True)
|
|
168
|
+
|
|
169
|
+
return [book for _, book in scored_books]
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Service coordinating concurrent search across multiple providers."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from typing import List, Optional
|
|
6
|
+
|
|
7
|
+
from bookcli.config import AppConfig
|
|
8
|
+
from bookcli.database import DatabaseManager
|
|
9
|
+
from bookcli.models import BookMetadata
|
|
10
|
+
from bookcli.cache import save_book_to_cache
|
|
11
|
+
from bookcli.services.ranking import rank_and_deduplicate
|
|
12
|
+
from bookcli.services.history import add_search_history
|
|
13
|
+
from bookcli.providers.google_books import GoogleBooksProvider
|
|
14
|
+
from bookcli.providers.openlibrary import OpenLibraryProvider
|
|
15
|
+
from bookcli.providers.gutenberg import GutenbergProvider
|
|
16
|
+
from bookcli.providers.internet_archive import InternetArchiveProvider
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
async def search_books(
|
|
22
|
+
query: str,
|
|
23
|
+
config: AppConfig,
|
|
24
|
+
db_manager: DatabaseManager,
|
|
25
|
+
author: Optional[str] = None,
|
|
26
|
+
isbn: Optional[str] = None,
|
|
27
|
+
publisher: Optional[str] = None,
|
|
28
|
+
subject: Optional[str] = None
|
|
29
|
+
) -> List[BookMetadata]:
|
|
30
|
+
"""Searches concurrently across all enabled book search providers, merges and ranks results.
|
|
31
|
+
|
|
32
|
+
Saves results to cache and logs the search query to history.
|
|
33
|
+
"""
|
|
34
|
+
providers = []
|
|
35
|
+
|
|
36
|
+
# Instantiate only enabled providers
|
|
37
|
+
if config.providers.google_books:
|
|
38
|
+
providers.append(GoogleBooksProvider(timeout_seconds=config.timeout_seconds))
|
|
39
|
+
if config.providers.openlibrary:
|
|
40
|
+
providers.append(OpenLibraryProvider(timeout_seconds=config.timeout_seconds))
|
|
41
|
+
if config.providers.gutenberg:
|
|
42
|
+
providers.append(GutenbergProvider(timeout_seconds=config.timeout_seconds))
|
|
43
|
+
if config.providers.internet_archive:
|
|
44
|
+
providers.append(InternetArchiveProvider(timeout_seconds=config.timeout_seconds))
|
|
45
|
+
|
|
46
|
+
if not providers:
|
|
47
|
+
logger.warning("No search providers are enabled.")
|
|
48
|
+
return []
|
|
49
|
+
|
|
50
|
+
# Run searches concurrently
|
|
51
|
+
tasks = [
|
|
52
|
+
provider.search(
|
|
53
|
+
query=query,
|
|
54
|
+
author=author,
|
|
55
|
+
isbn=isbn,
|
|
56
|
+
publisher=publisher,
|
|
57
|
+
subject=subject
|
|
58
|
+
)
|
|
59
|
+
for provider in providers
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
# Gather with return_exceptions=True to avoid one provider crash blocking others
|
|
63
|
+
search_results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
64
|
+
|
|
65
|
+
all_books: List[BookMetadata] = []
|
|
66
|
+
for provider, result in zip(providers, search_results):
|
|
67
|
+
if isinstance(result, Exception):
|
|
68
|
+
logger.error("Provider %s search failed: %s", provider.name, result)
|
|
69
|
+
elif result:
|
|
70
|
+
all_books.extend(result)
|
|
71
|
+
|
|
72
|
+
# Rank and deduplicate
|
|
73
|
+
ranked_books = rank_and_deduplicate(
|
|
74
|
+
books=all_books,
|
|
75
|
+
query=query,
|
|
76
|
+
author=author,
|
|
77
|
+
isbn=isbn
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
# Cache metadata for each book (so it can be retrieved via book info <id> or book download <id> instantly)
|
|
81
|
+
# This also allows offline metadata lookup.
|
|
82
|
+
cache_tasks = [save_book_to_cache(db_manager, book) for book in ranked_books]
|
|
83
|
+
if cache_tasks:
|
|
84
|
+
await asyncio.gather(*cache_tasks, return_exceptions=True)
|
|
85
|
+
|
|
86
|
+
# Log to history
|
|
87
|
+
try:
|
|
88
|
+
await add_search_history(
|
|
89
|
+
db_manager=db_manager,
|
|
90
|
+
query=query or f"[author:{author or ''} isbn:{isbn or ''} pub:{publisher or ''} subj:{subject or ''}]",
|
|
91
|
+
results_count=len(ranked_books)
|
|
92
|
+
)
|
|
93
|
+
except Exception as history_err:
|
|
94
|
+
logger.error("Failed to write search history: %s", history_err)
|
|
95
|
+
|
|
96
|
+
return ranked_books
|
bookcli/settings.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Default settings and constants for BookCLI."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
# Base Directory ~/.bookcli
|
|
7
|
+
BASE_DIR = Path(os.environ.get("BOOKCLI_HOME", Path.home() / ".bookcli"))
|
|
8
|
+
BASE_DIR.mkdir(parents=True, exist_ok=True)
|
|
9
|
+
|
|
10
|
+
# File Paths
|
|
11
|
+
DB_PATH = BASE_DIR / "bookcli.db"
|
|
12
|
+
CONFIG_PATH = BASE_DIR / "config.json"
|
|
13
|
+
DEFAULT_DOWNLOAD_DIR = BASE_DIR / "downloads"
|
|
14
|
+
DEFAULT_DOWNLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
|
15
|
+
|
|
16
|
+
# Default configuration settings
|
|
17
|
+
DEFAULT_CONFIG = {
|
|
18
|
+
"download_dir": str(DEFAULT_DOWNLOAD_DIR),
|
|
19
|
+
"cache_ttl_seconds": 86400, # 1 day
|
|
20
|
+
"timeout_seconds": 15,
|
|
21
|
+
"theme": "dark",
|
|
22
|
+
"providers": {
|
|
23
|
+
"google_books": True,
|
|
24
|
+
"openlibrary": True,
|
|
25
|
+
"gutenberg": True,
|
|
26
|
+
"internet_archive": True
|
|
27
|
+
}
|
|
28
|
+
}
|
bookcli/utils.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""General utility helper functions for formatting and CLI presentation."""
|
|
2
|
+
|
|
3
|
+
from typing import Union
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def format_size(size_in_bytes: Union[int, float]) -> str:
|
|
7
|
+
"""Formats raw byte sizes into human-readable strings (KB, MB, GB)."""
|
|
8
|
+
if size_in_bytes < 1024:
|
|
9
|
+
return f"{size_in_bytes} B"
|
|
10
|
+
elif size_in_bytes < 1024 * 1024:
|
|
11
|
+
return f"{size_in_bytes / 1024:.2f} KB"
|
|
12
|
+
elif size_in_bytes < 1024 * 1024 * 1024:
|
|
13
|
+
return f"{size_in_bytes / (1024 * 1024):.2f} MB"
|
|
14
|
+
else:
|
|
15
|
+
return f"{size_in_bytes / (1024 * 1024 * 1024):.2f} GB"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def format_speed(bytes_per_second: Union[int, float]) -> str:
|
|
19
|
+
"""Formats transfer speed into human-readable strings (KB/s, MB/s)."""
|
|
20
|
+
return f"{format_size(bytes_per_second)}/s"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def clean_filename(title: str) -> str:
|
|
24
|
+
"""Cleans a string to make it safe for a filename."""
|
|
25
|
+
# Strip non-alphanumeric, spaces, underscores, dashes
|
|
26
|
+
cleaned = "".join(c for c in title if c.isalnum() or c in (" ", "_", "-")).strip()
|
|
27
|
+
# Replace spaces with underscores
|
|
28
|
+
cleaned = cleaned.replace(" ", "_")
|
|
29
|
+
# Limit length
|
|
30
|
+
return cleaned[:100]
|