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 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
@@ -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