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.
Files changed (22) hide show
  1. {ranobelib_python_sdk-0.6.1 → ranobelib_python_sdk-0.8.0}/PKG-INFO +1 -1
  2. {ranobelib_python_sdk-0.6.1 → ranobelib_python_sdk-0.8.0}/pyproject.toml +1 -1
  3. {ranobelib_python_sdk-0.6.1 → ranobelib_python_sdk-0.8.0}/pyproject.toml.orig +1 -1
  4. {ranobelib_python_sdk-0.6.1 → ranobelib_python_sdk-0.8.0}/src/ranobelib/__init__.py +3 -1
  5. {ranobelib_python_sdk-0.6.1 → ranobelib_python_sdk-0.8.0}/src/ranobelib/catalog.py +104 -8
  6. {ranobelib_python_sdk-0.6.1 → ranobelib_python_sdk-0.8.0}/src/ranobelib/client.py +46 -0
  7. {ranobelib_python_sdk-0.6.1 → ranobelib_python_sdk-0.8.0}/src/ranobelib/models.py +20 -0
  8. {ranobelib_python_sdk-0.6.1 → ranobelib_python_sdk-0.8.0}/README.md +0 -0
  9. {ranobelib_python_sdk-0.6.1 → ranobelib_python_sdk-0.8.0}/src/ranobelib/cache.py +0 -0
  10. {ranobelib_python_sdk-0.6.1 → ranobelib_python_sdk-0.8.0}/src/ranobelib/console.py +0 -0
  11. {ranobelib_python_sdk-0.6.1 → ranobelib_python_sdk-0.8.0}/src/ranobelib/exceptions.py +0 -0
  12. {ranobelib_python_sdk-0.6.1 → ranobelib_python_sdk-0.8.0}/src/ranobelib/exporters/__init__.py +0 -0
  13. {ranobelib_python_sdk-0.6.1 → ranobelib_python_sdk-0.8.0}/src/ranobelib/exporters/_illustrations.py +0 -0
  14. {ranobelib_python_sdk-0.6.1 → ranobelib_python_sdk-0.8.0}/src/ranobelib/exporters/_shared.py +0 -0
  15. {ranobelib_python_sdk-0.6.1 → ranobelib_python_sdk-0.8.0}/src/ranobelib/exporters/epub.py +0 -0
  16. {ranobelib_python_sdk-0.6.1 → ranobelib_python_sdk-0.8.0}/src/ranobelib/exporters/fb2.py +0 -0
  17. {ranobelib_python_sdk-0.6.1 → ranobelib_python_sdk-0.8.0}/src/ranobelib/exporters/pdf.py +0 -0
  18. {ranobelib_python_sdk-0.6.1 → ranobelib_python_sdk-0.8.0}/src/ranobelib/exporters/txt.py +0 -0
  19. {ranobelib_python_sdk-0.6.1 → ranobelib_python_sdk-0.8.0}/src/ranobelib/numbering.py +0 -0
  20. {ranobelib_python_sdk-0.6.1 → ranobelib_python_sdk-0.8.0}/src/ranobelib/py.typed +0 -0
  21. {ranobelib_python_sdk-0.6.1 → ranobelib_python_sdk-0.8.0}/src/ranobelib/sdk.py +0 -0
  22. {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.6.1
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ranobelib-python-sdk"
3
- version = "0.6.1"
3
+ version = "0.8.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.6.1"
3
+ version = "0.8.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"
@@ -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). 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.
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, per_page=per_page, query=query, genres=genres, status=status, sort=sort
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, per_page=per_page, query=query, genres=genres, status=status, sort=sort
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
- return f"catalog:{page}:{per_page}:{query or ''}:{genres_part}:{status}:{sort}"
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)