ranobelib-python-sdk 0.1.0__tar.gz → 0.2.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 (21) hide show
  1. ranobelib_python_sdk-0.2.0/PKG-INFO +478 -0
  2. ranobelib_python_sdk-0.2.0/README.md +451 -0
  3. {ranobelib_python_sdk-0.1.0 → ranobelib_python_sdk-0.2.0}/pyproject.toml +1 -1
  4. {ranobelib_python_sdk-0.1.0 → ranobelib_python_sdk-0.2.0}/pyproject.toml.orig +1 -1
  5. {ranobelib_python_sdk-0.1.0 → ranobelib_python_sdk-0.2.0}/src/ranobelib/__init__.py +4 -0
  6. {ranobelib_python_sdk-0.1.0 → ranobelib_python_sdk-0.2.0}/src/ranobelib/exceptions.py +49 -1
  7. {ranobelib_python_sdk-0.1.0 → ranobelib_python_sdk-0.2.0}/src/ranobelib/sdk.py +97 -0
  8. ranobelib_python_sdk-0.1.0/PKG-INFO +0 -100
  9. ranobelib_python_sdk-0.1.0/README.md +0 -73
  10. {ranobelib_python_sdk-0.1.0 → ranobelib_python_sdk-0.2.0}/src/ranobelib/cache.py +0 -0
  11. {ranobelib_python_sdk-0.1.0 → ranobelib_python_sdk-0.2.0}/src/ranobelib/client.py +0 -0
  12. {ranobelib_python_sdk-0.1.0 → ranobelib_python_sdk-0.2.0}/src/ranobelib/exporters/__init__.py +0 -0
  13. {ranobelib_python_sdk-0.1.0 → ranobelib_python_sdk-0.2.0}/src/ranobelib/exporters/_illustrations.py +0 -0
  14. {ranobelib_python_sdk-0.1.0 → ranobelib_python_sdk-0.2.0}/src/ranobelib/exporters/_shared.py +0 -0
  15. {ranobelib_python_sdk-0.1.0 → ranobelib_python_sdk-0.2.0}/src/ranobelib/exporters/epub.py +0 -0
  16. {ranobelib_python_sdk-0.1.0 → ranobelib_python_sdk-0.2.0}/src/ranobelib/exporters/fb2.py +0 -0
  17. {ranobelib_python_sdk-0.1.0 → ranobelib_python_sdk-0.2.0}/src/ranobelib/exporters/pdf.py +0 -0
  18. {ranobelib_python_sdk-0.1.0 → ranobelib_python_sdk-0.2.0}/src/ranobelib/exporters/txt.py +0 -0
  19. {ranobelib_python_sdk-0.1.0 → ranobelib_python_sdk-0.2.0}/src/ranobelib/models.py +0 -0
  20. {ranobelib_python_sdk-0.1.0 → ranobelib_python_sdk-0.2.0}/src/ranobelib/numbering.py +0 -0
  21. {ranobelib_python_sdk-0.1.0 → ranobelib_python_sdk-0.2.0}/src/ranobelib/py.typed +0 -0
