ranobelib-python-sdk 0.1.0__tar.gz → 0.3.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 (19) hide show
  1. {ranobelib_python_sdk-0.1.0 → ranobelib_python_sdk-0.3.0}/PKG-INFO +15 -3
  2. {ranobelib_python_sdk-0.1.0 → ranobelib_python_sdk-0.3.0}/README.md +13 -2
  3. {ranobelib_python_sdk-0.1.0 → ranobelib_python_sdk-0.3.0}/pyproject.toml +2 -1
  4. {ranobelib_python_sdk-0.1.0 → ranobelib_python_sdk-0.3.0}/pyproject.toml.orig +2 -1
  5. {ranobelib_python_sdk-0.1.0 → ranobelib_python_sdk-0.3.0}/src/ranobelib/__init__.py +4 -0
  6. {ranobelib_python_sdk-0.1.0 → ranobelib_python_sdk-0.3.0}/src/ranobelib/exceptions.py +49 -1
  7. {ranobelib_python_sdk-0.1.0 → ranobelib_python_sdk-0.3.0}/src/ranobelib/sdk.py +97 -0
  8. {ranobelib_python_sdk-0.1.0 → ranobelib_python_sdk-0.3.0}/src/ranobelib/cache.py +0 -0
  9. {ranobelib_python_sdk-0.1.0 → ranobelib_python_sdk-0.3.0}/src/ranobelib/client.py +0 -0
  10. {ranobelib_python_sdk-0.1.0 → ranobelib_python_sdk-0.3.0}/src/ranobelib/exporters/__init__.py +0 -0
  11. {ranobelib_python_sdk-0.1.0 → ranobelib_python_sdk-0.3.0}/src/ranobelib/exporters/_illustrations.py +0 -0
  12. {ranobelib_python_sdk-0.1.0 → ranobelib_python_sdk-0.3.0}/src/ranobelib/exporters/_shared.py +0 -0
  13. {ranobelib_python_sdk-0.1.0 → ranobelib_python_sdk-0.3.0}/src/ranobelib/exporters/epub.py +0 -0
  14. {ranobelib_python_sdk-0.1.0 → ranobelib_python_sdk-0.3.0}/src/ranobelib/exporters/fb2.py +0 -0
  15. {ranobelib_python_sdk-0.1.0 → ranobelib_python_sdk-0.3.0}/src/ranobelib/exporters/pdf.py +0 -0
  16. {ranobelib_python_sdk-0.1.0 → ranobelib_python_sdk-0.3.0}/src/ranobelib/exporters/txt.py +0 -0
  17. {ranobelib_python_sdk-0.1.0 → ranobelib_python_sdk-0.3.0}/src/ranobelib/models.py +0 -0
  18. {ranobelib_python_sdk-0.1.0 → ranobelib_python_sdk-0.3.0}/src/ranobelib/numbering.py +0 -0
  19. {ranobelib_python_sdk-0.1.0 → ranobelib_python_sdk-0.3.0}/src/ranobelib/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ranobelib-python-sdk
3
- Version: 0.1.0
3
+ Version: 0.3.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
@@ -23,6 +23,7 @@ Requires-Python: >=3.11
23
23
  Project-URL: Homepage, https://github.com/LLIJIYAPNIK/ranobelib-python-sdk
24
24
  Project-URL: Repository, https://github.com/LLIJIYAPNIK/ranobelib-python-sdk
25
25
  Project-URL: Issues, https://github.com/LLIJIYAPNIK/ranobelib-python-sdk/issues
26
+ Project-URL: Changelog, https://github.com/LLIJIYAPNIK/ranobelib-python-sdk/blob/main/CHANGELOG.md
26
27
  Description-Content-Type: text/markdown
27
28
 
28
29
  # ranobelib-python-sdk
