ranobelib-python-sdk 0.4.1__tar.gz → 0.6.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.4.1 → ranobelib_python_sdk-0.6.0}/PKG-INFO +1 -1
- {ranobelib_python_sdk-0.4.1 → ranobelib_python_sdk-0.6.0}/pyproject.toml +1 -1
- {ranobelib_python_sdk-0.4.1 → ranobelib_python_sdk-0.6.0}/pyproject.toml.orig +1 -1
- {ranobelib_python_sdk-0.4.1 → ranobelib_python_sdk-0.6.0}/src/ranobelib/__init__.py +4 -1
- ranobelib_python_sdk-0.6.0/src/ranobelib/catalog.py +156 -0
- {ranobelib_python_sdk-0.4.1 → ranobelib_python_sdk-0.6.0}/src/ranobelib/client.py +54 -0
- {ranobelib_python_sdk-0.4.1 → ranobelib_python_sdk-0.6.0}/src/ranobelib/models.py +13 -0
- {ranobelib_python_sdk-0.4.1 → ranobelib_python_sdk-0.6.0}/src/ranobelib/sdk.py +13 -2
- {ranobelib_python_sdk-0.4.1 → ranobelib_python_sdk-0.6.0}/README.md +0 -0
- {ranobelib_python_sdk-0.4.1 → ranobelib_python_sdk-0.6.0}/src/ranobelib/cache.py +0 -0
- {ranobelib_python_sdk-0.4.1 → ranobelib_python_sdk-0.6.0}/src/ranobelib/console.py +0 -0
- {ranobelib_python_sdk-0.4.1 → ranobelib_python_sdk-0.6.0}/src/ranobelib/exceptions.py +0 -0
- {ranobelib_python_sdk-0.4.1 → ranobelib_python_sdk-0.6.0}/src/ranobelib/exporters/__init__.py +0 -0
- {ranobelib_python_sdk-0.4.1 → ranobelib_python_sdk-0.6.0}/src/ranobelib/exporters/_illustrations.py +0 -0
- {ranobelib_python_sdk-0.4.1 → ranobelib_python_sdk-0.6.0}/src/ranobelib/exporters/_shared.py +0 -0
- {ranobelib_python_sdk-0.4.1 → ranobelib_python_sdk-0.6.0}/src/ranobelib/exporters/epub.py +0 -0
- {ranobelib_python_sdk-0.4.1 → ranobelib_python_sdk-0.6.0}/src/ranobelib/exporters/fb2.py +0 -0
- {ranobelib_python_sdk-0.4.1 → ranobelib_python_sdk-0.6.0}/src/ranobelib/exporters/pdf.py +0 -0
- {ranobelib_python_sdk-0.4.1 → ranobelib_python_sdk-0.6.0}/src/ranobelib/exporters/txt.py +0 -0
- {ranobelib_python_sdk-0.4.1 → ranobelib_python_sdk-0.6.0}/src/ranobelib/numbering.py +0 -0
- {ranobelib_python_sdk-0.4.1 → ranobelib_python_sdk-0.6.0}/src/ranobelib/py.typed +0 -0
- {ranobelib_python_sdk-0.4.1 → ranobelib_python_sdk-0.6.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.6.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,5 +1,6 @@
|
|
|
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,
|
|
@@ -11,13 +12,15 @@ from ranobelib.exceptions import (
|
|
|
11
12
|
TitleNotFoundError,
|
|
12
13
|
VolumeNotFoundError,
|
|
13
14
|
)
|
|
14
|
-
from ranobelib.models import Chapter, ChapterBranch, Title, Volume
|
|
15
|
+
from ranobelib.models import CatalogPage, Chapter, ChapterBranch, Title, Volume
|
|
15
16
|
from ranobelib.sdk import RanobeLib
|
|
16
17
|
from ranobelib.sizing import chapter_size, volume_size
|
|
17
18
|
|
|
18
19
|
__all__ = [
|
|
19
20
|
"AmbiguousChapter",
|
|
20
21
|
"AuthRequiredError",
|
|
22
|
+
"Catalog",
|
|
23
|
+
"CatalogPage",
|
|
21
24
|
"Chapter",
|
|
22
25
|
"ChapterBranch",
|
|
23
26
|
"ChapterNotFoundError",
|
|
@@ -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
|
|
@@ -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
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
|
+
from collections.abc import Callable
|
|
6
7
|
from pathlib import Path
|
|
7
8
|
from types import TracebackType
|
|
8
9
|
from typing import Any, Self
|
|
@@ -233,6 +234,7 @@ class RanobeLib:
|
|
|
233
234
|
branch_id: int | None = None,
|
|
234
235
|
translation_index: int | None = None,
|
|
235
236
|
chapter_delay: float = 0.0,
|
|
237
|
+
on_chapter: Callable[[int, int], None] | None = None,
|
|
236
238
|
) -> list[Volume]:
|
|
237
239
|
"""Download every chapter of the title, across all its volumes.
|
|
238
240
|
|
|
@@ -258,6 +260,12 @@ class RanobeLib:
|
|
|
258
260
|
chapter_delay: Extra delay, in seconds, after fetching each chapter, on top of
|
|
259
261
|
``ApiClient``'s own per-request pacing — to be gentler on the API during a
|
|
260
262
|
large bulk download. Defaults to no extra delay.
|
|
263
|
+
on_chapter: Called with ``(completed, total)`` after each chapter is fetched, if
|
|
264
|
+
given — a programmatic progress hook for callers that aren't printing to a
|
|
265
|
+
console (e.g. a web app polling/streaming download progress to a browser),
|
|
266
|
+
who would otherwise have no way to observe anything before this whole
|
|
267
|
+
``await`` returns. Independent of ``verbosity``: both can be set at once,
|
|
268
|
+
each drives its own output.
|
|
261
269
|
|
|
262
270
|
Raises:
|
|
263
271
|
ValueError: If both ``branch_id`` and ``translation_index`` are given.
|
|
@@ -297,11 +305,14 @@ class RanobeLib:
|
|
|
297
305
|
raise MultipleTitleTranslationsError(self._slug_url, chapters=unresolved)
|
|
298
306
|
|
|
299
307
|
chapters = []
|
|
300
|
-
|
|
308
|
+
total = len(planned)
|
|
309
|
+
with self._reporter.progress(f"Downloading {self._slug_url}", total) as advance:
|
|
301
310
|
for index, (volume_str, number, selected_branch_id) in enumerate(planned):
|
|
302
311
|
chapters.append(await self._fetch_chapter(volume_str, number, selected_branch_id))
|
|
303
312
|
advance()
|
|
304
|
-
if
|
|
313
|
+
if on_chapter is not None:
|
|
314
|
+
on_chapter(index + 1, total)
|
|
315
|
+
if chapter_delay and index < total - 1:
|
|
305
316
|
await asyncio.sleep(chapter_delay)
|
|
306
317
|
return _group_into_volumes(chapters)
|
|
307
318
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ranobelib_python_sdk-0.4.1 → ranobelib_python_sdk-0.6.0}/src/ranobelib/exporters/__init__.py
RENAMED
|
File without changes
|
{ranobelib_python_sdk-0.4.1 → ranobelib_python_sdk-0.6.0}/src/ranobelib/exporters/_illustrations.py
RENAMED
|
File without changes
|
{ranobelib_python_sdk-0.4.1 → ranobelib_python_sdk-0.6.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
|