ranobelib-python-sdk 0.5.0__tar.gz → 0.6.1__tar.gz
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_python_sdk-0.5.0 → ranobelib_python_sdk-0.6.1}/PKG-INFO +1 -1
- {ranobelib_python_sdk-0.5.0 → ranobelib_python_sdk-0.6.1}/pyproject.toml +1 -1
- {ranobelib_python_sdk-0.5.0 → ranobelib_python_sdk-0.6.1}/pyproject.toml.orig +1 -1
- {ranobelib_python_sdk-0.5.0 → ranobelib_python_sdk-0.6.1}/src/ranobelib/__init__.py +6 -1
- ranobelib_python_sdk-0.6.1/src/ranobelib/catalog.py +156 -0
- {ranobelib_python_sdk-0.5.0 → ranobelib_python_sdk-0.6.1}/src/ranobelib/client.py +54 -0
- {ranobelib_python_sdk-0.5.0 → ranobelib_python_sdk-0.6.1}/src/ranobelib/exceptions.py +34 -1
- {ranobelib_python_sdk-0.5.0 → ranobelib_python_sdk-0.6.1}/src/ranobelib/models.py +13 -0
- {ranobelib_python_sdk-0.5.0 → ranobelib_python_sdk-0.6.1}/src/ranobelib/sdk.py +93 -9
- {ranobelib_python_sdk-0.5.0 → ranobelib_python_sdk-0.6.1}/README.md +0 -0
- {ranobelib_python_sdk-0.5.0 → ranobelib_python_sdk-0.6.1}/src/ranobelib/cache.py +0 -0
- {ranobelib_python_sdk-0.5.0 → ranobelib_python_sdk-0.6.1}/src/ranobelib/console.py +0 -0
- {ranobelib_python_sdk-0.5.0 → ranobelib_python_sdk-0.6.1}/src/ranobelib/exporters/__init__.py +0 -0
- {ranobelib_python_sdk-0.5.0 → ranobelib_python_sdk-0.6.1}/src/ranobelib/exporters/_illustrations.py +0 -0
- {ranobelib_python_sdk-0.5.0 → ranobelib_python_sdk-0.6.1}/src/ranobelib/exporters/_shared.py +0 -0
- {ranobelib_python_sdk-0.5.0 → ranobelib_python_sdk-0.6.1}/src/ranobelib/exporters/epub.py +0 -0
- {ranobelib_python_sdk-0.5.0 → ranobelib_python_sdk-0.6.1}/src/ranobelib/exporters/fb2.py +0 -0
- {ranobelib_python_sdk-0.5.0 → ranobelib_python_sdk-0.6.1}/src/ranobelib/exporters/pdf.py +0 -0
- {ranobelib_python_sdk-0.5.0 → ranobelib_python_sdk-0.6.1}/src/ranobelib/exporters/txt.py +0 -0
- {ranobelib_python_sdk-0.5.0 → ranobelib_python_sdk-0.6.1}/src/ranobelib/numbering.py +0 -0
- {ranobelib_python_sdk-0.5.0 → ranobelib_python_sdk-0.6.1}/src/ranobelib/py.typed +0 -0
- {ranobelib_python_sdk-0.5.0 → ranobelib_python_sdk-0.6.1}/src/ranobelib/sizing.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ranobelib-python-sdk
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.1
|
|
4
4
|
Summary: Async Python SDK for ranobelib.me: title metadata, chapter/volume downloads, and export to epub/fb2/txt/pdf
|
|
5
5
|
Keywords: ranobelib,ranobe,novel,webnovel,epub,fb2,sdk
|
|
6
6
|
Author: LLIJIYAPNIK
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
"""Async Python SDK for ranobelib.me."""
|
|
2
2
|
|
|
3
|
+
from ranobelib.catalog import Catalog
|
|
3
4
|
from ranobelib.exceptions import (
|
|
4
5
|
AmbiguousChapter,
|
|
5
6
|
AuthRequiredError,
|
|
6
7
|
ChapterNotFoundError,
|
|
8
|
+
DownloadTitleInterruptedError,
|
|
7
9
|
MultipleTitleTranslationsError,
|
|
8
10
|
MultipleTranslationsError,
|
|
9
11
|
RanobeLibError,
|
|
@@ -11,16 +13,19 @@ from ranobelib.exceptions import (
|
|
|
11
13
|
TitleNotFoundError,
|
|
12
14
|
VolumeNotFoundError,
|
|
13
15
|
)
|
|
14
|
-
from ranobelib.models import Chapter, ChapterBranch, Title, Volume
|
|
16
|
+
from ranobelib.models import CatalogPage, Chapter, ChapterBranch, Title, Volume
|
|
15
17
|
from ranobelib.sdk import RanobeLib
|
|
16
18
|
from ranobelib.sizing import chapter_size, volume_size
|
|
17
19
|
|
|
18
20
|
__all__ = [
|
|
19
21
|
"AmbiguousChapter",
|
|
20
22
|
"AuthRequiredError",
|
|
23
|
+
"Catalog",
|
|
24
|
+
"CatalogPage",
|
|
21
25
|
"Chapter",
|
|
22
26
|
"ChapterBranch",
|
|
23
27
|
"ChapterNotFoundError",
|
|
28
|
+
"DownloadTitleInterruptedError",
|
|
24
29
|
"MultipleTitleTranslationsError",
|
|
25
30
|
"MultipleTranslationsError",
|
|
26
31
|
"RanobeLib",
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Catalog listing/search: the ``Catalog`` class.
|
|
2
|
+
|
|
3
|
+
Unlike ``RanobeLib`` (scoped to one title via its URL/slug), catalog listing isn't scoped to
|
|
4
|
+
any title at all — see CLAUDE.md's roadmap entry for why this is a separate entry point with
|
|
5
|
+
its own ``ApiClient``/``DiskCache`` rather than a method on ``RanobeLib``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from types import TracebackType
|
|
12
|
+
from typing import Any, Self
|
|
13
|
+
|
|
14
|
+
from ranobelib.cache import DEFAULT_CACHE_DIR, DiskCache
|
|
15
|
+
from ranobelib.client import ApiClient
|
|
16
|
+
from ranobelib.models import CatalogPage, Title
|
|
17
|
+
|
|
18
|
+
DEFAULT_SORT = "last_chapter_at"
|
|
19
|
+
"""Default sort order: title's last-chapter timestamp — the closest real equivalent to
|
|
20
|
+
"recently updated" (the API has no ``updated_at`` sort value, see docs/api-notes.md)."""
|
|
21
|
+
|
|
22
|
+
MIN_PER_PAGE = 10
|
|
23
|
+
MAX_PER_PAGE = 60
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Catalog:
|
|
27
|
+
"""Entry point for listing/searching the ranobelib.me catalog.
|
|
28
|
+
|
|
29
|
+
Example:
|
|
30
|
+
```python
|
|
31
|
+
async with Catalog() as catalog:
|
|
32
|
+
page = await catalog.list_titles(query="dxd", genres=[34], status=1)
|
|
33
|
+
for title in page.items:
|
|
34
|
+
print(title.name)
|
|
35
|
+
```
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
*,
|
|
41
|
+
cache_dir: str | Path | None = None,
|
|
42
|
+
cache_ttl: float | None = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
"""Initialize the catalog client.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
cache_dir: Where to cache raw API responses on disk, one entry per distinct set
|
|
48
|
+
of listing parameters (page/filters/sort) — same disk cache as ``RanobeLib``
|
|
49
|
+
uses (``cache.py``), just keyed by listing parameters instead of a title's
|
|
50
|
+
slug. Defaults to ``.ranobelib_cache`` in the current working directory.
|
|
51
|
+
cache_ttl: Seconds after which a cached page is treated as stale and re-fetched.
|
|
52
|
+
``None`` (the default) means cached pages never expire on their own — see
|
|
53
|
+
``refresh=True`` on ``list_titles()`` to force one anyway.
|
|
54
|
+
"""
|
|
55
|
+
self._client = ApiClient()
|
|
56
|
+
self._cache = DiskCache(
|
|
57
|
+
cache_dir if cache_dir is not None else DEFAULT_CACHE_DIR, ttl=cache_ttl
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
async def __aenter__(self) -> Self:
|
|
61
|
+
return self
|
|
62
|
+
|
|
63
|
+
async def __aexit__(
|
|
64
|
+
self,
|
|
65
|
+
exc_type: type[BaseException] | None,
|
|
66
|
+
exc: BaseException | None,
|
|
67
|
+
traceback: TracebackType | None,
|
|
68
|
+
) -> None:
|
|
69
|
+
await self.aclose()
|
|
70
|
+
|
|
71
|
+
async def aclose(self) -> None:
|
|
72
|
+
"""Close the underlying HTTP client."""
|
|
73
|
+
await self._client.aclose()
|
|
74
|
+
|
|
75
|
+
async def list_titles(
|
|
76
|
+
self,
|
|
77
|
+
*,
|
|
78
|
+
page: int = 1,
|
|
79
|
+
per_page: int = 30,
|
|
80
|
+
query: str | None = None,
|
|
81
|
+
genres: list[int] | None = None,
|
|
82
|
+
status: int | None = None,
|
|
83
|
+
sort: str = DEFAULT_SORT,
|
|
84
|
+
refresh: bool = False,
|
|
85
|
+
) -> CatalogPage:
|
|
86
|
+
"""Fetch one page of catalog listing/search results.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
page: 1-based page number.
|
|
90
|
+
per_page: How many titles per page. Must be between ``MIN_PER_PAGE`` (10) and
|
|
91
|
+
``MAX_PER_PAGE`` (60) — the API's own limits (see docs/api-notes.md);
|
|
92
|
+
anything outside that range raises here instead of a confusing 422 from
|
|
93
|
+
the API.
|
|
94
|
+
query: Free-text search term, matched against name/rus_name/eng_name. ``None``
|
|
95
|
+
(the default) or an empty string lists without searching.
|
|
96
|
+
genres: Genre ids to filter by. A title must have *all* of them, not just one
|
|
97
|
+
(AND, not OR — see docs/api-notes.md). The SDK doesn't validate these against
|
|
98
|
+
a known genre list — there's no public, unauthenticated way to fetch one (see
|
|
99
|
+
docs/api-notes.md) — an unrecognized id just matches nothing.
|
|
100
|
+
status: A single ``Title.status.id`` to filter by (e.g. ongoing vs. completed).
|
|
101
|
+
sort: Sort order. Despite the name, this is sent as the API's ``sort_by``
|
|
102
|
+
parameter — an actual ``sort`` parameter exists but is silently ignored by
|
|
103
|
+
the API (see docs/api-notes.md). Known accepted values: ``"name"``,
|
|
104
|
+
``"created_at"``, ``"views"``, ``"chap_count"``, ``"last_chapter_at"``
|
|
105
|
+
(the default), ``"rate_avg"``, ``"random"`` — this list isn't guaranteed
|
|
106
|
+
exhaustive.
|
|
107
|
+
refresh: Bypass the disk cache and re-fetch from the API even if this exact
|
|
108
|
+
combination of parameters was already cached.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
A ``CatalogPage``: the matching titles (as ``Title`` models) plus whether a next
|
|
112
|
+
page exists.
|
|
113
|
+
|
|
114
|
+
Raises:
|
|
115
|
+
ValueError: If ``page`` is less than 1, or ``per_page`` is outside
|
|
116
|
+
``MIN_PER_PAGE..MAX_PER_PAGE``.
|
|
117
|
+
"""
|
|
118
|
+
if page < 1:
|
|
119
|
+
raise ValueError(f"page must be at least 1, got {page}.")
|
|
120
|
+
if not MIN_PER_PAGE <= per_page <= MAX_PER_PAGE:
|
|
121
|
+
raise ValueError(
|
|
122
|
+
f"per_page must be between {MIN_PER_PAGE} and {MAX_PER_PAGE}, got {per_page}."
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
key = _cache_key(
|
|
126
|
+
page=page, per_page=per_page, query=query, genres=genres, status=status, sort=sort
|
|
127
|
+
)
|
|
128
|
+
if not refresh:
|
|
129
|
+
cached = self._cache.get(key)
|
|
130
|
+
if cached is not None:
|
|
131
|
+
return _build_page(cached)
|
|
132
|
+
|
|
133
|
+
data = await self._client.list_titles(
|
|
134
|
+
page=page, per_page=per_page, query=query, genres=genres, status=status, sort=sort
|
|
135
|
+
)
|
|
136
|
+
self._cache.set(key, data)
|
|
137
|
+
return _build_page(data)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _cache_key(
|
|
141
|
+
*,
|
|
142
|
+
page: int,
|
|
143
|
+
per_page: int,
|
|
144
|
+
query: str | None,
|
|
145
|
+
genres: list[int] | None,
|
|
146
|
+
status: int | None,
|
|
147
|
+
sort: str,
|
|
148
|
+
) -> str:
|
|
149
|
+
genres_part = ",".join(str(genre_id) for genre_id in genres or [])
|
|
150
|
+
return f"catalog:{page}:{per_page}:{query or ''}:{genres_part}:{status}:{sort}"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _build_page(data: dict[str, Any]) -> CatalogPage:
|
|
154
|
+
items = [Title.model_validate(item) for item in data["data"]]
|
|
155
|
+
meta = data["meta"]
|
|
156
|
+
return CatalogPage(items=items, page=meta["current_page"], has_next_page=meta["has_next_page"])
|
|
@@ -163,6 +163,60 @@ class ApiClient:
|
|
|
163
163
|
data: dict[str, Any] = response.json()["data"]
|
|
164
164
|
return data
|
|
165
165
|
|
|
166
|
+
async def list_titles(
|
|
167
|
+
self,
|
|
168
|
+
*,
|
|
169
|
+
page: int,
|
|
170
|
+
per_page: int,
|
|
171
|
+
query: str | None,
|
|
172
|
+
genres: list[int] | None,
|
|
173
|
+
status: int | None,
|
|
174
|
+
sort: str,
|
|
175
|
+
) -> dict[str, Any]:
|
|
176
|
+
"""Fetch one page of the catalog listing/search results.
|
|
177
|
+
|
|
178
|
+
Unlike the other ``ApiClient`` methods, this returns the *whole* response body, not
|
|
179
|
+
just ``data`` — callers need ``meta.has_next_page`` too, and there's no total count
|
|
180
|
+
to derive it from otherwise (see docs/api-notes.md).
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
page: 1-based page number.
|
|
184
|
+
per_page: Page size. The API only accepts ``10..60`` (validated by the caller,
|
|
185
|
+
see ``Catalog.list_titles``); values outside that raise here via a 422.
|
|
186
|
+
query: Free-text search term, matched against name/rus_name/eng_name. ``None``
|
|
187
|
+
or empty omits the ``q`` parameter entirely.
|
|
188
|
+
genres: Genre ids to filter by. A title must have *all* of them (AND, not OR —
|
|
189
|
+
see docs/api-notes.md), not just any one.
|
|
190
|
+
status: A single ``Title.status.id`` to filter by.
|
|
191
|
+
sort: Forwarded as the API's ``sort_by`` parameter, not ``sort`` — the API
|
|
192
|
+
silently ignores an actual ``sort`` parameter (see docs/api-notes.md); this
|
|
193
|
+
mismatch between the SDK's public keyword and the wire parameter name is
|
|
194
|
+
deliberate, not a typo.
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
The full JSON response: ``{"data": [...], "meta": {...}, "links": {...}}``.
|
|
198
|
+
"""
|
|
199
|
+
params: list[tuple[str, str | int | float | bool | None]] = [
|
|
200
|
+
("site_id[]", RANOBELIB_SITE_ID),
|
|
201
|
+
("page", str(page)),
|
|
202
|
+
("limit", str(per_page)),
|
|
203
|
+
("sort_by", sort),
|
|
204
|
+
]
|
|
205
|
+
if query:
|
|
206
|
+
params.append(("q", query))
|
|
207
|
+
for genre_id in genres or []:
|
|
208
|
+
params.append(("genres[]", str(genre_id)))
|
|
209
|
+
if status is not None:
|
|
210
|
+
params.append(("status[]", str(status)))
|
|
211
|
+
|
|
212
|
+
response = await self._get("/manga", params=httpx.QueryParams(params))
|
|
213
|
+
self._raise_for_status(
|
|
214
|
+
response,
|
|
215
|
+
not_found=RanobeLibError(f"Unexpected 404 from catalog listing (page={page})"),
|
|
216
|
+
)
|
|
217
|
+
result: dict[str, Any] = response.json()
|
|
218
|
+
return result
|
|
219
|
+
|
|
166
220
|
async def _get(self, url: str, *, params: Any = None) -> httpx.Response:
|
|
167
221
|
"""Issue a GET request, bounded by concurrency/pacing and retried on 429/5xx."""
|
|
168
222
|
attempt = 0
|
|
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
from typing import TYPE_CHECKING, NamedTuple
|
|
6
6
|
|
|
7
7
|
if TYPE_CHECKING:
|
|
8
|
-
from ranobelib.models import ChapterBranch
|
|
8
|
+
from ranobelib.models import ChapterBranch, Volume
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
class RanobeLibError(Exception):
|
|
@@ -164,3 +164,36 @@ class RateLimitError(RanobeLibError):
|
|
|
164
164
|
self.retry_after = retry_after
|
|
165
165
|
suffix = f" (retry after {retry_after}s)" if retry_after is not None else ""
|
|
166
166
|
super().__init__(f"Rate limited by the API{suffix}")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class DownloadTitleInterruptedError(RanobeLibError):
|
|
170
|
+
"""Raised by ``download_title()`` when a chapter fetch fails unrecoverably partway through.
|
|
171
|
+
|
|
172
|
+
``download_title()`` fetches hundreds-to-thousands of chapters sequentially for a long
|
|
173
|
+
title, which can outlast even the extra, bulk-specific rate-limit retry budget described
|
|
174
|
+
in ``download_title()``'s ``max_rate_limit_retries`` — or hit some other unrecoverable
|
|
175
|
+
error (e.g. ``AuthRequiredError`` on a chapter that turns out to need a paywall/auth
|
|
176
|
+
token). Rather than letting that error propagate on its own and silently discard every
|
|
177
|
+
chapter already fetched, it's wrapped in this exception instead, carrying what was
|
|
178
|
+
already downloaded — so a caller can keep that partial result, or simply call
|
|
179
|
+
``download_title()`` again: the SDK's disk cache means already-fetched chapters aren't
|
|
180
|
+
re-requested, so a retry resumes close to where this one stopped rather than restarting
|
|
181
|
+
the whole title. See docs/api-notes.md's "Rate limiting и retry" section and issue #41.
|
|
182
|
+
|
|
183
|
+
Attributes:
|
|
184
|
+
slug_url: The title's ``{id}--{slug}`` identifier.
|
|
185
|
+
volumes: Chapters fetched before the failure, grouped into volumes the same way a
|
|
186
|
+
successful ``download_title()`` call would return them.
|
|
187
|
+
completed: How many chapters were fetched before the failure.
|
|
188
|
+
total: How many chapters ``download_title()`` had planned to fetch in total.
|
|
189
|
+
"""
|
|
190
|
+
|
|
191
|
+
def __init__(self, slug_url: str, *, volumes: list[Volume], completed: int, total: int) -> None:
|
|
192
|
+
self.slug_url = slug_url
|
|
193
|
+
self.volumes = volumes
|
|
194
|
+
self.completed = completed
|
|
195
|
+
self.total = total
|
|
196
|
+
super().__init__(
|
|
197
|
+
f"download_title() for {slug_url!r} was interrupted after {completed}/{total} "
|
|
198
|
+
"chapter(s) fetched — see __cause__ for why, .volumes for what was already fetched."
|
|
199
|
+
)
|
|
@@ -329,3 +329,16 @@ class Volume(BaseModel):
|
|
|
329
329
|
|
|
330
330
|
number: str
|
|
331
331
|
chapters: list[Chapter] = Field(default_factory=list)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
class CatalogPage(BaseModel):
|
|
335
|
+
"""One page of catalog listing/search results, as returned by ``Catalog.list_titles()``.
|
|
336
|
+
|
|
337
|
+
``items`` reuses ``Title`` as-is — a catalog list item has every field ``Title`` requires,
|
|
338
|
+
the ones it doesn't send (``genres``, ``summary``, ``chapter_count``, ...) just come back
|
|
339
|
+
at their defaults, same as any other partially-populated ``Title`` (see docs/api-notes.md).
|
|
340
|
+
"""
|
|
341
|
+
|
|
342
|
+
items: list[Title]
|
|
343
|
+
page: int
|
|
344
|
+
has_next_page: bool
|
|
@@ -14,8 +14,11 @@ from ranobelib.console import Reporter, Verbosity
|
|
|
14
14
|
from ranobelib.exceptions import (
|
|
15
15
|
AmbiguousChapter,
|
|
16
16
|
ChapterNotFoundError,
|
|
17
|
+
DownloadTitleInterruptedError,
|
|
17
18
|
MultipleTitleTranslationsError,
|
|
18
19
|
MultipleTranslationsError,
|
|
20
|
+
RanobeLibError,
|
|
21
|
+
RateLimitError,
|
|
19
22
|
VolumeNotFoundError,
|
|
20
23
|
)
|
|
21
24
|
from ranobelib.exporters import EXPORTERS
|
|
@@ -23,6 +26,16 @@ from ranobelib.models import Chapter, ChapterBranch, Title, Volume
|
|
|
23
26
|
from ranobelib.numbering import parse_slug_url
|
|
24
27
|
from ranobelib.sizing import DEFAULT_AVERAGE_IMAGE_SIZE, DEFAULT_SAMPLE_SIZE, chapter_size
|
|
25
28
|
|
|
29
|
+
DEFAULT_RATE_LIMIT_RETRIES = 6
|
|
30
|
+
"""Default ``download_title(max_rate_limit_retries=...)`` — see docs/api-notes.md."""
|
|
31
|
+
|
|
32
|
+
_RATE_LIMIT_RETRY_BASE_DELAY = 5.0
|
|
33
|
+
"""Base delay, in seconds, for download_title()'s bulk rate-limit backoff (doubled per
|
|
34
|
+
attempt, capped at _RATE_LIMIT_RETRY_MAX_DELAY) — used when the API's 429 doesn't send a
|
|
35
|
+
Retry-After header."""
|
|
36
|
+
|
|
37
|
+
_RATE_LIMIT_RETRY_MAX_DELAY = 60.0
|
|
38
|
+
|
|
26
39
|
_INFO_FIELDS = [
|
|
27
40
|
"background",
|
|
28
41
|
"eng_name",
|
|
@@ -235,6 +248,7 @@ class RanobeLib:
|
|
|
235
248
|
translation_index: int | None = None,
|
|
236
249
|
chapter_delay: float = 0.0,
|
|
237
250
|
on_chapter: Callable[[int, int], None] | None = None,
|
|
251
|
+
max_rate_limit_retries: int = DEFAULT_RATE_LIMIT_RETRIES,
|
|
238
252
|
) -> list[Volume]:
|
|
239
253
|
"""Download every chapter of the title, across all its volumes.
|
|
240
254
|
|
|
@@ -248,6 +262,14 @@ class RanobeLib:
|
|
|
248
262
|
``branch_id`` or ``translation_index`` resolves every one of them), rather than
|
|
249
263
|
failing partway through a long download on the first ambiguous chapter.
|
|
250
264
|
|
|
265
|
+
A long title's sequential download can outlast ``ApiClient``'s own retry budget for
|
|
266
|
+
429s (tuned for a single one-off request, not hundreds/thousands of sequential
|
|
267
|
+
fetches — see docs/api-notes.md). On top of that, each chapter fetch that still comes
|
|
268
|
+
back rate-limited is retried again here, up to ``max_rate_limit_retries`` times, with
|
|
269
|
+
its own (coarser) backoff. If a chapter fetch still fails after that — from rate
|
|
270
|
+
limiting or anything else — the chapters already downloaded are not discarded: they're
|
|
271
|
+
attached to a raised ``DownloadTitleInterruptedError`` instead of being lost.
|
|
272
|
+
|
|
251
273
|
Args:
|
|
252
274
|
branch_id: Translation to use for every chapter that has more than one — same
|
|
253
275
|
meaning as ``get_chapter(..., branch_id=...)``, applied title-wide. Chapters
|
|
@@ -266,11 +288,20 @@ class RanobeLib:
|
|
|
266
288
|
who would otherwise have no way to observe anything before this whole
|
|
267
289
|
``await`` returns. Independent of ``verbosity``: both can be set at once,
|
|
268
290
|
each drives its own output.
|
|
291
|
+
max_rate_limit_retries: How many extra times to retry a single chapter fetch that
|
|
292
|
+
comes back rate-limited, on top of ``ApiClient``'s own retries, before giving
|
|
293
|
+
up on the whole download — see above. Each retry honors the API's
|
|
294
|
+
``Retry-After`` if it sent one, otherwise waits with its own capped
|
|
295
|
+
exponential backoff. Set to ``0`` to disable this extra layer and let a
|
|
296
|
+
persistent ``RateLimitError`` end the download immediately (still wrapped in
|
|
297
|
+
``DownloadTitleInterruptedError`` with whatever was already fetched).
|
|
269
298
|
|
|
270
299
|
Raises:
|
|
271
300
|
ValueError: If both ``branch_id`` and ``translation_index`` are given.
|
|
272
301
|
MultipleTitleTranslationsError: If the title has one or more chapters with more
|
|
273
302
|
than one translation that ``branch_id``/``translation_index`` didn't resolve.
|
|
303
|
+
DownloadTitleInterruptedError: If fetching a chapter fails unrecoverably partway
|
|
304
|
+
through the download — carries every chapter already fetched.
|
|
274
305
|
"""
|
|
275
306
|
if branch_id is not None and translation_index is not None:
|
|
276
307
|
raise ValueError("Pass at most one of branch_id or translation_index, not both.")
|
|
@@ -304,16 +335,31 @@ class RanobeLib:
|
|
|
304
335
|
if unresolved:
|
|
305
336
|
raise MultipleTitleTranslationsError(self._slug_url, chapters=unresolved)
|
|
306
337
|
|
|
307
|
-
chapters = []
|
|
338
|
+
chapters: list[Chapter] = []
|
|
308
339
|
total = len(planned)
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
340
|
+
try:
|
|
341
|
+
with self._reporter.progress(f"Downloading {self._slug_url}", total) as advance:
|
|
342
|
+
for index, (volume_str, number, selected_branch_id) in enumerate(planned):
|
|
343
|
+
chapters.append(
|
|
344
|
+
await self._fetch_chapter_riding_out_rate_limits(
|
|
345
|
+
volume_str,
|
|
346
|
+
number,
|
|
347
|
+
selected_branch_id,
|
|
348
|
+
max_rate_limit_retries=max_rate_limit_retries,
|
|
349
|
+
)
|
|
350
|
+
)
|
|
351
|
+
advance()
|
|
352
|
+
if on_chapter is not None:
|
|
353
|
+
on_chapter(index + 1, total)
|
|
354
|
+
if chapter_delay and index < total - 1:
|
|
355
|
+
await asyncio.sleep(chapter_delay)
|
|
356
|
+
except RanobeLibError as exc:
|
|
357
|
+
raise DownloadTitleInterruptedError(
|
|
358
|
+
self._slug_url,
|
|
359
|
+
volumes=_group_into_volumes(chapters),
|
|
360
|
+
completed=len(chapters),
|
|
361
|
+
total=total,
|
|
362
|
+
) from exc
|
|
317
363
|
return _group_into_volumes(chapters)
|
|
318
364
|
|
|
319
365
|
async def estimate_title_size(
|
|
@@ -476,6 +522,44 @@ class RanobeLib:
|
|
|
476
522
|
self._cache.set(key, data)
|
|
477
523
|
return Chapter.model_validate(data)
|
|
478
524
|
|
|
525
|
+
async def _fetch_chapter_riding_out_rate_limits(
|
|
526
|
+
self,
|
|
527
|
+
volume_str: str,
|
|
528
|
+
number: str,
|
|
529
|
+
branch_id: int | None,
|
|
530
|
+
*,
|
|
531
|
+
max_rate_limit_retries: int,
|
|
532
|
+
) -> Chapter:
|
|
533
|
+
"""Fetch one chapter for a bulk download, retrying past ``ApiClient``'s own budget.
|
|
534
|
+
|
|
535
|
+
``ApiClient._get()`` already retries a 429 a few times with its own backoff, tuned
|
|
536
|
+
for a single request (see docs/api-notes.md) — not enough to ride out sustained rate
|
|
537
|
+
limiting partway through a long sequential download. This adds a second, coarser
|
|
538
|
+
retry layer on top: on a ``RateLimitError`` that already survived ``ApiClient``'s own
|
|
539
|
+
retries, wait (honoring ``Retry-After`` if given, otherwise capped exponential
|
|
540
|
+
backoff) and try again, up to ``max_rate_limit_retries`` times, before finally letting
|
|
541
|
+
the error propagate to the caller (``download_title()``, which turns it into
|
|
542
|
+
``DownloadTitleInterruptedError`` carrying whatever was already downloaded).
|
|
543
|
+
"""
|
|
544
|
+
attempt = 0
|
|
545
|
+
while True:
|
|
546
|
+
try:
|
|
547
|
+
return await self._fetch_chapter(volume_str, number, branch_id)
|
|
548
|
+
except RateLimitError as exc:
|
|
549
|
+
if attempt >= max_rate_limit_retries:
|
|
550
|
+
raise
|
|
551
|
+
delay = exc.retry_after
|
|
552
|
+
if delay is None:
|
|
553
|
+
delay = min(
|
|
554
|
+
_RATE_LIMIT_RETRY_BASE_DELAY * (2**attempt), _RATE_LIMIT_RETRY_MAX_DELAY
|
|
555
|
+
)
|
|
556
|
+
self._reporter.log(
|
|
557
|
+
f"[rate-limit] chapter {volume_str}/{number}: retrying in {delay:g}s "
|
|
558
|
+
f"(attempt {attempt + 1}/{max_rate_limit_retries})"
|
|
559
|
+
)
|
|
560
|
+
await asyncio.sleep(delay)
|
|
561
|
+
attempt += 1
|
|
562
|
+
|
|
479
563
|
def _resolve_branch_id(
|
|
480
564
|
self, volume_str: str, number: str, raw_chapters: list[dict[str, Any]]
|
|
481
565
|
) -> int | None:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ranobelib_python_sdk-0.5.0 → ranobelib_python_sdk-0.6.1}/src/ranobelib/exporters/__init__.py
RENAMED
|
File without changes
|
{ranobelib_python_sdk-0.5.0 → ranobelib_python_sdk-0.6.1}/src/ranobelib/exporters/_illustrations.py
RENAMED
|
File without changes
|
{ranobelib_python_sdk-0.5.0 → ranobelib_python_sdk-0.6.1}/src/ranobelib/exporters/_shared.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|