ranobelib-python-sdk 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.
- ranobelib/__init__.py +28 -0
- ranobelib/cache.py +64 -0
- ranobelib/client.py +210 -0
- ranobelib/exceptions.py +118 -0
- ranobelib/exporters/__init__.py +56 -0
- ranobelib/exporters/_illustrations.py +170 -0
- ranobelib/exporters/_shared.py +11 -0
- ranobelib/exporters/epub.py +117 -0
- ranobelib/exporters/fb2.py +164 -0
- ranobelib/exporters/pdf.py +147 -0
- ranobelib/exporters/txt.py +89 -0
- ranobelib/models.py +245 -0
- ranobelib/numbering.py +34 -0
- ranobelib/py.typed +0 -0
- ranobelib/sdk.py +321 -0
- ranobelib_python_sdk-0.1.0.dist-info/METADATA +100 -0
- ranobelib_python_sdk-0.1.0.dist-info/RECORD +18 -0
- ranobelib_python_sdk-0.1.0.dist-info/WHEEL +4 -0
ranobelib/__init__.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Async Python SDK for ranobelib.me."""
|
|
2
|
+
|
|
3
|
+
from ranobelib.exceptions import (
|
|
4
|
+
AuthRequiredError,
|
|
5
|
+
ChapterNotFoundError,
|
|
6
|
+
MultipleTranslationsError,
|
|
7
|
+
RanobeLibError,
|
|
8
|
+
RateLimitError,
|
|
9
|
+
TitleNotFoundError,
|
|
10
|
+
VolumeNotFoundError,
|
|
11
|
+
)
|
|
12
|
+
from ranobelib.models import Chapter, ChapterBranch, Title, Volume
|
|
13
|
+
from ranobelib.sdk import RanobeLib
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"AuthRequiredError",
|
|
17
|
+
"Chapter",
|
|
18
|
+
"ChapterBranch",
|
|
19
|
+
"ChapterNotFoundError",
|
|
20
|
+
"MultipleTranslationsError",
|
|
21
|
+
"RanobeLib",
|
|
22
|
+
"RanobeLibError",
|
|
23
|
+
"RateLimitError",
|
|
24
|
+
"Title",
|
|
25
|
+
"TitleNotFoundError",
|
|
26
|
+
"Volume",
|
|
27
|
+
"VolumeNotFoundError",
|
|
28
|
+
]
|
ranobelib/cache.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Disk cache for raw JSON API responses, keyed by request identity."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
DEFAULT_CACHE_DIR = Path(".ranobelib_cache")
|
|
13
|
+
"""Default cache directory, relative to the current working directory."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DiskCache:
|
|
17
|
+
"""Caches JSON-serializable values to disk, keyed by an arbitrary string.
|
|
18
|
+
|
|
19
|
+
One file per key, named by the key's hash (so keys built from slugs, chapter numbers,
|
|
20
|
+
etc. are always safe filenames), holding the cached value plus the time it was written
|
|
21
|
+
so ``ttl`` can be checked on read without touching any other entry. Corrupted or
|
|
22
|
+
unreadable entries are treated as a cache miss rather than raised as an error, since a
|
|
23
|
+
stale/broken cache file shouldn't stop the SDK from just re-fetching the data.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
cache_dir: str | Path,
|
|
29
|
+
*,
|
|
30
|
+
ttl: float | None = None,
|
|
31
|
+
clock: Callable[[], float] = time.time,
|
|
32
|
+
) -> None:
|
|
33
|
+
"""Initialize the cache.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
cache_dir: Directory to store cache files in. Created lazily on first write.
|
|
37
|
+
ttl: Seconds after which a cache entry is treated as a miss. ``None`` (the
|
|
38
|
+
default) means entries never expire.
|
|
39
|
+
clock: Time source used to stamp and check entries. Overridable for tests.
|
|
40
|
+
"""
|
|
41
|
+
self._dir = Path(cache_dir)
|
|
42
|
+
self._ttl = ttl
|
|
43
|
+
self._clock = clock
|
|
44
|
+
|
|
45
|
+
def get(self, key: str) -> Any | None:
|
|
46
|
+
"""Return the cached value for ``key``, or ``None`` on a miss or expiry."""
|
|
47
|
+
try:
|
|
48
|
+
payload = json.loads(self._path_for(key).read_text(encoding="utf-8"))
|
|
49
|
+
except (OSError, json.JSONDecodeError, KeyError):
|
|
50
|
+
return None
|
|
51
|
+
if self._ttl is not None and self._clock() - payload["cached_at"] > self._ttl:
|
|
52
|
+
return None
|
|
53
|
+
return payload["data"]
|
|
54
|
+
|
|
55
|
+
def set(self, key: str, data: Any) -> None:
|
|
56
|
+
"""Write ``data`` to disk under ``key``, creating the cache directory if needed."""
|
|
57
|
+
path = self._path_for(key)
|
|
58
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
payload = {"cached_at": self._clock(), "data": data}
|
|
60
|
+
path.write_text(json.dumps(payload), encoding="utf-8")
|
|
61
|
+
|
|
62
|
+
def _path_for(self, key: str) -> Path:
|
|
63
|
+
digest = hashlib.sha256(key.encode("utf-8")).hexdigest()
|
|
64
|
+
return self._dir / f"{digest}.json"
|
ranobelib/client.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Low-level HTTP client for the api.cdnlibs.org JSON API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Awaitable, Callable
|
|
8
|
+
from types import TracebackType
|
|
9
|
+
from typing import Any, Self
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from ranobelib.exceptions import (
|
|
14
|
+
AuthRequiredError,
|
|
15
|
+
ChapterNotFoundError,
|
|
16
|
+
RanobeLibError,
|
|
17
|
+
RateLimitError,
|
|
18
|
+
TitleNotFoundError,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
API_BASE_URL = "https://api.cdnlibs.org/api"
|
|
22
|
+
"""Base URL of the lib.social JSON API shared across the network's sites."""
|
|
23
|
+
|
|
24
|
+
RANOBELIB_SITE_ID = "3"
|
|
25
|
+
"""``Site-Id`` header value that scopes requests to ranobelib.me. See docs/api-notes.md."""
|
|
26
|
+
|
|
27
|
+
_RETRYABLE_STATUSES = frozenset({429, *range(500, 600)})
|
|
28
|
+
|
|
29
|
+
DEFAULT_REQUEST_DELAY = 0.2
|
|
30
|
+
"""Default minimum time, in seconds, between the start of one request and the next."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ApiClient:
|
|
34
|
+
"""Thin async wrapper around the undocumented api.cdnlibs.org JSON API.
|
|
35
|
+
|
|
36
|
+
Bounds concurrency with a semaphore, paces request starts with a small fixed delay, and
|
|
37
|
+
retries 429/5xx responses with exponential backoff — see docs/api-notes.md for why a
|
|
38
|
+
manual implementation was chosen over a dependency like ``tenacity`` for this.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
*,
|
|
44
|
+
base_url: str = API_BASE_URL,
|
|
45
|
+
timeout: float = 15.0,
|
|
46
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
47
|
+
max_concurrency: int = 5,
|
|
48
|
+
request_delay: float | None = None,
|
|
49
|
+
max_retries: int = 3,
|
|
50
|
+
retry_base_delay: float = 1.0,
|
|
51
|
+
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
|
|
52
|
+
clock: Callable[[], float] = time.monotonic,
|
|
53
|
+
) -> None:
|
|
54
|
+
"""Initialize the client.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
base_url: API base URL.
|
|
58
|
+
timeout: Per-request timeout, in seconds.
|
|
59
|
+
transport: Custom ``httpx`` transport, e.g. a mock transport for tests.
|
|
60
|
+
max_concurrency: Maximum number of requests in flight at once.
|
|
61
|
+
request_delay: Minimum time, in seconds, between the start of one request and
|
|
62
|
+
the next, even under ``max_concurrency``. ``None`` (the default) uses
|
|
63
|
+
``DEFAULT_REQUEST_DELAY``.
|
|
64
|
+
max_retries: How many times to retry a 429 or 5xx response before giving up and
|
|
65
|
+
raising.
|
|
66
|
+
retry_base_delay: Base delay, in seconds, for exponential backoff between
|
|
67
|
+
retries (doubled per attempt) — used unless a 429 response's
|
|
68
|
+
``Retry-After`` header says otherwise.
|
|
69
|
+
sleep: Sleep function used for pacing and backoff. Overridable for tests.
|
|
70
|
+
clock: Time source used for pacing. Overridable for tests.
|
|
71
|
+
"""
|
|
72
|
+
self._http = httpx.AsyncClient(
|
|
73
|
+
base_url=base_url,
|
|
74
|
+
timeout=timeout,
|
|
75
|
+
transport=transport,
|
|
76
|
+
headers={
|
|
77
|
+
"Site-Id": RANOBELIB_SITE_ID,
|
|
78
|
+
"Accept": "application/json",
|
|
79
|
+
},
|
|
80
|
+
)
|
|
81
|
+
self._semaphore = asyncio.Semaphore(max_concurrency)
|
|
82
|
+
self._request_delay = DEFAULT_REQUEST_DELAY if request_delay is None else request_delay
|
|
83
|
+
self._max_retries = max_retries
|
|
84
|
+
self._retry_base_delay = retry_base_delay
|
|
85
|
+
self._sleep = sleep
|
|
86
|
+
self._clock = clock
|
|
87
|
+
self._pacing_lock = asyncio.Lock()
|
|
88
|
+
self._last_request_at: float | None = None
|
|
89
|
+
|
|
90
|
+
async def __aenter__(self) -> Self:
|
|
91
|
+
return self
|
|
92
|
+
|
|
93
|
+
async def __aexit__(
|
|
94
|
+
self,
|
|
95
|
+
exc_type: type[BaseException] | None,
|
|
96
|
+
exc: BaseException | None,
|
|
97
|
+
traceback: TracebackType | None,
|
|
98
|
+
) -> None:
|
|
99
|
+
await self.aclose()
|
|
100
|
+
|
|
101
|
+
async def aclose(self) -> None:
|
|
102
|
+
"""Close the underlying HTTP connection pool."""
|
|
103
|
+
await self._http.aclose()
|
|
104
|
+
|
|
105
|
+
async def get_title(self, slug_url: str, *, fields: list[str] | None = None) -> dict[str, Any]:
|
|
106
|
+
"""Fetch raw title metadata for ``slug_url``.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
slug_url: The title's ``{id}--{slug}`` identifier.
|
|
110
|
+
fields: Extra ``fields[]`` values to request from the API.
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
The ``data`` object from the API response.
|
|
114
|
+
"""
|
|
115
|
+
params = httpx.QueryParams([("fields[]", field) for field in fields or []])
|
|
116
|
+
response = await self._get(f"/manga/{slug_url}", params=params)
|
|
117
|
+
self._raise_for_status(response, not_found=TitleNotFoundError(slug_url))
|
|
118
|
+
data: dict[str, Any] = response.json()["data"]
|
|
119
|
+
return data
|
|
120
|
+
|
|
121
|
+
async def get_chapters(self, slug_url: str) -> list[dict[str, Any]]:
|
|
122
|
+
"""Fetch the raw chapter list for ``slug_url`` (single request, no pagination).
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
slug_url: The title's ``{id}--{slug}`` identifier.
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
The ``data`` array from the API response.
|
|
129
|
+
"""
|
|
130
|
+
response = await self._get(f"/manga/{slug_url}/chapters")
|
|
131
|
+
self._raise_for_status(response, not_found=TitleNotFoundError(slug_url))
|
|
132
|
+
data: list[dict[str, Any]] = response.json()["data"]
|
|
133
|
+
return data
|
|
134
|
+
|
|
135
|
+
async def get_chapter(
|
|
136
|
+
self,
|
|
137
|
+
slug_url: str,
|
|
138
|
+
*,
|
|
139
|
+
number: str,
|
|
140
|
+
volume: str,
|
|
141
|
+
branch_id: int | None = None,
|
|
142
|
+
) -> dict[str, Any]:
|
|
143
|
+
"""Fetch a single chapter's metadata and content.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
slug_url: The title's ``{id}--{slug}`` identifier.
|
|
147
|
+
number: The chapter number, as returned by the API (may contain a decimal).
|
|
148
|
+
volume: The volume number.
|
|
149
|
+
branch_id: Which translation team's branch to fetch, for chapters with more
|
|
150
|
+
than one. Defaults to whichever branch the API picks (see
|
|
151
|
+
docs/api-notes.md — this is not simply the first branch listed).
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
The ``data`` object from the API response.
|
|
155
|
+
"""
|
|
156
|
+
params = {"number": number, "volume": volume}
|
|
157
|
+
if branch_id is not None:
|
|
158
|
+
params["branch_id"] = str(branch_id)
|
|
159
|
+
response = await self._get(f"/manga/{slug_url}/chapter", params=params)
|
|
160
|
+
self._raise_for_status(
|
|
161
|
+
response, not_found=ChapterNotFoundError(slug_url, volume=volume, number=number)
|
|
162
|
+
)
|
|
163
|
+
data: dict[str, Any] = response.json()["data"]
|
|
164
|
+
return data
|
|
165
|
+
|
|
166
|
+
async def _get(self, url: str, *, params: Any = None) -> httpx.Response:
|
|
167
|
+
"""Issue a GET request, bounded by concurrency/pacing and retried on 429/5xx."""
|
|
168
|
+
attempt = 0
|
|
169
|
+
while True:
|
|
170
|
+
async with self._semaphore:
|
|
171
|
+
await self._pace()
|
|
172
|
+
response = await self._http.get(url, params=params)
|
|
173
|
+
if response.status_code not in _RETRYABLE_STATUSES or attempt >= self._max_retries:
|
|
174
|
+
return response
|
|
175
|
+
await self._sleep(self._backoff_delay(attempt, response))
|
|
176
|
+
attempt += 1
|
|
177
|
+
|
|
178
|
+
async def _pace(self) -> None:
|
|
179
|
+
"""Wait, if needed, so this request starts at least ``request_delay`` after the last."""
|
|
180
|
+
async with self._pacing_lock:
|
|
181
|
+
now = self._clock()
|
|
182
|
+
if self._last_request_at is not None:
|
|
183
|
+
wait = self._last_request_at + self._request_delay - now
|
|
184
|
+
if wait > 0:
|
|
185
|
+
await self._sleep(wait)
|
|
186
|
+
self._last_request_at = self._clock()
|
|
187
|
+
|
|
188
|
+
def _backoff_delay(self, attempt: int, response: httpx.Response) -> float:
|
|
189
|
+
if response.status_code == 429:
|
|
190
|
+
retry_after = response.headers.get("Retry-After")
|
|
191
|
+
if retry_after is not None:
|
|
192
|
+
try:
|
|
193
|
+
return float(retry_after)
|
|
194
|
+
except ValueError:
|
|
195
|
+
pass
|
|
196
|
+
delay: float = self._retry_base_delay * (2**attempt)
|
|
197
|
+
return delay
|
|
198
|
+
|
|
199
|
+
def _raise_for_status(self, response: httpx.Response, *, not_found: RanobeLibError) -> None:
|
|
200
|
+
if response.status_code == 404:
|
|
201
|
+
raise not_found
|
|
202
|
+
if response.status_code == 403:
|
|
203
|
+
raise AuthRequiredError(str(response.request.url))
|
|
204
|
+
if response.status_code == 429:
|
|
205
|
+
retry_after = response.headers.get("Retry-After")
|
|
206
|
+
raise RateLimitError(float(retry_after) if retry_after else None)
|
|
207
|
+
try:
|
|
208
|
+
response.raise_for_status()
|
|
209
|
+
except httpx.HTTPStatusError as exc:
|
|
210
|
+
raise RanobeLibError(f"Unexpected API response: {exc}") from exc
|
ranobelib/exceptions.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Custom exceptions raised by the SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from ranobelib.models import ChapterBranch
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class RanobeLibError(Exception):
|
|
12
|
+
"""Base class for all errors raised by the SDK."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TitleNotFoundError(RanobeLibError):
|
|
16
|
+
"""Raised when a title cannot be found on ranobelib.me.
|
|
17
|
+
|
|
18
|
+
Attributes:
|
|
19
|
+
slug_url: The title's ``{id}--{slug}`` identifier that couldn't be found.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, slug_url: str) -> None:
|
|
23
|
+
self.slug_url = slug_url
|
|
24
|
+
super().__init__(f"Title not found: {slug_url!r}")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ChapterNotFoundError(RanobeLibError):
|
|
28
|
+
"""Raised when a chapter cannot be found for a given volume/number.
|
|
29
|
+
|
|
30
|
+
Attributes:
|
|
31
|
+
slug_url: The title's ``{id}--{slug}`` identifier.
|
|
32
|
+
volume: The volume number that was requested.
|
|
33
|
+
number: The chapter number that was requested.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, slug_url: str, *, volume: str, number: str) -> None:
|
|
37
|
+
self.slug_url = slug_url
|
|
38
|
+
self.volume = volume
|
|
39
|
+
self.number = number
|
|
40
|
+
super().__init__(f"Chapter not found: {slug_url!r} volume={volume!r} number={number!r}")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class VolumeNotFoundError(RanobeLibError):
|
|
44
|
+
"""Raised when a title has no chapters for a given volume number.
|
|
45
|
+
|
|
46
|
+
Attributes:
|
|
47
|
+
slug_url: The title's ``{id}--{slug}`` identifier.
|
|
48
|
+
volume: The volume number that was requested.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(self, slug_url: str, *, volume: str) -> None:
|
|
52
|
+
self.slug_url = slug_url
|
|
53
|
+
self.volume = volume
|
|
54
|
+
super().__init__(f"Volume not found: {slug_url!r} volume={volume!r}")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class MultipleTranslationsError(RanobeLibError):
|
|
58
|
+
"""Raised when a chapter has more than one team's translation and none was selected.
|
|
59
|
+
|
|
60
|
+
The SDK does not guess a default: the API's own default when ``branch_id`` is omitted
|
|
61
|
+
is not simply the first branch listed, and the actual rule it uses isn't documented
|
|
62
|
+
(see docs/api-notes.md) — returning it silently would be unpredictable. Call
|
|
63
|
+
``RanobeLib.get_translations()`` to list the available branches, then pass one's
|
|
64
|
+
``branch_id`` explicitly.
|
|
65
|
+
|
|
66
|
+
Attributes:
|
|
67
|
+
slug_url: The title's ``{id}--{slug}`` identifier.
|
|
68
|
+
volume: The chapter's volume number.
|
|
69
|
+
number: The chapter number.
|
|
70
|
+
branches: The chapter's available translations, as returned by
|
|
71
|
+
``RanobeLib.get_translations()``.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def __init__(
|
|
75
|
+
self, slug_url: str, *, volume: str, number: str, branches: list[ChapterBranch]
|
|
76
|
+
) -> None:
|
|
77
|
+
self.slug_url = slug_url
|
|
78
|
+
self.volume = volume
|
|
79
|
+
self.number = number
|
|
80
|
+
self.branches = branches
|
|
81
|
+
options = ", ".join(
|
|
82
|
+
f"branch_id={branch.branch_id} ({_describe_branch(branch)})" for branch in branches
|
|
83
|
+
)
|
|
84
|
+
super().__init__(
|
|
85
|
+
f"Multiple translations for {slug_url!r} volume={volume!r} number={number!r}: "
|
|
86
|
+
f"{options}. Pass branch_id to select one (see RanobeLib.get_translations())."
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _describe_branch(branch: ChapterBranch) -> str:
|
|
91
|
+
if branch.teams:
|
|
92
|
+
return ", ".join(team.name for team in branch.teams)
|
|
93
|
+
return branch.user.username
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class AuthRequiredError(RanobeLibError):
|
|
97
|
+
"""Raised when the requested content requires authorization (paid or early access).
|
|
98
|
+
|
|
99
|
+
Attributes:
|
|
100
|
+
url: The request URL that returned 403.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
def __init__(self, url: str) -> None:
|
|
104
|
+
self.url = url
|
|
105
|
+
super().__init__(f"Authorization required to access: {url}")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class RateLimitError(RanobeLibError):
|
|
109
|
+
"""Raised when the API responds with 429 Too Many Requests.
|
|
110
|
+
|
|
111
|
+
Attributes:
|
|
112
|
+
retry_after: The response's ``Retry-After`` value, in seconds, if it sent one.
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
def __init__(self, retry_after: float | None = None) -> None:
|
|
116
|
+
self.retry_after = retry_after
|
|
117
|
+
suffix = f" (retry after {retry_after}s)" if retry_after is not None else ""
|
|
118
|
+
super().__init__(f"Rate limited by the API{suffix}")
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Exporter protocol and format registry.
|
|
2
|
+
|
|
3
|
+
A new export format is a new module in this package: define a class implementing
|
|
4
|
+
``Exporter`` and decorate it with ``@register`` — no changes needed here or in ``sdk.py``.
|
|
5
|
+
Every module in this package is auto-imported below so its ``@register`` runs on its own.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import importlib
|
|
11
|
+
import pkgutil
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import ClassVar, Protocol, TypeVar, runtime_checkable
|
|
14
|
+
|
|
15
|
+
from ranobelib.models import Chapter, Title
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@runtime_checkable
|
|
19
|
+
class Exporter(Protocol):
|
|
20
|
+
"""Renders a title's chapters to a single file in some format."""
|
|
21
|
+
|
|
22
|
+
format: ClassVar[str]
|
|
23
|
+
"""The registry key this exporter is selected by, e.g. ``"txt"``."""
|
|
24
|
+
|
|
25
|
+
async def export(self, title: Title, chapters: list[Chapter], output_path: Path) -> Path:
|
|
26
|
+
"""Write ``chapters`` (in the given order) to ``output_path``.
|
|
27
|
+
|
|
28
|
+
``async`` since embedding illustrations (epub, pdf) requires downloading them —
|
|
29
|
+
the SDK is async-only throughout (see CLAUDE.md), so this can't drop to a sync
|
|
30
|
+
HTTP call. txt/fb2 do no I/O and just don't ``await`` anything in their bodies.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
title: The chapters' parent title, for metadata (name, authors, ...).
|
|
34
|
+
chapters: The chapters to include, in the order they should appear.
|
|
35
|
+
output_path: Where to write the exported file.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
``output_path``, once the file has been written.
|
|
39
|
+
"""
|
|
40
|
+
...
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
ExporterT = TypeVar("ExporterT", bound=type[Exporter])
|
|
44
|
+
|
|
45
|
+
EXPORTERS: dict[str, type[Exporter]] = {}
|
|
46
|
+
"""Registered exporters, keyed by ``Exporter.format``."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def register(exporter: ExporterT) -> ExporterT:
|
|
50
|
+
"""Class decorator: register ``exporter`` under its ``format`` key."""
|
|
51
|
+
EXPORTERS[exporter.format] = exporter
|
|
52
|
+
return exporter
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
for _module_info in pkgutil.iter_modules(__path__, f"{__name__}."):
|
|
56
|
+
importlib.import_module(_module_info.name)
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Illustration handling shared between exporters that embed images (epub, pdf).
|
|
2
|
+
|
|
3
|
+
Not part of the public Exporter protocol. Chapter content's ``<img>`` tags carry absolute
|
|
4
|
+
URLs (see docs/api-notes.md); embedding them means downloading the bytes and rewriting each
|
|
5
|
+
``src`` to wherever the target format keeps embedded resources (a local file inside an epub
|
|
6
|
+
zip, a ``data:`` URI inline in an HTML string for pdf) — everything up to that last,
|
|
7
|
+
format-specific rewrite step is identical between the two, hence living here once.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import html
|
|
14
|
+
from html.parser import HTMLParser
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from urllib.parse import urlsplit
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
|
|
20
|
+
from ranobelib.models import Cover
|
|
21
|
+
|
|
22
|
+
_VOID_TAGS = frozenset({"br", "hr", "img"})
|
|
23
|
+
|
|
24
|
+
_MEDIA_TYPES = {
|
|
25
|
+
".jpg": "image/jpeg",
|
|
26
|
+
".jpeg": "image/jpeg",
|
|
27
|
+
".png": "image/png",
|
|
28
|
+
".gif": "image/gif",
|
|
29
|
+
".webp": "image/webp",
|
|
30
|
+
}
|
|
31
|
+
_DEFAULT_EXTENSION = ".jpg"
|
|
32
|
+
|
|
33
|
+
IMAGE_CONCURRENCY = 5
|
|
34
|
+
"""Same conservative default as ApiClient's max_concurrency (see client.py) — these are a
|
|
35
|
+
different host (site/CDN uploads, not api.cdnlibs.org) with unknown rate limits of their
|
|
36
|
+
own, so this doesn't reuse ApiClient's own semaphore/retry machinery, just a plain cap.
|
|
37
|
+
A failed download is skipped rather than retried: embedding is best-effort, one broken
|
|
38
|
+
image shouldn't fail the whole export."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class _ImageUrlCollector(HTMLParser):
|
|
42
|
+
"""Collects every ``<img src="...">`` URL referenced in a chapter-content HTML fragment."""
|
|
43
|
+
|
|
44
|
+
def __init__(self) -> None:
|
|
45
|
+
super().__init__(convert_charrefs=True)
|
|
46
|
+
self.urls: list[str] = []
|
|
47
|
+
|
|
48
|
+
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
49
|
+
if tag == "img":
|
|
50
|
+
src = dict(attrs).get("src")
|
|
51
|
+
if src:
|
|
52
|
+
self.urls.append(src)
|
|
53
|
+
|
|
54
|
+
handle_startendtag = handle_starttag
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def extract_image_urls(html_fragment: str) -> list[str]:
|
|
58
|
+
"""List the ``<img src="...">`` URLs referenced in a chapter-content HTML fragment."""
|
|
59
|
+
collector = _ImageUrlCollector()
|
|
60
|
+
collector.feed(html_fragment)
|
|
61
|
+
return collector.urls
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class _ImageSrcRewriter(HTMLParser):
|
|
65
|
+
"""Re-emits chapter-content HTML with ``<img src="...">`` URLs replaced per a mapping.
|
|
66
|
+
|
|
67
|
+
Rebuilds the markup tag-by-tag (rather than a targeted regex substitution) for the same
|
|
68
|
+
reason the txt/fb2 exporters use ``HTMLParser`` over regex: HTML-string-format chapters
|
|
69
|
+
pass the site's own markup through as-is (see docs/api-notes.md). Everything other than
|
|
70
|
+
``img`` passes through unchanged; both consumers (ebooklib's ``lxml.html``-based parser
|
|
71
|
+
for epub, WeasyPrint's own HTML parser for pdf) are lenient enough not to need strict
|
|
72
|
+
XHTML here.
|
|
73
|
+
|
|
74
|
+
An ``<img>`` whose URL isn't in ``url_to_local`` (download failed or was never
|
|
75
|
+
attempted) is dropped entirely rather than kept pointing at the original external URL —
|
|
76
|
+
a dangling absolute-URL reference isn't a self-contained embed, just a broken-looking
|
|
77
|
+
image in most consumers, so "not embedded" reads as "not present" instead.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
def __init__(self, url_to_local: dict[str, str]) -> None:
|
|
81
|
+
super().__init__(convert_charrefs=True)
|
|
82
|
+
self._url_to_local = url_to_local
|
|
83
|
+
self._output: list[str] = []
|
|
84
|
+
|
|
85
|
+
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
86
|
+
self._emit(tag, attrs, self_closing=tag in _VOID_TAGS)
|
|
87
|
+
|
|
88
|
+
def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
89
|
+
self._emit(tag, attrs, self_closing=True)
|
|
90
|
+
|
|
91
|
+
def _emit(self, tag: str, attrs: list[tuple[str, str | None]], *, self_closing: bool) -> None:
|
|
92
|
+
if tag == "img":
|
|
93
|
+
local_src = self._url_to_local.get(dict(attrs).get("src") or "")
|
|
94
|
+
if local_src is None:
|
|
95
|
+
return
|
|
96
|
+
attrs = [(name, local_src if name == "src" else value) for name, value in attrs]
|
|
97
|
+
|
|
98
|
+
parts = [f"<{tag}"]
|
|
99
|
+
for name, value in attrs:
|
|
100
|
+
if value is None:
|
|
101
|
+
parts.append(f" {name}")
|
|
102
|
+
else:
|
|
103
|
+
parts.append(f' {name}="{html.escape(value, quote=True)}"')
|
|
104
|
+
parts.append(" />" if self_closing else ">")
|
|
105
|
+
self._output.append("".join(parts))
|
|
106
|
+
|
|
107
|
+
def handle_endtag(self, tag: str) -> None:
|
|
108
|
+
if tag not in _VOID_TAGS:
|
|
109
|
+
self._output.append(f"</{tag}>")
|
|
110
|
+
|
|
111
|
+
def handle_data(self, data: str) -> None:
|
|
112
|
+
self._output.append(html.escape(data))
|
|
113
|
+
|
|
114
|
+
def get_html(self) -> str:
|
|
115
|
+
return "".join(self._output)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def rewrite_image_srcs(html_fragment: str, url_to_local: dict[str, str]) -> str:
|
|
119
|
+
"""Rewrite ``<img src="...">`` URLs in ``html_fragment`` per ``url_to_local``."""
|
|
120
|
+
rewriter = _ImageSrcRewriter(url_to_local)
|
|
121
|
+
rewriter.feed(html_fragment)
|
|
122
|
+
return rewriter.get_html()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def pick_cover_url(cover: Cover) -> str | None:
|
|
126
|
+
"""The best available cover URL, preferring the largest: default, then md, then thumbnail."""
|
|
127
|
+
return cover.default or cover.md or cover.thumbnail
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def guess_extension(url: str) -> str:
|
|
131
|
+
"""A file extension for ``url``, from its path, falling back to a default."""
|
|
132
|
+
suffix = Path(urlsplit(url).path).suffix.lower()
|
|
133
|
+
return suffix if suffix in _MEDIA_TYPES else _DEFAULT_EXTENSION
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def media_type_for(extension: str) -> str:
|
|
137
|
+
"""The MIME media type for a file extension from ``guess_extension``."""
|
|
138
|
+
return _MEDIA_TYPES.get(extension, _MEDIA_TYPES[_DEFAULT_EXTENSION])
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
async def _try_download(
|
|
142
|
+
client: httpx.AsyncClient, semaphore: asyncio.Semaphore, url: str
|
|
143
|
+
) -> bytes | None:
|
|
144
|
+
async with semaphore:
|
|
145
|
+
try:
|
|
146
|
+
response = await client.get(url)
|
|
147
|
+
response.raise_for_status()
|
|
148
|
+
except httpx.HTTPError:
|
|
149
|
+
return None
|
|
150
|
+
return response.content
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
async def download_images(
|
|
154
|
+
client: httpx.AsyncClient, urls: list[str], *, concurrency: int = IMAGE_CONCURRENCY
|
|
155
|
+
) -> dict[str, bytes]:
|
|
156
|
+
"""Download each of ``urls`` concurrently, skipping (not retrying) any that fail.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
client: The client to download with.
|
|
160
|
+
urls: URLs to fetch. Not deduplicated — that's the caller's job (chapters often
|
|
161
|
+
share images).
|
|
162
|
+
concurrency: Maximum simultaneous downloads.
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
A mapping from URL to its downloaded bytes, containing only the URLs that
|
|
166
|
+
succeeded — failed URLs are simply absent, not mapped to ``None``.
|
|
167
|
+
"""
|
|
168
|
+
semaphore = asyncio.Semaphore(concurrency)
|
|
169
|
+
results = await asyncio.gather(*(_try_download(client, semaphore, url) for url in urls))
|
|
170
|
+
return {url: data for url, data in zip(urls, results, strict=True) if data is not None}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Helpers shared between exporter implementations (not part of the public Exporter protocol)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ranobelib.models import Chapter
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def chapter_heading(chapter: Chapter) -> str:
|
|
9
|
+
"""A human-readable "Volume X, Chapter Y[: Name]" heading for ``chapter``."""
|
|
10
|
+
heading = f"Volume {chapter.volume}, Chapter {chapter.number}"
|
|
11
|
+
return f"{heading}: {chapter.name}" if chapter.name else heading
|