mangalib-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.
- mangalib_python_sdk-0.1.0/PKG-INFO +100 -0
- mangalib_python_sdk-0.1.0/README.md +73 -0
- mangalib_python_sdk-0.1.0/pyproject.toml +60 -0
- mangalib_python_sdk-0.1.0/pyproject.toml.orig +55 -0
- mangalib_python_sdk-0.1.0/src/mangalib/__init__.py +65 -0
- mangalib_python_sdk-0.1.0/src/mangalib/_http.py +30 -0
- mangalib_python_sdk-0.1.0/src/mangalib/cache.py +55 -0
- mangalib_python_sdk-0.1.0/src/mangalib/catalog.py +68 -0
- mangalib_python_sdk-0.1.0/src/mangalib/client.py +99 -0
- mangalib_python_sdk-0.1.0/src/mangalib/exceptions.py +58 -0
- mangalib_python_sdk-0.1.0/src/mangalib/images.py +89 -0
- mangalib_python_sdk-0.1.0/src/mangalib/models.py +185 -0
- mangalib_python_sdk-0.1.0/src/mangalib/py.typed +0 -0
- mangalib_python_sdk-0.1.0/src/mangalib/sdk.py +207 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mangalib-python-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Async Python SDK for mangalib.me: title metadata, chapters, translations, catalog and page images
|
|
5
|
+
Keywords: mangalib,manga,manhwa,comics,lib.social,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: httpx>=0.27
|
|
19
|
+
Requires-Dist: libsocial-core>=1.0,<2
|
|
20
|
+
Requires-Dist: pydantic>=2.7
|
|
21
|
+
Requires-Python: >=3.11
|
|
22
|
+
Project-URL: Homepage, https://github.com/LLIJIYAPNIK/ranobelib-python-sdk
|
|
23
|
+
Project-URL: Repository, https://github.com/LLIJIYAPNIK/ranobelib-python-sdk
|
|
24
|
+
Project-URL: Issues, https://github.com/LLIJIYAPNIK/ranobelib-python-sdk/issues
|
|
25
|
+
Project-URL: Changelog, https://github.com/LLIJIYAPNIK/ranobelib-python-sdk/blob/main/packages/mangalib/CHANGELOG.md
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# mangalib-python-sdk
|
|
29
|
+
|
|
30
|
+
[](https://pypi.org/project/mangalib-python-sdk/)
|
|
31
|
+
[](../../LICENSE)
|
|
32
|
+
[](pyproject.toml)
|
|
33
|
+
[](https://github.com/LLIJIYAPNIK/ranobelib-python-sdk/actions/workflows/ci.yml)
|
|
34
|
+
|
|
35
|
+
Async Python SDK for [mangalib.me](https://mangalib.me): title metadata, table of contents,
|
|
36
|
+
translations, catalog search, and chapter pages as images.
|
|
37
|
+
|
|
38
|
+
Like [`ranobelib-python-sdk`](https://pypi.org/project/ranobelib-python-sdk/), it talks to
|
|
39
|
+
the undocumented but open JSON API behind the site (`api.cdnlibs.org`, shared by the whole
|
|
40
|
+
lib.social network) instead of scraping HTML. The shared part (API client, request pacing,
|
|
41
|
+
cache, titles, chapters, catalog) comes from `libsocial-core`, installed with it, so a
|
|
42
|
+
process using both SDKs paces its requests to the API together.
|
|
43
|
+
|
|
44
|
+
> **Status:** early. Titles, chapters, translations, the catalog and page downloads work;
|
|
45
|
+
> export to CBZ/PDF is planned. There is no login, so paid and early-access chapters can't
|
|
46
|
+
> be read: fetching one raises `AuthRequiredError`.
|
|
47
|
+
|
|
48
|
+
## Installation
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
uv add mangalib-python-sdk
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
or
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
pip install mangalib-python-sdk
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Requires Python 3.11+.
|
|
61
|
+
|
|
62
|
+
## Quickstart
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
from mangalib import Catalog, MangaLib
|
|
66
|
+
|
|
67
|
+
async with MangaLib("https://mangalib.me/ru/manga/7965--chainsaw-man") as lib:
|
|
68
|
+
info = await lib.get_info()
|
|
69
|
+
toc = await lib.get_table_of_contents()
|
|
70
|
+
|
|
71
|
+
# Chapter 1 has two translations: pick one (see get_translations()).
|
|
72
|
+
translations = await lib.get_translations(volume=1, number="1")
|
|
73
|
+
chapter = await lib.get_chapter(volume=1, number="1", branch_id=translations[0].branch_id)
|
|
74
|
+
|
|
75
|
+
for page in chapter.pages:
|
|
76
|
+
print(page.number, page.width, page.height)
|
|
77
|
+
images = await lib.download_pages(chapter) # list[bytes], in reading order
|
|
78
|
+
|
|
79
|
+
async with Catalog() as catalog:
|
|
80
|
+
page = await catalog.list_titles(query="chainsaw")
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
- `get_chapter()` / `get_chapters()` / `get_volume()` / `get_volumes()` /
|
|
84
|
+
`download_title()` return chapters with `pages` (`Page`: position, size, path on an image
|
|
85
|
+
server).
|
|
86
|
+
- `ChapterBranch.is_open` (from `get_table_of_contents()` / `get_translations()`) says
|
|
87
|
+
whether a translation can be read without an account.
|
|
88
|
+
- `download_page()` / `download_pages()` fall back from one image server to the next; see
|
|
89
|
+
`list_image_servers()` and the API notes for why `compress` is tried first.
|
|
90
|
+
|
|
91
|
+
## Documentation
|
|
92
|
+
|
|
93
|
+
Full API reference: **<https://LLIJIYAPNIK.github.io/ranobelib-python-sdk/>**. What the API
|
|
94
|
+
returns for MangaLib, and what was checked: the "MangaLib" section of
|
|
95
|
+
[`docs/api-notes.md`](../../docs/api-notes.md). [`CHANGELOG.md`](CHANGELOG.md) lists what
|
|
96
|
+
changed in each release.
|
|
97
|
+
|
|
98
|
+
## License
|
|
99
|
+
|
|
100
|
+
MIT — see [LICENSE](../../LICENSE).
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# mangalib-python-sdk
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/mangalib-python-sdk/)
|
|
4
|
+
[](../../LICENSE)
|
|
5
|
+
[](pyproject.toml)
|
|
6
|
+
[](https://github.com/LLIJIYAPNIK/ranobelib-python-sdk/actions/workflows/ci.yml)
|
|
7
|
+
|
|
8
|
+
Async Python SDK for [mangalib.me](https://mangalib.me): title metadata, table of contents,
|
|
9
|
+
translations, catalog search, and chapter pages as images.
|
|
10
|
+
|
|
11
|
+
Like [`ranobelib-python-sdk`](https://pypi.org/project/ranobelib-python-sdk/), it talks to
|
|
12
|
+
the undocumented but open JSON API behind the site (`api.cdnlibs.org`, shared by the whole
|
|
13
|
+
lib.social network) instead of scraping HTML. The shared part (API client, request pacing,
|
|
14
|
+
cache, titles, chapters, catalog) comes from `libsocial-core`, installed with it, so a
|
|
15
|
+
process using both SDKs paces its requests to the API together.
|
|
16
|
+
|
|
17
|
+
> **Status:** early. Titles, chapters, translations, the catalog and page downloads work;
|
|
18
|
+
> export to CBZ/PDF is planned. There is no login, so paid and early-access chapters can't
|
|
19
|
+
> be read: fetching one raises `AuthRequiredError`.
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
uv add mangalib-python-sdk
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
or
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install mangalib-python-sdk
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Requires Python 3.11+.
|
|
34
|
+
|
|
35
|
+
## Quickstart
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from mangalib import Catalog, MangaLib
|
|
39
|
+
|
|
40
|
+
async with MangaLib("https://mangalib.me/ru/manga/7965--chainsaw-man") as lib:
|
|
41
|
+
info = await lib.get_info()
|
|
42
|
+
toc = await lib.get_table_of_contents()
|
|
43
|
+
|
|
44
|
+
# Chapter 1 has two translations: pick one (see get_translations()).
|
|
45
|
+
translations = await lib.get_translations(volume=1, number="1")
|
|
46
|
+
chapter = await lib.get_chapter(volume=1, number="1", branch_id=translations[0].branch_id)
|
|
47
|
+
|
|
48
|
+
for page in chapter.pages:
|
|
49
|
+
print(page.number, page.width, page.height)
|
|
50
|
+
images = await lib.download_pages(chapter) # list[bytes], in reading order
|
|
51
|
+
|
|
52
|
+
async with Catalog() as catalog:
|
|
53
|
+
page = await catalog.list_titles(query="chainsaw")
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
- `get_chapter()` / `get_chapters()` / `get_volume()` / `get_volumes()` /
|
|
57
|
+
`download_title()` return chapters with `pages` (`Page`: position, size, path on an image
|
|
58
|
+
server).
|
|
59
|
+
- `ChapterBranch.is_open` (from `get_table_of_contents()` / `get_translations()`) says
|
|
60
|
+
whether a translation can be read without an account.
|
|
61
|
+
- `download_page()` / `download_pages()` fall back from one image server to the next; see
|
|
62
|
+
`list_image_servers()` and the API notes for why `compress` is tried first.
|
|
63
|
+
|
|
64
|
+
## Documentation
|
|
65
|
+
|
|
66
|
+
Full API reference: **<https://LLIJIYAPNIK.github.io/ranobelib-python-sdk/>**. What the API
|
|
67
|
+
returns for MangaLib, and what was checked: the "MangaLib" section of
|
|
68
|
+
[`docs/api-notes.md`](../../docs/api-notes.md). [`CHANGELOG.md`](CHANGELOG.md) lists what
|
|
69
|
+
changed in each release.
|
|
70
|
+
|
|
71
|
+
## License
|
|
72
|
+
|
|
73
|
+
MIT — see [LICENSE](../../LICENSE).
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "mangalib-python-sdk"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Async Python SDK for mangalib.me: title metadata, chapters, translations, catalog and page images"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
requires-python = ">=3.11"
|
|
8
|
+
keywords = [
|
|
9
|
+
"mangalib",
|
|
10
|
+
"manga",
|
|
11
|
+
"manhwa",
|
|
12
|
+
"comics",
|
|
13
|
+
"lib.social",
|
|
14
|
+
"sdk",
|
|
15
|
+
]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 2 - Pre-Alpha",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Programming Language :: Python :: 3.13",
|
|
24
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
25
|
+
"Typing :: Typed",
|
|
26
|
+
]
|
|
27
|
+
dependencies = [
|
|
28
|
+
"httpx>=0.27",
|
|
29
|
+
"libsocial-core>=1.0,<2",
|
|
30
|
+
"pydantic>=2.7",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[[project.authors]]
|
|
34
|
+
name = "LLIJIYAPNIK"
|
|
35
|
+
email = "kas4wot@yandex.ru"
|
|
36
|
+
|
|
37
|
+
[project.urls]
|
|
38
|
+
Homepage = "https://github.com/LLIJIYAPNIK/ranobelib-python-sdk"
|
|
39
|
+
Repository = "https://github.com/LLIJIYAPNIK/ranobelib-python-sdk"
|
|
40
|
+
Issues = "https://github.com/LLIJIYAPNIK/ranobelib-python-sdk/issues"
|
|
41
|
+
Changelog = "https://github.com/LLIJIYAPNIK/ranobelib-python-sdk/blob/main/packages/mangalib/CHANGELOG.md"
|
|
42
|
+
|
|
43
|
+
[build-system]
|
|
44
|
+
requires = ["uv_build>=0.11.26,<0.12.0"]
|
|
45
|
+
build-backend = "uv_build"
|
|
46
|
+
|
|
47
|
+
[tool.uv.build-backend]
|
|
48
|
+
module-name = "mangalib"
|
|
49
|
+
|
|
50
|
+
[tool.uv.sources.libsocial-core]
|
|
51
|
+
workspace = true
|
|
52
|
+
|
|
53
|
+
[dependency-groups]
|
|
54
|
+
dev = [
|
|
55
|
+
"pytest>=8.0",
|
|
56
|
+
"pytest-asyncio>=0.24",
|
|
57
|
+
"pytest-cov>=5.0",
|
|
58
|
+
"pytest-recording>=0.13",
|
|
59
|
+
"vcrpy>=6.0",
|
|
60
|
+
]
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "mangalib-python-sdk"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Async Python SDK for mangalib.me: title metadata, chapters, translations, catalog and page images"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
requires-python = ">=3.11"
|
|
8
|
+
authors = [
|
|
9
|
+
{ name = "LLIJIYAPNIK", email = "kas4wot@yandex.ru" }
|
|
10
|
+
]
|
|
11
|
+
keywords = ["mangalib", "manga", "manhwa", "comics", "lib.social", "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
|
+
"httpx>=0.27",
|
|
25
|
+
"libsocial-core>=1.0,<2",
|
|
26
|
+
"pydantic>=2.7",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.urls]
|
|
30
|
+
Homepage = "https://github.com/LLIJIYAPNIK/ranobelib-python-sdk"
|
|
31
|
+
Repository = "https://github.com/LLIJIYAPNIK/ranobelib-python-sdk"
|
|
32
|
+
Issues = "https://github.com/LLIJIYAPNIK/ranobelib-python-sdk/issues"
|
|
33
|
+
Changelog = "https://github.com/LLIJIYAPNIK/ranobelib-python-sdk/blob/main/packages/mangalib/CHANGELOG.md"
|
|
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 = "mangalib"
|
|
41
|
+
|
|
42
|
+
# Inside this repository the core comes from the workspace, not PyPI.
|
|
43
|
+
[tool.uv.sources]
|
|
44
|
+
libsocial-core = { workspace = true }
|
|
45
|
+
|
|
46
|
+
# Test stack for this package only, same reason as ranobelib's: `uv run --package
|
|
47
|
+
# mangalib-python-sdk` installs the package's own groups, not the root's.
|
|
48
|
+
[dependency-groups]
|
|
49
|
+
dev = [
|
|
50
|
+
"pytest>=8.0",
|
|
51
|
+
"pytest-asyncio>=0.24",
|
|
52
|
+
"pytest-cov>=5.0",
|
|
53
|
+
"pytest-recording>=0.13",
|
|
54
|
+
"vcrpy>=6.0",
|
|
55
|
+
]
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Async Python SDK for mangalib.me."""
|
|
2
|
+
|
|
3
|
+
from mangalib.cache import CacheStats, cache_stats, clear_cache
|
|
4
|
+
from mangalib.catalog import Catalog
|
|
5
|
+
from mangalib.exceptions import (
|
|
6
|
+
AccessBlockedError,
|
|
7
|
+
AmbiguousChapter,
|
|
8
|
+
AuthRequiredError,
|
|
9
|
+
ChapterNotFoundError,
|
|
10
|
+
DownloadTitleInterruptedError,
|
|
11
|
+
MangaLibError,
|
|
12
|
+
MultipleTitleTranslationsError,
|
|
13
|
+
MultipleTranslationsError,
|
|
14
|
+
PageDownloadError,
|
|
15
|
+
RateLimitError,
|
|
16
|
+
TitleNotFoundError,
|
|
17
|
+
VolumeNotFoundError,
|
|
18
|
+
)
|
|
19
|
+
from mangalib.images import page_url
|
|
20
|
+
from mangalib.models import (
|
|
21
|
+
CatalogPage,
|
|
22
|
+
Chapter,
|
|
23
|
+
ChapterBranch,
|
|
24
|
+
Country,
|
|
25
|
+
Genre,
|
|
26
|
+
ImageServer,
|
|
27
|
+
Label,
|
|
28
|
+
Page,
|
|
29
|
+
RestrictedView,
|
|
30
|
+
Title,
|
|
31
|
+
Volume,
|
|
32
|
+
)
|
|
33
|
+
from mangalib.sdk import MangaLib
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"AccessBlockedError",
|
|
37
|
+
"AmbiguousChapter",
|
|
38
|
+
"AuthRequiredError",
|
|
39
|
+
"CacheStats",
|
|
40
|
+
"Catalog",
|
|
41
|
+
"CatalogPage",
|
|
42
|
+
"Chapter",
|
|
43
|
+
"ChapterBranch",
|
|
44
|
+
"ChapterNotFoundError",
|
|
45
|
+
"Country",
|
|
46
|
+
"DownloadTitleInterruptedError",
|
|
47
|
+
"Genre",
|
|
48
|
+
"ImageServer",
|
|
49
|
+
"Label",
|
|
50
|
+
"MangaLib",
|
|
51
|
+
"MangaLibError",
|
|
52
|
+
"MultipleTitleTranslationsError",
|
|
53
|
+
"MultipleTranslationsError",
|
|
54
|
+
"Page",
|
|
55
|
+
"PageDownloadError",
|
|
56
|
+
"RateLimitError",
|
|
57
|
+
"RestrictedView",
|
|
58
|
+
"Title",
|
|
59
|
+
"TitleNotFoundError",
|
|
60
|
+
"Volume",
|
|
61
|
+
"VolumeNotFoundError",
|
|
62
|
+
"cache_stats",
|
|
63
|
+
"clear_cache",
|
|
64
|
+
"page_url",
|
|
65
|
+
]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""mangalib.me's site identity and browser headers (not public API)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from libsocial import Site, browser_headers_for
|
|
10
|
+
|
|
11
|
+
SITE_ORIGIN = "https://mangalib.me"
|
|
12
|
+
"""Origin of the mangalib.me website, as its own pages send it."""
|
|
13
|
+
|
|
14
|
+
MANGALIB = Site(id=1, origin=SITE_ORIGIN)
|
|
15
|
+
"""mangalib.me on the shared lib.social API: ``Site-Id: 1`` (see docs/api-notes.md)."""
|
|
16
|
+
|
|
17
|
+
IMAGE_ACCEPT = "image/*"
|
|
18
|
+
"""``Accept`` sent for page images, as a browser's ``<img>`` request would."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def image_headers(overrides: Mapping[str, str] | None = None) -> httpx.Headers:
|
|
22
|
+
"""Headers for downloading page images: mangalib.me's browser headers, ``Accept: image/*``.
|
|
23
|
+
|
|
24
|
+
A key in ``overrides`` replaces the default one, matched case-insensitively — the same
|
|
25
|
+
merge ``ApiClient(headers=...)`` does for API requests.
|
|
26
|
+
"""
|
|
27
|
+
headers = httpx.Headers({**browser_headers_for(SITE_ORIGIN), "Accept": IMAGE_ACCEPT})
|
|
28
|
+
if overrides:
|
|
29
|
+
headers.update(overrides)
|
|
30
|
+
return headers
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Disk cache maintenance: ``cache_stats()`` and ``clear_cache()``.
|
|
2
|
+
|
|
3
|
+
The cache itself is ``libsocial-core``'s; this module only gives its helpers mangalib's
|
|
4
|
+
default directory, ``.mangalib_cache``, the one ``MangaLib``/``Catalog`` use. The on-disk
|
|
5
|
+
layout is internal, so code outside the SDK should go through these rather than measure or
|
|
6
|
+
delete the cache's files itself.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from libsocial import cache as _core
|
|
14
|
+
from libsocial.cache import CacheStats
|
|
15
|
+
|
|
16
|
+
DEFAULT_CACHE_DIR = Path(".mangalib_cache")
|
|
17
|
+
"""Default cache directory, relative to the current working directory."""
|
|
18
|
+
|
|
19
|
+
__all__ = ["DEFAULT_CACHE_DIR", "CacheStats", "cache_stats", "clear_cache"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def cache_stats(cache_dir: str | Path = DEFAULT_CACHE_DIR) -> CacheStats:
|
|
23
|
+
"""Count the entries in an SDK disk cache and their total size on disk.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
cache_dir: The cache directory — the same ``cache_dir`` passed to ``MangaLib`` /
|
|
27
|
+
``Catalog`` (default: theirs, ``.mangalib_cache`` in the current directory).
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
The number of entries and their total size in bytes. A missing directory is an empty
|
|
31
|
+
cache, ``CacheStats(entries=0, bytes=0)``, not an error.
|
|
32
|
+
"""
|
|
33
|
+
return _core.cache_stats(cache_dir)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def clear_cache(
|
|
37
|
+
cache_dir: str | Path = DEFAULT_CACHE_DIR, *, older_than: float | None = None
|
|
38
|
+
) -> CacheStats:
|
|
39
|
+
"""Remove entries from an SDK disk cache.
|
|
40
|
+
|
|
41
|
+
Only files the cache itself writes are removed — anything else in the directory, and the
|
|
42
|
+
directory itself, is left alone.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
cache_dir: The cache directory — see ``cache_stats()``.
|
|
46
|
+
older_than: If given, only remove entries written more than this many seconds ago,
|
|
47
|
+
plus any corrupted entry. ``None`` (the default) removes every entry.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
The number of entries removed and the bytes they took.
|
|
51
|
+
|
|
52
|
+
Raises:
|
|
53
|
+
ValueError: If ``older_than`` is negative.
|
|
54
|
+
"""
|
|
55
|
+
return _core.clear_cache(cache_dir, older_than=older_than)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Catalog listing/search: the ``Catalog`` class.
|
|
2
|
+
|
|
3
|
+
Catalog listing isn't scoped to any title, so it's a separate entry point rather than a
|
|
4
|
+
method on ``MangaLib``. The listing itself is ``libsocial-core``'s (``libsocial.Catalog``);
|
|
5
|
+
this class points it at mangalib.me and its own models.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from libsocial import Catalog as _CoreCatalog
|
|
14
|
+
from libsocial.catalog import DEFAULT_SORT, MAX_PER_PAGE, MIN_PER_PAGE
|
|
15
|
+
from mangalib._http import MANGALIB
|
|
16
|
+
from mangalib.cache import DEFAULT_CACHE_DIR
|
|
17
|
+
from mangalib.client import ApiClient
|
|
18
|
+
from mangalib.models import CatalogPage, Title
|
|
19
|
+
|
|
20
|
+
__all__ = ["DEFAULT_SORT", "MAX_PER_PAGE", "MIN_PER_PAGE", "Catalog"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Catalog(_CoreCatalog[Title]):
|
|
24
|
+
"""Entry point for listing/searching the mangalib.me catalog.
|
|
25
|
+
|
|
26
|
+
Lists titles (``list_titles()``) and the filter options for them (``list_genres()``,
|
|
27
|
+
``list_countries()`` — on mangalib.me these are the title types: manga, manhwa, manhua,
|
|
28
|
+
... — and ``list_statuses()``), each filtered to mangalib.me.
|
|
29
|
+
|
|
30
|
+
Example:
|
|
31
|
+
```python
|
|
32
|
+
async with Catalog() as catalog:
|
|
33
|
+
page = await catalog.list_titles(query="chainsaw", statuses=[1])
|
|
34
|
+
for title in page.items:
|
|
35
|
+
print(title.name)
|
|
36
|
+
```
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
site = MANGALIB
|
|
40
|
+
page_model = CatalogPage
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
*,
|
|
45
|
+
cache_dir: str | Path | None = None,
|
|
46
|
+
cache_ttl: float | None = None,
|
|
47
|
+
headers: Mapping[str, str] | None = None,
|
|
48
|
+
) -> None:
|
|
49
|
+
"""Initialize the catalog client.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
cache_dir: Where to cache raw API responses on disk, one entry per distinct set
|
|
53
|
+
of listing parameters. Defaults to ``.mangalib_cache`` in the current
|
|
54
|
+
working directory, the same as ``MangaLib``'s.
|
|
55
|
+
cache_ttl: Seconds after which a cached page is treated as stale and re-fetched.
|
|
56
|
+
``None`` (the default) means cached pages never expire on their own — see
|
|
57
|
+
``refresh=True`` on ``list_titles()`` to force one anyway.
|
|
58
|
+
headers: Extra HTTP headers merged over the SDK's defaults, same as
|
|
59
|
+
``MangaLib(headers=...)`` — see there.
|
|
60
|
+
"""
|
|
61
|
+
super().__init__(
|
|
62
|
+
cache_dir=cache_dir if cache_dir is not None else DEFAULT_CACHE_DIR,
|
|
63
|
+
cache_ttl=cache_ttl,
|
|
64
|
+
headers=headers,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
def _create_client(self, headers: Mapping[str, str] | None) -> ApiClient:
|
|
68
|
+
return ApiClient(headers=headers)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Low-level HTTP client for the api.cdnlibs.org JSON API, scoped to mangalib.me.
|
|
2
|
+
|
|
3
|
+
The client is ``libsocial-core``'s (``libsocial.ApiClient``); this subclass fixes its site to
|
|
4
|
+
mangalib.me and adds what only a manga chapter needs: refusing closed (paid/early-access)
|
|
5
|
+
chapters, and the list of image servers its pages are served from.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from libsocial import ApiClient as _CoreApiClient
|
|
15
|
+
from libsocial.exceptions import AuthRequiredError, LibSocialError
|
|
16
|
+
from mangalib._http import MANGALIB
|
|
17
|
+
|
|
18
|
+
__all__ = ["ApiClient"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ApiClient(_CoreApiClient):
|
|
22
|
+
"""``libsocial.ApiClient`` for mangalib.me.
|
|
23
|
+
|
|
24
|
+
Bounds concurrency, paces request starts and retries 429/5xx with backoff — see
|
|
25
|
+
``libsocial.ApiClient``. Pacing is shared with every other client in the process talking
|
|
26
|
+
to api.cdnlibs.org (a ranobelib client too), unless ``pacer`` is given.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
headers_hint = "MangaLib(..., headers=...) / Catalog(headers=...)"
|
|
30
|
+
|
|
31
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
32
|
+
"""Initialize the client.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
**kwargs: The keyword arguments of ``libsocial.ApiClient`` (``base_url``,
|
|
36
|
+
``timeout``, ``transport``, ``headers``, ``max_concurrency``,
|
|
37
|
+
``request_delay``, ``max_retries``, ``retry_base_delay``, ``sleep``,
|
|
38
|
+
``pacer``) — everything but ``site``, which is always mangalib.me.
|
|
39
|
+
"""
|
|
40
|
+
super().__init__(MANGALIB, **kwargs)
|
|
41
|
+
|
|
42
|
+
async def get_chapter(
|
|
43
|
+
self,
|
|
44
|
+
slug_url: str,
|
|
45
|
+
*,
|
|
46
|
+
number: str,
|
|
47
|
+
volume: str,
|
|
48
|
+
branch_id: int | None = None,
|
|
49
|
+
) -> dict[str, Any]:
|
|
50
|
+
"""Fetch a single chapter's metadata and pages.
|
|
51
|
+
|
|
52
|
+
A closed chapter (paid, or still in early access) doesn't come back as a 403: the API
|
|
53
|
+
answers 200 with ``restricted_view.is_open: false`` and no ``pages`` at all (see
|
|
54
|
+
docs/api-notes.md, "MangaLib"). That's turned into ``AuthRequiredError`` here, before
|
|
55
|
+
the response reaches the disk cache — a cached refusal would outlive the chapter
|
|
56
|
+
opening up when its early access ends.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
slug_url: The title's ``{id}--{slug}`` identifier.
|
|
60
|
+
number: The chapter number, as returned by the API (may contain a decimal).
|
|
61
|
+
volume: The volume number.
|
|
62
|
+
branch_id: Which translation to fetch, for chapters with more than one.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
The ``data`` object from the API response.
|
|
66
|
+
|
|
67
|
+
Raises:
|
|
68
|
+
AuthRequiredError: If the chapter is closed to readers without an account.
|
|
69
|
+
"""
|
|
70
|
+
data = await super().get_chapter(
|
|
71
|
+
slug_url, number=number, volume=volume, branch_id=branch_id
|
|
72
|
+
)
|
|
73
|
+
restricted_view = data.get("restricted_view")
|
|
74
|
+
if isinstance(restricted_view, dict) and not restricted_view.get("is_open", True):
|
|
75
|
+
params = {"number": number, "volume": volume}
|
|
76
|
+
if branch_id is not None:
|
|
77
|
+
params["branch_id"] = str(branch_id)
|
|
78
|
+
url = self._http.build_request("GET", f"/manga/{slug_url}/chapter", params=params).url
|
|
79
|
+
raise AuthRequiredError(str(url))
|
|
80
|
+
return data
|
|
81
|
+
|
|
82
|
+
async def list_image_servers(self) -> list[dict[str, Any]]:
|
|
83
|
+
"""Fetch the raw image server list shared across the whole lib.social network.
|
|
84
|
+
|
|
85
|
+
Same shape and caveats as ``list_genres``: not site-scoped by any request parameter,
|
|
86
|
+
every server known to the network at once, each tagged with the site ids it serves
|
|
87
|
+
via ``site_ids``.
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
The raw ``data.imageServers`` array from the API response, unfiltered by site.
|
|
91
|
+
"""
|
|
92
|
+
response = await self._get(
|
|
93
|
+
"/constants", params=httpx.QueryParams([("fields[]", "imageServers")])
|
|
94
|
+
)
|
|
95
|
+
self._raise_for_status(
|
|
96
|
+
response, not_found=LibSocialError("Unexpected 404 from image server list")
|
|
97
|
+
)
|
|
98
|
+
data: list[dict[str, Any]] = response.json()["data"]["imageServers"]
|
|
99
|
+
return data
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Exceptions raised by the SDK.
|
|
2
|
+
|
|
3
|
+
Everything but ``PageDownloadError`` is ``libsocial-core``'s, re-exported here: the core
|
|
4
|
+
raises them, so ``except mangalib.TitleNotFoundError`` and ``except libsocial.TitleNotFoundError``
|
|
5
|
+
catch the same thing. ``MangaLibError`` is the core's base class, so ``except MangaLibError``
|
|
6
|
+
catches every error the SDK raises.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from libsocial.exceptions import (
|
|
12
|
+
AccessBlockedError,
|
|
13
|
+
AmbiguousChapter,
|
|
14
|
+
AuthRequiredError,
|
|
15
|
+
ChapterNotFoundError,
|
|
16
|
+
DownloadTitleInterruptedError,
|
|
17
|
+
LibSocialError,
|
|
18
|
+
MultipleTitleTranslationsError,
|
|
19
|
+
MultipleTranslationsError,
|
|
20
|
+
RateLimitError,
|
|
21
|
+
TitleNotFoundError,
|
|
22
|
+
VolumeNotFoundError,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
MangaLibError = LibSocialError
|
|
26
|
+
"""Base class for all errors raised by the SDK (``libsocial.LibSocialError``)."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class PageDownloadError(LibSocialError):
|
|
30
|
+
"""Raised when a page image couldn't be downloaded from any image server.
|
|
31
|
+
|
|
32
|
+
Each server is tried in turn (see ``MangaLib.download_page()``); this is raised only
|
|
33
|
+
after the last one failed too.
|
|
34
|
+
|
|
35
|
+
Attributes:
|
|
36
|
+
failures: ``(url, reason)`` for every attempt, in the order they were made.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, failures: list[tuple[str, str]]) -> None:
|
|
40
|
+
self.failures = failures
|
|
41
|
+
attempts = "; ".join(f"{url}: {reason}" for url, reason in failures)
|
|
42
|
+
super().__init__(f"Couldn't download the page from any image server ({attempts})")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
"AccessBlockedError",
|
|
47
|
+
"AmbiguousChapter",
|
|
48
|
+
"AuthRequiredError",
|
|
49
|
+
"ChapterNotFoundError",
|
|
50
|
+
"DownloadTitleInterruptedError",
|
|
51
|
+
"MangaLibError",
|
|
52
|
+
"MultipleTitleTranslationsError",
|
|
53
|
+
"MultipleTranslationsError",
|
|
54
|
+
"PageDownloadError",
|
|
55
|
+
"RateLimitError",
|
|
56
|
+
"TitleNotFoundError",
|
|
57
|
+
"VolumeNotFoundError",
|
|
58
|
+
]
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Page images: their URLs on an image server, and downloading them with server fallback.
|
|
2
|
+
|
|
3
|
+
The API lists several image servers (``ImageServer``) and doesn't say which one a page is
|
|
4
|
+
on — every server serves every page under the same path. They don't all let a script in,
|
|
5
|
+
though: when checked, the DDoS-Guard edge in front of ``main``/``secondary`` answered every
|
|
6
|
+
request with an HTML 403 whatever the headers, while ``compress``/``download`` served the
|
|
7
|
+
full-size image (see docs/api-notes.md, "MangaLib"). So downloads start from
|
|
8
|
+
``compress`` and fall back through the others.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from mangalib.exceptions import PageDownloadError
|
|
16
|
+
from mangalib.models import ImageServer, Page
|
|
17
|
+
|
|
18
|
+
__all__ = ["DEFAULT_SERVER_ORDER", "page_url"]
|
|
19
|
+
|
|
20
|
+
DEFAULT_SERVER_ORDER = ("compress", "download", "main", "secondary")
|
|
21
|
+
"""Image servers tried, in this order, when no server is chosen. ``crop`` isn't one of
|
|
22
|
+
them: it serves 310×450 previews, not the page itself — choose it explicitly if that's what
|
|
23
|
+
you want."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def page_url(page: Page, server: ImageServer) -> str:
|
|
27
|
+
"""The full URL of ``page``'s image on ``server``.
|
|
28
|
+
|
|
29
|
+
``Page.url`` starts with ``//``; the slashes are collapsed to one (both forms serve the
|
|
30
|
+
same image, checked on every server).
|
|
31
|
+
"""
|
|
32
|
+
return f"{server.url.rstrip('/')}/{page.url.lstrip('/')}"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def server_order(servers: list[ImageServer], server: str | None) -> list[ImageServer]:
|
|
36
|
+
"""The servers to try for a page, in order (not public API).
|
|
37
|
+
|
|
38
|
+
``server`` first, if given, then ``DEFAULT_SERVER_ORDER``, skipping any that has the
|
|
39
|
+
same URL as one already in the list — retrying the same host under another name
|
|
40
|
+
wouldn't help.
|
|
41
|
+
|
|
42
|
+
Raises:
|
|
43
|
+
ValueError: If ``server`` isn't one of ``servers``, or none of the servers is known.
|
|
44
|
+
"""
|
|
45
|
+
by_id = {item.id: item for item in servers}
|
|
46
|
+
if server is not None and server not in by_id:
|
|
47
|
+
available = ", ".join(sorted(by_id)) or "(none)"
|
|
48
|
+
raise ValueError(f"Unknown image server {server!r}. Available: {available}")
|
|
49
|
+
|
|
50
|
+
ids = [server] if server is not None else []
|
|
51
|
+
ids += [item for item in DEFAULT_SERVER_ORDER if item != server]
|
|
52
|
+
order: list[ImageServer] = []
|
|
53
|
+
for item in ids:
|
|
54
|
+
candidate = by_id.get(item)
|
|
55
|
+
if candidate is not None and all(other.url != candidate.url for other in order):
|
|
56
|
+
order.append(candidate)
|
|
57
|
+
if not order:
|
|
58
|
+
raise ValueError(
|
|
59
|
+
"None of the API's image servers is one the SDK knows how to use "
|
|
60
|
+
f"({', '.join(sorted(by_id)) or 'none listed'}); pass server= explicitly."
|
|
61
|
+
)
|
|
62
|
+
return order
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
async def download_page(client: httpx.AsyncClient, page: Page, servers: list[ImageServer]) -> bytes:
|
|
66
|
+
"""Download ``page`` from the first of ``servers`` that serves it (not public API).
|
|
67
|
+
|
|
68
|
+
A server fails over to the next one on a network error, a non-2xx status, or a response
|
|
69
|
+
that isn't an image — DDoS-Guard's refusal is an HTML page.
|
|
70
|
+
|
|
71
|
+
Raises:
|
|
72
|
+
PageDownloadError: If every server failed.
|
|
73
|
+
"""
|
|
74
|
+
failures: list[tuple[str, str]] = []
|
|
75
|
+
for server in servers:
|
|
76
|
+
url = page_url(page, server)
|
|
77
|
+
try:
|
|
78
|
+
response = await client.get(url)
|
|
79
|
+
except httpx.HTTPError as exc:
|
|
80
|
+
failures.append((url, f"{type(exc).__name__}: {exc}"))
|
|
81
|
+
continue
|
|
82
|
+
content_type = response.headers.get("Content-Type", "")
|
|
83
|
+
if not response.is_success:
|
|
84
|
+
failures.append((url, f"HTTP {response.status_code} ({content_type or 'no type'})"))
|
|
85
|
+
elif not content_type.lower().startswith("image/"):
|
|
86
|
+
failures.append((url, f"not an image ({content_type or 'no type'})"))
|
|
87
|
+
else:
|
|
88
|
+
return response.content
|
|
89
|
+
raise PageDownloadError(failures)
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Pydantic models: Title, Chapter, Page, Volume, and related types.
|
|
2
|
+
|
|
3
|
+
Everything that isn't specific to mangalib.me is ``libsocial-core``'s and re-exported here
|
|
4
|
+
unchanged (``Cover``, ``Label``, ``Genre``, ``Tag``, ``Country``, ``Person``, ``Team``,
|
|
5
|
+
``ChapterUser``). ``Title``, ``ChapterBranch``, ``Chapter``, ``Volume`` and ``CatalogPage``
|
|
6
|
+
subclass the core's with what is: a chapter's ``pages`` instead of text, whether a
|
|
7
|
+
translation is closed to readers without an account (``restricted_view``), and
|
|
8
|
+
``Title.base_names`` without mangalib's format suffixes.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
|
|
16
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
17
|
+
|
|
18
|
+
from libsocial import models as _core
|
|
19
|
+
from libsocial.models import ChapterUser, Country, Cover, Genre, Label, Person, Tag, Team
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"CatalogPage",
|
|
23
|
+
"Chapter",
|
|
24
|
+
"ChapterBranch",
|
|
25
|
+
"ChapterUser",
|
|
26
|
+
"Country",
|
|
27
|
+
"Cover",
|
|
28
|
+
"Genre",
|
|
29
|
+
"ImageServer",
|
|
30
|
+
"Label",
|
|
31
|
+
"Page",
|
|
32
|
+
"Person",
|
|
33
|
+
"RestrictedView",
|
|
34
|
+
"Tag",
|
|
35
|
+
"Team",
|
|
36
|
+
"Title",
|
|
37
|
+
"Volume",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
_FORMAT_WORD = (
|
|
41
|
+
r"manga|манга|manhwa|манхва|manhua|маньхуа|webtoon|вебтун"
|
|
42
|
+
r"|web\s*-?\s*comics?|веб\s*-?\s*комикс|comics?|comic\s+book|комикс"
|
|
43
|
+
)
|
|
44
|
+
_FORMAT_SUFFIX = re.compile(
|
|
45
|
+
rf"[\s,:/]*(?:\(\s*(?:{_FORMAT_WORD})\s*\)|\[\s*(?:{_FORMAT_WORD})\s*\])\s*$",
|
|
46
|
+
re.IGNORECASE,
|
|
47
|
+
)
|
|
48
|
+
"""A bare format marker at the end of a title name — ``(Manhwa)``, ``(webtoon)``,
|
|
49
|
+
``[Вебтун]``, ``(Манга)``, ``(Комикс)``, ... — plus the separator (``,``/``:``/``/``) sometimes
|
|
50
|
+
left before it. The word list comes from every name in the live catalog (see
|
|
51
|
+
docs/api-notes.md, "Title names without a format marker"). Tails that say more than the
|
|
52
|
+
format — ``(манга-версия)``, ``(Color Webtoon)``, ``(Fan Comics)``, ``(Graphic Novel)`` — tell
|
|
53
|
+
versions of a work apart, so they're kept."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _strip_format_suffix(name: str) -> str:
|
|
57
|
+
"""``name`` without its trailing format marker(s) — the same rule as ranobelib's."""
|
|
58
|
+
while (match := _FORMAT_SUFFIX.search(name)) is not None:
|
|
59
|
+
name = name[: match.start()]
|
|
60
|
+
return name.strip()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Title(_core.Title):
|
|
64
|
+
"""Metadata for a single title (manga, manhwa, manhua, comic, ...)."""
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def base_names(self) -> list[str]:
|
|
68
|
+
"""The title's names without a trailing format marker.
|
|
69
|
+
|
|
70
|
+
``rus_name``, ``eng_name``, ``name`` and ``other_names``, in that order, each with a
|
|
71
|
+
bare format marker at the end removed — ``"Return of the Runebound Professor
|
|
72
|
+
(webtoon)"`` → ``"Return of the Runebound Professor"``, likewise ``(Manhwa)``,
|
|
73
|
+
``(Манга)``, ``[Вебтун]``, ``(Комикс)``, ... Unlike ranobelib.me's ``(Новелла)``,
|
|
74
|
+
mangalib.me doesn't append one itself: only a few dozen names have one, typed by
|
|
75
|
+
hand. Other bracketed tails — ``(Doujinshi)``, ``(Colored)``, ``(перезапуск)``, a
|
|
76
|
+
year, ``(манга-версия)`` — tell titles apart and are kept. Whitespace is trimmed;
|
|
77
|
+
empty names and duplicates (keeping the first) are dropped. Nothing else is
|
|
78
|
+
normalized (case, punctuation): that's up to whatever compares the names.
|
|
79
|
+
|
|
80
|
+
The original fields are left as they are. On ``Catalog.list_titles()`` items
|
|
81
|
+
``other_names`` is empty (the listing doesn't return it), so it contributes nothing.
|
|
82
|
+
"""
|
|
83
|
+
names = [self.rus_name, self.eng_name, self.name, *self.other_names]
|
|
84
|
+
stripped = (_strip_format_suffix(name) for name in names if name)
|
|
85
|
+
return list(dict.fromkeys(name for name in stripped if name))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class RestrictedView(BaseModel):
|
|
89
|
+
"""Access to a paid or early-access chapter translation.
|
|
90
|
+
|
|
91
|
+
Present only on translations that are (or were) restricted. Logging in isn't supported
|
|
92
|
+
by the SDK, so a closed one can't be read through it — see ``ChapterBranch.is_open``.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
is_open: bool
|
|
96
|
+
"""Whether anyone can read it now, without an account."""
|
|
97
|
+
expired_at: datetime | None = None
|
|
98
|
+
"""When early access ends and the chapter opens for everyone; ``None`` for a chapter
|
|
99
|
+
that stays paid."""
|
|
100
|
+
price: int | None = None
|
|
101
|
+
"""The price the site asks, in its own currency. The chapter list leaves it ``None``;
|
|
102
|
+
fetching the chapter fills it in."""
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class ChapterBranch(_core.ChapterBranch):
|
|
106
|
+
"""A single team's (or solo uploader's) translation of a chapter.
|
|
107
|
+
|
|
108
|
+
A chapter has more than one branch when several teams have translated it
|
|
109
|
+
independently; see ``Chapter.branches_count``.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
restricted_view: RestrictedView | None = None
|
|
113
|
+
"""Set when this translation is paid or in early access — see ``is_open``."""
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def is_open(self) -> bool:
|
|
117
|
+
"""Whether this translation can be read without an account.
|
|
118
|
+
|
|
119
|
+
``False`` means ``MangaLib.get_chapter()`` raises ``AuthRequiredError`` for it.
|
|
120
|
+
"""
|
|
121
|
+
return self.restricted_view is None or self.restricted_view.is_open
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class Page(BaseModel):
|
|
125
|
+
"""One page image of a chapter.
|
|
126
|
+
|
|
127
|
+
``url`` is relative to an image server (see ``ImageServer`` and ``mangalib.page_url()``):
|
|
128
|
+
the API doesn't say which server a page is on, every server serves every page.
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
132
|
+
|
|
133
|
+
id: int
|
|
134
|
+
number: int = Field(alias="slug")
|
|
135
|
+
"""1-based position in the chapter — the API's ``slug``."""
|
|
136
|
+
image: str
|
|
137
|
+
"""The image's file name. Its extension isn't reliable as a format: the ``compress``
|
|
138
|
+
server sends a JPEG for a ``.png`` name (see docs/api-notes.md)."""
|
|
139
|
+
url: str
|
|
140
|
+
"""Path of the image on an image server, e.g. ``"//manga/{slug}/chapters/1-1/00.jpg"``."""
|
|
141
|
+
width: int | None = None
|
|
142
|
+
height: int | None = None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class Chapter(_core.Chapter):
|
|
146
|
+
"""A chapter: volume, number, name, available translations, and optionally its pages.
|
|
147
|
+
|
|
148
|
+
``index``/``item_number``/``branches_count``/``branches`` come from the chapter-list
|
|
149
|
+
endpoint (see ``MangaLib.get_table_of_contents()``) and are ``None``/empty when a
|
|
150
|
+
``Chapter`` instead comes from fetching a single chapter, which has a different response
|
|
151
|
+
shape. ``pages`` is the reverse: only filled in by fetching the chapter
|
|
152
|
+
(``MangaLib.get_chapter()`` and friends), in reading order.
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
# list[ChapterBranch] here vs list[core ChapterBranch] in the base class: lists are
|
|
156
|
+
# invariant, so mypy flags the narrower item type, which pydantic itself is fine with.
|
|
157
|
+
branches: list[ChapterBranch] = Field(default_factory=list) # type: ignore[assignment]
|
|
158
|
+
pages: list[Page] = Field(default_factory=list)
|
|
159
|
+
restricted_view: RestrictedView | None = None
|
|
160
|
+
"""Set on a fetched chapter whose translation is (or was) paid or in early access."""
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class ImageServer(BaseModel):
|
|
164
|
+
"""A server page images are downloaded from (``GET /constants?fields[]=imageServers``).
|
|
165
|
+
|
|
166
|
+
A page's full URL is the server's ``url`` followed by ``Page.url``.
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
id: str
|
|
170
|
+
"""The API's name for it: ``"main"``, ``"secondary"``, ``"compress"``, ``"download"``,
|
|
171
|
+
``"crop"`` (see docs/api-notes.md for which of them actually serve pages)."""
|
|
172
|
+
label: str
|
|
173
|
+
url: str
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class Volume(_core.Volume[Chapter]):
|
|
177
|
+
"""A volume: its number and the chapters it contains, in title order."""
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class CatalogPage(_core.CatalogPage[Title]):
|
|
181
|
+
"""One page of catalog listing/search results, as returned by ``Catalog.list_titles()``.
|
|
182
|
+
|
|
183
|
+
``items`` reuses ``Title`` as-is: the fields a listing doesn't send (``genres``,
|
|
184
|
+
``summary``, ``chapter_count``, ``last_chapter_at``, ...) come back at their defaults.
|
|
185
|
+
"""
|
|
File without changes
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Public facade of the SDK: the ``MangaLib`` class.
|
|
2
|
+
|
|
3
|
+
Fetching a title, its chapters and translations is ``libsocial-core``'s
|
|
4
|
+
(``libsocial.TitleClient``); ``MangaLib`` points it at mangalib.me and its own models, and
|
|
5
|
+
adds what depends on a chapter being page images: the image servers and page downloads.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from libsocial import TitleClient
|
|
17
|
+
from libsocial.console import Verbosity
|
|
18
|
+
from libsocial.exceptions import ChapterNotFoundError
|
|
19
|
+
from libsocial.title import DEFAULT_RATE_LIMIT_RETRIES
|
|
20
|
+
from mangalib import images
|
|
21
|
+
from mangalib._http import MANGALIB, image_headers
|
|
22
|
+
from mangalib.cache import DEFAULT_CACHE_DIR
|
|
23
|
+
from mangalib.client import ApiClient
|
|
24
|
+
from mangalib.models import Chapter, ChapterBranch, ImageServer, Page, Title, Volume
|
|
25
|
+
|
|
26
|
+
__all__ = ["DEFAULT_RATE_LIMIT_RETRIES", "MangaLib"]
|
|
27
|
+
|
|
28
|
+
_IMAGE_CONCURRENCY = 5
|
|
29
|
+
"""Page images downloaded at once by ``download_pages()`` — the same conservative value as
|
|
30
|
+
``ApiClient``'s ``max_concurrency``: the image servers' limits are unknown."""
|
|
31
|
+
|
|
32
|
+
_IMAGE_SERVERS_CACHE_KEY = "constants:imageServers"
|
|
33
|
+
"""Disk-cache key of the image server list. Not per site: the cached response covers the
|
|
34
|
+
whole network, and is filtered to mangalib.me on every read."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class MangaLib(TitleClient[Title, Chapter, Volume]):
|
|
38
|
+
"""Entry point for interacting with a single mangalib.me title.
|
|
39
|
+
|
|
40
|
+
Example:
|
|
41
|
+
```python
|
|
42
|
+
async with MangaLib("https://mangalib.me/ru/manga/7965--chainsaw-man") as lib:
|
|
43
|
+
chapter = await lib.get_chapter(volume=1, number="1", branch_id=4667)
|
|
44
|
+
first_page = await lib.download_page(chapter.pages[0])
|
|
45
|
+
```
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
site = MANGALIB
|
|
49
|
+
title_model = Title
|
|
50
|
+
chapter_model = Chapter
|
|
51
|
+
volume_model = Volume
|
|
52
|
+
_client: ApiClient
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
url: str,
|
|
57
|
+
*,
|
|
58
|
+
cache_dir: str | Path | None = None,
|
|
59
|
+
cache_ttl: float | None = None,
|
|
60
|
+
verbosity: Verbosity = False,
|
|
61
|
+
headers: Mapping[str, str] | None = None,
|
|
62
|
+
) -> None:
|
|
63
|
+
"""Initialize the SDK for a title.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
url: A mangalib.me title URL, or a bare ``{id}--{slug}`` identifier.
|
|
67
|
+
cache_dir: Where to cache raw API responses (title metadata, chapter list,
|
|
68
|
+
chapter pages, image servers) on disk. Page images themselves aren't
|
|
69
|
+
cached. Defaults to ``.mangalib_cache`` in the current working directory.
|
|
70
|
+
cache_ttl: Seconds after which a cached response is treated as stale and
|
|
71
|
+
re-fetched. ``None`` (the default) means cached responses never expire on
|
|
72
|
+
their own — see ``refresh=True`` on individual methods to force one anyway.
|
|
73
|
+
verbosity: Console output level. ``False`` (default): silent. ``"progress_only"``:
|
|
74
|
+
progress bars during ``download_title()``. ``"full"``: the same, plus a line
|
|
75
|
+
logged for every title/chapter-list/chapter fetch, noting whether it was
|
|
76
|
+
served from the disk cache or the API.
|
|
77
|
+
headers: Extra HTTP headers merged over the SDK's defaults (browser-like
|
|
78
|
+
``Origin``/``Referer``/``User-Agent``, see docs/api-notes.md, section "WAF
|
|
79
|
+
403") — a key given here replaces the default one, matched
|
|
80
|
+
case-insensitively. Applied to every API request and to page image
|
|
81
|
+
downloads. Meant for when the site's edge protection changes which requests
|
|
82
|
+
it lets through (``AccessBlockedError``). ``None`` (the default) sends the
|
|
83
|
+
defaults as-is.
|
|
84
|
+
"""
|
|
85
|
+
super().__init__(
|
|
86
|
+
url,
|
|
87
|
+
cache_dir=cache_dir if cache_dir is not None else DEFAULT_CACHE_DIR,
|
|
88
|
+
cache_ttl=cache_ttl,
|
|
89
|
+
verbosity=verbosity,
|
|
90
|
+
headers=headers,
|
|
91
|
+
)
|
|
92
|
+
self._image_client: httpx.AsyncClient | None = None
|
|
93
|
+
self._image_servers: list[ImageServer] | None = None
|
|
94
|
+
|
|
95
|
+
def _create_client(self, headers: Mapping[str, str] | None) -> ApiClient:
|
|
96
|
+
return ApiClient(headers=headers)
|
|
97
|
+
|
|
98
|
+
def _create_image_client(self) -> httpx.AsyncClient:
|
|
99
|
+
"""The HTTP client page images are downloaded with — separate from the API's: other
|
|
100
|
+
hosts, no ``Site-Id``, ``Accept: image/*``."""
|
|
101
|
+
return httpx.AsyncClient(headers=image_headers(self._headers), timeout=30.0)
|
|
102
|
+
|
|
103
|
+
async def aclose(self) -> None:
|
|
104
|
+
"""Close the underlying HTTP clients."""
|
|
105
|
+
await super().aclose()
|
|
106
|
+
if self._image_client is not None:
|
|
107
|
+
await self._image_client.aclose()
|
|
108
|
+
|
|
109
|
+
# The base class returns list[libsocial.ChapterBranch]; this returns mangalib's subclass
|
|
110
|
+
# of it (with restricted_view), which lists' invariance makes mypy reject as an override.
|
|
111
|
+
async def get_translations( # type: ignore[override]
|
|
112
|
+
self, volume: int, number: str
|
|
113
|
+
) -> list[ChapterBranch]:
|
|
114
|
+
"""Fetch the available translations (branches) for a chapter.
|
|
115
|
+
|
|
116
|
+
Each one says whether it's open to read without an account (``ChapterBranch.is_open``).
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
volume: The chapter's volume number.
|
|
120
|
+
number: The chapter number.
|
|
121
|
+
|
|
122
|
+
Raises:
|
|
123
|
+
ChapterNotFoundError: If no chapter exists for this volume/number.
|
|
124
|
+
"""
|
|
125
|
+
volume_str = str(volume)
|
|
126
|
+
for item in await self._get_chapters():
|
|
127
|
+
if item.get("volume") == volume_str and item.get("number") == number:
|
|
128
|
+
return [ChapterBranch.model_validate(branch) for branch in item["branches"] or []]
|
|
129
|
+
raise ChapterNotFoundError(self.slug_url, volume=volume_str, number=number)
|
|
130
|
+
|
|
131
|
+
async def list_image_servers(self, *, refresh: bool = False) -> list[ImageServer]:
|
|
132
|
+
"""List the servers mangalib.me serves page images from.
|
|
133
|
+
|
|
134
|
+
Not every one serves pages to a script: see ``download_page()`` for which are tried.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
refresh: Bypass the disk cache and re-fetch from the API.
|
|
138
|
+
"""
|
|
139
|
+
if self._image_servers is not None and not refresh:
|
|
140
|
+
return self._image_servers
|
|
141
|
+
data = None if refresh else self._cache.get(_IMAGE_SERVERS_CACHE_KEY)
|
|
142
|
+
if data is None:
|
|
143
|
+
data = await self._client.list_image_servers()
|
|
144
|
+
self._cache.set(_IMAGE_SERVERS_CACHE_KEY, data)
|
|
145
|
+
self._image_servers = [
|
|
146
|
+
ImageServer.model_validate(item)
|
|
147
|
+
for item in data
|
|
148
|
+
if self.site.id in item.get("site_ids", []) and item.get("url")
|
|
149
|
+
]
|
|
150
|
+
return self._image_servers
|
|
151
|
+
|
|
152
|
+
async def download_page(self, page: Page, *, server: str | None = None) -> bytes:
|
|
153
|
+
"""Download one page image.
|
|
154
|
+
|
|
155
|
+
Tries ``server`` first, if given, then ``images.DEFAULT_SERVER_ORDER`` (``compress``,
|
|
156
|
+
``download``, ``main``, ``secondary``), moving on to the next on a network error,
|
|
157
|
+
an HTTP error or a response that isn't an image. ``compress`` comes first because
|
|
158
|
+
it's the one that served full-size pages when checked; ``main``/``secondary`` sit
|
|
159
|
+
behind a DDoS-Guard check scripts don't pass (see docs/api-notes.md, "MangaLib").
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
page: The page, from a fetched chapter's ``pages``.
|
|
163
|
+
server: The ``ImageServer.id`` to try first (see ``list_image_servers()``).
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
The image file's bytes, as the server sent them.
|
|
167
|
+
|
|
168
|
+
Raises:
|
|
169
|
+
ValueError: If ``server`` isn't one of ``list_image_servers()``.
|
|
170
|
+
PageDownloadError: If no server served the image.
|
|
171
|
+
"""
|
|
172
|
+
order = images.server_order(await self.list_image_servers(), server)
|
|
173
|
+
if self._image_client is None:
|
|
174
|
+
self._image_client = self._create_image_client()
|
|
175
|
+
return await images.download_page(self._image_client, page, order)
|
|
176
|
+
|
|
177
|
+
async def download_pages(self, chapter: Chapter, *, server: str | None = None) -> list[bytes]:
|
|
178
|
+
"""Download every page image of a fetched chapter, in reading order.
|
|
179
|
+
|
|
180
|
+
Up to five pages are downloaded at once, each with ``download_page()``'s server
|
|
181
|
+
fallback.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
chapter: A chapter fetched with ``get_chapter()`` (or ``get_chapters()``,
|
|
185
|
+
``get_volume()``, ...): one from ``get_table_of_contents()`` has no pages.
|
|
186
|
+
server: The ``ImageServer.id`` to try first — see ``download_page()``.
|
|
187
|
+
|
|
188
|
+
Raises:
|
|
189
|
+
ValueError: If ``server`` isn't one of ``list_image_servers()``.
|
|
190
|
+
PageDownloadError: If a page couldn't be downloaded from any server; the other
|
|
191
|
+
downloads still in flight are cancelled.
|
|
192
|
+
"""
|
|
193
|
+
semaphore = asyncio.Semaphore(_IMAGE_CONCURRENCY)
|
|
194
|
+
|
|
195
|
+
async def fetch(page: Page) -> bytes:
|
|
196
|
+
async with semaphore:
|
|
197
|
+
return await self.download_page(page, server=server)
|
|
198
|
+
|
|
199
|
+
# Resolve the servers once, before starting the downloads, so a bad ``server``
|
|
200
|
+
# raises ValueError itself rather than inside a task group's ExceptionGroup.
|
|
201
|
+
images.server_order(await self.list_image_servers(), server)
|
|
202
|
+
try:
|
|
203
|
+
async with asyncio.TaskGroup() as group:
|
|
204
|
+
tasks = [group.create_task(fetch(page)) for page in chapter.pages]
|
|
205
|
+
except* Exception as errors:
|
|
206
|
+
raise errors.exceptions[0] from None
|
|
207
|
+
return [task.result() for task in tasks]
|