ranobelib-python-sdk 0.5.0__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.5.0 → ranobelib_python_sdk-0.6.0}/PKG-INFO +1 -1
- {ranobelib_python_sdk-0.5.0 → ranobelib_python_sdk-0.6.0}/pyproject.toml +1 -1
- {ranobelib_python_sdk-0.5.0 → ranobelib_python_sdk-0.6.0}/pyproject.toml.orig +1 -1
- {ranobelib_python_sdk-0.5.0 → 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.5.0 → ranobelib_python_sdk-0.6.0}/src/ranobelib/client.py +54 -0
- {ranobelib_python_sdk-0.5.0 → ranobelib_python_sdk-0.6.0}/src/ranobelib/models.py +13 -0
- {ranobelib_python_sdk-0.5.0 → ranobelib_python_sdk-0.6.0}/README.md +0 -0
- {ranobelib_python_sdk-0.5.0 → ranobelib_python_sdk-0.6.0}/src/ranobelib/cache.py +0 -0
- {ranobelib_python_sdk-0.5.0 → ranobelib_python_sdk-0.6.0}/src/ranobelib/console.py +0 -0
- {ranobelib_python_sdk-0.5.0 → ranobelib_python_sdk-0.6.0}/src/ranobelib/exceptions.py +0 -0
- {ranobelib_python_sdk-0.5.0 → ranobelib_python_sdk-0.6.0}/src/ranobelib/exporters/__init__.py +0 -0
- {ranobelib_python_sdk-0.5.0 → ranobelib_python_sdk-0.6.0}/src/ranobelib/exporters/_illustrations.py +0 -0
- {ranobelib_python_sdk-0.5.0 → ranobelib_python_sdk-0.6.0}/src/ranobelib/exporters/_shared.py +0 -0
- {ranobelib_python_sdk-0.5.0 → ranobelib_python_sdk-0.6.0}/src/ranobelib/exporters/epub.py +0 -0
- {ranobelib_python_sdk-0.5.0 → ranobelib_python_sdk-0.6.0}/src/ranobelib/exporters/fb2.py +0 -0
- {ranobelib_python_sdk-0.5.0 → ranobelib_python_sdk-0.6.0}/src/ranobelib/exporters/pdf.py +0 -0
- {ranobelib_python_sdk-0.5.0 → ranobelib_python_sdk-0.6.0}/src/ranobelib/exporters/txt.py +0 -0
- {ranobelib_python_sdk-0.5.0 → ranobelib_python_sdk-0.6.0}/src/ranobelib/numbering.py +0 -0
- {ranobelib_python_sdk-0.5.0 → ranobelib_python_sdk-0.6.0}/src/ranobelib/py.typed +0 -0
- {ranobelib_python_sdk-0.5.0 → ranobelib_python_sdk-0.6.0}/src/ranobelib/sdk.py +0 -0
- {ranobelib_python_sdk-0.5.0 → 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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ranobelib_python_sdk-0.5.0 → ranobelib_python_sdk-0.6.0}/src/ranobelib/exporters/__init__.py
RENAMED
|
File without changes
|
{ranobelib_python_sdk-0.5.0 → ranobelib_python_sdk-0.6.0}/src/ranobelib/exporters/_illustrations.py
RENAMED
|
File without changes
|
{ranobelib_python_sdk-0.5.0 → 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
|
|
File without changes
|