ranobelib-python-sdk 0.6.1__tar.gz → 0.8.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.6.1 → ranobelib_python_sdk-0.8.0}/PKG-INFO +1 -1
- {ranobelib_python_sdk-0.6.1 → ranobelib_python_sdk-0.8.0}/pyproject.toml +1 -1
- {ranobelib_python_sdk-0.6.1 → ranobelib_python_sdk-0.8.0}/pyproject.toml.orig +1 -1
- {ranobelib_python_sdk-0.6.1 → ranobelib_python_sdk-0.8.0}/src/ranobelib/__init__.py +3 -1
- {ranobelib_python_sdk-0.6.1 → ranobelib_python_sdk-0.8.0}/src/ranobelib/catalog.py +104 -8
- {ranobelib_python_sdk-0.6.1 → ranobelib_python_sdk-0.8.0}/src/ranobelib/client.py +46 -0
- {ranobelib_python_sdk-0.6.1 → ranobelib_python_sdk-0.8.0}/src/ranobelib/models.py +20 -0
- {ranobelib_python_sdk-0.6.1 → ranobelib_python_sdk-0.8.0}/README.md +0 -0
- {ranobelib_python_sdk-0.6.1 → ranobelib_python_sdk-0.8.0}/src/ranobelib/cache.py +0 -0
- {ranobelib_python_sdk-0.6.1 → ranobelib_python_sdk-0.8.0}/src/ranobelib/console.py +0 -0
- {ranobelib_python_sdk-0.6.1 → ranobelib_python_sdk-0.8.0}/src/ranobelib/exceptions.py +0 -0
- {ranobelib_python_sdk-0.6.1 → ranobelib_python_sdk-0.8.0}/src/ranobelib/exporters/__init__.py +0 -0
- {ranobelib_python_sdk-0.6.1 → ranobelib_python_sdk-0.8.0}/src/ranobelib/exporters/_illustrations.py +0 -0
- {ranobelib_python_sdk-0.6.1 → ranobelib_python_sdk-0.8.0}/src/ranobelib/exporters/_shared.py +0 -0
- {ranobelib_python_sdk-0.6.1 → ranobelib_python_sdk-0.8.0}/src/ranobelib/exporters/epub.py +0 -0
- {ranobelib_python_sdk-0.6.1 → ranobelib_python_sdk-0.8.0}/src/ranobelib/exporters/fb2.py +0 -0
- {ranobelib_python_sdk-0.6.1 → ranobelib_python_sdk-0.8.0}/src/ranobelib/exporters/pdf.py +0 -0
- {ranobelib_python_sdk-0.6.1 → ranobelib_python_sdk-0.8.0}/src/ranobelib/exporters/txt.py +0 -0
- {ranobelib_python_sdk-0.6.1 → ranobelib_python_sdk-0.8.0}/src/ranobelib/numbering.py +0 -0
- {ranobelib_python_sdk-0.6.1 → ranobelib_python_sdk-0.8.0}/src/ranobelib/py.typed +0 -0
- {ranobelib_python_sdk-0.6.1 → ranobelib_python_sdk-0.8.0}/src/ranobelib/sdk.py +0 -0
- {ranobelib_python_sdk-0.6.1 → ranobelib_python_sdk-0.8.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.8.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
|
|
@@ -13,7 +13,7 @@ from ranobelib.exceptions import (
|
|
|
13
13
|
TitleNotFoundError,
|
|
14
14
|
VolumeNotFoundError,
|
|
15
15
|
)
|
|
16
|
-
from ranobelib.models import CatalogPage, Chapter, ChapterBranch, Title, Volume
|
|
16
|
+
from ranobelib.models import CatalogPage, Chapter, ChapterBranch, Country, Genre, Title, Volume
|
|
17
17
|
from ranobelib.sdk import RanobeLib
|
|
18
18
|
from ranobelib.sizing import chapter_size, volume_size
|
|
19
19
|
|
|
@@ -25,7 +25,9 @@ __all__ = [
|
|
|
25
25
|
"Chapter",
|
|
26
26
|
"ChapterBranch",
|
|
27
27
|
"ChapterNotFoundError",
|
|
28
|
+
"Country",
|
|
28
29
|
"DownloadTitleInterruptedError",
|
|
30
|
+
"Genre",
|
|
29
31
|
"MultipleTitleTranslationsError",
|
|
30
32
|
"MultipleTranslationsError",
|
|
31
33
|
"RanobeLib",
|
|
@@ -12,8 +12,8 @@ from types import TracebackType
|
|
|
12
12
|
from typing import Any, Self
|
|
13
13
|
|
|
14
14
|
from ranobelib.cache import DEFAULT_CACHE_DIR, DiskCache
|
|
15
|
-
from ranobelib.client import ApiClient
|
|
16
|
-
from ranobelib.models import CatalogPage, Title
|
|
15
|
+
from ranobelib.client import RANOBELIB_SITE_ID, ApiClient
|
|
16
|
+
from ranobelib.models import CatalogPage, Country, Genre, Title
|
|
17
17
|
|
|
18
18
|
DEFAULT_SORT = "last_chapter_at"
|
|
19
19
|
"""Default sort order: title's last-chapter timestamp — the closest real equivalent to
|
|
@@ -79,7 +79,9 @@ class Catalog:
|
|
|
79
79
|
per_page: int = 30,
|
|
80
80
|
query: str | None = None,
|
|
81
81
|
genres: list[int] | None = None,
|
|
82
|
+
tags: list[int] | None = None,
|
|
82
83
|
status: int | None = None,
|
|
84
|
+
country: int | None = None,
|
|
83
85
|
sort: str = DEFAULT_SORT,
|
|
84
86
|
refresh: bool = False,
|
|
85
87
|
) -> CatalogPage:
|
|
@@ -94,10 +96,19 @@ class Catalog:
|
|
|
94
96
|
query: Free-text search term, matched against name/rus_name/eng_name. ``None``
|
|
95
97
|
(the default) or an empty string lists without searching.
|
|
96
98
|
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).
|
|
98
|
-
|
|
99
|
-
|
|
99
|
+
(AND, not OR — see docs/api-notes.md). Not validated against ``list_genres()``
|
|
100
|
+
before sending — an unrecognized id just matches nothing rather than erroring.
|
|
101
|
+
tags: ``Tag.id``s (from ``Title.tags``) to filter by. Same AND semantics as
|
|
102
|
+
``genres`` — a title must have *all* of them, not just any one (confirmed
|
|
103
|
+
against the live API, see docs/api-notes.md; not the OR some might expect
|
|
104
|
+
from tags being more numerous/specific than genres). Also not validated
|
|
105
|
+
before sending, same as ``genres``.
|
|
100
106
|
status: A single ``Title.status.id`` to filter by (e.g. ongoing vs. completed).
|
|
107
|
+
country: A single ``Country.id`` to filter by — a title only has one country of
|
|
108
|
+
origin, unlike ``genres``. Ids come from ``list_countries()``. Sent on the
|
|
109
|
+
wire as the API's ``types[]`` parameter, not a ``country``/``countries[]``
|
|
110
|
+
one — see docs/api-notes.md for why (``Country`` mirrors the API's own
|
|
111
|
+
"type" concept, which isn't strictly limited to literal countries).
|
|
101
112
|
sort: Sort order. Despite the name, this is sent as the API's ``sort_by``
|
|
102
113
|
parameter — an actual ``sort`` parameter exists but is silently ignored by
|
|
103
114
|
the API (see docs/api-notes.md). Known accepted values: ``"name"``,
|
|
@@ -123,7 +134,14 @@ class Catalog:
|
|
|
123
134
|
)
|
|
124
135
|
|
|
125
136
|
key = _cache_key(
|
|
126
|
-
page=page,
|
|
137
|
+
page=page,
|
|
138
|
+
per_page=per_page,
|
|
139
|
+
query=query,
|
|
140
|
+
genres=genres,
|
|
141
|
+
tags=tags,
|
|
142
|
+
status=status,
|
|
143
|
+
country=country,
|
|
144
|
+
sort=sort,
|
|
127
145
|
)
|
|
128
146
|
if not refresh:
|
|
129
147
|
cached = self._cache.get(key)
|
|
@@ -131,11 +149,73 @@ class Catalog:
|
|
|
131
149
|
return _build_page(cached)
|
|
132
150
|
|
|
133
151
|
data = await self._client.list_titles(
|
|
134
|
-
page=page,
|
|
152
|
+
page=page,
|
|
153
|
+
per_page=per_page,
|
|
154
|
+
query=query,
|
|
155
|
+
genres=genres,
|
|
156
|
+
tags=tags,
|
|
157
|
+
status=status,
|
|
158
|
+
country=country,
|
|
159
|
+
sort=sort,
|
|
135
160
|
)
|
|
136
161
|
self._cache.set(key, data)
|
|
137
162
|
return _build_page(data)
|
|
138
163
|
|
|
164
|
+
async def list_genres(self, *, refresh: bool = False) -> list[Genre]:
|
|
165
|
+
"""Fetch the full list of catalog genres (id → name), for use as filter options
|
|
166
|
+
with ``list_titles(genres=[...])``.
|
|
167
|
+
|
|
168
|
+
The underlying endpoint (``GET /api/constants?fields[]=genres``) has no site-scoping
|
|
169
|
+
parameter — it returns the full genre list shared across the whole lib.social
|
|
170
|
+
network in one shot, each tagged with the site ids it applies to (see
|
|
171
|
+
docs/api-notes.md). This filters that down to genres tagged for ranobelib.me before
|
|
172
|
+
returning, so callers don't get filter options (e.g. "Детское") that could never
|
|
173
|
+
match any ranobelib title.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
refresh: Bypass the disk cache and re-fetch from the API even if the genre list
|
|
177
|
+
was already cached.
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
Every ``Genre`` (id, name, adult flag) that applies to ranobelib.me.
|
|
181
|
+
"""
|
|
182
|
+
key = "catalog:genres"
|
|
183
|
+
if not refresh:
|
|
184
|
+
cached = self._cache.get(key)
|
|
185
|
+
if cached is not None:
|
|
186
|
+
return _build_genres(cached)
|
|
187
|
+
|
|
188
|
+
data = await self._client.list_genres()
|
|
189
|
+
self._cache.set(key, data)
|
|
190
|
+
return _build_genres(data)
|
|
191
|
+
|
|
192
|
+
async def list_countries(self, *, refresh: bool = False) -> list[Country]:
|
|
193
|
+
"""Fetch the full list of catalog countries (id → name), for use as filter options
|
|
194
|
+
with ``list_titles(country=...)``.
|
|
195
|
+
|
|
196
|
+
Mirrors ``list_genres()`` exactly, down to the network-wide, not-site-scoped shape
|
|
197
|
+
of the underlying endpoint (``GET /api/constants?fields[]=types`` — the API's own
|
|
198
|
+
name for what this SDK calls "country" is "type", see docs/api-notes.md and
|
|
199
|
+
``Country``'s docstring). This filters that down to entries tagged for ranobelib.me
|
|
200
|
+
before returning, same as ``list_genres()`` does for genres.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
refresh: Bypass the disk cache and re-fetch from the API even if the country
|
|
204
|
+
list was already cached.
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
Every ``Country`` (id, name) that applies to ranobelib.me.
|
|
208
|
+
"""
|
|
209
|
+
key = "catalog:countries"
|
|
210
|
+
if not refresh:
|
|
211
|
+
cached = self._cache.get(key)
|
|
212
|
+
if cached is not None:
|
|
213
|
+
return _build_countries(cached)
|
|
214
|
+
|
|
215
|
+
data = await self._client.list_countries()
|
|
216
|
+
self._cache.set(key, data)
|
|
217
|
+
return _build_countries(data)
|
|
218
|
+
|
|
139
219
|
|
|
140
220
|
def _cache_key(
|
|
141
221
|
*,
|
|
@@ -143,14 +223,30 @@ def _cache_key(
|
|
|
143
223
|
per_page: int,
|
|
144
224
|
query: str | None,
|
|
145
225
|
genres: list[int] | None,
|
|
226
|
+
tags: list[int] | None,
|
|
146
227
|
status: int | None,
|
|
228
|
+
country: int | None,
|
|
147
229
|
sort: str,
|
|
148
230
|
) -> str:
|
|
149
231
|
genres_part = ",".join(str(genre_id) for genre_id in genres or [])
|
|
150
|
-
|
|
232
|
+
tags_part = ",".join(str(tag_id) for tag_id in tags or [])
|
|
233
|
+
return (
|
|
234
|
+
f"catalog:{page}:{per_page}:{query or ''}:{genres_part}:{tags_part}:"
|
|
235
|
+
f"{status}:{country}:{sort}"
|
|
236
|
+
)
|
|
151
237
|
|
|
152
238
|
|
|
153
239
|
def _build_page(data: dict[str, Any]) -> CatalogPage:
|
|
154
240
|
items = [Title.model_validate(item) for item in data["data"]]
|
|
155
241
|
meta = data["meta"]
|
|
156
242
|
return CatalogPage(items=items, page=meta["current_page"], has_next_page=meta["has_next_page"])
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _build_genres(data: list[dict[str, Any]]) -> list[Genre]:
|
|
246
|
+
site_id = int(RANOBELIB_SITE_ID)
|
|
247
|
+
return [Genre.model_validate(item) for item in data if site_id in item.get("site_ids", [])]
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _build_countries(data: list[dict[str, Any]]) -> list[Country]:
|
|
251
|
+
site_id = int(RANOBELIB_SITE_ID)
|
|
252
|
+
return [Country.model_validate(item) for item in data if site_id in item.get("site_ids", [])]
|
|
@@ -170,7 +170,9 @@ class ApiClient:
|
|
|
170
170
|
per_page: int,
|
|
171
171
|
query: str | None,
|
|
172
172
|
genres: list[int] | None,
|
|
173
|
+
tags: list[int] | None,
|
|
173
174
|
status: int | None,
|
|
175
|
+
country: int | None,
|
|
174
176
|
sort: str,
|
|
175
177
|
) -> dict[str, Any]:
|
|
176
178
|
"""Fetch one page of the catalog listing/search results.
|
|
@@ -187,7 +189,12 @@ class ApiClient:
|
|
|
187
189
|
or empty omits the ``q`` parameter entirely.
|
|
188
190
|
genres: Genre ids to filter by. A title must have *all* of them (AND, not OR —
|
|
189
191
|
see docs/api-notes.md), not just any one.
|
|
192
|
+
tags: Tag ids to filter by. Same AND semantics as ``genres`` — a title must have
|
|
193
|
+
*all* of them, not just any one (see docs/api-notes.md).
|
|
190
194
|
status: A single ``Title.status.id`` to filter by.
|
|
195
|
+
country: A single ``Country.id`` to filter by. Sent on the wire as ``types[]``
|
|
196
|
+
(repeatable array parameter, but only ever given one value here) — the API's
|
|
197
|
+
own name for this concept is "type", not "country" (see docs/api-notes.md).
|
|
191
198
|
sort: Forwarded as the API's ``sort_by`` parameter, not ``sort`` — the API
|
|
192
199
|
silently ignores an actual ``sort`` parameter (see docs/api-notes.md); this
|
|
193
200
|
mismatch between the SDK's public keyword and the wire parameter name is
|
|
@@ -206,8 +213,12 @@ class ApiClient:
|
|
|
206
213
|
params.append(("q", query))
|
|
207
214
|
for genre_id in genres or []:
|
|
208
215
|
params.append(("genres[]", str(genre_id)))
|
|
216
|
+
for tag_id in tags or []:
|
|
217
|
+
params.append(("tags[]", str(tag_id)))
|
|
209
218
|
if status is not None:
|
|
210
219
|
params.append(("status[]", str(status)))
|
|
220
|
+
if country is not None:
|
|
221
|
+
params.append(("types[]", str(country)))
|
|
211
222
|
|
|
212
223
|
response = await self._get("/manga", params=httpx.QueryParams(params))
|
|
213
224
|
self._raise_for_status(
|
|
@@ -217,6 +228,41 @@ class ApiClient:
|
|
|
217
228
|
result: dict[str, Any] = response.json()
|
|
218
229
|
return result
|
|
219
230
|
|
|
231
|
+
async def list_genres(self) -> list[dict[str, Any]]:
|
|
232
|
+
"""Fetch the raw genre list shared across the whole lib.social network.
|
|
233
|
+
|
|
234
|
+
Unlike ``list_titles``, this endpoint isn't site-scoped by any request parameter
|
|
235
|
+
(confirmed: a ``Site-Id`` header changes nothing about the response) — it returns
|
|
236
|
+
every genre known to the network at once, each tagged with the site ids it applies
|
|
237
|
+
to via a ``site_ids`` field. Callers must filter to ``RANOBELIB_SITE_ID`` themselves
|
|
238
|
+
(see ``Catalog.list_genres``, which drops e.g. site-5-only genres like "Детское"
|
|
239
|
+
before returning).
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
The raw ``data.genres`` array from the API response, unfiltered by site.
|
|
243
|
+
"""
|
|
244
|
+
response = await self._get("/constants", params=httpx.QueryParams([("fields[]", "genres")]))
|
|
245
|
+
self._raise_for_status(response, not_found=RanobeLibError("Unexpected 404 from genre list"))
|
|
246
|
+
data: list[dict[str, Any]] = response.json()["data"]["genres"]
|
|
247
|
+
return data
|
|
248
|
+
|
|
249
|
+
async def list_countries(self) -> list[dict[str, Any]]:
|
|
250
|
+
"""Fetch the raw country/type list shared across the whole lib.social network.
|
|
251
|
+
|
|
252
|
+
Same shape and caveats as ``list_genres``: not site-scoped by any request parameter,
|
|
253
|
+
returns every "type" known to the network at once, each tagged with the site ids it
|
|
254
|
+
applies to via ``site_ids``. Named ``list_countries``/``fields[]=types`` mismatch is
|
|
255
|
+
deliberate — see ``Catalog.list_countries`` and docs/api-notes.md for why this reuses
|
|
256
|
+
the API's own "type" concept rather than the unrelated ``fields[]=countries`` field.
|
|
257
|
+
|
|
258
|
+
Returns:
|
|
259
|
+
The raw ``data.types`` array from the API response, unfiltered by site.
|
|
260
|
+
"""
|
|
261
|
+
response = await self._get("/constants", params=httpx.QueryParams([("fields[]", "types")]))
|
|
262
|
+
self._raise_for_status(response, not_found=RanobeLibError("Unexpected 404 from type list"))
|
|
263
|
+
data: list[dict[str, Any]] = response.json()["data"]["types"]
|
|
264
|
+
return data
|
|
265
|
+
|
|
220
266
|
async def _get(self, url: str, *, params: Any = None) -> httpx.Response:
|
|
221
267
|
"""Issue a GET request, bounded by concurrency/pacing and retried on 429/5xx."""
|
|
222
268
|
attempt = 0
|
|
@@ -206,6 +206,25 @@ class Tag(BaseModel):
|
|
|
206
206
|
adult: bool = False
|
|
207
207
|
|
|
208
208
|
|
|
209
|
+
class Country(BaseModel):
|
|
210
|
+
"""A title's country/region of origin.
|
|
211
|
+
|
|
212
|
+
Despite the name (matching issue #48's requested public shape), this maps onto what the
|
|
213
|
+
raw API itself calls "type" (`Title`'s raw `type` field; the catalog filter's `types[]`
|
|
214
|
+
parameter; `GET /api/constants?fields[]=types`) — not a `country`/`countries[]`/
|
|
215
|
+
`fields[]=countries` concept, which turned out to be something unrelated (see
|
|
216
|
+
docs/api-notes.md). For ranobelib.me specifically, the values are three literal
|
|
217
|
+
countries (Japan, Korea, China) plus three additional non-national origin categories the
|
|
218
|
+
site groups the same way: original English-language work, original (non-translated) web
|
|
219
|
+
novel, and fanfiction — surfaced as-is, not filtered down to "real" countries only.
|
|
220
|
+
"""
|
|
221
|
+
|
|
222
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
223
|
+
|
|
224
|
+
id: int
|
|
225
|
+
name: str = Field(alias="label")
|
|
226
|
+
|
|
227
|
+
|
|
209
228
|
class Person(BaseModel):
|
|
210
229
|
"""An author or artist credited on a title."""
|
|
211
230
|
|
|
@@ -243,6 +262,7 @@ class Title(BaseModel):
|
|
|
243
262
|
summary: str | None = None
|
|
244
263
|
release_date: str | None = Field(default=None, alias="releaseDate")
|
|
245
264
|
is_licensed: bool = False
|
|
265
|
+
country: Country | None = Field(default=None, alias="type")
|
|
246
266
|
genres: list[Genre] = Field(default_factory=list)
|
|
247
267
|
tags: list[Tag] = Field(default_factory=list)
|
|
248
268
|
authors: list[Person] = Field(default_factory=list)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ranobelib_python_sdk-0.6.1 → ranobelib_python_sdk-0.8.0}/src/ranobelib/exporters/__init__.py
RENAMED
|
File without changes
|
{ranobelib_python_sdk-0.6.1 → ranobelib_python_sdk-0.8.0}/src/ranobelib/exporters/_illustrations.py
RENAMED
|
File without changes
|
{ranobelib_python_sdk-0.6.1 → ranobelib_python_sdk-0.8.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
|