@@ -0,0 +1,478 @@
1
+ Metadata-Version: 2.4
2
+ Name: ranobelib-python-sdk
3
+ Version: 0.2.0
4
+ Summary: Async Python SDK for ranobelib.me: title metadata, chapter/volume downloads, and export to epub/fb2/txt/pdf
5
+ Keywords: ranobelib,ranobe,novel,webnovel,epub,fb2,sdk
6
+ Author: LLIJIYAPNIK
7
+ Author-email: LLIJIYAPNIK <kas4wot@yandex.ru>
8
+ License-Expression: MIT
9
+ Classifier: Development Status :: 2 - Pre-Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
17
+ Classifier: Typing :: Typed
18
+ Requires-Dist: ebooklib>=0.20
19
+ Requires-Dist: httpx>=0.27
20
+ Requires-Dist: pydantic>=2.7
21
+ Requires-Dist: weasyprint>=69.0
22
+ Requires-Python: >=3.11
23
+ Project-URL: Homepage, https://github.com/LLIJIYAPNIK/ranobelib-python-sdk
24
+ Project-URL: Repository, https://github.com/LLIJIYAPNIK/ranobelib-python-sdk
25
+ Project-URL: Issues, https://github.com/LLIJIYAPNIK/ranobelib-python-sdk/issues
26
+ Description-Content-Type: text/markdown
27
+
28
+ # ranobelib-python-sdk
29
+
30
+ [![PyPI](https://img.shields.io/pypi/v/ranobelib-python-sdk)](https://pypi.org/project/ranobelib-python-sdk/)
31
+ [![License: MIT](https://img.shields.io/github/license/LLIJIYAPNIK/ranobelib-python-sdk)](LICENSE)
32
+ [![Python versions](https://img.shields.io/badge/python-3.11%20%7C%203.12%20%7C%203.13-blue)](pyproject.toml)
33
+ [![CI](https://github.com/LLIJIYAPNIK/ranobelib-python-sdk/actions/workflows/ci.yml/badge.svg)](https://github.com/LLIJIYAPNIK/ranobelib-python-sdk/actions/workflows/ci.yml)
34
+ [![Coverage Status](https://coveralls.io/repos/github/LLIJIYAPNIK/ranobelib-python-sdk/badge.svg?branch=main)](https://coveralls.io/github/LLIJIYAPNIK/ranobelib-python-sdk?branch=main)
35
+ [![Docs](https://github.com/LLIJIYAPNIK/ranobelib-python-sdk/actions/workflows/docs.yml/badge.svg)](https://github.com/LLIJIYAPNIK/ranobelib-python-sdk/actions/workflows/docs.yml)
36
+
37
+ Async Python SDK for [ranobelib.me](https://ranobelib.me): fetch title metadata, download
38
+ chapters and volumes, and export to epub, fb2, txt, or pdf.
39
+
40
+ The SDK talks to the undocumented but open JSON API behind the site (`api.cdnlibs.org`,
41
+ part of the lib.social network) instead of scraping HTML.
42
+
43
+ > **Status:** all the calls in the quickstart below are implemented — metadata, table of
44
+ > contents, single/bulk chapter and volume downloads, whole-title bulk download, translation
45
+ > selection, and export to
46
+ > `"txt"`, `"fb2"`, `"epub"`, or `"pdf"` (cover + in-chapter illustrations embedded in
47
+ > epub/pdf). Raw API responses are cached to disk, and the underlying client bounds
48
+ > concurrency, paces requests, and retries 429/5xx with backoff. `fmt="pdf"` needs
49
+ > WeasyPrint's native dependencies (Pango/cairo/GTK3) installed on the system — `pip
50
+ > install`/`uv add` alone doesn't guarantee that on every platform; without them,
51
+ > `export(fmt="pdf")` raises the same "unknown format" error the other three formats don't.
52
+ > Authorization (private/18+/paid content) is out of scope — see `CLAUDE.md` for the full
53
+ > architecture and design decisions behind all of this.
54
+
55
+ ## Installation
56
+
57
+ ```bash
58
+ uv add ranobelib-python-sdk
59
+ ```
60
+
61
+ or
62
+
63
+ ```bash
64
+ pip install ranobelib-python-sdk
65
+ ```
66
+
67
+ Requires Python 3.11+.
68
+
69
+ ## Quickstart
70
+
71
+ ```python
72
+ from ranobelib import RanobeLib
73
+
74
+ async with RanobeLib("https://ranobelib.me/ru/book/6712--high-school-dxd-novel") as lib:
75
+ info = await lib.get_info()
76
+ toc = await lib.get_table_of_contents()
77
+
78
+ chapter = await lib.get_chapter(volume=6, number="51.6")
79
+ volume = await lib.get_volume(volume=6)
80
+
81
+ chapters = await lib.get_chapters([(6, "51.6"), (6, "52")])
82
+ volumes = await lib.get_volumes([1, 2, 3])
83
+
84
+ translations = await lib.get_translations(volume=6, number="51.6")
85
+ chapter = await lib.get_chapter(volume=6, number="51.6", branch_id=...)
86
+
87
+ all_volumes = await lib.download_title(translation_index=0, chapter_delay=0.5)
88
+
89
+ await lib.export(chapters, fmt="epub", path="output.epub")
90
+ ```
91
+
92
+ This is the full current public API — everything above is implemented and works today.
93
+
94
+ ## Examples
95
+
96
+ Every snippet below is a complete, self-contained script — save it as `example.py` and run
97
+ `python example.py`. Each one really was run against the live site to produce the "Result"
98
+ shown; only chapter content is truncated with `[:200]`/`[:300]` for readability, everything
99
+ else is the real output verbatim.
100
+
101
+ ### Title metadata
102
+
103
+ ```python
104
+ import asyncio
105
+
106
+ from ranobelib import RanobeLib
107
+
108
+
109
+ async def main() -> None:
110
+ async with RanobeLib("https://ranobelib.me/ru/book/6712--high-school-dxd-novel") as lib:
111
+ info = await lib.get_info()
112
+ print(info.name)
113
+ print(info.rus_name)
114
+ print(info.status.label)
115
+ print(info.chapter_count)
116
+ print([genre.name for genre in info.genres[:5]])
117
+
118
+
119
+ asyncio.run(main())
120
+ ```
121
+
122
+ **Result:**
123
+
124
+ ```
125
+ Haisukuru Di Di (Novel)
126
+ Старшая школа D×D (Новелла)
127
+ Завершён
128
+ 308
129
+ ['Боевик', 'Боевые искусства', 'Вампиры', 'Гарем', 'Драма']
130
+ ```
131
+
132
+ ### Table of contents
133
+
134
+ ```python
135
+ import asyncio
136
+
137
+ from ranobelib import RanobeLib
138
+
139
+
140
+ async def main() -> None:
141
+ async with RanobeLib("https://ranobelib.me/ru/book/91443--new-hero-in-dxd") as lib:
142
+ volumes = await lib.get_table_of_contents()
143
+ for volume in volumes:
144
+ print(f"Volume {volume.number}: {len(volume.chapters)} chapters")
145
+ print(volumes[0].chapters[0].name)
146
+
147
+
148
+ asyncio.run(main())
149
+ ```
150
+
151
+ **Result:**
152
+
153
+ ```
154
+ Volume 0: 1 chapters
155
+ Volume 1: 46 chapters
156
+ (НЕ ГОТОВО) Возможный гарем
157
+ ```
158
+
159
+ ### A single chapter
160
+
161
+ ```python
162
+ import asyncio
163
+
164
+ from ranobelib import RanobeLib
165
+
166
+
167
+ async def main() -> None:
168
+ async with RanobeLib("https://ranobelib.me/ru/book/91443--new-hero-in-dxd") as lib:
169
+ chapter = await lib.get_chapter(volume=1, number="1")
170
+ print(chapter.name)
171
+ print(chapter.content[:200])
172
+
173
+
174
+ asyncio.run(main())
175
+ ```
176
+
177
+ **Result:**
178
+
179
+ ```
180
+ Глава 1
181
+ <p data-paragraph-index="1">"Наконец, я наконец-то могу вернуться." - говорю я с широкой
182
+ улыбкой на лице, хотя по моим щекам текут слезы. Прошло слишком много времени с тех пор,
183
+ как я застрял в этом м
184
+ ```
185
+
186
+ ### Several chapters at once
187
+
188
+ ```python
189
+ import asyncio
190
+
191
+ from ranobelib import RanobeLib
192
+
193
+
194
+ async def main() -> None:
195
+ async with RanobeLib("https://ranobelib.me/ru/book/91443--new-hero-in-dxd") as lib:
196
+ chapters = await lib.get_chapters([(1, "1"), (1, "2")])
197
+ for chapter in chapters:
198
+ print(chapter.volume, chapter.number, len(chapter.content))
199
+
200
+
201
+ asyncio.run(main())
202
+ ```
203
+
204
+ **Result:**
205
+
206
+ ```
207
+ 1 1 30722
208
+ 1 2 22747
209
+ ```
210
+
211
+ ### A whole volume
212
+
213
+ ```python
214
+ import asyncio
215
+
216
+ from ranobelib import RanobeLib
217
+
218
+
219
+ async def main() -> None:
220
+ async with RanobeLib("https://ranobelib.me/ru/book/91443--new-hero-in-dxd") as lib:
221
+ volume = await lib.get_volume(0)
222
+ print(volume.number, [chapter.number for chapter in volume.chapters])
223
+
224
+
225
+ asyncio.run(main())
226
+ ```
227
+
228
+ **Result:**
229
+
230
+ ```
231
+ 0 ['1']
232
+ ```
233
+
234
+ ### Several volumes at once
235
+
236
+ ```python
237
+ import asyncio
238
+
239
+ from ranobelib import RanobeLib
240
+
241
+
242
+ async def main() -> None:
243
+ async with RanobeLib("https://ranobelib.me/ru/book/91443--new-hero-in-dxd") as lib:
244
+ volumes = await lib.get_volumes([0, 1])
245
+ for volume in volumes:
246
+ print(volume.number, len(volume.chapters))
247
+
248
+
249
+ asyncio.run(main())
250
+ ```
251
+
252
+ **Result:**
253
+
254
+ ```
255
+ 0 1
256
+ 1 46
257
+ ```
258
+
259
+ ### Selecting a translation
260
+
261
+ `11407--solo-leveling` has two competing translations of its prologue. `get_chapter()`
262
+ refuses to guess which one you want — it raises `MultipleTranslationsError` instead —
263
+ unless `branch_id` is given explicitly, so you list the options with `get_translations()`
264
+ first:
265
+
266
+ ```python
267
+ import asyncio
268
+
269
+ from ranobelib import MultipleTranslationsError, RanobeLib
270
+
271
+
272
+ async def main() -> None:
273
+ async with RanobeLib("https://ranobelib.me/ru/book/11407--solo-leveling") as lib:
274
+ branches = await lib.get_translations(volume=1, number="0")
275
+ for branch in branches:
276
+ print(branch.branch_id, [team.name for team in branch.teams])
277
+
278
+ try:
279
+ await lib.get_chapter(volume=1, number="0")
280
+ except MultipleTranslationsError as exc:
281
+ print("ambiguous, pick one:", [b.branch_id for b in exc.branches])
282
+
283
+ chapter = await lib.get_chapter(volume=1, number="0", branch_id=branches[0].branch_id)
284
+ print(chapter.content[:60])
285
+
286
+
287
+ asyncio.run(main())
288
+ ```
289
+
290
+ **Result:**
291
+
292
+ ```
293
+ 2251 ['BerkuD13']
294
+ 635 ['Неизвестный']
295
+ ambiguous, pick one: [2251, 635]
296
+ <p data-paragraph-index="1">Прокачка уровня в одиночку</p><p data-paragraph-index="2">0 . Пролог</p
297
+ ```
298
+
299
+ ### Downloading a whole title
300
+
301
+ `download_title()` fetches every chapter of a title in one call. When a title has chapters
302
+ with more than one translation, it raises `MultipleTitleTranslationsError` listing *all* of
303
+ them up front (not just the first one hit) — pass `branch_id` or `translation_index` to
304
+ resolve them:
305
+
306
+ ```python
307
+ import asyncio
308
+
309
+ from ranobelib import MultipleTitleTranslationsError, RanobeLib
310
+
311
+
312
+ async def main() -> None:
313
+ async with RanobeLib("https://ranobelib.me/ru/book/40195--enbizaka-no-shitateya") as lib:
314
+ volumes = await lib.download_title(chapter_delay=0.5)
315
+ for volume in volumes:
316
+ print(volume.number, [chapter.number for chapter in volume.chapters])
317
+
318
+ slug = "113306--bungou-stray-dogs-gaiden-ayatsuji-yukito-vs-kyogoku-natsuhiko"
319
+ async with RanobeLib(f"https://ranobelib.me/ru/book/{slug}") as lib:
320
+ try:
321
+ await lib.download_title()
322
+ except MultipleTitleTranslationsError as exc:
323
+ print(f"{len(exc.chapters)} ambiguous chapter(s):")
324
+ for chapter in exc.chapters:
325
+ print(" ", chapter.volume, chapter.number, [b.branch_id for b in chapter.branches])
326
+
327
+ volumes = await lib.download_title(translation_index=0)
328
+ print([chapter.number for chapter in volumes[0].chapters])
329
+
330
+
331
+ asyncio.run(main())
332
+ ```
333
+
334
+ **Result:**
335
+
336
+ ```
337
+ 1 ['0', '1']
338
+ 3 ambiguous chapter(s):
339
+ 1 0 [12954, 12955]
340
+ 1 1 [12955, 12954]
341
+ 1 2 [12954, 12955]
342
+ ['0', '1', '2', '3', '4', '5']
343
+ ```
344
+
345
+ Note chapter `1`'s branches are listed in the opposite order from chapters `0`/`2` —
346
+ `translation_index=0` resolves by each chapter's own branch order, not a shared `branch_id`,
347
+ which is why it picks a different team for chapter `1` than for `0`/`2` here. Use `branch_id`
348
+ instead when you know the specific translation you want and it's shared across chapters.
349
+
350
+ ### Disk caching
351
+
352
+ Raw API responses are cached to disk by default, so a repeated call for the same data
353
+ doesn't re-fetch it:
354
+
355
+ ```python
356
+ import asyncio
357
+ import time
358
+
359
+ from ranobelib import RanobeLib
360
+
361
+
362
+ async def main() -> None:
363
+ async with RanobeLib(
364
+ "https://ranobelib.me/ru/book/91443--new-hero-in-dxd", cache_dir=".ranobelib_cache"
365
+ ) as lib:
366
+ start = time.perf_counter()
367
+ await lib.get_table_of_contents()
368
+ print(f"first call: {time.perf_counter() - start:.3f}s (hits the API)")
369
+
370
+ start = time.perf_counter()
371
+ await lib.get_table_of_contents()
372
+ print(f"second call: {time.perf_counter() - start:.3f}s (served from disk cache)")
373
+
374
+ await lib.get_table_of_contents(refresh=True) # Bypasses the cache explicitly.
375
+
376
+
377
+ asyncio.run(main())
378
+ ```
379
+
380
+ **Result** (your exact timings will vary, but the gap between the two calls won't):
381
+
382
+ ```
383
+ first call: 0.318s (hits the API)
384
+ second call: 0.001s (served from disk cache)
385
+ ```
386
+
387
+ ### Exporting to txt / fb2 / epub / pdf
388
+
389
+ ```python
390
+ import asyncio
391
+
392
+ from ranobelib import RanobeLib
393
+
394
+
395
+ async def main() -> None:
396
+ async with RanobeLib("https://ranobelib.me/ru/book/91443--new-hero-in-dxd") as lib:
397
+ chapters = await lib.get_chapters([(1, "1"), (1, "2")])
398
+ for fmt in ("txt", "fb2", "epub", "pdf"):
399
+ path = f"output.{fmt}"
400
+ result = await lib.export(chapters, fmt=fmt, path=path)
401
+ print(fmt, "->", result)
402
+
403
+
404
+ asyncio.run(main())
405
+ ```
406
+
407
+ **Result** (file sizes from the real run — `pdf` needs
408
+ [WeasyPrint's native dependencies](https://doc.courtbouillon.org/weasyprint/stable/first_steps.html#installation)
409
+ installed; without them this raises `ValueError: Unknown export format 'pdf'. Available:
410
+ epub, fb2, txt` instead of the line shown here):
411
+
412
+ ```
413
+ txt -> output.txt (84,044 bytes)
414
+ fb2 -> output.fb2 (87,691 bytes)
415
+ epub -> output.epub (1,442,724 bytes, cover + illustrations embedded)
416
+ pdf -> output.pdf (cover + illustrations embedded, needs WeasyPrint)
417
+ ```
418
+
419
+ `output.txt`'s first few lines, for a sense of the format:
420
+
421
+ ```
422
+ New Hero in DxD
423
+
424
+
425
+ Volume 1, Chapter 1: Глава 1
426
+
427
+ "Наконец, я наконец-то могу вернуться." - говорю я с широкой улыбкой на лице, хотя по моим
428
+ щекам текут слезы. Прошло слишком много времени с тех пор, как я застрял в этом месте, я
429
+ больше не могу с этим справляться. Поначалу это было круто, но через нек...
430
+ ```
431
+
432
+ ### Error handling
433
+
434
+ ```python
435
+ import asyncio
436
+
437
+ from ranobelib import ChapterNotFoundError, RanobeLib, TitleNotFoundError
438
+
439
+
440
+ async def main() -> None:
441
+ async with RanobeLib("https://ranobelib.me/ru/book/1--this-title-does-not-exist-zzz") as lib:
442
+ try:
443
+ await lib.get_info()
444
+ except TitleNotFoundError as exc:
445
+ print(exc)
446
+
447
+ async with RanobeLib("https://ranobelib.me/ru/book/91443--new-hero-in-dxd") as lib:
448
+ try:
449
+ await lib.get_chapter(volume=999, number="9999")
450
+ except ChapterNotFoundError as exc:
451
+ print(exc)
452
+
453
+
454
+ asyncio.run(main())
455
+ ```
456
+
457
+ **Result:**
458
+
459
+ ```
460
+ Title not found: '1--this-title-does-not-exist-zzz'
461
+ Chapter not found: '91443--new-hero-in-dxd' volume='999' number='9999'
462
+ ```
463
+
464
+ `AuthRequiredError` (403, paid/early-access content) and `RateLimitError` (429 after
465
+ retries are exhausted) follow the same pattern — see the
466
+ [API reference](https://LLIJIYAPNIK.github.io/ranobelib-python-sdk/reference/) for every
467
+ exception's attributes.
468
+
469
+ ## Documentation
470
+
471
+ Full API reference and guides: **<https://LLIJIYAPNIK.github.io/ranobelib-python-sdk/>**
472
+ (built with `mkdocs-material` + `mkdocstrings`, deployed on push to `main`). `CLAUDE.md`
473
+ documents the full architecture, public API contract, and the reasoning behind the SDK's
474
+ design decisions.
475
+
476
+ ## License
477
+
478
+ MIT — see [LICENSE](LICENSE).