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.
Files changed (23) hide show
  1. {ranobelib_python_sdk-0.10.0 → ranobelib_python_sdk-0.12.0}/PKG-INFO +1 -1
  2. {ranobelib_python_sdk-0.10.0 → ranobelib_python_sdk-0.12.0}/pyproject.toml +1 -1
  3. {ranobelib_python_sdk-0.10.0 → ranobelib_python_sdk-0.12.0}/pyproject.toml.orig +1 -1
  4. {ranobelib_python_sdk-0.10.0 → ranobelib_python_sdk-0.12.0}/src/ranobelib/__init__.py +4 -0
  5. ranobelib_python_sdk-0.12.0/src/ranobelib/_http.py +45 -0
  6. {ranobelib_python_sdk-0.10.0 → ranobelib_python_sdk-0.12.0}/src/ranobelib/catalog.py +75 -11
  7. {ranobelib_python_sdk-0.10.0 → ranobelib_python_sdk-0.12.0}/src/ranobelib/client.py +56 -5
  8. {ranobelib_python_sdk-0.10.0 → ranobelib_python_sdk-0.12.0}/src/ranobelib/exceptions.py +28 -0
  9. {ranobelib_python_sdk-0.10.0 → ranobelib_python_sdk-0.12.0}/src/ranobelib/exporters/__init__.py +6 -1
  10. {ranobelib_python_sdk-0.10.0 → ranobelib_python_sdk-0.12.0}/src/ranobelib/exporters/epub.py +11 -2
  11. {ranobelib_python_sdk-0.10.0 → ranobelib_python_sdk-0.12.0}/src/ranobelib/exporters/fb2.py +4 -1
  12. {ranobelib_python_sdk-0.10.0 → ranobelib_python_sdk-0.12.0}/src/ranobelib/exporters/pdf.py +10 -2
  13. {ranobelib_python_sdk-0.10.0 → ranobelib_python_sdk-0.12.0}/src/ranobelib/exporters/txt.py +4 -1
  14. {ranobelib_python_sdk-0.10.0 → ranobelib_python_sdk-0.12.0}/src/ranobelib/models.py +8 -2
  15. {ranobelib_python_sdk-0.10.0 → ranobelib_python_sdk-0.12.0}/src/ranobelib/sdk.py +18 -4
  16. {ranobelib_python_sdk-0.10.0 → ranobelib_python_sdk-0.12.0}/README.md +0 -0
  17. {ranobelib_python_sdk-0.10.0 → ranobelib_python_sdk-0.12.0}/src/ranobelib/cache.py +0 -0
  18. {ranobelib_python_sdk-0.10.0 → ranobelib_python_sdk-0.12.0}/src/ranobelib/console.py +0 -0
  19. {ranobelib_python_sdk-0.10.0 → ranobelib_python_sdk-0.12.0}/src/ranobelib/exporters/_illustrations.py +0 -0
  20. {ranobelib_python_sdk-0.10.0 → ranobelib_python_sdk-0.12.0}/src/ranobelib/exporters/_shared.py +0 -0
  21. {ranobelib_python_sdk-0.10.0 → ranobelib_python_sdk-0.12.0}/src/ranobelib/numbering.py +0 -0
  22. {ranobelib_python_sdk-0.10.0 → ranobelib_python_sdk-0.12.0}/src/ranobelib/py.typed +0 -0
  23. {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.10.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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ranobelib-python-sdk"
3
- version = "0.10.0"
3
+ version = "0.12.0"
4
4
  description = "Async Python SDK for ranobelib.me: title metadata, chapter/volume downloads, and export to epub/fb2/txt/pdf"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ranobelib-python-sdk"
3
- version = "0.10.0"
3
+ version = "0.12.0"
4
4
  description = "Async Python SDK for ranobelib.me: title metadata, chapter/volume downloads, and export to epub/fb2/txt/pdf"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -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], status=1)
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
- status: int | None = None,
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
- status: A single ``Title.status.id`` to filter by (e.g. ongoing vs. completed).
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, or ``per_page`` is outside
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
- status=status,
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
- status=status,
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
- status: int | None,
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"{status}:{countries_part}:{sort}"
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
- status: int | None,
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
- status: A single ``Title.status.id`` to filter by.
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
- if status is not None:
221
- params.append(("status[]", str(status)))
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
 
@@ -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, follow_redirects=True, transport=self._transport
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, follow_redirects=True, transport=self._transport
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``, ...) just come back
585
- at their defaults, same as any other partially-populated ``Title`` (see docs/api-notes.md).
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._client = ApiClient()
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(title, chapters, Path(path), on_chapter=advance)
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)