@@ -41,7 +42,8 @@ The SDK talks to the undocumented but open JSON API behind the site (`api.cdnlib
41
42
  part of the lib.social network) instead of scraping HTML.
42
43
 
43
44
  > **Status:** all the calls in the quickstart below are implemented — metadata, table of
44
- > contents, single/bulk chapter and volume downloads, translation selection, and export to
45
+ > contents, single/bulk chapter and volume downloads, whole-title bulk download, translation
46
+ > selection, and export to
45
47
  > `"txt"`, `"fb2"`, `"epub"`, or `"pdf"` (cover + in-chapter illustrations embedded in
46
48
  > epub/pdf). Raw API responses are cached to disk, and the underlying client bounds
47
49
  > concurrency, paces requests, and retries 429/5xx with backoff. `fmt="pdf"` needs
@@ -83,17 +85,27 @@ async with RanobeLib("https://ranobelib.me/ru/book/6712--high-school-dxd-novel")
83
85
  translations = await lib.get_translations(volume=6, number="51.6")
84
86
  chapter = await lib.get_chapter(volume=6, number="51.6", branch_id=...)
85
87
 
88
+ all_volumes = await lib.download_title(translation_index=0, chapter_delay=0.5)
89
+
86
90
  await lib.export(chapters, fmt="epub", path="output.epub")
87
91
  ```
88
92
 
89
93
  This is the full current public API — everything above is implemented and works today.
90
94
 
95
+ ## Examples
96
+
97
+ Complete, runnable scripts for every feature above — title metadata, table of contents,
98
+ single/bulk chapter and volume downloads, translation selection, whole-title bulk download,
99
+ disk caching, export to all four formats, and error handling — live in
100
+ **[`examples/`](examples/)**, one heavily commented file per feature. Start there for
101
+ copy-pasteable code.
102
+
91
103
  ## Documentation
92
104
 
93
105
  Full API reference and guides: **<https://LLIJIYAPNIK.github.io/ranobelib-python-sdk/>**
94
106
  (built with `mkdocs-material` + `mkdocstrings`, deployed on push to `main`). `CLAUDE.md`
95
107
  documents the full architecture, public API contract, and the reasoning behind the SDK's
96
- design decisions.
108
+ design decisions. [`CHANGELOG.md`](CHANGELOG.md) lists what changed in each release.
97
109
 
98
110
  ## License
99
111
 
@@ -14,7 +14,8 @@ The SDK talks to the undocumented but open JSON API behind the site (`api.cdnlib
14
14
  part of the lib.social network) instead of scraping HTML.
15
15
 
16
16
  > **Status:** all the calls in the quickstart below are implemented — metadata, table of
17
- > contents, single/bulk chapter and volume downloads, translation selection, and export to
17
+ > contents, single/bulk chapter and volume downloads, whole-title bulk download, translation
18
+ > selection, and export to
18
19
  > `"txt"`, `"fb2"`, `"epub"`, or `"pdf"` (cover + in-chapter illustrations embedded in
19
20
  > epub/pdf). Raw API responses are cached to disk, and the underlying client bounds
20
21
  > concurrency, paces requests, and retries 429/5xx with backoff. `fmt="pdf"` needs
@@ -56,17 +57,27 @@ async with RanobeLib("https://ranobelib.me/ru/book/6712--high-school-dxd-novel")
56
57
  translations = await lib.get_translations(volume=6, number="51.6")
57
58
  chapter = await lib.get_chapter(volume=6, number="51.6", branch_id=...)
58
59
 
60
+ all_volumes = await lib.download_title(translation_index=0, chapter_delay=0.5)
61
+
59
62
  await lib.export(chapters, fmt="epub", path="output.epub")
60
63
  ```
61
64
 
62
65
  This is the full current public API — everything above is implemented and works today.
63
66
 
67
+ ## Examples
68
+
69
+ Complete, runnable scripts for every feature above — title metadata, table of contents,
70
+ single/bulk chapter and volume downloads, translation selection, whole-title bulk download,
71
+ disk caching, export to all four formats, and error handling — live in
72
+ **[`examples/`](examples/)**, one heavily commented file per feature. Start there for
73
+ copy-pasteable code.
74
+
64
75
  ## Documentation
65
76
 
66
77
  Full API reference and guides: **<https://LLIJIYAPNIK.github.io/ranobelib-python-sdk/>**
67
78
  (built with `mkdocs-material` + `mkdocstrings`, deployed on push to `main`). `CLAUDE.md`
68
79
  documents the full architecture, public API contract, and the reasoning behind the SDK's
69
- design decisions.
80
+ design decisions. [`CHANGELOG.md`](CHANGELOG.md) lists what changed in each release.
70
81
 
71
82
  ## License
72
83
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ranobelib-python-sdk"
3
- version = "0.1.0"
3
+ version = "0.3.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"
@@ -40,6 +40,7 @@ email = "kas4wot@yandex.ru"
40
40
  Homepage = "https://github.com/LLIJIYAPNIK/ranobelib-python-sdk"
41
41
  Repository = "https://github.com/LLIJIYAPNIK/ranobelib-python-sdk"
42
42
  Issues = "https://github.com/LLIJIYAPNIK/ranobelib-python-sdk/issues"
43
+ Changelog = "https://github.com/LLIJIYAPNIK/ranobelib-python-sdk/blob/main/CHANGELOG.md"
43
44
 
44
45
  [build-system]
45
46
  requires = ["uv_build>=0.11.26,<0.12.0"]
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ranobelib-python-sdk"
3
- version = "0.1.0"
3
+ version = "0.3.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"
@@ -31,6 +31,7 @@ dependencies = [
31
31
  Homepage = "https://github.com/LLIJIYAPNIK/ranobelib-python-sdk"
32
32
  Repository = "https://github.com/LLIJIYAPNIK/ranobelib-python-sdk"
33
33
  Issues = "https://github.com/LLIJIYAPNIK/ranobelib-python-sdk/issues"
34
+ Changelog = "https://github.com/LLIJIYAPNIK/ranobelib-python-sdk/blob/main/CHANGELOG.md"
34
35
 
35
36
  [build-system]
36
37
  requires = ["uv_build>=0.11.26,<0.12.0"]
@@ -1,8 +1,10 @@
1
1
  """Async Python SDK for ranobelib.me."""
2
2
 
3
3
  from ranobelib.exceptions import (
4
+ AmbiguousChapter,
4
5
  AuthRequiredError,
5
6
  ChapterNotFoundError,
7
+ MultipleTitleTranslationsError,
6
8
  MultipleTranslationsError,
7
9
  RanobeLibError,
8
10
  RateLimitError,
@@ -13,10 +15,12 @@ from ranobelib.models import Chapter, ChapterBranch, Title, Volume
13
15
  from ranobelib.sdk import RanobeLib
14
16
 
15
17
  __all__ = [
18
+ "AmbiguousChapter",
16
19
  "AuthRequiredError",
17
20
  "Chapter",
18
21
  "ChapterBranch",
19
22
  "ChapterNotFoundError",
23
+ "MultipleTitleTranslationsError",
20
24
  "MultipleTranslationsError",
21
25
  "RanobeLib",
22
26
  "RanobeLibError",
@@ -2,7 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from typing import TYPE_CHECKING
5
+ from typing import TYPE_CHECKING, NamedTuple
6
6
 
7
7
  if TYPE_CHECKING:
8
8
  from ranobelib.models import ChapterBranch
@@ -93,6 +93,54 @@ def _describe_branch(branch: ChapterBranch) -> str:
93
93
  return branch.user.username
94
94
 
95
95
 
96
+ class AmbiguousChapter(NamedTuple):
97
+ """One chapter that ``RanobeLib.download_title()`` couldn't resolve a translation for.
98
+
99
+ Attributes:
100
+ volume: The chapter's volume number.
101
+ number: The chapter number.
102
+ branches: The chapter's available translations, as returned by
103
+ ``RanobeLib.get_translations()``.
104
+ """
105
+
106
+ volume: str
107
+ number: str
108
+ branches: list[ChapterBranch]
109
+
110
+
111
+ class MultipleTitleTranslationsError(RanobeLibError):
112
+ """Raised by ``download_title()`` when one or more chapters have an unresolved translation.
113
+
114
+ Unlike ``MultipleTranslationsError`` (one chapter), this can list several: a title-wide
115
+ bulk download checks every chapter up front rather than stopping at the first ambiguous
116
+ one, so a caller doesn't have to fix one chapter, rerun, hit the next, and rerun again.
117
+ Pass ``branch_id`` or ``translation_index`` to ``download_title()`` to resolve them, or
118
+ fetch the listed chapters individually with ``get_chapter(..., branch_id=...)``.
119
+
120
+ Attributes:
121
+ slug_url: The title's ``{id}--{slug}`` identifier.
122
+ chapters: The ambiguous chapters and their available translations.
123
+ """
124
+
125
+ def __init__(self, slug_url: str, *, chapters: list[AmbiguousChapter]) -> None:
126
+ self.slug_url = slug_url
127
+ self.chapters = chapters
128
+ lines = [
129
+ f" volume={chapter.volume!r} number={chapter.number!r}: "
130
+ + ", ".join(
131
+ f"branch_id={branch.branch_id} ({_describe_branch(branch)})"
132
+ for branch in chapter.branches
133
+ )
134
+ for chapter in chapters
135
+ ]
136
+ super().__init__(
137
+ f"{len(chapters)} chapter(s) in {slug_url!r} have more than one translation "
138
+ "unresolved by the given branch_id/translation_index. Pass branch_id or "
139
+ "translation_index to download_title() to select one for every chapter listed, "
140
+ "or fetch them individually with get_chapter(..., branch_id=...):\n" + "\n".join(lines)
141
+ )
142
+
143
+
96
144
  class AuthRequiredError(RanobeLibError):
97
145
  """Raised when the requested content requires authorization (paid or early access).
98
146
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import asyncio
5
6
  from pathlib import Path
6
7
  from types import TracebackType
7
8
  from typing import Any, Self
@@ -9,7 +10,9 @@ from typing import Any, Self
9
10
  from ranobelib.cache import DEFAULT_CACHE_DIR, DiskCache
10
11
  from ranobelib.client import ApiClient
11
12
  from ranobelib.exceptions import (
13
+ AmbiguousChapter,
12
14
  ChapterNotFoundError,
15
+ MultipleTitleTranslationsError,
13
16
  MultipleTranslationsError,
14
17
  VolumeNotFoundError,
15
18
  )
@@ -215,6 +218,82 @@ class RanobeLib:
215
218
  raw_chapters = await self._get_chapters()
216
219
  return [await self._build_volume(volume, raw_chapters) for volume in volumes]
217
220
 
221
+ async def download_title(
222
+ self,
223
+ *,
224
+ branch_id: int | None = None,
225
+ translation_index: int | None = None,
226
+ chapter_delay: float = 0.0,
227
+ ) -> list[Volume]:
228
+ """Download every chapter of the title, across all its volumes.
229
+
230
+ Fetches the chapter list once, then every chapter's content, sequentially, in the
231
+ API's own order — same pacing/retry/backoff as any other chapter fetch (see
232
+ ``ApiClient``), plus ``chapter_delay`` on top if given.
233
+
234
+ Resolving translations is checked for the whole title up front, not chapter by
235
+ chapter: if any chapters have more than one translation, this fetches nothing and
236
+ raises ``MultipleTitleTranslationsError`` listing *all* of them at once (unless
237
+ ``branch_id`` or ``translation_index`` resolves every one of them), rather than
238
+ failing partway through a long download on the first ambiguous chapter.
239
+
240
+ Args:
241
+ branch_id: Translation to use for every chapter that has more than one — same
242
+ meaning as ``get_chapter(..., branch_id=...)``, applied title-wide. Chapters
243
+ with only one translation are unaffected. Mutually exclusive with
244
+ ``translation_index``.
245
+ translation_index: Alternative to ``branch_id``: pick the translation at this
246
+ position in each ambiguous chapter's ``branches`` list (``0`` for the first,
247
+ and so on) — useful when ambiguous chapters don't share one ``branch_id``
248
+ across the title. Mutually exclusive with ``branch_id``.
249
+ chapter_delay: Extra delay, in seconds, after fetching each chapter, on top of
250
+ ``ApiClient``'s own per-request pacing — to be gentler on the API during a
251
+ large bulk download. Defaults to no extra delay.
252
+
253
+ Raises:
254
+ ValueError: If both ``branch_id`` and ``translation_index`` are given.
255
+ MultipleTitleTranslationsError: If the title has one or more chapters with more
256
+ than one translation that ``branch_id``/``translation_index`` didn't resolve.
257
+ """
258
+ if branch_id is not None and translation_index is not None:
259
+ raise ValueError("Pass at most one of branch_id or translation_index, not both.")
260
+
261
+ raw_chapters = await self._get_chapters()
262
+
263
+ planned: list[tuple[str, str, int | None]] = []
264
+ unresolved: list[AmbiguousChapter] = []
265
+ for item in raw_chapters:
266
+ volume_str: str = item["volume"]
267
+ number: str = item["number"]
268
+ branches = item.get("branches") or []
269
+ if len(branches) <= 1:
270
+ planned.append((volume_str, number, None))
271
+ continue
272
+
273
+ resolved, selected = _resolve_bulk_branch_id(
274
+ branches, branch_id=branch_id, translation_index=translation_index
275
+ )
276
+ if resolved:
277
+ planned.append((volume_str, number, selected))
278
+ else:
279
+ unresolved.append(
280
+ AmbiguousChapter(
281
+ volume=volume_str,
282
+ number=number,
283
+ branches=[ChapterBranch.model_validate(branch) for branch in branches],
284
+ )
285
+ )
286
+
287
+ if unresolved:
288
+ raise MultipleTitleTranslationsError(self._slug_url, chapters=unresolved)
289
+
290
+ chapters = []
291
+ for index, (volume_str, number, selected_branch_id) in enumerate(planned):
292
+ chapters.append(await self._fetch_chapter(volume_str, number, selected_branch_id))
293
+ if chapter_delay and index < len(planned) - 1:
294
+ await asyncio.sleep(chapter_delay)
295
+ return _group_into_volumes(chapters)
296
+
218
297
  async def export(self, chapters: list[Chapter], *, fmt: str, path: str | Path) -> Path:
219
298
  """Export chapters to a file.
220
299
 
@@ -307,6 +386,24 @@ def _group_into_volumes(chapters: list[Chapter]) -> list[Volume]:
307
386
  return [Volume(number=number, chapters=items) for number, items in grouped.items()]
308
387
 
309
388
 
389
+ def _resolve_bulk_branch_id(
390
+ branches: list[dict[str, Any]], *, branch_id: int | None, translation_index: int | None
391
+ ) -> tuple[bool, int | None]:
392
+ """Resolve which branch_id to request for one ambiguous chapter in ``download_title()``.
393
+
394
+ Returns ``(True, branch_id)`` if resolved, ``(False, None)`` if ``branch_id``/
395
+ ``translation_index`` doesn't identify one of this chapter's branches.
396
+ """
397
+ if branch_id is not None:
398
+ for branch in branches:
399
+ if branch.get("branch_id") == branch_id:
400
+ return True, branch_id
401
+ return False, None
402
+ if translation_index is not None and 0 <= translation_index < len(branches):
403
+ return True, branches[translation_index].get("branch_id")
404
+ return False, None
405
+
406
+
310
407
  def _find_raw_chapter(
311
408
  raw_chapters: list[dict[str, Any]], volume_str: str, number: str
312
409
  ) -> dict[str, Any] | None: