mangalib-python-sdk 0.1.0__tar.gz → 0.2.1__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 → mangalib_python_sdk-0.2.1}/PKG-INFO +39 -14
- {mangalib_python_sdk-0.1.0 → mangalib_python_sdk-0.2.1}/README.md +32 -9
- {mangalib_python_sdk-0.1.0 → mangalib_python_sdk-0.2.1}/pyproject.toml +10 -5
- {mangalib_python_sdk-0.1.0 → mangalib_python_sdk-0.2.1}/pyproject.toml.orig +11 -5
- mangalib_python_sdk-0.2.1/src/mangalib/exporters/__init__.py +73 -0
- mangalib_python_sdk-0.2.1/src/mangalib/exporters/_shared.py +33 -0
- mangalib_python_sdk-0.2.1/src/mangalib/exporters/_writing.py +27 -0
- mangalib_python_sdk-0.2.1/src/mangalib/exporters/cbz.py +143 -0
- mangalib_python_sdk-0.2.1/src/mangalib/exporters/pdf.py +83 -0
- mangalib_python_sdk-0.2.1/src/mangalib/images.py +183 -0
- {mangalib_python_sdk-0.1.0 → mangalib_python_sdk-0.2.1}/src/mangalib/models.py +3 -0
- mangalib_python_sdk-0.2.1/src/mangalib/sdk.py +524 -0
- mangalib_python_sdk-0.1.0/src/mangalib/images.py +0 -89
- mangalib_python_sdk-0.1.0/src/mangalib/sdk.py +0 -207
- {mangalib_python_sdk-0.1.0 → mangalib_python_sdk-0.2.1}/src/mangalib/__init__.py +0 -0
- {mangalib_python_sdk-0.1.0 → mangalib_python_sdk-0.2.1}/src/mangalib/_http.py +0 -0
- {mangalib_python_sdk-0.1.0 → mangalib_python_sdk-0.2.1}/src/mangalib/cache.py +0 -0
- {mangalib_python_sdk-0.1.0 → mangalib_python_sdk-0.2.1}/src/mangalib/catalog.py +0 -0
- {mangalib_python_sdk-0.1.0 → mangalib_python_sdk-0.2.1}/src/mangalib/client.py +0 -0
- {mangalib_python_sdk-0.1.0 → mangalib_python_sdk-0.2.1}/src/mangalib/exceptions.py +0 -0
- {mangalib_python_sdk-0.1.0 → mangalib_python_sdk-0.2.1}/src/mangalib/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mangalib-python-sdk
|
|
3
|
-
Version: 0.1
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: Async Python SDK for mangalib.me: title metadata, chapters, translations, catalog and page images
|
|
5
5
|
Keywords: mangalib,manga,manhwa,comics,lib.social,sdk
|
|
6
6
|
Author: LLIJIYAPNIK
|
|
@@ -18,22 +18,24 @@ Classifier: Typing :: Typed
|
|
|
18
18
|
Requires-Dist: httpx>=0.27
|
|
19
19
|
Requires-Dist: libsocial-core>=1.0,<2
|
|
20
20
|
Requires-Dist: pydantic>=2.7
|
|
21
|
+
Requires-Dist: img2pdf>=0.5 ; extra == 'pdf'
|
|
21
22
|
Requires-Python: >=3.11
|
|
22
|
-
Project-URL: Homepage, https://github.com/LLIJIYAPNIK/
|
|
23
|
-
Project-URL: Repository, https://github.com/LLIJIYAPNIK/
|
|
24
|
-
Project-URL: Issues, https://github.com/LLIJIYAPNIK/
|
|
25
|
-
Project-URL: Changelog, https://github.com/LLIJIYAPNIK/
|
|
23
|
+
Project-URL: Homepage, https://github.com/LLIJIYAPNIK/libsocial-sdk
|
|
24
|
+
Project-URL: Repository, https://github.com/LLIJIYAPNIK/libsocial-sdk
|
|
25
|
+
Project-URL: Issues, https://github.com/LLIJIYAPNIK/libsocial-sdk/issues
|
|
26
|
+
Project-URL: Changelog, https://github.com/LLIJIYAPNIK/libsocial-sdk/blob/main/packages/mangalib/CHANGELOG.md
|
|
27
|
+
Provides-Extra: pdf
|
|
26
28
|
Description-Content-Type: text/markdown
|
|
27
29
|
|
|
28
30
|
# mangalib-python-sdk
|
|
29
31
|
|
|
30
32
|
[](https://pypi.org/project/mangalib-python-sdk/)
|
|
31
|
-
[](../../LICENSE)
|
|
32
34
|
[](pyproject.toml)
|
|
33
|
-
[](https://github.com/LLIJIYAPNIK/libsocial-sdk/actions/workflows/ci.yml)
|
|
34
36
|
|
|
35
37
|
Async Python SDK for [mangalib.me](https://mangalib.me): title metadata, table of contents,
|
|
36
|
-
translations, catalog search,
|
|
38
|
+
translations, catalog search, chapter pages as images, and export to CBZ or PDF.
|
|
37
39
|
|
|
38
40
|
Like [`ranobelib-python-sdk`](https://pypi.org/project/ranobelib-python-sdk/), it talks to
|
|
39
41
|
the undocumented but open JSON API behind the site (`api.cdnlibs.org`, shared by the whole
|
|
@@ -41,9 +43,8 @@ lib.social network) instead of scraping HTML. The shared part (API client, reque
|
|
|
41
43
|
cache, titles, chapters, catalog) comes from `libsocial-core`, installed with it, so a
|
|
42
44
|
process using both SDKs paces its requests to the API together.
|
|
43
45
|
|
|
44
|
-
> **Status:** early.
|
|
45
|
-
>
|
|
46
|
-
> be read: fetching one raises `AuthRequiredError`.
|
|
46
|
+
> **Status:** early. There is no login, so paid and early-access chapters can't be read:
|
|
47
|
+
> fetching one raises `AuthRequiredError`, and a whole-title download leaves them out.
|
|
47
48
|
|
|
48
49
|
## Installation
|
|
49
50
|
|
|
@@ -57,7 +58,9 @@ or
|
|
|
57
58
|
pip install mangalib-python-sdk
|
|
58
59
|
```
|
|
59
60
|
|
|
60
|
-
|
|
61
|
+
PDF export needs the `pdf` extra (img2pdf): `uv add "mangalib-python-sdk[pdf]"` /
|
|
62
|
+
`pip install "mangalib-python-sdk[pdf]"`. Without it, `"pdf"` just isn't one of the export
|
|
63
|
+
formats. Requires Python 3.11+.
|
|
61
64
|
|
|
62
65
|
## Quickstart
|
|
63
66
|
|
|
@@ -76,6 +79,13 @@ async with MangaLib("https://mangalib.me/ru/manga/7965--chainsaw-man") as lib:
|
|
|
76
79
|
print(page.number, page.width, page.height)
|
|
77
80
|
images = await lib.download_pages(chapter) # list[bytes], in reading order
|
|
78
81
|
|
|
82
|
+
# One chapter, several, a volume, or the whole title, into one file.
|
|
83
|
+
await lib.export([chapter], fmt="cbz", path="chainsaw-man-1.cbz")
|
|
84
|
+
volumes = await lib.download_title(translation_index=0, on_chapter=print)
|
|
85
|
+
await lib.export(volumes, fmt="pdf", path="chainsaw-man.pdf", on_chapter=print)
|
|
86
|
+
|
|
87
|
+
size = await lib.estimate_title_size() # bytes of page images, from a sample
|
|
88
|
+
|
|
79
89
|
async with Catalog() as catalog:
|
|
80
90
|
page = await catalog.list_titles(query="chainsaw")
|
|
81
91
|
```
|
|
@@ -86,11 +96,26 @@ async with Catalog() as catalog:
|
|
|
86
96
|
- `ChapterBranch.is_open` (from `get_table_of_contents()` / `get_translations()`) says
|
|
87
97
|
whether a translation can be read without an account.
|
|
88
98
|
- `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.
|
|
99
|
+
`list_image_servers()` and the API notes for why `compress` is tried first. Requests to
|
|
100
|
+
an image host are paced across the process and retried on 429/5xx; `concurrency=` sets
|
|
101
|
+
how many pages are fetched at once.
|
|
102
|
+
- `export()` writes `"cbz"` (pages in order + `ComicInfo.xml`) or `"pdf"` (a page per image,
|
|
103
|
+
an outline entry per chapter); `mangalib.exporters.EXPORTERS` lists what's available.
|
|
104
|
+
- `chapter_size()` / `volume_size()` give the exact size of the page images, from `HEAD`
|
|
105
|
+
requests; `estimate_title_size()` extrapolates from a sample of chapters.
|
|
106
|
+
|
|
107
|
+
## Examples
|
|
108
|
+
|
|
109
|
+
Complete, runnable scripts for every feature above — title metadata, table of contents,
|
|
110
|
+
chapters and volumes, translations and closed chapters, whole-title download, page images
|
|
111
|
+
and image servers, export to CBZ/PDF and to a format of your own, sizes, the catalog, disk
|
|
112
|
+
caching and error handling — live in
|
|
113
|
+
**[`examples/mangalib/`](https://github.com/LLIJIYAPNIK/libsocial-sdk/tree/main/examples/mangalib)**,
|
|
114
|
+
one heavily commented file per feature.
|
|
90
115
|
|
|
91
116
|
## Documentation
|
|
92
117
|
|
|
93
|
-
Full API reference: **<https://LLIJIYAPNIK.github.io/
|
|
118
|
+
Full API reference: **<https://LLIJIYAPNIK.github.io/libsocial-sdk/>**. What the API
|
|
94
119
|
returns for MangaLib, and what was checked: the "MangaLib" section of
|
|
95
120
|
[`docs/api-notes.md`](../../docs/api-notes.md). [`CHANGELOG.md`](CHANGELOG.md) lists what
|
|
96
121
|
changed in each release.
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# mangalib-python-sdk
|
|
2
2
|
|
|
3
3
|
[](https://pypi.org/project/mangalib-python-sdk/)
|
|
4
|
-
[](../../LICENSE)
|
|
5
5
|
[](pyproject.toml)
|
|
6
|
-
[](https://github.com/LLIJIYAPNIK/libsocial-sdk/actions/workflows/ci.yml)
|
|
7
7
|
|
|
8
8
|
Async Python SDK for [mangalib.me](https://mangalib.me): title metadata, table of contents,
|
|
9
|
-
translations, catalog search,
|
|
9
|
+
translations, catalog search, chapter pages as images, and export to CBZ or PDF.
|
|
10
10
|
|
|
11
11
|
Like [`ranobelib-python-sdk`](https://pypi.org/project/ranobelib-python-sdk/), it talks to
|
|
12
12
|
the undocumented but open JSON API behind the site (`api.cdnlibs.org`, shared by the whole
|
|
@@ -14,9 +14,8 @@ lib.social network) instead of scraping HTML. The shared part (API client, reque
|
|
|
14
14
|
cache, titles, chapters, catalog) comes from `libsocial-core`, installed with it, so a
|
|
15
15
|
process using both SDKs paces its requests to the API together.
|
|
16
16
|
|
|
17
|
-
> **Status:** early.
|
|
18
|
-
>
|
|
19
|
-
> be read: fetching one raises `AuthRequiredError`.
|
|
17
|
+
> **Status:** early. There is no login, so paid and early-access chapters can't be read:
|
|
18
|
+
> fetching one raises `AuthRequiredError`, and a whole-title download leaves them out.
|
|
20
19
|
|
|
21
20
|
## Installation
|
|
22
21
|
|
|
@@ -30,7 +29,9 @@ or
|
|
|
30
29
|
pip install mangalib-python-sdk
|
|
31
30
|
```
|
|
32
31
|
|
|
33
|
-
|
|
32
|
+
PDF export needs the `pdf` extra (img2pdf): `uv add "mangalib-python-sdk[pdf]"` /
|
|
33
|
+
`pip install "mangalib-python-sdk[pdf]"`. Without it, `"pdf"` just isn't one of the export
|
|
34
|
+
formats. Requires Python 3.11+.
|
|
34
35
|
|
|
35
36
|
## Quickstart
|
|
36
37
|
|
|
@@ -49,6 +50,13 @@ async with MangaLib("https://mangalib.me/ru/manga/7965--chainsaw-man") as lib:
|
|
|
49
50
|
print(page.number, page.width, page.height)
|
|
50
51
|
images = await lib.download_pages(chapter) # list[bytes], in reading order
|
|
51
52
|
|
|
53
|
+
# One chapter, several, a volume, or the whole title, into one file.
|
|
54
|
+
await lib.export([chapter], fmt="cbz", path="chainsaw-man-1.cbz")
|
|
55
|
+
volumes = await lib.download_title(translation_index=0, on_chapter=print)
|
|
56
|
+
await lib.export(volumes, fmt="pdf", path="chainsaw-man.pdf", on_chapter=print)
|
|
57
|
+
|
|
58
|
+
size = await lib.estimate_title_size() # bytes of page images, from a sample
|
|
59
|
+
|
|
52
60
|
async with Catalog() as catalog:
|
|
53
61
|
page = await catalog.list_titles(query="chainsaw")
|
|
54
62
|
```
|
|
@@ -59,11 +67,26 @@ async with Catalog() as catalog:
|
|
|
59
67
|
- `ChapterBranch.is_open` (from `get_table_of_contents()` / `get_translations()`) says
|
|
60
68
|
whether a translation can be read without an account.
|
|
61
69
|
- `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.
|
|
70
|
+
`list_image_servers()` and the API notes for why `compress` is tried first. Requests to
|
|
71
|
+
an image host are paced across the process and retried on 429/5xx; `concurrency=` sets
|
|
72
|
+
how many pages are fetched at once.
|
|
73
|
+
- `export()` writes `"cbz"` (pages in order + `ComicInfo.xml`) or `"pdf"` (a page per image,
|
|
74
|
+
an outline entry per chapter); `mangalib.exporters.EXPORTERS` lists what's available.
|
|
75
|
+
- `chapter_size()` / `volume_size()` give the exact size of the page images, from `HEAD`
|
|
76
|
+
requests; `estimate_title_size()` extrapolates from a sample of chapters.
|
|
77
|
+
|
|
78
|
+
## Examples
|
|
79
|
+
|
|
80
|
+
Complete, runnable scripts for every feature above — title metadata, table of contents,
|
|
81
|
+
chapters and volumes, translations and closed chapters, whole-title download, page images
|
|
82
|
+
and image servers, export to CBZ/PDF and to a format of your own, sizes, the catalog, disk
|
|
83
|
+
caching and error handling — live in
|
|
84
|
+
**[`examples/mangalib/`](https://github.com/LLIJIYAPNIK/libsocial-sdk/tree/main/examples/mangalib)**,
|
|
85
|
+
one heavily commented file per feature.
|
|
63
86
|
|
|
64
87
|
## Documentation
|
|
65
88
|
|
|
66
|
-
Full API reference: **<https://LLIJIYAPNIK.github.io/
|
|
89
|
+
Full API reference: **<https://LLIJIYAPNIK.github.io/libsocial-sdk/>**. What the API
|
|
67
90
|
returns for MangaLib, and what was checked: the "MangaLib" section of
|
|
68
91
|
[`docs/api-notes.md`](../../docs/api-notes.md). [`CHANGELOG.md`](CHANGELOG.md) lists what
|
|
69
92
|
changed in each release.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "mangalib-python-sdk"
|
|
3
|
-
version = "0.1
|
|
3
|
+
version = "0.2.1"
|
|
4
4
|
description = "Async Python SDK for mangalib.me: title metadata, chapters, translations, catalog and page images"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -34,11 +34,14 @@ dependencies = [
|
|
|
34
34
|
name = "LLIJIYAPNIK"
|
|
35
35
|
email = "kas4wot@yandex.ru"
|
|
36
36
|
|
|
37
|
+
[project.optional-dependencies]
|
|
38
|
+
pdf = ["img2pdf>=0.5"]
|
|
39
|
+
|
|
37
40
|
[project.urls]
|
|
38
|
-
Homepage = "https://github.com/LLIJIYAPNIK/
|
|
39
|
-
Repository = "https://github.com/LLIJIYAPNIK/
|
|
40
|
-
Issues = "https://github.com/LLIJIYAPNIK/
|
|
41
|
-
Changelog = "https://github.com/LLIJIYAPNIK/
|
|
41
|
+
Homepage = "https://github.com/LLIJIYAPNIK/libsocial-sdk"
|
|
42
|
+
Repository = "https://github.com/LLIJIYAPNIK/libsocial-sdk"
|
|
43
|
+
Issues = "https://github.com/LLIJIYAPNIK/libsocial-sdk/issues"
|
|
44
|
+
Changelog = "https://github.com/LLIJIYAPNIK/libsocial-sdk/blob/main/packages/mangalib/CHANGELOG.md"
|
|
42
45
|
|
|
43
46
|
[build-system]
|
|
44
47
|
requires = ["uv_build>=0.11.26,<0.12.0"]
|
|
@@ -52,6 +55,8 @@ workspace = true
|
|
|
52
55
|
|
|
53
56
|
[dependency-groups]
|
|
54
57
|
dev = [
|
|
58
|
+
"img2pdf>=0.5",
|
|
59
|
+
"pypdf>=6.14.2",
|
|
55
60
|
"pytest>=8.0",
|
|
56
61
|
"pytest-asyncio>=0.24",
|
|
57
62
|
"pytest-cov>=5.0",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "mangalib-python-sdk"
|
|
3
|
-
version = "0.1
|
|
3
|
+
version = "0.2.1"
|
|
4
4
|
description = "Async Python SDK for mangalib.me: title metadata, chapters, translations, catalog and page images"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -26,11 +26,15 @@ dependencies = [
|
|
|
26
26
|
"pydantic>=2.7",
|
|
27
27
|
]
|
|
28
28
|
|
|
29
|
+
[project.optional-dependencies]
|
|
30
|
+
# PDF export. Without it, "pdf" just isn't one of the export formats.
|
|
31
|
+
pdf = ["img2pdf>=0.5"]
|
|
32
|
+
|
|
29
33
|
[project.urls]
|
|
30
|
-
Homepage = "https://github.com/LLIJIYAPNIK/
|
|
31
|
-
Repository = "https://github.com/LLIJIYAPNIK/
|
|
32
|
-
Issues = "https://github.com/LLIJIYAPNIK/
|
|
33
|
-
Changelog = "https://github.com/LLIJIYAPNIK/
|
|
34
|
+
Homepage = "https://github.com/LLIJIYAPNIK/libsocial-sdk"
|
|
35
|
+
Repository = "https://github.com/LLIJIYAPNIK/libsocial-sdk"
|
|
36
|
+
Issues = "https://github.com/LLIJIYAPNIK/libsocial-sdk/issues"
|
|
37
|
+
Changelog = "https://github.com/LLIJIYAPNIK/libsocial-sdk/blob/main/packages/mangalib/CHANGELOG.md"
|
|
34
38
|
|
|
35
39
|
[build-system]
|
|
36
40
|
requires = ["uv_build>=0.11.26,<0.12.0"]
|
|
@@ -47,6 +51,8 @@ libsocial-core = { workspace = true }
|
|
|
47
51
|
# mangalib-python-sdk` installs the package's own groups, not the root's.
|
|
48
52
|
[dependency-groups]
|
|
49
53
|
dev = [
|
|
54
|
+
"img2pdf>=0.5",
|
|
55
|
+
"pypdf>=6.14.2",
|
|
50
56
|
"pytest>=8.0",
|
|
51
57
|
"pytest-asyncio>=0.24",
|
|
52
58
|
"pytest-cov>=5.0",
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Exporter protocol and format registry — the same contract as ``ranobelib.exporters``.
|
|
2
|
+
|
|
3
|
+
A new export format is a new module in this package: define a class implementing
|
|
4
|
+
``Exporter`` and decorate it with ``@register`` — no changes needed here or in ``sdk.py``.
|
|
5
|
+
Every module in this package is auto-imported below so its ``@register`` runs on its own.
|
|
6
|
+
A format whose optional dependency isn't installed (``pdf`` needs ``img2pdf``) skips its
|
|
7
|
+
``@register``, so it's simply not in ``EXPORTERS``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import importlib
|
|
13
|
+
import pkgutil
|
|
14
|
+
from collections.abc import Awaitable, Callable
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import ClassVar, Protocol, TypeVar, runtime_checkable
|
|
17
|
+
|
|
18
|
+
from mangalib.models import Chapter, Title
|
|
19
|
+
|
|
20
|
+
__all__ = ["EXPORTERS", "Exporter", "FetchPages", "register"]
|
|
21
|
+
|
|
22
|
+
FetchPages = Callable[[Chapter], Awaitable[list[bytes]]]
|
|
23
|
+
"""Downloads a chapter's page images, in reading order — ``MangaLib.download_pages()``."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@runtime_checkable
|
|
27
|
+
class Exporter(Protocol):
|
|
28
|
+
"""Renders a title's chapters, as page images, to a single file in some format."""
|
|
29
|
+
|
|
30
|
+
format: ClassVar[str]
|
|
31
|
+
"""The registry key this exporter is selected by, e.g. ``"cbz"``."""
|
|
32
|
+
|
|
33
|
+
async def export(
|
|
34
|
+
self,
|
|
35
|
+
title: Title,
|
|
36
|
+
chapters: list[Chapter],
|
|
37
|
+
output_path: Path,
|
|
38
|
+
*,
|
|
39
|
+
fetch_pages: FetchPages,
|
|
40
|
+
on_chapter: Callable[[], None] | None = None,
|
|
41
|
+
) -> Path:
|
|
42
|
+
"""Write ``chapters`` (in the given order) to ``output_path``.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
title: The chapters' parent title, for metadata (name, authors, ...).
|
|
46
|
+
chapters: The chapters to include, in the order they should appear — fetched
|
|
47
|
+
ones, with ``pages``.
|
|
48
|
+
output_path: Where to write the exported file.
|
|
49
|
+
fetch_pages: Downloads one chapter's page images. The exporter calls it once per
|
|
50
|
+
chapter, in order, so it never holds more than it has to in memory.
|
|
51
|
+
on_chapter: Called once per chapter written, if given — drives
|
|
52
|
+
``MangaLib.export()``'s progress reporting.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
``output_path``, once the file has been written.
|
|
56
|
+
"""
|
|
57
|
+
...
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
ExporterT = TypeVar("ExporterT", bound=type[Exporter])
|
|
61
|
+
|
|
62
|
+
EXPORTERS: dict[str, type[Exporter]] = {}
|
|
63
|
+
"""Registered exporters, keyed by ``Exporter.format``."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def register(exporter: ExporterT) -> ExporterT:
|
|
67
|
+
"""Class decorator: register ``exporter`` under its ``format`` key."""
|
|
68
|
+
EXPORTERS[exporter.format] = exporter
|
|
69
|
+
return exporter
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
for _module_info in pkgutil.iter_modules(__path__, f"{__name__}."):
|
|
73
|
+
importlib.import_module(_module_info.name)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Helpers shared by the exporters (not public API)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from mangalib.models import Chapter
|
|
6
|
+
|
|
7
|
+
_SIGNATURES = (
|
|
8
|
+
(b"\xff\xd8\xff", "jpg"),
|
|
9
|
+
(b"\x89PNG\r\n\x1a\n", "png"),
|
|
10
|
+
(b"GIF87a", "gif"),
|
|
11
|
+
(b"GIF89a", "gif"),
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def image_extension(data: bytes) -> str:
|
|
16
|
+
"""The file extension for an image, from its bytes rather than its name.
|
|
17
|
+
|
|
18
|
+
A page's file name isn't a reliable guide: the ``compress`` server sends JPEG for
|
|
19
|
+
``.png`` names (see docs/api-notes.md). Unrecognized data gets ``"jpg"``, what the
|
|
20
|
+
servers send nearly always.
|
|
21
|
+
"""
|
|
22
|
+
if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
|
|
23
|
+
return "webp"
|
|
24
|
+
for signature, extension in _SIGNATURES:
|
|
25
|
+
if data.startswith(signature):
|
|
26
|
+
return extension
|
|
27
|
+
return "jpg"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def chapter_label(chapter: Chapter) -> str:
|
|
31
|
+
"""``"Том 1 Глава 5"`` plus the chapter's name, as the site shows a chapter."""
|
|
32
|
+
label = f"Том {chapter.volume} Глава {chapter.number}"
|
|
33
|
+
return f"{label} — {chapter.name}" if chapter.name else label
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Writing an export file atomically (not public API)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import os
|
|
7
|
+
import tempfile
|
|
8
|
+
from collections.abc import Iterator
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@contextlib.contextmanager
|
|
13
|
+
def atomic_output(path: Path) -> Iterator[Path]:
|
|
14
|
+
"""A temporary file next to ``path`` that replaces it only if the block succeeds.
|
|
15
|
+
|
|
16
|
+
An export downloads every page as it goes, so it can fail halfway (a page no server
|
|
17
|
+
serves, a closed chapter); without this, that would leave a truncated file behind, or
|
|
18
|
+
clobber a good one from an earlier run.
|
|
19
|
+
"""
|
|
20
|
+
descriptor, name = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".part")
|
|
21
|
+
os.close(descriptor)
|
|
22
|
+
temporary = Path(name)
|
|
23
|
+
try:
|
|
24
|
+
yield temporary
|
|
25
|
+
os.replace(temporary, path)
|
|
26
|
+
finally:
|
|
27
|
+
temporary.unlink(missing_ok=True)
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""CBZ exporter: a zip of the page images in reading order, with ``ComicInfo.xml``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import xml.etree.ElementTree as ET
|
|
6
|
+
import zipfile
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import ClassVar
|
|
10
|
+
|
|
11
|
+
from mangalib._http import SITE_ORIGIN
|
|
12
|
+
from mangalib.exporters import FetchPages, register
|
|
13
|
+
from mangalib.exporters._shared import chapter_label, image_extension
|
|
14
|
+
from mangalib.exporters._writing import atomic_output
|
|
15
|
+
from mangalib.models import Chapter, Title
|
|
16
|
+
|
|
17
|
+
_RIGHT_TO_LEFT_TYPES = frozenset({"Манга"})
|
|
18
|
+
"""Title types (``Title.country``) read right to left — ``ComicInfo``'s
|
|
19
|
+
``Manga=YesAndRightToLeft``. Manhwa, manhua and the rest are read left to right, and the
|
|
20
|
+
field is left out for them rather than guessed further."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@register
|
|
24
|
+
class CbzExporter:
|
|
25
|
+
"""Writes chapters as one CBZ: every page image in reading order, plus ``ComicInfo.xml``.
|
|
26
|
+
|
|
27
|
+
Pages are stored without recompression (they're JPEGs already), named by their position
|
|
28
|
+
in the book with zero padding (``0001.jpg``, ``0002.jpg``, ...) so every reader sorts
|
|
29
|
+
them right, with the extension taken from the image's bytes. Where each chapter starts is
|
|
30
|
+
recorded as a ``Bookmark`` on its first page in ``ComicInfo.xml`` (schema 2.1, as read by
|
|
31
|
+
Komga, Kavita, ComicRack and others), which also carries the title's metadata.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
format: ClassVar[str] = "cbz"
|
|
35
|
+
|
|
36
|
+
async def export(
|
|
37
|
+
self,
|
|
38
|
+
title: Title,
|
|
39
|
+
chapters: list[Chapter],
|
|
40
|
+
output_path: Path,
|
|
41
|
+
*,
|
|
42
|
+
fetch_pages: FetchPages,
|
|
43
|
+
on_chapter: Callable[[], None] | None = None,
|
|
44
|
+
) -> Path:
|
|
45
|
+
"""Write ``chapters`` to ``output_path`` as a CBZ — see ``Exporter.export()``."""
|
|
46
|
+
total_pages = sum(len(chapter.pages) for chapter in chapters)
|
|
47
|
+
width = max(4, len(str(total_pages)))
|
|
48
|
+
entries: list[_PageEntry] = []
|
|
49
|
+
with (
|
|
50
|
+
atomic_output(output_path) as temporary,
|
|
51
|
+
zipfile.ZipFile(temporary, "w", zipfile.ZIP_STORED) as archive,
|
|
52
|
+
):
|
|
53
|
+
for chapter in chapters:
|
|
54
|
+
images = await fetch_pages(chapter)
|
|
55
|
+
for position, (page, data) in enumerate(zip(chapter.pages, images, strict=True)):
|
|
56
|
+
index = len(entries)
|
|
57
|
+
archive.writestr(f"{index + 1:0{width}}.{image_extension(data)}", data)
|
|
58
|
+
entries.append(
|
|
59
|
+
_PageEntry(
|
|
60
|
+
size=len(data),
|
|
61
|
+
width=page.width,
|
|
62
|
+
height=page.height,
|
|
63
|
+
bookmark=chapter_label(chapter) if position == 0 else None,
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
if on_chapter is not None:
|
|
67
|
+
on_chapter()
|
|
68
|
+
archive.writestr("ComicInfo.xml", comic_info(title, chapters, entries))
|
|
69
|
+
return output_path
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class _PageEntry:
|
|
73
|
+
"""What ``ComicInfo.xml`` says about one page of the book."""
|
|
74
|
+
|
|
75
|
+
def __init__(
|
|
76
|
+
self, *, size: int, width: int | None, height: int | None, bookmark: str | None
|
|
77
|
+
) -> None:
|
|
78
|
+
self.size = size
|
|
79
|
+
self.width = width
|
|
80
|
+
self.height = height
|
|
81
|
+
self.bookmark = bookmark
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def comic_info(title: Title, chapters: list[Chapter], pages: list[_PageEntry]) -> bytes:
|
|
85
|
+
"""``ComicInfo.xml`` for a book of ``chapters`` (not public API).
|
|
86
|
+
|
|
87
|
+
Elements are written in the schema's order, only when there's a value. ``Number`` (and
|
|
88
|
+
a chapter's name as ``Title``) is set for a single chapter, ``Volume`` when every chapter
|
|
89
|
+
is in the same numbered volume — a book spanning several has neither.
|
|
90
|
+
"""
|
|
91
|
+
root = ET.Element(
|
|
92
|
+
"ComicInfo",
|
|
93
|
+
{
|
|
94
|
+
"xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance",
|
|
95
|
+
"xmlns:xsd": "http://www.w3.org/2001/XMLSchema",
|
|
96
|
+
},
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
def add(tag: str, value: object) -> None:
|
|
100
|
+
if value not in (None, "", []):
|
|
101
|
+
ET.SubElement(root, tag).text = str(value)
|
|
102
|
+
|
|
103
|
+
volumes = {chapter.volume for chapter in chapters}
|
|
104
|
+
single = chapters[0] if len(chapters) == 1 else None
|
|
105
|
+
volume = next(iter(volumes)) if len(volumes) == 1 else None
|
|
106
|
+
if single is not None:
|
|
107
|
+
book_title = single.name
|
|
108
|
+
elif volume is not None:
|
|
109
|
+
book_title = f"Том {volume}"
|
|
110
|
+
else:
|
|
111
|
+
book_title = None
|
|
112
|
+
teams = dict.fromkeys(team.name for chapter in chapters for team in chapter.teams)
|
|
113
|
+
|
|
114
|
+
add("Title", book_title)
|
|
115
|
+
add("Series", title.rus_name or title.name)
|
|
116
|
+
add("Number", single.number if single is not None else None)
|
|
117
|
+
add("Volume", volume if volume is not None and volume.isdigit() else None)
|
|
118
|
+
add("Summary", title.summary)
|
|
119
|
+
add("Year", title.release_date if (title.release_date or "").isdigit() else None)
|
|
120
|
+
add("Writer", ", ".join(person.name for person in title.authors))
|
|
121
|
+
add("Penciller", ", ".join(person.name for person in title.artists))
|
|
122
|
+
add("Translator", ", ".join(teams))
|
|
123
|
+
add("Genre", ", ".join(genre.name for genre in title.genres))
|
|
124
|
+
add("Tags", ", ".join(tag.name for tag in title.tags))
|
|
125
|
+
add("Web", f"{SITE_ORIGIN}/ru/manga/{title.slug_url}")
|
|
126
|
+
add("PageCount", len(pages))
|
|
127
|
+
add("LanguageISO", "ru")
|
|
128
|
+
if title.country is not None and title.country.name in _RIGHT_TO_LEFT_TYPES:
|
|
129
|
+
add("Manga", "YesAndRightToLeft")
|
|
130
|
+
|
|
131
|
+
pages_element = ET.SubElement(root, "Pages")
|
|
132
|
+
for index, page in enumerate(pages):
|
|
133
|
+
attributes = {"Image": str(index), "ImageSize": str(page.size)}
|
|
134
|
+
if page.width is not None and page.height is not None:
|
|
135
|
+
attributes["ImageWidth"] = str(page.width)
|
|
136
|
+
attributes["ImageHeight"] = str(page.height)
|
|
137
|
+
if page.bookmark is not None:
|
|
138
|
+
attributes["Bookmark"] = page.bookmark
|
|
139
|
+
ET.SubElement(pages_element, "Page", attributes)
|
|
140
|
+
|
|
141
|
+
ET.indent(root)
|
|
142
|
+
data: bytes = ET.tostring(root, encoding="utf-8", xml_declaration=True)
|
|
143
|
+
return data
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""PDF exporter: one PDF page per page image, via img2pdf (``mangalib-python-sdk[pdf]``)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import tempfile
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import ClassVar
|
|
10
|
+
|
|
11
|
+
from mangalib.exporters import FetchPages, register
|
|
12
|
+
from mangalib.exporters._shared import chapter_label, image_extension
|
|
13
|
+
from mangalib.exporters._writing import atomic_output
|
|
14
|
+
from mangalib.models import Chapter, Title
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
import img2pdf
|
|
18
|
+
import pikepdf
|
|
19
|
+
except ImportError:
|
|
20
|
+
img2pdf = None
|
|
21
|
+
pikepdf = None # type: ignore[assignment]
|
|
22
|
+
# The "pdf" extra isn't installed: "pdf" just isn't registered below, and
|
|
23
|
+
# MangaLib.export(fmt="pdf") raises the same ValueError as for any unknown format.
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class PdfExporter:
|
|
27
|
+
"""Writes chapters as one PDF: a page per page image, an outline entry per chapter.
|
|
28
|
+
|
|
29
|
+
JPEGs are embedded as they are, without recompression (img2pdf), so the file is about
|
|
30
|
+
the size of the images. Each PDF page is the image's own size; a tall webtoon strip
|
|
31
|
+
beyond the PDF page-size limit is scaled with ``/UserUnit`` rather than cut. The PDF is
|
|
32
|
+
assembled in memory, so for a whole long title CBZ is the lighter choice.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
format: ClassVar[str] = "pdf"
|
|
36
|
+
|
|
37
|
+
async def export(
|
|
38
|
+
self,
|
|
39
|
+
title: Title,
|
|
40
|
+
chapters: list[Chapter],
|
|
41
|
+
output_path: Path,
|
|
42
|
+
*,
|
|
43
|
+
fetch_pages: FetchPages,
|
|
44
|
+
on_chapter: Callable[[], None] | None = None,
|
|
45
|
+
) -> Path:
|
|
46
|
+
"""Write ``chapters`` to ``output_path`` as a PDF — see ``Exporter.export()``."""
|
|
47
|
+
with tempfile.TemporaryDirectory(prefix="mangalib-pdf-") as directory:
|
|
48
|
+
files: list[Path] = []
|
|
49
|
+
outline: list[tuple[str, int]] = []
|
|
50
|
+
for chapter in chapters:
|
|
51
|
+
images = await fetch_pages(chapter)
|
|
52
|
+
if images:
|
|
53
|
+
outline.append((chapter_label(chapter), len(files)))
|
|
54
|
+
for data in images:
|
|
55
|
+
path = Path(directory) / f"{len(files):06}.{image_extension(data)}"
|
|
56
|
+
path.write_bytes(data)
|
|
57
|
+
files.append(path)
|
|
58
|
+
if on_chapter is not None:
|
|
59
|
+
on_chapter()
|
|
60
|
+
with atomic_output(output_path) as temporary:
|
|
61
|
+
await asyncio.to_thread(_write_pdf, title, files, outline, temporary)
|
|
62
|
+
return output_path
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _write_pdf(title: Title, files: list[Path], outline: list[tuple[str, int]], path: Path) -> None:
|
|
66
|
+
"""Assemble the PDF (CPU-bound and synchronous, so it runs off the event loop)."""
|
|
67
|
+
with path.open("wb") as stream:
|
|
68
|
+
img2pdf.convert(
|
|
69
|
+
[str(file) for file in files],
|
|
70
|
+
outputstream=stream,
|
|
71
|
+
title=title.rus_name or title.name,
|
|
72
|
+
author=", ".join(person.name for person in title.authors) or None,
|
|
73
|
+
nodate=True,
|
|
74
|
+
)
|
|
75
|
+
with pikepdf.open(path, allow_overwriting_input=True) as pdf:
|
|
76
|
+
with pdf.open_outline() as document_outline:
|
|
77
|
+
for label, page_index in outline:
|
|
78
|
+
document_outline.root.append(pikepdf.OutlineItem(label, page_index))
|
|
79
|
+
pdf.save(path)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
if img2pdf is not None:
|
|
83
|
+
register(PdfExporter)
|