ranobelib-python-sdk 0.11.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.11.0 → ranobelib_python_sdk-0.12.0}/PKG-INFO +1 -1
- {ranobelib_python_sdk-0.11.0 → ranobelib_python_sdk-0.12.0}/pyproject.toml +1 -1
- {ranobelib_python_sdk-0.11.0 → ranobelib_python_sdk-0.12.0}/pyproject.toml.orig +1 -1
- {ranobelib_python_sdk-0.11.0 → ranobelib_python_sdk-0.12.0}/src/ranobelib/__init__.py +2 -0
- {ranobelib_python_sdk-0.11.0 → ranobelib_python_sdk-0.12.0}/src/ranobelib/catalog.py +70 -10
- {ranobelib_python_sdk-0.11.0 → ranobelib_python_sdk-0.12.0}/src/ranobelib/client.py +31 -4
- {ranobelib_python_sdk-0.11.0 → ranobelib_python_sdk-0.12.0}/src/ranobelib/models.py +8 -2
- {ranobelib_python_sdk-0.11.0 → ranobelib_python_sdk-0.12.0}/src/ranobelib/sdk.py +1 -0
- {ranobelib_python_sdk-0.11.0 → ranobelib_python_sdk-0.12.0}/README.md +0 -0
- {ranobelib_python_sdk-0.11.0 → ranobelib_python_sdk-0.12.0}/src/ranobelib/_http.py +0 -0
- {ranobelib_python_sdk-0.11.0 → ranobelib_python_sdk-0.12.0}/src/ranobelib/cache.py +0 -0
- {ranobelib_python_sdk-0.11.0 → ranobelib_python_sdk-0.12.0}/src/ranobelib/console.py +0 -0
- {ranobelib_python_sdk-0.11.0 → ranobelib_python_sdk-0.12.0}/src/ranobelib/exceptions.py +0 -0
- {ranobelib_python_sdk-0.11.0 → ranobelib_python_sdk-0.12.0}/src/ranobelib/exporters/__init__.py +0 -0
- {ranobelib_python_sdk-0.11.0 → ranobelib_python_sdk-0.12.0}/src/ranobelib/exporters/_illustrations.py +0 -0
- {ranobelib_python_sdk-0.11.0 → ranobelib_python_sdk-0.12.0}/src/ranobelib/exporters/_shared.py +0 -0
- {ranobelib_python_sdk-0.11.0 → ranobelib_python_sdk-0.12.0}/src/ranobelib/exporters/epub.py +0 -0
- {ranobelib_python_sdk-0.11.0 → ranobelib_python_sdk-0.12.0}/src/ranobelib/exporters/fb2.py +0 -0
- {ranobelib_python_sdk-0.11.0 → ranobelib_python_sdk-0.12.0}/src/ranobelib/exporters/pdf.py +0 -0
- {ranobelib_python_sdk-0.11.0 → ranobelib_python_sdk-0.12.0}/src/ranobelib/exporters/txt.py +0 -0
- {ranobelib_python_sdk-0.11.0 → ranobelib_python_sdk-0.12.0}/src/ranobelib/numbering.py +0 -0
- {ranobelib_python_sdk-0.11.0 → ranobelib_python_sdk-0.12.0}/src/ranobelib/py.typed +0 -0
- {ranobelib_python_sdk-0.11.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
|
|
@@ -21,6 +21,7 @@ from ranobelib.models import (
|
|
|
21
21
|
Country,
|
|
22
22
|
Footnote,
|
|
23
23
|
Genre,
|
|
24
|
+
Label,
|
|
24
25
|
Title,
|
|
25
26
|
Volume,
|
|
26
27
|
)
|
|
@@ -40,6 +41,7 @@ __all__ = [
|
|
|
40
41
|
"DownloadTitleInterruptedError",
|
|
41
42
|
"Footnote",
|
|
42
43
|
"Genre",
|
|
44
|
+
"Label",
|
|
43
45
|
"MultipleTitleTranslationsError",
|
|
44
46
|
"MultipleTranslationsError",
|
|
45
47
|
"RanobeLib",
|
|
@@ -14,7 +14,7 @@ from typing import Any, Self
|
|
|
14
14
|
|
|
15
15
|
from ranobelib.cache import DEFAULT_CACHE_DIR, DiskCache
|
|
16
16
|
from ranobelib.client import RANOBELIB_SITE_ID, ApiClient
|
|
17
|
-
from ranobelib.models import CatalogPage, Country, Genre, Title
|
|
17
|
+
from ranobelib.models import CatalogPage, Country, Genre, Label, Title
|
|
18
18
|
|
|
19
19
|
DEFAULT_SORT = "last_chapter_at"
|
|
20
20
|
"""Default sort order: title's last-chapter timestamp — the closest real equivalent to
|
|
@@ -30,7 +30,7 @@ class Catalog:
|
|
|
30
30
|
Example:
|
|
31
31
|
```python
|
|
32
32
|
async with Catalog() as catalog:
|
|
33
|
-
page = await catalog.list_titles(query="dxd", genres=[34],
|
|
33
|
+
page = await catalog.list_titles(query="dxd", genres=[34], statuses=[1])
|
|
34
34
|
for title in page.items:
|
|
35
35
|
print(title.name)
|
|
36
36
|
```
|
|
@@ -84,8 +84,10 @@ class Catalog:
|
|
|
84
84
|
query: str | None = None,
|
|
85
85
|
genres: list[int] | None = None,
|
|
86
86
|
tags: list[int] | None = None,
|
|
87
|
-
|
|
87
|
+
statuses: list[int] | None = None,
|
|
88
88
|
countries: list[int] | None = None,
|
|
89
|
+
min_chapters: int | None = None,
|
|
90
|
+
max_chapters: int | None = None,
|
|
89
91
|
sort: str = DEFAULT_SORT,
|
|
90
92
|
refresh: bool = False,
|
|
91
93
|
) -> CatalogPage:
|
|
@@ -107,7 +109,10 @@ class Catalog:
|
|
|
107
109
|
against the live API, see docs/api-notes.md; not the OR some might expect
|
|
108
110
|
from tags being more numerous/specific than genres). Also not validated
|
|
109
111
|
before sending, same as ``genres``.
|
|
110
|
-
|
|
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()``.
|
|
111
116
|
countries: ``Country.id``s to filter by. A title matches if its own country is
|
|
112
117
|
*any* of these (OR, not AND like ``genres``/``tags`` — a title only has one
|
|
113
118
|
country of origin, so requiring all of them could never match past the first,
|
|
@@ -116,6 +121,11 @@ class Catalog:
|
|
|
116
121
|
parameter, not a ``country``/``countries[]`` one — see docs/api-notes.md for
|
|
117
122
|
why (``Country`` mirrors the API's own "type" concept, which isn't strictly
|
|
118
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.
|
|
119
129
|
sort: Sort order. Despite the name, this is sent as the API's ``sort_by``
|
|
120
130
|
parameter — an actual ``sort`` parameter exists but is silently ignored by
|
|
121
131
|
the API (see docs/api-notes.md). Known accepted values: ``"name"``,
|
|
@@ -130,8 +140,9 @@ class Catalog:
|
|
|
130
140
|
page exists.
|
|
131
141
|
|
|
132
142
|
Raises:
|
|
133
|
-
ValueError: If ``page`` is less than 1,
|
|
134
|
-
``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``.
|
|
135
146
|
"""
|
|
136
147
|
if page < 1:
|
|
137
148
|
raise ValueError(f"page must be at least 1, got {page}.")
|
|
@@ -139,6 +150,16 @@ class Catalog:
|
|
|
139
150
|
raise ValueError(
|
|
140
151
|
f"per_page must be between {MIN_PER_PAGE} and {MAX_PER_PAGE}, got {per_page}."
|
|
141
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
|
+
)
|
|
142
163
|
|
|
143
164
|
key = _cache_key(
|
|
144
165
|
page=page,
|
|
@@ -146,8 +167,10 @@ class Catalog:
|
|
|
146
167
|
query=query,
|
|
147
168
|
genres=genres,
|
|
148
169
|
tags=tags,
|
|
149
|
-
|
|
170
|
+
statuses=statuses,
|
|
150
171
|
countries=countries,
|
|
172
|
+
min_chapters=min_chapters,
|
|
173
|
+
max_chapters=max_chapters,
|
|
151
174
|
sort=sort,
|
|
152
175
|
)
|
|
153
176
|
if not refresh:
|
|
@@ -161,8 +184,10 @@ class Catalog:
|
|
|
161
184
|
query=query,
|
|
162
185
|
genres=genres,
|
|
163
186
|
tags=tags,
|
|
164
|
-
|
|
187
|
+
statuses=statuses,
|
|
165
188
|
countries=countries,
|
|
189
|
+
min_chapters=min_chapters,
|
|
190
|
+
max_chapters=max_chapters,
|
|
166
191
|
sort=sort,
|
|
167
192
|
)
|
|
168
193
|
self._cache.set(key, data)
|
|
@@ -223,6 +248,33 @@ class Catalog:
|
|
|
223
248
|
self._cache.set(key, data)
|
|
224
249
|
return _build_countries(data)
|
|
225
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
|
+
|
|
226
278
|
|
|
227
279
|
def _cache_key(
|
|
228
280
|
*,
|
|
@@ -231,16 +283,19 @@ def _cache_key(
|
|
|
231
283
|
query: str | None,
|
|
232
284
|
genres: list[int] | None,
|
|
233
285
|
tags: list[int] | None,
|
|
234
|
-
|
|
286
|
+
statuses: list[int] | None,
|
|
235
287
|
countries: list[int] | None,
|
|
288
|
+
min_chapters: int | None,
|
|
289
|
+
max_chapters: int | None,
|
|
236
290
|
sort: str,
|
|
237
291
|
) -> str:
|
|
238
292
|
genres_part = ",".join(str(genre_id) for genre_id in genres or [])
|
|
239
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 [])
|
|
240
295
|
countries_part = ",".join(str(country_id) for country_id in countries or [])
|
|
241
296
|
return (
|
|
242
297
|
f"catalog:{page}:{per_page}:{query or ''}:{genres_part}:{tags_part}:"
|
|
243
|
-
f"{
|
|
298
|
+
f"{statuses_part}:{countries_part}:{min_chapters}:{max_chapters}:{sort}"
|
|
244
299
|
)
|
|
245
300
|
|
|
246
301
|
|
|
@@ -258,3 +313,8 @@ def _build_genres(data: list[dict[str, Any]]) -> list[Genre]:
|
|
|
258
313
|
def _build_countries(data: list[dict[str, Any]]) -> list[Country]:
|
|
259
314
|
site_id = int(RANOBELIB_SITE_ID)
|
|
260
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", [])]
|
|
@@ -189,8 +189,10 @@ class ApiClient:
|
|
|
189
189
|
query: str | None,
|
|
190
190
|
genres: list[int] | None,
|
|
191
191
|
tags: list[int] | None,
|
|
192
|
-
|
|
192
|
+
statuses: list[int] | None,
|
|
193
193
|
countries: list[int] | None,
|
|
194
|
+
min_chapters: int | None,
|
|
195
|
+
max_chapters: int | None,
|
|
194
196
|
sort: str,
|
|
195
197
|
) -> dict[str, Any]:
|
|
196
198
|
"""Fetch one page of the catalog listing/search results.
|
|
@@ -209,12 +211,16 @@ class ApiClient:
|
|
|
209
211
|
see docs/api-notes.md), not just any one.
|
|
210
212
|
tags: Tag ids to filter by. Same AND semantics as ``genres`` — a title must have
|
|
211
213
|
*all* of them, not just any one (see docs/api-notes.md).
|
|
212
|
-
|
|
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.
|
|
213
217
|
countries: ``Country.id``s to filter by. A title matches if its own country is
|
|
214
218
|
*any* of these (OR, not AND like ``genres``/``tags`` — a title only has one
|
|
215
219
|
country, confirmed against the live API, see docs/api-notes.md). Sent on the
|
|
216
220
|
wire as repeated ``types[]`` parameters — the API's own name for this concept
|
|
217
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``.
|
|
218
224
|
sort: Forwarded as the API's ``sort_by`` parameter, not ``sort`` — the API
|
|
219
225
|
silently ignores an actual ``sort`` parameter (see docs/api-notes.md); this
|
|
220
226
|
mismatch between the SDK's public keyword and the wire parameter name is
|
|
@@ -235,10 +241,14 @@ class ApiClient:
|
|
|
235
241
|
params.append(("genres[]", str(genre_id)))
|
|
236
242
|
for tag_id in tags or []:
|
|
237
243
|
params.append(("tags[]", str(tag_id)))
|
|
238
|
-
|
|
239
|
-
params.append(("status[]", str(
|
|
244
|
+
for status_id in statuses or []:
|
|
245
|
+
params.append(("status[]", str(status_id)))
|
|
240
246
|
for country_id in countries or []:
|
|
241
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)))
|
|
242
252
|
|
|
243
253
|
response = await self._get("/manga", params=httpx.QueryParams(params))
|
|
244
254
|
self._raise_for_status(
|
|
@@ -283,6 +293,23 @@ class ApiClient:
|
|
|
283
293
|
data: list[dict[str, Any]] = response.json()["data"]["types"]
|
|
284
294
|
return data
|
|
285
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
|
+
|
|
286
313
|
async def _get(self, url: str, *, params: Any = None) -> httpx.Response:
|
|
287
314
|
"""Issue a GET request, bounded by concurrency/pacing and retried on 429/5xx."""
|
|
288
315
|
attempt = 0
|
|
@@ -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]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ranobelib_python_sdk-0.11.0 → ranobelib_python_sdk-0.12.0}/src/ranobelib/exporters/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{ranobelib_python_sdk-0.11.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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|