ranobelib-python-sdk 0.10.0__tar.gz → 0.12.0__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.10.0 → ranobelib_python_sdk-0.12.0}/PKG-INFO +1 -1
- {ranobelib_python_sdk-0.10.0 → ranobelib_python_sdk-0.12.0}/pyproject.toml +1 -1
- {ranobelib_python_sdk-0.10.0 → ranobelib_python_sdk-0.12.0}/pyproject.toml.orig +1 -1
- {ranobelib_python_sdk-0.10.0 → ranobelib_python_sdk-0.12.0}/src/ranobelib/__init__.py +4 -0
- ranobelib_python_sdk-0.12.0/src/ranobelib/_http.py +45 -0
- {ranobelib_python_sdk-0.10.0 → ranobelib_python_sdk-0.12.0}/src/ranobelib/catalog.py +75 -11
- {ranobelib_python_sdk-0.10.0 → ranobelib_python_sdk-0.12.0}/src/ranobelib/client.py +56 -5
- {ranobelib_python_sdk-0.10.0 → ranobelib_python_sdk-0.12.0}/src/ranobelib/exceptions.py +28 -0
- {ranobelib_python_sdk-0.10.0 → ranobelib_python_sdk-0.12.0}/src/ranobelib/exporters/__init__.py +6 -1
- {ranobelib_python_sdk-0.10.0 → ranobelib_python_sdk-0.12.0}/src/ranobelib/exporters/epub.py +11 -2
- {ranobelib_python_sdk-0.10.0 → ranobelib_python_sdk-0.12.0}/src/ranobelib/exporters/fb2.py +4 -1
- {ranobelib_python_sdk-0.10.0 → ranobelib_python_sdk-0.12.0}/src/ranobelib/exporters/pdf.py +10 -2
- {ranobelib_python_sdk-0.10.0 → ranobelib_python_sdk-0.12.0}/src/ranobelib/exporters/txt.py +4 -1
- {ranobelib_python_sdk-0.10.0 → ranobelib_python_sdk-0.12.0}/src/ranobelib/models.py +8 -2
- {ranobelib_python_sdk-0.10.0 → ranobelib_python_sdk-0.12.0}/src/ranobelib/sdk.py +18 -4
- {ranobelib_python_sdk-0.10.0 → ranobelib_python_sdk-0.12.0}/README.md +0 -0
- {ranobelib_python_sdk-0.10.0 → ranobelib_python_sdk-0.12.0}/src/ranobelib/cache.py +0 -0
- {ranobelib_python_sdk-0.10.0 → ranobelib_python_sdk-0.12.0}/src/ranobelib/console.py +0 -0
- {ranobelib_python_sdk-0.10.0 → ranobelib_python_sdk-0.12.0}/src/ranobelib/exporters/_illustrations.py +0 -0
- {ranobelib_python_sdk-0.10.0 → ranobelib_python_sdk-0.12.0}/src/ranobelib/exporters/_shared.py +0 -0
- {ranobelib_python_sdk-0.10.0 → ranobelib_python_sdk-0.12.0}/src/ranobelib/numbering.py +0 -0
- {ranobelib_python_sdk-0.10.0 → ranobelib_python_sdk-0.12.0}/src/ranobelib/py.typed +0 -0
- {ranobelib_python_sdk-0.10.0 → ranobelib_python_sdk-0.12.0}/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.12.0
|
|
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
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from ranobelib.catalog import Catalog
|
|
4
4
|
from ranobelib.exceptions import (
|
|
5
|
+
AccessBlockedError,
|
|
5
6
|
AmbiguousChapter,
|
|
6
7
|
AuthRequiredError,
|
|
7
8
|
ChapterNotFoundError,
|
|
@@ -20,6 +21,7 @@ from ranobelib.models import (
|
|
|
20
21
|
Country,
|
|
21
22
|
Footnote,
|
|
22
23
|
Genre,
|
|
24
|
+
Label,
|
|
23
25
|
Title,
|
|
24
26
|
Volume,
|
|
25
27
|
)
|
|
@@ -27,6 +29,7 @@ from ranobelib.sdk import RanobeLib
|
|
|
27
29
|
from ranobelib.sizing import chapter_size, volume_size
|
|
28
30
|
|
|
29
31
|
__all__ = [
|
|
32
|
+
"AccessBlockedError",
|
|
30
33
|
"AmbiguousChapter",
|
|
31
34
|
"AuthRequiredError",
|
|
32
35
|
"Catalog",
|
|
@@ -38,6 +41,7 @@ __all__ = [
|
|
|
38
41
|
"DownloadTitleInterruptedError",
|
|
39
42
|
"Footnote",
|
|
40
43
|
"Genre",
|
|
44
|
+
"Label",
|
|
41
45
|
"MultipleTitleTranslationsError",
|
|
42
46
|
"MultipleTranslationsError",
|
|
43
47
|
"RanobeLib",
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""HTTP settings shared by every client the SDK opens (not public API).
|
|
2
|
+
|
|
3
|
+
Lives in its own module rather than in ``client.py`` because it isn't specific to the
|
|
4
|
+
api.cdnlibs.org JSON API: the exporters' image-downloading clients (cover.cdnlibs.org,
|
|
5
|
+
ranobelib.me uploads) need the same headers, and importing them from here keeps the
|
|
6
|
+
exporters from depending on ``ApiClient``'s module just for a constant.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
SITE_ORIGIN = "https://ranobelib.me"
|
|
16
|
+
"""Origin of the ranobelib.me website, as its own pages send it."""
|
|
17
|
+
|
|
18
|
+
BROWSER_HEADERS: dict[str, str] = {
|
|
19
|
+
"Origin": SITE_ORIGIN,
|
|
20
|
+
"Referer": f"{SITE_ORIGIN}/",
|
|
21
|
+
"User-Agent": (
|
|
22
|
+
"Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 "
|
|
23
|
+
"(KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1"
|
|
24
|
+
),
|
|
25
|
+
}
|
|
26
|
+
"""Headers a browser on ranobelib.me sends, so requests look like they come from the site.
|
|
27
|
+
|
|
28
|
+
The DDoS-Guard edge in front of api.cdnlibs.org and cover.cdnlibs.org rejects requests
|
|
29
|
+
without a non-empty ``Referer`` with an HTML 403 page (see docs/api-notes.md, section
|
|
30
|
+
"WAF 403"). Any ``Referer`` value was enough when checked; the site's own one is sent,
|
|
31
|
+
together with ``Origin`` and a real browser ``User-Agent``, so the request as a whole matches
|
|
32
|
+
what the site itself sends rather than advertising ``python-httpx``.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def browser_headers(overrides: Mapping[str, str] | None = None) -> httpx.Headers:
|
|
37
|
+
"""``BROWSER_HEADERS`` with ``overrides`` merged over them.
|
|
38
|
+
|
|
39
|
+
A key in ``overrides`` replaces the default one, matched case-insensitively (``httpx``
|
|
40
|
+
headers semantics) — the same merge ``ApiClient(headers=...)`` does for API requests.
|
|
41
|
+
"""
|
|
42
|
+
headers = httpx.Headers(BROWSER_HEADERS)
|
|
43
|
+
if overrides:
|
|
44
|
+
headers.update(overrides)
|
|
45
|
+
return headers
|
|
@@ -7,13 +7,14 @@ its own ``ApiClient``/``DiskCache`` rather than a method on ``RanobeLib``.
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
+
from collections.abc import Mapping
|
|
10
11
|
from pathlib import Path
|
|
11
12
|
from types import TracebackType
|
|
12
13
|
from typing import Any, Self
|
|
13
14
|
|
|
14
15
|
from ranobelib.cache import DEFAULT_CACHE_DIR, DiskCache
|
|
15
16
|
from ranobelib.client import RANOBELIB_SITE_ID, ApiClient
|
|
16
|
-
from ranobelib.models import CatalogPage, Country, Genre, Title
|
|
17
|
+
from ranobelib.models import CatalogPage, Country, Genre, Label, Title
|
|
17
18
|
|
|
18
19
|
DEFAULT_SORT = "last_chapter_at"
|
|
19
20
|
"""Default sort order: title's last-chapter timestamp — the closest real equivalent to
|
|
@@ -29,7 +30,7 @@ class Catalog:
|
|
|
29
30
|
Example:
|
|
30
31
|
```python
|
|
31
32
|
async with Catalog() as catalog:
|
|
32
|
-
page = await catalog.list_titles(query="dxd", genres=[34],
|
|
33
|
+
page = await catalog.list_titles(query="dxd", genres=[34], statuses=[1])
|
|
33
34
|
for title in page.items:
|
|
34
35
|
print(title.name)
|
|
35
36
|
```
|
|
@@ -40,6 +41,7 @@ class Catalog:
|
|
|
40
41
|
*,
|
|
41
42
|
cache_dir: str | Path | None = None,
|
|
42
43
|
cache_ttl: float | None = None,
|
|
44
|
+
headers: Mapping[str, str] | None = None,
|
|
43
45
|
) -> None:
|
|
44
46
|
"""Initialize the catalog client.
|
|
45
47
|
|
|
@@ -51,8 +53,10 @@ class Catalog:
|
|
|
51
53
|
cache_ttl: Seconds after which a cached page is treated as stale and re-fetched.
|
|
52
54
|
``None`` (the default) means cached pages never expire on their own — see
|
|
53
55
|
``refresh=True`` on ``list_titles()`` to force one anyway.
|
|
56
|
+
headers: Extra HTTP headers merged over the SDK's defaults, same as
|
|
57
|
+
``RanobeLib(headers=...)`` — see there.
|
|
54
58
|
"""
|
|
55
|
-
self._client = ApiClient()
|
|
59
|
+
self._client = ApiClient(headers=headers)
|
|
56
60
|
self._cache = DiskCache(
|
|
57
61
|
cache_dir if cache_dir is not None else DEFAULT_CACHE_DIR, ttl=cache_ttl
|
|
58
62
|
)
|
|
@@ -80,8 +84,10 @@ class Catalog:
|
|
|
80
84
|
query: str | None = None,
|
|
81
85
|
genres: list[int] | None = None,
|
|
82
86
|
tags: list[int] | None = None,
|
|
83
|
-
|
|
87
|
+
statuses: list[int] | None = None,
|
|
84
88
|
countries: list[int] | None = None,
|
|
89
|
+
min_chapters: int | None = None,
|
|
90
|
+
max_chapters: int | None = None,
|
|
85
91
|
sort: str = DEFAULT_SORT,
|
|
86
92
|
refresh: bool = False,
|
|
87
93
|
) -> CatalogPage:
|
|
@@ -103,7 +109,10 @@ class Catalog:
|
|
|
103
109
|
against the live API, see docs/api-notes.md; not the OR some might expect
|
|
104
110
|
from tags being more numerous/specific than genres). Also not validated
|
|
105
111
|
before sending, same as ``genres``.
|
|
106
|
-
|
|
112
|
+
statuses: ``Title.status.id``s to filter by (e.g. ongoing, completed). A title
|
|
113
|
+
matches if its own status is *any* of these (OR, same as ``countries`` — a
|
|
114
|
+
title only has one status, see docs/api-notes.md). ``None`` or an empty list
|
|
115
|
+
applies no status filter. Ids come from ``list_statuses()``.
|
|
107
116
|
countries: ``Country.id``s to filter by. A title matches if its own country is
|
|
108
117
|
*any* of these (OR, not AND like ``genres``/``tags`` — a title only has one
|
|
109
118
|
country of origin, so requiring all of them could never match past the first,
|
|
@@ -112,6 +121,11 @@ class Catalog:
|
|
|
112
121
|
parameter, not a ``country``/``countries[]`` one — see docs/api-notes.md for
|
|
113
122
|
why (``Country`` mirrors the API's own "type" concept, which isn't strictly
|
|
114
123
|
limited to literal countries).
|
|
124
|
+
min_chapters: Only titles with at least this many chapters (inclusive). Counts
|
|
125
|
+
the same number as ``Title.chapter_count`` (uploaded chapters). ``None``
|
|
126
|
+
(the default) applies no lower bound.
|
|
127
|
+
max_chapters: Only titles with at most this many chapters (inclusive). ``None``
|
|
128
|
+
(the default) applies no upper bound.
|
|
115
129
|
sort: Sort order. Despite the name, this is sent as the API's ``sort_by``
|
|
116
130
|
parameter — an actual ``sort`` parameter exists but is silently ignored by
|
|
117
131
|
the API (see docs/api-notes.md). Known accepted values: ``"name"``,
|
|
@@ -126,8 +140,9 @@ class Catalog:
|
|
|
126
140
|
page exists.
|
|
127
141
|
|
|
128
142
|
Raises:
|
|
129
|
-
ValueError: If ``page`` is less than 1,
|
|
130
|
-
``MIN_PER_PAGE..MAX_PER_PAGE
|
|
143
|
+
ValueError: If ``page`` is less than 1, ``per_page`` is outside
|
|
144
|
+
``MIN_PER_PAGE..MAX_PER_PAGE``, ``min_chapters``/``max_chapters`` is
|
|
145
|
+
negative, or ``min_chapters`` is greater than ``max_chapters``.
|
|
131
146
|
"""
|
|
132
147
|
if page < 1:
|
|
133
148
|
raise ValueError(f"page must be at least 1, got {page}.")
|
|
@@ -135,6 +150,16 @@ class Catalog:
|
|
|
135
150
|
raise ValueError(
|
|
136
151
|
f"per_page must be between {MIN_PER_PAGE} and {MAX_PER_PAGE}, got {per_page}."
|
|
137
152
|
)
|
|
153
|
+
for name, value in (("min_chapters", min_chapters), ("max_chapters", max_chapters)):
|
|
154
|
+
if value is not None and value < 0:
|
|
155
|
+
raise ValueError(f"{name} must be at least 0, got {value}.")
|
|
156
|
+
if min_chapters is not None and max_chapters is not None and min_chapters > max_chapters:
|
|
157
|
+
# The API itself silently swaps the bounds instead (see docs/api-notes.md) —
|
|
158
|
+
# raising here rather than relying on that undocumented behavior.
|
|
159
|
+
raise ValueError(
|
|
160
|
+
f"min_chapters ({min_chapters}) must not be greater than "
|
|
161
|
+
f"max_chapters ({max_chapters})."
|
|
162
|
+
)
|
|
138
163
|
|
|
139
164
|
key = _cache_key(
|
|
140
165
|
page=page,
|
|
@@ -142,8 +167,10 @@ class Catalog:
|
|
|
142
167
|
query=query,
|
|
143
168
|
genres=genres,
|
|
144
169
|
tags=tags,
|
|
145
|
-
|
|
170
|
+
statuses=statuses,
|
|
146
171
|
countries=countries,
|
|
172
|
+
min_chapters=min_chapters,
|
|
173
|
+
max_chapters=max_chapters,
|
|
147
174
|
sort=sort,
|
|
148
175
|
)
|
|
149
176
|
if not refresh:
|
|
@@ -157,8 +184,10 @@ class Catalog:
|
|
|
157
184
|
query=query,
|
|
158
185
|
genres=genres,
|
|
159
186
|
tags=tags,
|
|
160
|
-
|
|
187
|
+
statuses=statuses,
|
|
161
188
|
countries=countries,
|
|
189
|
+
min_chapters=min_chapters,
|
|
190
|
+
max_chapters=max_chapters,
|
|
162
191
|
sort=sort,
|
|
163
192
|
)
|
|
164
193
|
self._cache.set(key, data)
|
|
@@ -219,6 +248,33 @@ class Catalog:
|
|
|
219
248
|
self._cache.set(key, data)
|
|
220
249
|
return _build_countries(data)
|
|
221
250
|
|
|
251
|
+
async def list_statuses(self, *, refresh: bool = False) -> list[Label]:
|
|
252
|
+
"""Fetch the full list of title statuses (id → label), for use as filter options
|
|
253
|
+
with ``list_titles(statuses=...)``.
|
|
254
|
+
|
|
255
|
+
Mirrors ``list_genres()``/``list_countries()``: the underlying endpoint
|
|
256
|
+
(``GET /api/constants?fields[]=status``) isn't site-scoped, so entries not tagged for
|
|
257
|
+
ranobelib.me are dropped before returning (see docs/api-notes.md). Returns the same
|
|
258
|
+
``Label`` model as ``Title.status``, so a title's status can be compared against
|
|
259
|
+
these directly.
|
|
260
|
+
|
|
261
|
+
Args:
|
|
262
|
+
refresh: Bypass the disk cache and re-fetch from the API even if the status list
|
|
263
|
+
was already cached.
|
|
264
|
+
|
|
265
|
+
Returns:
|
|
266
|
+
Every status (``Label``: id, label) that applies to ranobelib.me.
|
|
267
|
+
"""
|
|
268
|
+
key = "catalog:statuses"
|
|
269
|
+
if not refresh:
|
|
270
|
+
cached = self._cache.get(key)
|
|
271
|
+
if cached is not None:
|
|
272
|
+
return _build_statuses(cached)
|
|
273
|
+
|
|
274
|
+
data = await self._client.list_statuses()
|
|
275
|
+
self._cache.set(key, data)
|
|
276
|
+
return _build_statuses(data)
|
|
277
|
+
|
|
222
278
|
|
|
223
279
|
def _cache_key(
|
|
224
280
|
*,
|
|
@@ -227,16 +283,19 @@ def _cache_key(
|
|
|
227
283
|
query: str | None,
|
|
228
284
|
genres: list[int] | None,
|
|
229
285
|
tags: list[int] | None,
|
|
230
|
-
|
|
286
|
+
statuses: list[int] | None,
|
|
231
287
|
countries: list[int] | None,
|
|
288
|
+
min_chapters: int | None,
|
|
289
|
+
max_chapters: int | None,
|
|
232
290
|
sort: str,
|
|
233
291
|
) -> str:
|
|
234
292
|
genres_part = ",".join(str(genre_id) for genre_id in genres or [])
|
|
235
293
|
tags_part = ",".join(str(tag_id) for tag_id in tags or [])
|
|
294
|
+
statuses_part = ",".join(str(status_id) for status_id in statuses or [])
|
|
236
295
|
countries_part = ",".join(str(country_id) for country_id in countries or [])
|
|
237
296
|
return (
|
|
238
297
|
f"catalog:{page}:{per_page}:{query or ''}:{genres_part}:{tags_part}:"
|
|
239
|
-
f"{
|
|
298
|
+
f"{statuses_part}:{countries_part}:{min_chapters}:{max_chapters}:{sort}"
|
|
240
299
|
)
|
|
241
300
|
|
|
242
301
|
|
|
@@ -254,3 +313,8 @@ def _build_genres(data: list[dict[str, Any]]) -> list[Genre]:
|
|
|
254
313
|
def _build_countries(data: list[dict[str, Any]]) -> list[Country]:
|
|
255
314
|
site_id = int(RANOBELIB_SITE_ID)
|
|
256
315
|
return [Country.model_validate(item) for item in data if site_id in item.get("site_ids", [])]
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _build_statuses(data: list[dict[str, Any]]) -> list[Label]:
|
|
319
|
+
site_id = int(RANOBELIB_SITE_ID)
|
|
320
|
+
return [Label.model_validate(item) for item in data if site_id in item.get("site_ids", [])]
|
|
@@ -4,13 +4,15 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
6
|
import time
|
|
7
|
-
from collections.abc import Awaitable, Callable
|
|
7
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
8
8
|
from types import TracebackType
|
|
9
9
|
from typing import Any, Self
|
|
10
10
|
|
|
11
11
|
import httpx
|
|
12
12
|
|
|
13
|
+
from ranobelib._http import BROWSER_HEADERS
|
|
13
14
|
from ranobelib.exceptions import (
|
|
15
|
+
AccessBlockedError,
|
|
14
16
|
AuthRequiredError,
|
|
15
17
|
ChapterNotFoundError,
|
|
16
18
|
RanobeLibError,
|
|
@@ -36,6 +38,11 @@ class ApiClient:
|
|
|
36
38
|
Bounds concurrency with a semaphore, paces request starts with a small fixed delay, and
|
|
37
39
|
retries 429/5xx responses with exponential backoff — see docs/api-notes.md for why a
|
|
38
40
|
manual implementation was chosen over a dependency like ``tenacity`` for this.
|
|
41
|
+
|
|
42
|
+
Every request carries browser-like ``Origin``/``Referer``/``User-Agent`` headers on top
|
|
43
|
+
of the API-specific ``Site-Id``/``Accept``: without a non-empty ``Referer``, the
|
|
44
|
+
DDoS-Guard edge in front of the API answers every request with an HTML 403 page (see
|
|
45
|
+
docs/api-notes.md, section "WAF 403").
|
|
39
46
|
"""
|
|
40
47
|
|
|
41
48
|
def __init__(
|
|
@@ -44,6 +51,7 @@ class ApiClient:
|
|
|
44
51
|
base_url: str = API_BASE_URL,
|
|
45
52
|
timeout: float = 15.0,
|
|
46
53
|
transport: httpx.AsyncBaseTransport | None = None,
|
|
54
|
+
headers: Mapping[str, str] | None = None,
|
|
47
55
|
max_concurrency: int = 5,
|
|
48
56
|
request_delay: float | None = None,
|
|
49
57
|
max_retries: int = 3,
|
|
@@ -57,6 +65,13 @@ class ApiClient:
|
|
|
57
65
|
base_url: API base URL.
|
|
58
66
|
timeout: Per-request timeout, in seconds.
|
|
59
67
|
transport: Custom ``httpx`` transport, e.g. a mock transport for tests.
|
|
68
|
+
headers: Extra headers merged over the defaults (browser-like ``Origin``/
|
|
69
|
+
``Referer``/``User-Agent`` plus the API's ``Site-Id``/``Accept``); a key
|
|
70
|
+
given here replaces the default one, matched case-insensitively. Meant for
|
|
71
|
+
swapping in a different ``User-Agent`` if the site's edge protection changes
|
|
72
|
+
its rules, without waiting for an SDK release. Overriding ``Site-Id`` or
|
|
73
|
+
``Accept`` is possible but breaks the API's contract (see
|
|
74
|
+
docs/api-notes.md) — only do it knowingly.
|
|
60
75
|
max_concurrency: Maximum number of requests in flight at once.
|
|
61
76
|
request_delay: Minimum time, in seconds, between the start of one request and
|
|
62
77
|
the next, even under ``max_concurrency``. ``None`` (the default) uses
|
|
@@ -74,10 +89,13 @@ class ApiClient:
|
|
|
74
89
|
timeout=timeout,
|
|
75
90
|
transport=transport,
|
|
76
91
|
headers={
|
|
92
|
+
**BROWSER_HEADERS,
|
|
77
93
|
"Site-Id": RANOBELIB_SITE_ID,
|
|
78
94
|
"Accept": "application/json",
|
|
79
95
|
},
|
|
80
96
|
)
|
|
97
|
+
if headers is not None:
|
|
98
|
+
self._http.headers.update(headers)
|
|
81
99
|
self._semaphore = asyncio.Semaphore(max_concurrency)
|
|
82
100
|
self._request_delay = DEFAULT_REQUEST_DELAY if request_delay is None else request_delay
|
|
83
101
|
self._max_retries = max_retries
|
|
@@ -171,8 +189,10 @@ class ApiClient:
|
|
|
171
189
|
query: str | None,
|
|
172
190
|
genres: list[int] | None,
|
|
173
191
|
tags: list[int] | None,
|
|
174
|
-
|
|
192
|
+
statuses: list[int] | None,
|
|
175
193
|
countries: list[int] | None,
|
|
194
|
+
min_chapters: int | None,
|
|
195
|
+
max_chapters: int | None,
|
|
176
196
|
sort: str,
|
|
177
197
|
) -> dict[str, Any]:
|
|
178
198
|
"""Fetch one page of the catalog listing/search results.
|
|
@@ -191,12 +211,16 @@ class ApiClient:
|
|
|
191
211
|
see docs/api-notes.md), not just any one.
|
|
192
212
|
tags: Tag ids to filter by. Same AND semantics as ``genres`` — a title must have
|
|
193
213
|
*all* of them, not just any one (see docs/api-notes.md).
|
|
194
|
-
|
|
214
|
+
statuses: ``Title.status.id``s to filter by. A title matches if its own status
|
|
215
|
+
is *any* of these (OR, like ``countries`` — a title only has one status, see
|
|
216
|
+
docs/api-notes.md). Sent on the wire as repeated ``status[]`` parameters.
|
|
195
217
|
countries: ``Country.id``s to filter by. A title matches if its own country is
|
|
196
218
|
*any* of these (OR, not AND like ``genres``/``tags`` — a title only has one
|
|
197
219
|
country, confirmed against the live API, see docs/api-notes.md). Sent on the
|
|
198
220
|
wire as repeated ``types[]`` parameters — the API's own name for this concept
|
|
199
221
|
is "type", not "country" (see docs/api-notes.md).
|
|
222
|
+
min_chapters: Minimum chapter count, inclusive. Sent as ``chap_count_min``.
|
|
223
|
+
max_chapters: Maximum chapter count, inclusive. Sent as ``chap_count_max``.
|
|
200
224
|
sort: Forwarded as the API's ``sort_by`` parameter, not ``sort`` — the API
|
|
201
225
|
silently ignores an actual ``sort`` parameter (see docs/api-notes.md); this
|
|
202
226
|
mismatch between the SDK's public keyword and the wire parameter name is
|
|
@@ -217,10 +241,14 @@ class ApiClient:
|
|
|
217
241
|
params.append(("genres[]", str(genre_id)))
|
|
218
242
|
for tag_id in tags or []:
|
|
219
243
|
params.append(("tags[]", str(tag_id)))
|
|
220
|
-
|
|
221
|
-
params.append(("status[]", str(
|
|
244
|
+
for status_id in statuses or []:
|
|
245
|
+
params.append(("status[]", str(status_id)))
|
|
222
246
|
for country_id in countries or []:
|
|
223
247
|
params.append(("types[]", str(country_id)))
|
|
248
|
+
if min_chapters is not None:
|
|
249
|
+
params.append(("chap_count_min", str(min_chapters)))
|
|
250
|
+
if max_chapters is not None:
|
|
251
|
+
params.append(("chap_count_max", str(max_chapters)))
|
|
224
252
|
|
|
225
253
|
response = await self._get("/manga", params=httpx.QueryParams(params))
|
|
226
254
|
self._raise_for_status(
|
|
@@ -265,6 +293,23 @@ class ApiClient:
|
|
|
265
293
|
data: list[dict[str, Any]] = response.json()["data"]["types"]
|
|
266
294
|
return data
|
|
267
295
|
|
|
296
|
+
async def list_statuses(self) -> list[dict[str, Any]]:
|
|
297
|
+
"""Fetch the raw title status list shared across the whole lib.social network.
|
|
298
|
+
|
|
299
|
+
Same shape and caveats as ``list_genres``: not site-scoped by any request parameter,
|
|
300
|
+
returns every status known to the network at once, each tagged with the site ids it
|
|
301
|
+
applies to via ``site_ids``.
|
|
302
|
+
|
|
303
|
+
Returns:
|
|
304
|
+
The raw ``data.status`` array from the API response, unfiltered by site.
|
|
305
|
+
"""
|
|
306
|
+
response = await self._get("/constants", params=httpx.QueryParams([("fields[]", "status")]))
|
|
307
|
+
self._raise_for_status(
|
|
308
|
+
response, not_found=RanobeLibError("Unexpected 404 from status list")
|
|
309
|
+
)
|
|
310
|
+
data: list[dict[str, Any]] = response.json()["data"]["status"]
|
|
311
|
+
return data
|
|
312
|
+
|
|
268
313
|
async def _get(self, url: str, *, params: Any = None) -> httpx.Response:
|
|
269
314
|
"""Issue a GET request, bounded by concurrency/pacing and retried on 429/5xx."""
|
|
270
315
|
attempt = 0
|
|
@@ -302,6 +347,12 @@ class ApiClient:
|
|
|
302
347
|
if response.status_code == 404:
|
|
303
348
|
raise not_found
|
|
304
349
|
if response.status_code == 403:
|
|
350
|
+
# The API's own 403 is JSON; the DDoS-Guard edge in front of it answers with an
|
|
351
|
+
# HTML page instead. Told apart by Content-Type, not by the page's wording, which
|
|
352
|
+
# can change (see docs/api-notes.md, section "WAF 403").
|
|
353
|
+
media_type = response.headers.get("Content-Type", "").split(";")[0].strip().lower()
|
|
354
|
+
if media_type != "application/json":
|
|
355
|
+
raise AccessBlockedError(str(response.request.url))
|
|
305
356
|
raise AuthRequiredError(str(response.request.url))
|
|
306
357
|
if response.status_code == 429:
|
|
307
358
|
retry_after = response.headers.get("Retry-After")
|
|
@@ -153,6 +153,34 @@ class AuthRequiredError(RanobeLibError):
|
|
|
153
153
|
super().__init__(f"Authorization required to access: {url}")
|
|
154
154
|
|
|
155
155
|
|
|
156
|
+
class AccessBlockedError(RanobeLibError):
|
|
157
|
+
"""Raised when the site's edge protection rejects a request before it reaches the API.
|
|
158
|
+
|
|
159
|
+
Not an authorization problem, unlike ``AuthRequiredError``: the DDoS-Guard edge in front
|
|
160
|
+
of api.cdnlibs.org answers with an HTML 403 page (the API's own 403 is JSON) when it
|
|
161
|
+
doesn't like the request — so far, a missing or empty ``Referer`` (see
|
|
162
|
+
docs/api-notes.md, section "WAF 403"). The SDK already sends browser-like headers, so
|
|
163
|
+
seeing this means either the edge changed its rules — check for an SDK update, or send
|
|
164
|
+
what it now expects via ``headers=`` on ``RanobeLib``/``Catalog`` (or ``ApiClient``, if
|
|
165
|
+
using it directly) — or the edge is blocking the network/IP itself, which no header
|
|
166
|
+
fixes. Not retried: resending the same request gets
|
|
167
|
+
the same answer.
|
|
168
|
+
|
|
169
|
+
Attributes:
|
|
170
|
+
url: The request URL that returned 403.
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
def __init__(self, url: str) -> None:
|
|
174
|
+
self.url = url
|
|
175
|
+
super().__init__(
|
|
176
|
+
f"Request blocked by the site's protection (not an authorization issue): {url}. "
|
|
177
|
+
"The site may have changed which requests it accepts: check for an SDK update, "
|
|
178
|
+
"or send different headers via RanobeLib(..., headers=...) / "
|
|
179
|
+
"Catalog(headers=...). If the same URL doesn't open in a browser on this network "
|
|
180
|
+
"either, the IP itself is blocked and no header change will help."
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
156
184
|
class RateLimitError(RanobeLibError):
|
|
157
185
|
"""Raised when the API responds with 429 Too Many Requests.
|
|
158
186
|
|
{ranobelib_python_sdk-0.10.0 → ranobelib_python_sdk-0.12.0}/src/ranobelib/exporters/__init__.py
RENAMED
|
@@ -9,7 +9,7 @@ from __future__ import annotations
|
|
|
9
9
|
|
|
10
10
|
import importlib
|
|
11
11
|
import pkgutil
|
|
12
|
-
from collections.abc import Callable
|
|
12
|
+
from collections.abc import Callable, Mapping
|
|
13
13
|
from pathlib import Path
|
|
14
14
|
from typing import ClassVar, Protocol, TypeVar, runtime_checkable
|
|
15
15
|
|
|
@@ -30,6 +30,7 @@ class Exporter(Protocol):
|
|
|
30
30
|
output_path: Path,
|
|
31
31
|
*,
|
|
32
32
|
on_chapter: Callable[[], None] | None = None,
|
|
33
|
+
headers: Mapping[str, str] | None = None,
|
|
33
34
|
) -> Path:
|
|
34
35
|
"""Write ``chapters`` (in the given order) to ``output_path``.
|
|
35
36
|
|
|
@@ -45,6 +46,10 @@ class Exporter(Protocol):
|
|
|
45
46
|
``RanobeLib.export()``'s progress bar (see CLAUDE.md's roadmap step 23).
|
|
46
47
|
For epub/pdf this covers the per-chapter embedding step, not the earlier
|
|
47
48
|
illustration-download step, which isn't itself progress-reported.
|
|
49
|
+
headers: Extra HTTP headers for any downloads the exporter makes (epub/pdf
|
|
50
|
+
illustrations), merged over the SDK's browser-like defaults —
|
|
51
|
+
``RanobeLib.export()`` passes its own ``headers=`` through here. Exporters
|
|
52
|
+
that make no requests (txt, fb2) accept and ignore it.
|
|
48
53
|
|
|
49
54
|
Returns:
|
|
50
55
|
``output_path``, once the file has been written.
|
|
@@ -3,13 +3,14 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import html
|
|
6
|
-
from collections.abc import Callable
|
|
6
|
+
from collections.abc import Callable, Mapping
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
from typing import ClassVar
|
|
9
9
|
|
|
10
10
|
import httpx
|
|
11
11
|
from ebooklib import epub
|
|
12
12
|
|
|
13
|
+
from ranobelib._http import browser_headers
|
|
13
14
|
from ranobelib.exporters import register
|
|
14
15
|
from ranobelib.exporters._illustrations import (
|
|
15
16
|
download_images,
|
|
@@ -47,6 +48,7 @@ class EpubExporter:
|
|
|
47
48
|
output_path: Path,
|
|
48
49
|
*,
|
|
49
50
|
on_chapter: Callable[[], None] | None = None,
|
|
51
|
+
headers: Mapping[str, str] | None = None,
|
|
50
52
|
) -> Path:
|
|
51
53
|
"""Write ``chapters`` to ``output_path`` as an EPUB file.
|
|
52
54
|
|
|
@@ -60,6 +62,8 @@ class EpubExporter:
|
|
|
60
62
|
output_path: Where to write the ``.epub`` file.
|
|
61
63
|
on_chapter: Called once per chapter embedded, if given — after illustrations
|
|
62
64
|
have already been downloaded, see the ``Exporter`` protocol's docstring.
|
|
65
|
+
headers: Extra HTTP headers for illustration downloads, merged over the
|
|
66
|
+
SDK's browser-like defaults (a given key replaces the default one).
|
|
63
67
|
|
|
64
68
|
Returns:
|
|
65
69
|
``output_path``.
|
|
@@ -71,8 +75,13 @@ class EpubExporter:
|
|
|
71
75
|
for author in title.authors:
|
|
72
76
|
book.add_author(author.name)
|
|
73
77
|
|
|
78
|
+
# Browser headers: cover.cdnlibs.org's edge answers requests without a (non-empty)
|
|
79
|
+
# Referer with a 403, same as the API (see docs/api-notes.md, section "WAF 403").
|
|
74
80
|
async with httpx.AsyncClient(
|
|
75
|
-
timeout=15.0,
|
|
81
|
+
timeout=15.0,
|
|
82
|
+
follow_redirects=True,
|
|
83
|
+
transport=self._transport,
|
|
84
|
+
headers=browser_headers(headers),
|
|
76
85
|
) as client:
|
|
77
86
|
cover_url = pick_cover_url(title.cover)
|
|
78
87
|
if cover_url is not None:
|
|
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import datetime
|
|
6
6
|
import xml.etree.ElementTree as ET
|
|
7
|
-
from collections.abc import Callable
|
|
7
|
+
from collections.abc import Callable, Mapping
|
|
8
8
|
from html.parser import HTMLParser
|
|
9
9
|
from pathlib import Path
|
|
10
10
|
from typing import ClassVar
|
|
@@ -153,6 +153,7 @@ class Fb2Exporter:
|
|
|
153
153
|
output_path: Path,
|
|
154
154
|
*,
|
|
155
155
|
on_chapter: Callable[[], None] | None = None,
|
|
156
|
+
headers: Mapping[str, str] | None = None,
|
|
156
157
|
) -> Path:
|
|
157
158
|
"""Write ``chapters`` to ``output_path`` as FB2 XML.
|
|
158
159
|
|
|
@@ -161,6 +162,8 @@ class Fb2Exporter:
|
|
|
161
162
|
chapters: The chapters to include, in the order they should appear.
|
|
162
163
|
output_path: Where to write the ``.fb2`` file.
|
|
163
164
|
on_chapter: Called once per chapter written, if given.
|
|
165
|
+
headers: Accepted for the ``Exporter`` protocol and ignored — this format
|
|
166
|
+
downloads nothing (no illustrations in fb2, see CLAUDE.md).
|
|
164
167
|
|
|
165
168
|
Returns:
|
|
166
169
|
``output_path``.
|
|
@@ -5,12 +5,13 @@ from __future__ import annotations
|
|
|
5
5
|
import asyncio
|
|
6
6
|
import base64
|
|
7
7
|
import html
|
|
8
|
-
from collections.abc import Callable
|
|
8
|
+
from collections.abc import Callable, Mapping
|
|
9
9
|
from pathlib import Path
|
|
10
10
|
from typing import ClassVar
|
|
11
11
|
|
|
12
12
|
import httpx
|
|
13
13
|
|
|
14
|
+
from ranobelib._http import browser_headers
|
|
14
15
|
from ranobelib.exporters import register
|
|
15
16
|
from ranobelib.exporters._illustrations import (
|
|
16
17
|
download_images,
|
|
@@ -106,6 +107,7 @@ if weasyprint is not None:
|
|
|
106
107
|
output_path: Path,
|
|
107
108
|
*,
|
|
108
109
|
on_chapter: Callable[[], None] | None = None,
|
|
110
|
+
headers: Mapping[str, str] | None = None,
|
|
109
111
|
) -> Path:
|
|
110
112
|
"""Write ``chapters`` to ``output_path`` as a PDF file.
|
|
111
113
|
|
|
@@ -116,12 +118,18 @@ if weasyprint is not None:
|
|
|
116
118
|
on_chapter: Called once per chapter rendered to HTML, if given — after
|
|
117
119
|
illustrations have already been downloaded, see the ``Exporter``
|
|
118
120
|
protocol's docstring.
|
|
121
|
+
headers: Extra HTTP headers for illustration downloads, merged over the
|
|
122
|
+
SDK's browser-like defaults (a given key replaces the default one).
|
|
119
123
|
|
|
120
124
|
Returns:
|
|
121
125
|
``output_path``.
|
|
122
126
|
"""
|
|
127
|
+
# Browser headers for the cover CDN's edge — see the same comment in epub.py.
|
|
123
128
|
async with httpx.AsyncClient(
|
|
124
|
-
timeout=15.0,
|
|
129
|
+
timeout=15.0,
|
|
130
|
+
follow_redirects=True,
|
|
131
|
+
transport=self._transport,
|
|
132
|
+
headers=browser_headers(headers),
|
|
125
133
|
) as client:
|
|
126
134
|
cover_url = pick_cover_url(title.cover)
|
|
127
135
|
cover_data_uri = None
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
from collections.abc import Callable
|
|
5
|
+
from collections.abc import Callable, Mapping
|
|
6
6
|
from html.parser import HTMLParser
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
from typing import ClassVar
|
|
@@ -77,6 +77,7 @@ class TxtExporter:
|
|
|
77
77
|
output_path: Path,
|
|
78
78
|
*,
|
|
79
79
|
on_chapter: Callable[[], None] | None = None,
|
|
80
|
+
headers: Mapping[str, str] | None = None,
|
|
80
81
|
) -> Path:
|
|
81
82
|
"""Write ``chapters`` to ``output_path`` as plain text.
|
|
82
83
|
|
|
@@ -85,6 +86,8 @@ class TxtExporter:
|
|
|
85
86
|
chapters: The chapters to include, in the order they should appear.
|
|
86
87
|
output_path: Where to write the ``.txt`` file.
|
|
87
88
|
on_chapter: Called once per chapter written, if given.
|
|
89
|
+
headers: Accepted for the ``Exporter`` protocol and ignored — this format
|
|
90
|
+
downloads nothing (no illustrations in txt, see CLAUDE.md).
|
|
88
91
|
|
|
89
92
|
Returns:
|
|
90
93
|
``output_path``.
|
|
@@ -463,6 +463,11 @@ class Title(BaseModel):
|
|
|
463
463
|
artists: list[Person] = Field(default_factory=list)
|
|
464
464
|
teams: list[Team] = Field(default_factory=list)
|
|
465
465
|
chapter_count: int | None = None
|
|
466
|
+
last_chapter_at: datetime | None = Field(default=None, alias="last_item_at")
|
|
467
|
+
"""When the most recent chapter was published — the value the catalog's
|
|
468
|
+
``sort="last_chapter_at"`` orders by. Only ``RanobeLib.get_info()`` fills it in: the
|
|
469
|
+
catalog listing endpoint doesn't return it (see docs/api-notes.md), so it's ``None`` on
|
|
470
|
+
``Catalog.list_titles()`` items, and on a title with no chapters yet."""
|
|
466
471
|
|
|
467
472
|
@model_validator(mode="before")
|
|
468
473
|
@classmethod
|
|
@@ -581,8 +586,9 @@ class CatalogPage(BaseModel):
|
|
|
581
586
|
"""One page of catalog listing/search results, as returned by ``Catalog.list_titles()``.
|
|
582
587
|
|
|
583
588
|
``items`` reuses ``Title`` as-is — a catalog list item has every field ``Title`` requires,
|
|
584
|
-
the ones it doesn't send (``genres``, ``summary``, ``chapter_count``,
|
|
585
|
-
at their defaults, same as any other partially-populated ``Title``
|
|
589
|
+
the ones it doesn't send (``genres``, ``summary``, ``chapter_count``, ``last_chapter_at``,
|
|
590
|
+
...) just come back at their defaults, same as any other partially-populated ``Title``
|
|
591
|
+
(see docs/api-notes.md).
|
|
586
592
|
"""
|
|
587
593
|
|
|
588
594
|
items: list[Title]
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
|
-
from collections.abc import Callable
|
|
6
|
+
from collections.abc import Callable, Mapping
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
from types import TracebackType
|
|
9
9
|
from typing import Any, Self
|
|
@@ -48,6 +48,7 @@ _INFO_FIELDS = [
|
|
|
48
48
|
"authors",
|
|
49
49
|
"artists",
|
|
50
50
|
"chap_count",
|
|
51
|
+
"last_item_at",
|
|
51
52
|
]
|
|
52
53
|
|
|
53
54
|
|
|
@@ -68,6 +69,7 @@ class RanobeLib:
|
|
|
68
69
|
cache_dir: str | Path | None = None,
|
|
69
70
|
cache_ttl: float | None = None,
|
|
70
71
|
verbosity: Verbosity = False,
|
|
72
|
+
headers: Mapping[str, str] | None = None,
|
|
71
73
|
) -> None:
|
|
72
74
|
"""Initialize the SDK for a title.
|
|
73
75
|
|
|
@@ -85,9 +87,18 @@ class RanobeLib:
|
|
|
85
87
|
``"full"``: the same progress bars, plus a line logged for every title/
|
|
86
88
|
chapter-list/chapter-content fetch, noting whether it was served from the
|
|
87
89
|
disk cache or the API.
|
|
90
|
+
headers: Extra HTTP headers merged over the SDK's defaults (browser-like
|
|
91
|
+
``Origin``/``Referer``/``User-Agent``, see docs/api-notes.md, section "WAF
|
|
92
|
+
403") — a key given here replaces the default one, matched
|
|
93
|
+
case-insensitively. Applied to every API request and, through ``export()``,
|
|
94
|
+
to epub/pdf illustration downloads. Meant for when the site's edge protection
|
|
95
|
+
changes which requests it lets through (``AccessBlockedError``): send what it
|
|
96
|
+
now expects without waiting for an SDK release. ``None`` (the default) sends
|
|
97
|
+
the defaults as-is.
|
|
88
98
|
"""
|
|
89
99
|
self._slug_url = parse_slug_url(url)
|
|
90
|
-
self.
|
|
100
|
+
self._headers = dict(headers) if headers is not None else None
|
|
101
|
+
self._client = ApiClient(headers=self._headers)
|
|
91
102
|
self._cache = DiskCache(
|
|
92
103
|
cache_dir if cache_dir is not None else DEFAULT_CACHE_DIR, ttl=cache_ttl
|
|
93
104
|
)
|
|
@@ -457,7 +468,8 @@ class RanobeLib:
|
|
|
457
468
|
chapters: The chapters to include, in the order they should appear.
|
|
458
469
|
path: Where to write the exported file.
|
|
459
470
|
fmt: Export format — a key of ``ranobelib.exporters.EXPORTERS``
|
|
460
|
-
(currently: ``"txt"``, ``"fb2"``, ``"epub"``
|
|
471
|
+
(currently: ``"txt"``, ``"fb2"``, ``"epub"``, and ``"pdf"`` where
|
|
472
|
+
WeasyPrint's native libraries are available).
|
|
461
473
|
|
|
462
474
|
Raises:
|
|
463
475
|
ValueError: If ``fmt`` isn't a registered export format.
|
|
@@ -468,7 +480,9 @@ class RanobeLib:
|
|
|
468
480
|
raise ValueError(f"Unknown export format {fmt!r}. Available: {available}")
|
|
469
481
|
title = await self.get_info()
|
|
470
482
|
with self._reporter.progress(f"Exporting to {fmt}", len(chapters)) as advance:
|
|
471
|
-
return await exporter_cls().export(
|
|
483
|
+
return await exporter_cls().export(
|
|
484
|
+
title, chapters, Path(path), on_chapter=advance, headers=self._headers
|
|
485
|
+
)
|
|
472
486
|
|
|
473
487
|
async def _build_volume(self, volume: int, raw_chapters: list[dict[str, Any]]) -> Volume:
|
|
474
488
|
volume_str = str(volume)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ranobelib_python_sdk-0.10.0 → ranobelib_python_sdk-0.12.0}/src/ranobelib/exporters/_shared.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|