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.
@@ -0,0 +1,211 @@
1
+ """Project Gutenberg (via Gutendex 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 GutenbergProvider(BaseProvider):
15
+ """Project Gutenberg provider using Gutendex API."""
16
+
17
+ def __init__(self, timeout_seconds: int = 15):
18
+ super().__init__(name="gutenberg", timeout_seconds=timeout_seconds)
19
+ self.base_url = "https://gutendex.com/books/"
20
+
21
+ def _format_author_name(self, name: str) -> str:
22
+ """Converts 'Austen, Jane' to 'Jane Austen'."""
23
+ if "," in name:
24
+ parts = name.split(",", 1)
25
+ return f"{parts[1].strip()} {parts[0].strip()}"
26
+ return name
27
+
28
+ def _map_item_to_metadata(self, item: dict) -> BookMetadata:
29
+ """Maps Gutendex book to BookMetadata."""
30
+ book_id = f"gutenberg:{item.get('id')}"
31
+ title = item.get("title", "Unknown Gutenberg Book")
32
+
33
+ # Authors
34
+ raw_authors = item.get("authors", [])
35
+ authors = [self._format_author_name(a.get("name", "")) for a in raw_authors if a.get("name")]
36
+
37
+ # Description
38
+ subjects = item.get("subjects", [])
39
+ bookshelves = item.get("bookshelves", [])
40
+ description = f"Subjects: {', '.join(subjects)}"
41
+ if bookshelves:
42
+ description += f" | Bookshelves: {', '.join(bookshelves)}"
43
+
44
+ # Languages
45
+ languages = item.get("languages", [])
46
+ language = languages[0] if languages else "en"
47
+
48
+ # Formats and Cover
49
+ formats = item.get("formats", {})
50
+
51
+ # Look for cover URL
52
+ # Gutendex formats dict can contain images:
53
+ cover_url = None
54
+ for fmt, url in formats.items():
55
+ if "image/jpeg" in fmt and "cover.medium" in url:
56
+ cover_url = url
57
+ break
58
+ if not cover_url:
59
+ for fmt, url in formats.items():
60
+ if "image/jpeg" in fmt and "cover.small" in url:
61
+ cover_url = url
62
+ break
63
+ if not cover_url:
64
+ # Fallback to any jpeg image in formats
65
+ for fmt, url in formats.items():
66
+ if "image" in fmt:
67
+ cover_url = url
68
+ break
69
+
70
+ # Download Availability and URL
71
+ download_available = False
72
+ download_url = None
73
+ file_format = None
74
+
75
+ # Prefer epub
76
+ epub_key = "application/epub+zip"
77
+ txt_utf8_key = "text/plain; charset=utf-8"
78
+ txt_us_ascii_key = "text/plain; charset=us-ascii"
79
+ txt_key = "text/plain"
80
+
81
+ if epub_key in formats:
82
+ download_url = formats[epub_key]
83
+ download_available = True
84
+ file_format = "epub"
85
+ elif txt_utf8_key in formats:
86
+ download_url = formats[txt_utf8_key]
87
+ download_available = True
88
+ file_format = "txt"
89
+ elif txt_us_ascii_key in formats:
90
+ download_url = formats[txt_us_ascii_key]
91
+ download_available = True
92
+ file_format = "txt"
93
+ elif txt_key in formats:
94
+ download_url = formats[txt_key]
95
+ download_available = True
96
+ file_format = "txt"
97
+ else:
98
+ # Try to grab the first text/html or other formats
99
+ for fmt, url in formats.items():
100
+ if "epub" in fmt:
101
+ download_url = url
102
+ download_available = True
103
+ file_format = "epub"
104
+ break
105
+ elif "text" in fmt:
106
+ download_url = url
107
+ download_available = True
108
+ file_format = "txt"
109
+ break
110
+
111
+ return BookMetadata(
112
+ id=book_id,
113
+ title=title,
114
+ subtitle=None,
115
+ authors=authors,
116
+ description=description,
117
+ language=language,
118
+ publisher="Project Gutenberg",
119
+ published_year=None, # Gutenberg works are public domain reprints
120
+ pages=None,
121
+ isbn=None,
122
+ cover_url=cover_url,
123
+ download_url=download_url,
124
+ source=self.name,
125
+ download_availability=download_available,
126
+ file_format=file_format
127
+ )
128
+
129
+ async def search(
130
+ self,
131
+ query: str,
132
+ author: Optional[str] = None,
133
+ isbn: Optional[str] = None,
134
+ publisher: Optional[str] = None,
135
+ subject: Optional[str] = None
136
+ ) -> List[BookMetadata]:
137
+ """Searches Project Gutenberg books using Gutendex."""
138
+ # Gutendex supports a general search param
139
+ params = {}
140
+
141
+ # If query is provided, use search.
142
+ # If query is empty but we have author or subject, we search that.
143
+ search_terms = []
144
+ if query.strip():
145
+ search_terms.append(query.strip())
146
+ if author:
147
+ search_terms.append(author)
148
+
149
+ if search_terms:
150
+ params["search"] = " ".join(search_terms)
151
+
152
+ if subject:
153
+ params["topic"] = subject
154
+
155
+ # ISBN is not applicable to Gutenberg books generally, but if requested, we skip or search normally.
156
+ if isbn:
157
+ params["search"] = isbn
158
+
159
+ # Gutenberg publisher is always Gutenberg, so if publisher is requested and isn't gutenberg, we'd get 0 results.
160
+ if publisher and "gutenberg" not in publisher.lower():
161
+ return []
162
+
163
+ try:
164
+ async with httpx.AsyncClient(timeout=self.timeout_seconds, follow_redirects=True) as client:
165
+ response = await client.get(self.base_url, params=params)
166
+ if response.status_code != 200:
167
+ raise ProviderError(f"Gutendex API returned status {response.status_code}")
168
+
169
+ data = response.json()
170
+ results = []
171
+ for item in data.get("results", []):
172
+ try:
173
+ results.append(self._map_item_to_metadata(item))
174
+ except Exception as e:
175
+ logger.warning("Error parsing Gutendex book: %s", e)
176
+ return results
177
+
178
+ except httpx.RequestError as e:
179
+ logger.error("HTTP error querying Gutendex: %s", e)
180
+ raise ProviderError(f"Network error querying Gutendex API: {e}")
181
+ except Exception as e:
182
+ logger.error("Error searching Gutendex: %s", e)
183
+ raise ProviderError(f"Gutenberg search failed: {e}")
184
+
185
+ async def get_book(self, book_id: str) -> Optional[BookMetadata]:
186
+ """Gets detailed metadata for a single Gutenberg book by its ID."""
187
+ actual_id = book_id.split(":", 1)[-1]
188
+ url = f"{self.base_url}/{actual_id}"
189
+
190
+ try:
191
+ async with httpx.AsyncClient(timeout=self.timeout_seconds, follow_redirects=True) as client:
192
+ response = await client.get(url)
193
+ if response.status_code == 404:
194
+ return None
195
+ if response.status_code != 200:
196
+ raise ProviderError(f"Gutendex book endpoint returned status {response.status_code}")
197
+
198
+ return self._map_item_to_metadata(response.json())
199
+ except httpx.RequestError as e:
200
+ raise ProviderError(f"Network error getting Gutenberg book: {e}")
201
+ except Exception as e:
202
+ raise ProviderError(f"Gutenberg book retrieval failed: {e}")
203
+
204
+ async def download(self, book: BookMetadata, dest_path: str) -> None:
205
+ """Downloads the Gutenberg book. Delegates to central downloader."""
206
+ if not book.download_availability or not book.download_url:
207
+ raise DownloadError("No download URL available for this Gutenberg book.")
208
+
209
+ # Import dynamically to avoid circular dependencies
210
+ from bookcli.downloader import download_file
211
+ await download_file(book.download_url, dest_path, self.timeout_seconds)
@@ -0,0 +1,266 @@
1
+ """Internet Archive 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 InternetArchiveProvider(BaseProvider):
15
+ """Internet Archive provider using the Advanced Search and Metadata APIs."""
16
+
17
+ def __init__(self, timeout_seconds: int = 15):
18
+ super().__init__(name="internet_archive", timeout_seconds=timeout_seconds)
19
+ self.search_url = "https://archive.org/advancedsearch.php"
20
+ self.metadata_url = "https://archive.org/metadata"
21
+
22
+ def _build_query(
23
+ self,
24
+ query: str,
25
+ author: Optional[str] = None,
26
+ isbn: Optional[str] = None,
27
+ publisher: Optional[str] = None,
28
+ subject: Optional[str] = None
29
+ ) -> str:
30
+ """Builds an Lucene-style search query for the Internet Archive API."""
31
+ parts = ["mediatype:texts"]
32
+
33
+ if query.strip():
34
+ # Escape queries or keep them simple. Quoting title search terms is safer.
35
+ parts.append(f'(title:("{query}") OR "{query}")')
36
+ if author:
37
+ parts.append(f'creator:("{author}")')
38
+ if isbn:
39
+ # IA occasionally indexes isbn under isbn field
40
+ parts.append(f'isbn:("{isbn}")')
41
+ if publisher:
42
+ parts.append(f'publisher:("{publisher}")')
43
+ if subject:
44
+ parts.append(f'subject:("{subject}")')
45
+
46
+ return " AND ".join(parts)
47
+
48
+ def _parse_creator(self, creator_field: Optional[object]) -> List[str]:
49
+ """Creator field in IA can be a string, a list of strings, or None."""
50
+ if not creator_field:
51
+ return []
52
+ if isinstance(creator_field, list):
53
+ return [str(c) for c in creator_field if c]
54
+ if isinstance(creator_field, str):
55
+ return [creator_field]
56
+ return [str(creator_field)]
57
+
58
+ def _parse_year(self, date_field: Optional[str]) -> Optional[int]:
59
+ """Extracts the year from '2005-10-12T00:00:00Z' or '2005'."""
60
+ if not date_field:
61
+ return None
62
+ try:
63
+ # Take the first 4 characters
64
+ year = date_field[:4]
65
+ return int(year)
66
+ except (ValueError, IndexError):
67
+ return None
68
+
69
+ def _map_doc_to_metadata(self, doc: dict) -> BookMetadata:
70
+ """Maps an IA search document to BookMetadata."""
71
+ identifier = doc.get("identifier", "")
72
+ book_id = f"internet_archive:{identifier}"
73
+
74
+ title = doc.get("title", "Unknown Title")
75
+ authors = self._parse_creator(doc.get("creator"))
76
+ publisher = doc.get("publisher")
77
+ if isinstance(publisher, list) and publisher:
78
+ publisher = publisher[0]
79
+
80
+ published_year = self._parse_year(doc.get("date"))
81
+ description = doc.get("description")
82
+ if isinstance(description, list) and description:
83
+ description = description[0]
84
+
85
+ language = doc.get("language")
86
+ if isinstance(language, list) and language:
87
+ language = language[0]
88
+
89
+ # Isbns (IA sometimes has list or string)
90
+ isbn_val = doc.get("isbn")
91
+ isbn = None
92
+ if isbn_val:
93
+ if isinstance(isbn_val, list):
94
+ isbn = isbn_val[0]
95
+ else:
96
+ isbn = str(isbn_val)
97
+
98
+ # Cover Image URL is standard for IA
99
+ cover_url = f"https://archive.org/services/img/{identifier}" if identifier else None
100
+
101
+ # Determine download availability
102
+ # We check the 'format' field if returned
103
+ formats = doc.get("format", [])
104
+ if isinstance(formats, str):
105
+ formats = [formats]
106
+
107
+ download_available = False
108
+ download_url = None
109
+ file_format = None
110
+
111
+ # Check if epub or pdf formats are present
112
+ has_epub = any("EPUB" in fmt.upper() for fmt in formats)
113
+ has_pdf = any("PDF" in fmt.upper() for fmt in formats)
114
+
115
+ if has_epub:
116
+ download_url = f"https://archive.org/download/{identifier}/{identifier}.epub"
117
+ download_available = True
118
+ file_format = "epub"
119
+ elif has_pdf:
120
+ download_url = f"https://archive.org/download/{identifier}/{identifier}.pdf"
121
+ download_available = True
122
+ file_format = "pdf"
123
+
124
+ return BookMetadata(
125
+ id=book_id,
126
+ title=title,
127
+ subtitle=None,
128
+ authors=authors,
129
+ description=description,
130
+ language=language,
131
+ publisher=publisher,
132
+ published_year=published_year,
133
+ pages=None,
134
+ isbn=isbn,
135
+ cover_url=cover_url,
136
+ download_url=download_url,
137
+ source=self.name,
138
+ download_availability=download_available,
139
+ file_format=file_format
140
+ )
141
+
142
+ async def search(
143
+ self,
144
+ query: str,
145
+ author: Optional[str] = None,
146
+ isbn: Optional[str] = None,
147
+ publisher: Optional[str] = None,
148
+ subject: Optional[str] = None
149
+ ) -> List[BookMetadata]:
150
+ """Searches Internet Archive advanced search."""
151
+ q_param = self._build_query(query, author, isbn, publisher, subject)
152
+ if not q_param:
153
+ return []
154
+
155
+ params = {
156
+ "q": q_param,
157
+ "fl[]": [
158
+ "identifier", "title", "creator", "publisher",
159
+ "date", "language", "description", "isbn", "format"
160
+ ],
161
+ "rows": 20,
162
+ "output": "json"
163
+ }
164
+
165
+ try:
166
+ async with httpx.AsyncClient(timeout=self.timeout_seconds) as client:
167
+ response = await client.get(self.search_url, params=params)
168
+ if response.status_code != 200:
169
+ raise ProviderError(f"Internet Archive API returned status {response.status_code}")
170
+
171
+ data = response.json()
172
+ response_data = data.get("response", {})
173
+ docs = response_data.get("docs", [])
174
+
175
+ results = []
176
+ for doc in docs:
177
+ try:
178
+ results.append(self._map_doc_to_metadata(doc))
179
+ except Exception as e:
180
+ logger.warning("Error parsing Internet Archive doc: %s", e)
181
+ return results
182
+
183
+ except httpx.RequestError as e:
184
+ logger.error("HTTP error searching Internet Archive: %s", e)
185
+ raise ProviderError(f"Network error querying Internet Archive: {e}")
186
+ except Exception as e:
187
+ logger.error("Error searching Internet Archive: %s", e)
188
+ raise ProviderError(f"Internet Archive search failed: {e}")
189
+
190
+ async def get_book(self, book_id: str) -> Optional[BookMetadata]:
191
+ """Gets detailed metadata from the IA Metadata API."""
192
+ actual_id = book_id.split(":", 1)[-1]
193
+ url = f"{self.metadata_url}/{actual_id}"
194
+
195
+ try:
196
+ async with httpx.AsyncClient(timeout=self.timeout_seconds) as client:
197
+ response = await client.get(url)
198
+ if response.status_code == 404:
199
+ return None
200
+ if response.status_code != 200:
201
+ raise ProviderError(f"Internet Archive Metadata API returned status {response.status_code}")
202
+
203
+ data = response.json()
204
+ metadata = data.get("metadata", {})
205
+
206
+ # Formats are in 'files' list in metadata API
207
+ files = data.get("files", [])
208
+ formats = [f.get("format", "") for f in files if f.get("format")]
209
+
210
+ # Construct standard doc for mapping
211
+ doc = {
212
+ "identifier": actual_id,
213
+ "title": metadata.get("title", ["Unknown Title"])[0] if isinstance(metadata.get("title"), list) else metadata.get("title"),
214
+ "creator": metadata.get("creator"),
215
+ "publisher": metadata.get("publisher"),
216
+ "date": metadata.get("date", [""])[0] if isinstance(metadata.get("date"), list) else metadata.get("date"),
217
+ "language": metadata.get("language"),
218
+ "description": metadata.get("description"),
219
+ "isbn": metadata.get("isbn"),
220
+ "format": formats
221
+ }
222
+
223
+ book = self._map_doc_to_metadata(doc)
224
+
225
+ # Override guessed download URL with exact filename from metadata
226
+ epub_name = None
227
+ pdf_name = None
228
+ for f in files:
229
+ name = f.get("name", "")
230
+ fmt = f.get("format", "").upper()
231
+ if "EPUB" in fmt and name.lower().endswith(".epub"):
232
+ epub_name = name
233
+ elif "PDF" in fmt and name.lower().endswith(".pdf"):
234
+ pdf_name = name
235
+
236
+ import urllib.parse
237
+ if epub_name:
238
+ safe_name = urllib.parse.quote(epub_name)
239
+ book.download_url = f"https://archive.org/download/{actual_id}/{safe_name}"
240
+ book.download_availability = True
241
+ book.file_format = "epub"
242
+ elif pdf_name:
243
+ safe_name = urllib.parse.quote(pdf_name)
244
+ book.download_url = f"https://archive.org/download/{actual_id}/{safe_name}"
245
+ book.download_availability = True
246
+ book.file_format = "pdf"
247
+ else:
248
+ book.download_url = None
249
+ book.download_availability = False
250
+ book.file_format = None
251
+
252
+ return book
253
+
254
+ except httpx.RequestError as e:
255
+ raise ProviderError(f"Network error getting IA metadata: {e}")
256
+ except Exception as e:
257
+ raise ProviderError(f"Internet Archive metadata retrieval failed: {e}")
258
+
259
+ async def download(self, book: BookMetadata, dest_path: str) -> None:
260
+ """Downloads the Internet Archive book. Delegates to central downloader."""
261
+ if not book.download_availability or not book.download_url:
262
+ raise DownloadError("No download URL available for this Internet Archive book.")
263
+
264
+ # Import dynamically to avoid circular dependencies
265
+ from bookcli.downloader import download_file
266
+ await download_file(book.download_url, dest_path, self.timeout_seconds)