ranobelib-python-sdk 0.1.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.1.0/PKG-INFO +100 -0
- ranobelib_python_sdk-0.1.0/README.md +73 -0
- ranobelib_python_sdk-0.1.0/pyproject.toml +115 -0
- ranobelib_python_sdk-0.1.0/pyproject.toml.orig +94 -0
- ranobelib_python_sdk-0.1.0/src/ranobelib/__init__.py +28 -0
- ranobelib_python_sdk-0.1.0/src/ranobelib/cache.py +64 -0
- ranobelib_python_sdk-0.1.0/src/ranobelib/client.py +210 -0
- ranobelib_python_sdk-0.1.0/src/ranobelib/exceptions.py +118 -0
- ranobelib_python_sdk-0.1.0/src/ranobelib/exporters/__init__.py +56 -0
- ranobelib_python_sdk-0.1.0/src/ranobelib/exporters/_illustrations.py +170 -0
- ranobelib_python_sdk-0.1.0/src/ranobelib/exporters/_shared.py +11 -0
- ranobelib_python_sdk-0.1.0/src/ranobelib/exporters/epub.py +117 -0
- ranobelib_python_sdk-0.1.0/src/ranobelib/exporters/fb2.py +164 -0
- ranobelib_python_sdk-0.1.0/src/ranobelib/exporters/pdf.py +147 -0
- ranobelib_python_sdk-0.1.0/src/ranobelib/exporters/txt.py +89 -0
- ranobelib_python_sdk-0.1.0/src/ranobelib/models.py +245 -0
- ranobelib_python_sdk-0.1.0/src/ranobelib/numbering.py +34 -0
- ranobelib_python_sdk-0.1.0/src/ranobelib/py.typed +0 -0
- ranobelib_python_sdk-0.1.0/src/ranobelib/sdk.py +321 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ranobelib-python-sdk
|
|
3
|
+
Version: 0.1.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
|
+
[](https://pypi.org/project/ranobelib-python-sdk/)
|
|
31
|
+
[](LICENSE)
|
|
32
|
+
[](pyproject.toml)
|
|
33
|
+
[](https://github.com/LLIJIYAPNIK/ranobelib-python-sdk/actions/workflows/ci.yml)
|
|
34
|
+
[](https://coveralls.io/github/LLIJIYAPNIK/ranobelib-python-sdk?branch=main)
|
|
35
|
+
[](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, translation selection, and export to
|
|
45
|
+
> `"txt"`, `"fb2"`, `"epub"`, or `"pdf"` (cover + in-chapter illustrations embedded in
|
|
46
|
+
> epub/pdf). Raw API responses are cached to disk, and the underlying client bounds
|
|
47
|
+
> concurrency, paces requests, and retries 429/5xx with backoff. `fmt="pdf"` needs
|
|
48
|
+
> WeasyPrint's native dependencies (Pango/cairo/GTK3) installed on the system — `pip
|
|
49
|
+
> install`/`uv add` alone doesn't guarantee that on every platform; without them,
|
|
50
|
+
> `export(fmt="pdf")` raises the same "unknown format" error the other three formats don't.
|
|
51
|
+
> Authorization (private/18+/paid content) is out of scope — see `CLAUDE.md` for the full
|
|
52
|
+
> architecture and design decisions behind all of this.
|
|
53
|
+
|
|
54
|
+
## Installation
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
uv add ranobelib-python-sdk
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
or
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
pip install ranobelib-python-sdk
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Requires Python 3.11+.
|
|
67
|
+
|
|
68
|
+
## Quickstart
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from ranobelib import RanobeLib
|
|
72
|
+
|
|
73
|
+
async with RanobeLib("https://ranobelib.me/ru/book/6712--high-school-dxd-novel") as lib:
|
|
74
|
+
info = await lib.get_info()
|
|
75
|
+
toc = await lib.get_table_of_contents()
|
|
76
|
+
|
|
77
|
+
chapter = await lib.get_chapter(volume=6, number="51.6")
|
|
78
|
+
volume = await lib.get_volume(volume=6)
|
|
79
|
+
|
|
80
|
+
chapters = await lib.get_chapters([(6, "51.6"), (6, "52")])
|
|
81
|
+
volumes = await lib.get_volumes([1, 2, 3])
|
|
82
|
+
|
|
83
|
+
translations = await lib.get_translations(volume=6, number="51.6")
|
|
84
|
+
chapter = await lib.get_chapter(volume=6, number="51.6", branch_id=...)
|
|
85
|
+
|
|
86
|
+
await lib.export(chapters, fmt="epub", path="output.epub")
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
This is the full current public API — everything above is implemented and works today.
|
|
90
|
+
|
|
91
|
+
## Documentation
|
|
92
|
+
|
|
93
|
+
Full API reference and guides: **<https://LLIJIYAPNIK.github.io/ranobelib-python-sdk/>**
|
|
94
|
+
(built with `mkdocs-material` + `mkdocstrings`, deployed on push to `main`). `CLAUDE.md`
|
|
95
|
+
documents the full architecture, public API contract, and the reasoning behind the SDK's
|
|
96
|
+
design decisions.
|
|
97
|
+
|
|
98
|
+
## License
|
|
99
|
+
|
|
100
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# ranobelib-python-sdk
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/ranobelib-python-sdk/)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
[](pyproject.toml)
|
|
6
|
+
[](https://github.com/LLIJIYAPNIK/ranobelib-python-sdk/actions/workflows/ci.yml)
|
|
7
|
+
[](https://coveralls.io/github/LLIJIYAPNIK/ranobelib-python-sdk?branch=main)
|
|
8
|
+
[](https://github.com/LLIJIYAPNIK/ranobelib-python-sdk/actions/workflows/docs.yml)
|
|
9
|
+
|
|
10
|
+
Async Python SDK for [ranobelib.me](https://ranobelib.me): fetch title metadata, download
|
|
11
|
+
chapters and volumes, and export to epub, fb2, txt, or pdf.
|
|
12
|
+
|
|
13
|
+
The SDK talks to the undocumented but open JSON API behind the site (`api.cdnlibs.org`,
|
|
14
|
+
part of the lib.social network) instead of scraping HTML.
|
|
15
|
+
|
|
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
|
|
18
|
+
> `"txt"`, `"fb2"`, `"epub"`, or `"pdf"` (cover + in-chapter illustrations embedded in
|
|
19
|
+
> epub/pdf). Raw API responses are cached to disk, and the underlying client bounds
|
|
20
|
+
> concurrency, paces requests, and retries 429/5xx with backoff. `fmt="pdf"` needs
|
|
21
|
+
> WeasyPrint's native dependencies (Pango/cairo/GTK3) installed on the system — `pip
|
|
22
|
+
> install`/`uv add` alone doesn't guarantee that on every platform; without them,
|
|
23
|
+
> `export(fmt="pdf")` raises the same "unknown format" error the other three formats don't.
|
|
24
|
+
> Authorization (private/18+/paid content) is out of scope — see `CLAUDE.md` for the full
|
|
25
|
+
> architecture and design decisions behind all of this.
|
|
26
|
+
|
|
27
|
+
## Installation
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
uv add ranobelib-python-sdk
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
or
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install ranobelib-python-sdk
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Requires Python 3.11+.
|
|
40
|
+
|
|
41
|
+
## Quickstart
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from ranobelib import RanobeLib
|
|
45
|
+
|
|
46
|
+
async with RanobeLib("https://ranobelib.me/ru/book/6712--high-school-dxd-novel") as lib:
|
|
47
|
+
info = await lib.get_info()
|
|
48
|
+
toc = await lib.get_table_of_contents()
|
|
49
|
+
|
|
50
|
+
chapter = await lib.get_chapter(volume=6, number="51.6")
|
|
51
|
+
volume = await lib.get_volume(volume=6)
|
|
52
|
+
|
|
53
|
+
chapters = await lib.get_chapters([(6, "51.6"), (6, "52")])
|
|
54
|
+
volumes = await lib.get_volumes([1, 2, 3])
|
|
55
|
+
|
|
56
|
+
translations = await lib.get_translations(volume=6, number="51.6")
|
|
57
|
+
chapter = await lib.get_chapter(volume=6, number="51.6", branch_id=...)
|
|
58
|
+
|
|
59
|
+
await lib.export(chapters, fmt="epub", path="output.epub")
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
This is the full current public API — everything above is implemented and works today.
|
|
63
|
+
|
|
64
|
+
## Documentation
|
|
65
|
+
|
|
66
|
+
Full API reference and guides: **<https://LLIJIYAPNIK.github.io/ranobelib-python-sdk/>**
|
|
67
|
+
(built with `mkdocs-material` + `mkdocstrings`, deployed on push to `main`). `CLAUDE.md`
|
|
68
|
+
documents the full architecture, public API contract, and the reasoning behind the SDK's
|
|
69
|
+
design decisions.
|
|
70
|
+
|
|
71
|
+
## License
|
|
72
|
+
|
|
73
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "ranobelib-python-sdk"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Async Python SDK for ranobelib.me: title metadata, chapter/volume downloads, and export to epub/fb2/txt/pdf"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
requires-python = ">=3.11"
|
|
8
|
+
keywords = [
|
|
9
|
+
"ranobelib",
|
|
10
|
+
"ranobe",
|
|
11
|
+
"novel",
|
|
12
|
+
"webnovel",
|
|
13
|
+
"epub",
|
|
14
|
+
"fb2",
|
|
15
|
+
"sdk",
|
|
16
|
+
]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 2 - Pre-Alpha",
|
|
19
|
+
"Intended Audience :: Developers",
|
|
20
|
+
"Operating System :: OS Independent",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
26
|
+
"Typing :: Typed",
|
|
27
|
+
]
|
|
28
|
+
dependencies = [
|
|
29
|
+
"ebooklib>=0.20",
|
|
30
|
+
"httpx>=0.27",
|
|
31
|
+
"pydantic>=2.7",
|
|
32
|
+
"weasyprint>=69.0",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[[project.authors]]
|
|
36
|
+
name = "LLIJIYAPNIK"
|
|
37
|
+
email = "kas4wot@yandex.ru"
|
|
38
|
+
|
|
39
|
+
[project.urls]
|
|
40
|
+
Homepage = "https://github.com/LLIJIYAPNIK/ranobelib-python-sdk"
|
|
41
|
+
Repository = "https://github.com/LLIJIYAPNIK/ranobelib-python-sdk"
|
|
42
|
+
Issues = "https://github.com/LLIJIYAPNIK/ranobelib-python-sdk/issues"
|
|
43
|
+
|
|
44
|
+
[build-system]
|
|
45
|
+
requires = ["uv_build>=0.11.26,<0.12.0"]
|
|
46
|
+
build-backend = "uv_build"
|
|
47
|
+
|
|
48
|
+
[tool.uv.build-backend]
|
|
49
|
+
module-name = "ranobelib"
|
|
50
|
+
|
|
51
|
+
[tool.ruff]
|
|
52
|
+
line-length = 100
|
|
53
|
+
target-version = "py311"
|
|
54
|
+
src = ["src"]
|
|
55
|
+
extend-exclude = ["CLAUDE.md"]
|
|
56
|
+
|
|
57
|
+
[tool.ruff.lint]
|
|
58
|
+
select = [
|
|
59
|
+
"E",
|
|
60
|
+
"F",
|
|
61
|
+
"I",
|
|
62
|
+
"UP",
|
|
63
|
+
"B",
|
|
64
|
+
"SIM",
|
|
65
|
+
"C4",
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
[tool.ruff.format]
|
|
69
|
+
quote-style = "double"
|
|
70
|
+
|
|
71
|
+
[tool.mypy]
|
|
72
|
+
python_version = "3.11"
|
|
73
|
+
strict = true
|
|
74
|
+
files = ["src"]
|
|
75
|
+
|
|
76
|
+
[[tool.mypy.overrides]]
|
|
77
|
+
module = [
|
|
78
|
+
"ebooklib",
|
|
79
|
+
"ebooklib.*",
|
|
80
|
+
"weasyprint",
|
|
81
|
+
"weasyprint.*",
|
|
82
|
+
]
|
|
83
|
+
ignore_missing_imports = true
|
|
84
|
+
|
|
85
|
+
[tool.pytest.ini_options]
|
|
86
|
+
asyncio_mode = "auto"
|
|
87
|
+
testpaths = ["tests"]
|
|
88
|
+
|
|
89
|
+
[tool.coverage.run]
|
|
90
|
+
source = ["ranobelib"]
|
|
91
|
+
|
|
92
|
+
[tool.coverage.report]
|
|
93
|
+
fail_under = 95
|
|
94
|
+
exclude_also = [
|
|
95
|
+
"if TYPE_CHECKING:",
|
|
96
|
+
'\.\.\.',
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
[dependency-groups]
|
|
100
|
+
dev = [
|
|
101
|
+
"coveralls>=4.0",
|
|
102
|
+
"lxml>=5.0",
|
|
103
|
+
"mypy>=1.10",
|
|
104
|
+
"pypdf>=6.14.2",
|
|
105
|
+
"pytest>=8.0",
|
|
106
|
+
"pytest-asyncio>=0.24",
|
|
107
|
+
"pytest-cov>=5.0",
|
|
108
|
+
"pytest-recording>=0.13",
|
|
109
|
+
"ruff>=0.6",
|
|
110
|
+
"vcrpy>=6.0",
|
|
111
|
+
]
|
|
112
|
+
docs = [
|
|
113
|
+
"mkdocs-material>=9.5",
|
|
114
|
+
"mkdocstrings[python]>=0.25",
|
|
115
|
+
]
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "ranobelib-python-sdk"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Async Python SDK for ranobelib.me: title metadata, chapter/volume downloads, and export to epub/fb2/txt/pdf"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
requires-python = ">=3.11"
|
|
8
|
+
authors = [
|
|
9
|
+
{ name = "LLIJIYAPNIK", email = "kas4wot@yandex.ru" }
|
|
10
|
+
]
|
|
11
|
+
keywords = ["ranobelib", "ranobe", "novel", "webnovel", "epub", "fb2", "sdk"]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 2 - Pre-Alpha",
|
|
14
|
+
"Intended Audience :: Developers",
|
|
15
|
+
"Operating System :: OS Independent",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3.11",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
"Programming Language :: Python :: 3.13",
|
|
20
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
21
|
+
"Typing :: Typed",
|
|
22
|
+
]
|
|
23
|
+
dependencies = [
|
|
24
|
+
"ebooklib>=0.20",
|
|
25
|
+
"httpx>=0.27",
|
|
26
|
+
"pydantic>=2.7",
|
|
27
|
+
"weasyprint>=69.0",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://github.com/LLIJIYAPNIK/ranobelib-python-sdk"
|
|
32
|
+
Repository = "https://github.com/LLIJIYAPNIK/ranobelib-python-sdk"
|
|
33
|
+
Issues = "https://github.com/LLIJIYAPNIK/ranobelib-python-sdk/issues"
|
|
34
|
+
|
|
35
|
+
[build-system]
|
|
36
|
+
requires = ["uv_build>=0.11.26,<0.12.0"]
|
|
37
|
+
build-backend = "uv_build"
|
|
38
|
+
|
|
39
|
+
[tool.uv.build-backend]
|
|
40
|
+
module-name = "ranobelib"
|
|
41
|
+
|
|
42
|
+
[dependency-groups]
|
|
43
|
+
dev = [
|
|
44
|
+
"coveralls>=4.0",
|
|
45
|
+
"lxml>=5.0",
|
|
46
|
+
"mypy>=1.10",
|
|
47
|
+
"pypdf>=6.14.2",
|
|
48
|
+
"pytest>=8.0",
|
|
49
|
+
"pytest-asyncio>=0.24",
|
|
50
|
+
"pytest-cov>=5.0",
|
|
51
|
+
"pytest-recording>=0.13",
|
|
52
|
+
"ruff>=0.6",
|
|
53
|
+
"vcrpy>=6.0",
|
|
54
|
+
]
|
|
55
|
+
docs = [
|
|
56
|
+
"mkdocs-material>=9.5",
|
|
57
|
+
"mkdocstrings[python]>=0.25",
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
[tool.ruff]
|
|
61
|
+
line-length = 100
|
|
62
|
+
target-version = "py311"
|
|
63
|
+
src = ["src"]
|
|
64
|
+
extend-exclude = ["CLAUDE.md"]
|
|
65
|
+
|
|
66
|
+
[tool.ruff.lint]
|
|
67
|
+
select = ["E", "F", "I", "UP", "B", "SIM", "C4"]
|
|
68
|
+
|
|
69
|
+
[tool.ruff.format]
|
|
70
|
+
quote-style = "double"
|
|
71
|
+
|
|
72
|
+
[tool.mypy]
|
|
73
|
+
python_version = "3.11"
|
|
74
|
+
strict = true
|
|
75
|
+
files = ["src"]
|
|
76
|
+
|
|
77
|
+
[[tool.mypy.overrides]]
|
|
78
|
+
# ebooklib and weasyprint ship no type stubs / py.typed marker.
|
|
79
|
+
module = ["ebooklib", "ebooklib.*", "weasyprint", "weasyprint.*"]
|
|
80
|
+
ignore_missing_imports = true
|
|
81
|
+
|
|
82
|
+
[tool.pytest.ini_options]
|
|
83
|
+
asyncio_mode = "auto"
|
|
84
|
+
testpaths = ["tests"]
|
|
85
|
+
|
|
86
|
+
[tool.coverage.run]
|
|
87
|
+
source = ["ranobelib"]
|
|
88
|
+
|
|
89
|
+
[tool.coverage.report]
|
|
90
|
+
fail_under = 95
|
|
91
|
+
exclude_also = [
|
|
92
|
+
"if TYPE_CHECKING:",
|
|
93
|
+
"\\.\\.\\.",
|
|
94
|
+
]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Async Python SDK for ranobelib.me."""
|
|
2
|
+
|
|
3
|
+
from ranobelib.exceptions import (
|
|
4
|
+
AuthRequiredError,
|
|
5
|
+
ChapterNotFoundError,
|
|
6
|
+
MultipleTranslationsError,
|
|
7
|
+
RanobeLibError,
|
|
8
|
+
RateLimitError,
|
|
9
|
+
TitleNotFoundError,
|
|
10
|
+
VolumeNotFoundError,
|
|
11
|
+
)
|
|
12
|
+
from ranobelib.models import Chapter, ChapterBranch, Title, Volume
|
|
13
|
+
from ranobelib.sdk import RanobeLib
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"AuthRequiredError",
|
|
17
|
+
"Chapter",
|
|
18
|
+
"ChapterBranch",
|
|
19
|
+
"ChapterNotFoundError",
|
|
20
|
+
"MultipleTranslationsError",
|
|
21
|
+
"RanobeLib",
|
|
22
|
+
"RanobeLibError",
|
|
23
|
+
"RateLimitError",
|
|
24
|
+
"Title",
|
|
25
|
+
"TitleNotFoundError",
|
|
26
|
+
"Volume",
|
|
27
|
+
"VolumeNotFoundError",
|
|
28
|
+
]
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Disk cache for raw JSON API responses, keyed by request identity."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
DEFAULT_CACHE_DIR = Path(".ranobelib_cache")
|
|
13
|
+
"""Default cache directory, relative to the current working directory."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DiskCache:
|
|
17
|
+
"""Caches JSON-serializable values to disk, keyed by an arbitrary string.
|
|
18
|
+
|
|
19
|
+
One file per key, named by the key's hash (so keys built from slugs, chapter numbers,
|
|
20
|
+
etc. are always safe filenames), holding the cached value plus the time it was written
|
|
21
|
+
so ``ttl`` can be checked on read without touching any other entry. Corrupted or
|
|
22
|
+
unreadable entries are treated as a cache miss rather than raised as an error, since a
|
|
23
|
+
stale/broken cache file shouldn't stop the SDK from just re-fetching the data.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
cache_dir: str | Path,
|
|
29
|
+
*,
|
|
30
|
+
ttl: float | None = None,
|
|
31
|
+
clock: Callable[[], float] = time.time,
|
|
32
|
+
) -> None:
|
|
33
|
+
"""Initialize the cache.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
cache_dir: Directory to store cache files in. Created lazily on first write.
|
|
37
|
+
ttl: Seconds after which a cache entry is treated as a miss. ``None`` (the
|
|
38
|
+
default) means entries never expire.
|
|
39
|
+
clock: Time source used to stamp and check entries. Overridable for tests.
|
|
40
|
+
"""
|
|
41
|
+
self._dir = Path(cache_dir)
|
|
42
|
+
self._ttl = ttl
|
|
43
|
+
self._clock = clock
|
|
44
|
+
|
|
45
|
+
def get(self, key: str) -> Any | None:
|
|
46
|
+
"""Return the cached value for ``key``, or ``None`` on a miss or expiry."""
|
|
47
|
+
try:
|
|
48
|
+
payload = json.loads(self._path_for(key).read_text(encoding="utf-8"))
|
|
49
|
+
except (OSError, json.JSONDecodeError, KeyError):
|
|
50
|
+
return None
|
|
51
|
+
if self._ttl is not None and self._clock() - payload["cached_at"] > self._ttl:
|
|
52
|
+
return None
|
|
53
|
+
return payload["data"]
|
|
54
|
+
|
|
55
|
+
def set(self, key: str, data: Any) -> None:
|
|
56
|
+
"""Write ``data`` to disk under ``key``, creating the cache directory if needed."""
|
|
57
|
+
path = self._path_for(key)
|
|
58
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
payload = {"cached_at": self._clock(), "data": data}
|
|
60
|
+
path.write_text(json.dumps(payload), encoding="utf-8")
|
|
61
|
+
|
|
62
|
+
def _path_for(self, key: str) -> Path:
|
|
63
|
+
digest = hashlib.sha256(key.encode("utf-8")).hexdigest()
|
|
64
|
+
return self._dir / f"{digest}.json"
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Low-level HTTP client for the api.cdnlibs.org JSON API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Awaitable, Callable
|
|
8
|
+
from types import TracebackType
|
|
9
|
+
from typing import Any, Self
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from ranobelib.exceptions import (
|
|
14
|
+
AuthRequiredError,
|
|
15
|
+
ChapterNotFoundError,
|
|
16
|
+
RanobeLibError,
|
|
17
|
+
RateLimitError,
|
|
18
|
+
TitleNotFoundError,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
API_BASE_URL = "https://api.cdnlibs.org/api"
|
|
22
|
+
"""Base URL of the lib.social JSON API shared across the network's sites."""
|
|
23
|
+
|
|
24
|
+
RANOBELIB_SITE_ID = "3"
|
|
25
|
+
"""``Site-Id`` header value that scopes requests to ranobelib.me. See docs/api-notes.md."""
|
|
26
|
+
|
|
27
|
+
_RETRYABLE_STATUSES = frozenset({429, *range(500, 600)})
|
|
28
|
+
|
|
29
|
+
DEFAULT_REQUEST_DELAY = 0.2
|
|
30
|
+
"""Default minimum time, in seconds, between the start of one request and the next."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ApiClient:
|
|
34
|
+
"""Thin async wrapper around the undocumented api.cdnlibs.org JSON API.
|
|
35
|
+
|
|
36
|
+
Bounds concurrency with a semaphore, paces request starts with a small fixed delay, and
|
|
37
|
+
retries 429/5xx responses with exponential backoff — see docs/api-notes.md for why a
|
|
38
|
+
manual implementation was chosen over a dependency like ``tenacity`` for this.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
*,
|
|
44
|
+
base_url: str = API_BASE_URL,
|
|
45
|
+
timeout: float = 15.0,
|
|
46
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
47
|
+
max_concurrency: int = 5,
|
|
48
|
+
request_delay: float | None = None,
|
|
49
|
+
max_retries: int = 3,
|
|
50
|
+
retry_base_delay: float = 1.0,
|
|
51
|
+
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
|
|
52
|
+
clock: Callable[[], float] = time.monotonic,
|
|
53
|
+
) -> None:
|
|
54
|
+
"""Initialize the client.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
base_url: API base URL.
|
|
58
|
+
timeout: Per-request timeout, in seconds.
|
|
59
|
+
transport: Custom ``httpx`` transport, e.g. a mock transport for tests.
|
|
60
|
+
max_concurrency: Maximum number of requests in flight at once.
|
|
61
|
+
request_delay: Minimum time, in seconds, between the start of one request and
|
|
62
|
+
the next, even under ``max_concurrency``. ``None`` (the default) uses
|
|
63
|
+
``DEFAULT_REQUEST_DELAY``.
|
|
64
|
+
max_retries: How many times to retry a 429 or 5xx response before giving up and
|
|
65
|
+
raising.
|
|
66
|
+
retry_base_delay: Base delay, in seconds, for exponential backoff between
|
|
67
|
+
retries (doubled per attempt) — used unless a 429 response's
|
|
68
|
+
``Retry-After`` header says otherwise.
|
|
69
|
+
sleep: Sleep function used for pacing and backoff. Overridable for tests.
|
|
70
|
+
clock: Time source used for pacing. Overridable for tests.
|
|
71
|
+
"""
|
|
72
|
+
self._http = httpx.AsyncClient(
|
|
73
|
+
base_url=base_url,
|
|
74
|
+
timeout=timeout,
|
|
75
|
+
transport=transport,
|
|
76
|
+
headers={
|
|
77
|
+
"Site-Id": RANOBELIB_SITE_ID,
|
|
78
|
+
"Accept": "application/json",
|
|
79
|
+
},
|
|
80
|
+
)
|
|
81
|
+
self._semaphore = asyncio.Semaphore(max_concurrency)
|
|
82
|
+
self._request_delay = DEFAULT_REQUEST_DELAY if request_delay is None else request_delay
|
|
83
|
+
self._max_retries = max_retries
|
|
84
|
+
self._retry_base_delay = retry_base_delay
|
|
85
|
+
self._sleep = sleep
|
|
86
|
+
self._clock = clock
|
|
87
|
+
self._pacing_lock = asyncio.Lock()
|
|
88
|
+
self._last_request_at: float | None = None
|
|
89
|
+
|
|
90
|
+
async def __aenter__(self) -> Self:
|
|
91
|
+
return self
|
|
92
|
+
|
|
93
|
+
async def __aexit__(
|
|
94
|
+
self,
|
|
95
|
+
exc_type: type[BaseException] | None,
|
|
96
|
+
exc: BaseException | None,
|
|
97
|
+
traceback: TracebackType | None,
|
|
98
|
+
) -> None:
|
|
99
|
+
await self.aclose()
|
|
100
|
+
|
|
101
|
+
async def aclose(self) -> None:
|
|
102
|
+
"""Close the underlying HTTP connection pool."""
|
|
103
|
+
await self._http.aclose()
|
|
104
|
+
|
|
105
|
+
async def get_title(self, slug_url: str, *, fields: list[str] | None = None) -> dict[str, Any]:
|
|
106
|
+
"""Fetch raw title metadata for ``slug_url``.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
slug_url: The title's ``{id}--{slug}`` identifier.
|
|
110
|
+
fields: Extra ``fields[]`` values to request from the API.
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
The ``data`` object from the API response.
|
|
114
|
+
"""
|
|
115
|
+
params = httpx.QueryParams([("fields[]", field) for field in fields or []])
|
|
116
|
+
response = await self._get(f"/manga/{slug_url}", params=params)
|
|
117
|
+
self._raise_for_status(response, not_found=TitleNotFoundError(slug_url))
|
|
118
|
+
data: dict[str, Any] = response.json()["data"]
|
|
119
|
+
return data
|
|
120
|
+
|
|
121
|
+
async def get_chapters(self, slug_url: str) -> list[dict[str, Any]]:
|
|
122
|
+
"""Fetch the raw chapter list for ``slug_url`` (single request, no pagination).
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
slug_url: The title's ``{id}--{slug}`` identifier.
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
The ``data`` array from the API response.
|
|
129
|
+
"""
|
|
130
|
+
response = await self._get(f"/manga/{slug_url}/chapters")
|
|
131
|
+
self._raise_for_status(response, not_found=TitleNotFoundError(slug_url))
|
|
132
|
+
data: list[dict[str, Any]] = response.json()["data"]
|
|
133
|
+
return data
|
|
134
|
+
|
|
135
|
+
async def get_chapter(
|
|
136
|
+
self,
|
|
137
|
+
slug_url: str,
|
|
138
|
+
*,
|
|
139
|
+
number: str,
|
|
140
|
+
volume: str,
|
|
141
|
+
branch_id: int | None = None,
|
|
142
|
+
) -> dict[str, Any]:
|
|
143
|
+
"""Fetch a single chapter's metadata and content.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
slug_url: The title's ``{id}--{slug}`` identifier.
|
|
147
|
+
number: The chapter number, as returned by the API (may contain a decimal).
|
|
148
|
+
volume: The volume number.
|
|
149
|
+
branch_id: Which translation team's branch to fetch, for chapters with more
|
|
150
|
+
than one. Defaults to whichever branch the API picks (see
|
|
151
|
+
docs/api-notes.md — this is not simply the first branch listed).
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
The ``data`` object from the API response.
|
|
155
|
+
"""
|
|
156
|
+
params = {"number": number, "volume": volume}
|
|
157
|
+
if branch_id is not None:
|
|
158
|
+
params["branch_id"] = str(branch_id)
|
|
159
|
+
response = await self._get(f"/manga/{slug_url}/chapter", params=params)
|
|
160
|
+
self._raise_for_status(
|
|
161
|
+
response, not_found=ChapterNotFoundError(slug_url, volume=volume, number=number)
|
|
162
|
+
)
|
|
163
|
+
data: dict[str, Any] = response.json()["data"]
|
|
164
|
+
return data
|
|
165
|
+
|
|
166
|
+
async def _get(self, url: str, *, params: Any = None) -> httpx.Response:
|
|
167
|
+
"""Issue a GET request, bounded by concurrency/pacing and retried on 429/5xx."""
|
|
168
|
+
attempt = 0
|
|
169
|
+
while True:
|
|
170
|
+
async with self._semaphore:
|
|
171
|
+
await self._pace()
|
|
172
|
+
response = await self._http.get(url, params=params)
|
|
173
|
+
if response.status_code not in _RETRYABLE_STATUSES or attempt >= self._max_retries:
|
|
174
|
+
return response
|
|
175
|
+
await self._sleep(self._backoff_delay(attempt, response))
|
|
176
|
+
attempt += 1
|
|
177
|
+
|
|
178
|
+
async def _pace(self) -> None:
|
|
179
|
+
"""Wait, if needed, so this request starts at least ``request_delay`` after the last."""
|
|
180
|
+
async with self._pacing_lock:
|
|
181
|
+
now = self._clock()
|
|
182
|
+
if self._last_request_at is not None:
|
|
183
|
+
wait = self._last_request_at + self._request_delay - now
|
|
184
|
+
if wait > 0:
|
|
185
|
+
await self._sleep(wait)
|
|
186
|
+
self._last_request_at = self._clock()
|
|
187
|
+
|
|
188
|
+
def _backoff_delay(self, attempt: int, response: httpx.Response) -> float:
|
|
189
|
+
if response.status_code == 429:
|
|
190
|
+
retry_after = response.headers.get("Retry-After")
|
|
191
|
+
if retry_after is not None:
|
|
192
|
+
try:
|
|
193
|
+
return float(retry_after)
|
|
194
|
+
except ValueError:
|
|
195
|
+
pass
|
|
196
|
+
delay: float = self._retry_base_delay * (2**attempt)
|
|
197
|
+
return delay
|
|
198
|
+
|
|
199
|
+
def _raise_for_status(self, response: httpx.Response, *, not_found: RanobeLibError) -> None:
|
|
200
|
+
if response.status_code == 404:
|
|
201
|
+
raise not_found
|
|
202
|
+
if response.status_code == 403:
|
|
203
|
+
raise AuthRequiredError(str(response.request.url))
|
|
204
|
+
if response.status_code == 429:
|
|
205
|
+
retry_after = response.headers.get("Retry-After")
|
|
206
|
+
raise RateLimitError(float(retry_after) if retry_after else None)
|
|
207
|
+
try:
|
|
208
|
+
response.raise_for_status()
|
|
209
|
+
except httpx.HTTPStatusError as exc:
|
|
210
|
+
raise RanobeLibError(f"Unexpected API response: {exc}") from exc
|