getcomici 0.0.2__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.
@@ -0,0 +1,12 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .ruff_cache/
8
+ .pytest_cache/
9
+ .coverage
10
+ docs/
11
+ pytest.xml
12
+ pytest-coverage.txt
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 eggplants
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,133 @@
1
+ Metadata-Version: 2.5
2
+ Name: getcomici
3
+ Version: 0.0.2
4
+ Summary: Retrieve and save images from manga distribution sites using Comici+.
5
+ Project-URL: Repository, https://github.com/eggplants/getcomici
6
+ Author-email: eggplants <w10776e8w@yahoo.co.jp>
7
+ License: MIT
8
+ License-File: LICENSE.txt
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Environment :: Console
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Classifier: Typing :: Typed
19
+ Requires-Python: >=3.11
20
+ Requires-Dist: beautifulsoup4>=4.12
21
+ Requires-Dist: pathvalidate>=3.2
22
+ Requires-Dist: pillow>=10.4
23
+ Requires-Dist: requests>=2.32
24
+ Requires-Dist: rich>=13.7
25
+ Description-Content-Type: text/markdown
26
+
27
+ # getcomici
28
+
29
+ [![PyPI](
30
+ <https://img.shields.io/pypi/v/getcomici?color=blue>
31
+ )](
32
+ <https://pypi.org/project/getcomici/>
33
+ ) [![CI](
34
+ <https://github.com/eggplants/getcomici/actions/workflows/ci.yml/badge.svg>
35
+ )](
36
+ <https://github.com/eggplants/getcomici/actions/workflows/ci.yml>
37
+ )
38
+
39
+ [![ghcr size](
40
+ <https://ghcr-badge.egpl.dev/eggplants/getcomici/size>
41
+ )](
42
+ <https://github.com/eggplants/getcomici/pkgs/container/getcomici>
43
+ )
44
+
45
+ Retrieve and save images from manga distribution sites using [Comici+](https://comici.co.jp/business/comici-plus).
46
+
47
+ ## Valid URL Formats
48
+
49
+ - `<host>/episodes/<id>`
50
+ - e.g. <https://mangabu.jp/episodes/71f48a2c352ed>
51
+
52
+ ## Available Hosts
53
+
54
+ - <https://asacomi.jp>
55
+ - <https://bibibi-comic.com>
56
+ - <https://championcross.jp>
57
+ - <https://comic-growl.com>
58
+ - <https://comic-room-base.com>
59
+ - <https://comic.j-nbooks.jp>
60
+ - <https://comicpash.jp>
61
+ - <https://comicride.jp>
62
+ - <https://comics.manga-bang.com>
63
+ - <https://comirela.com>
64
+ - <https://ebookstore.corkagency.com>
65
+ - <https://g-comi.jp>
66
+ - <https://hanayume.com>
67
+ - <https://hayacomic.jp>
68
+ - <https://heros-web.com>
69
+ - <https://kansai.mag-garden.co.jp>
70
+ - <https://kimicomi.com>
71
+ - <https://manga-zegra.com>
72
+ - <https://mangabu.jp>
73
+ - <https://mangalt.jp>
74
+ - <https://mangaspa.nikkan-spa.jp>
75
+ - <https://namicomic.jp>
76
+ - <https://piacomic.jp>
77
+ - <https://studio.booklista.co.jp>
78
+ - <https://takecomic.jp>
79
+ - <https://younganimal.com>
80
+ - <https://youngchampion.jp>
81
+
82
+ ## Installation
83
+
84
+ ```bash
85
+ # mise via github release
86
+ mise use -g github:eggplants/getcomici
87
+
88
+ # mise via pipx
89
+ mise use -g pipx:getcomici
90
+
91
+ # pipx
92
+ pipx install getcomici
93
+
94
+ # pip
95
+ pip install getcomici
96
+ ```
97
+
98
+ ### Docker
99
+
100
+ ```bash
101
+ docker pull ghcr.io/eggplants/getcomici
102
+
103
+ docker run --rm -v "$PWD:/work" -w /work \
104
+ ghcr.io/eggplants/getcomici https://mangabu.jp/episodes/71f48a2c352ed
105
+ ```
106
+
107
+ ## CLI
108
+
109
+ ```shellsession
110
+ $ cget https://mangabu.jp/episodes/71f48a2c352ed
111
+ get: https://mangabu.jp/episodes/71f48a2c352ed
112
+ Downloading... ━━━━━━━━━━━━ 100% ( 18/18 pages ) remain: 0:00:00 spent: 0:00:02
113
+ saved: IRUKA/prologue
114
+ done.
115
+ ```
116
+
117
+ ## Library
118
+
119
+ ```python
120
+ from getcomici import Comici
121
+
122
+ comici = Comici()
123
+ next_url, save_dir, saved = comici.get(
124
+ "https://mangabu.jp/episodes/71f48a2c352ed",
125
+ save_path="out",
126
+ )
127
+ ```
128
+
129
+ ## License
130
+
131
+ [MIT License](
132
+ <https://github.com/eggplants/getcomici/blob/master/LICENSE.txt>
133
+ )
@@ -0,0 +1,107 @@
1
+ # getcomici
2
+
3
+ [![PyPI](
4
+ <https://img.shields.io/pypi/v/getcomici?color=blue>
5
+ )](
6
+ <https://pypi.org/project/getcomici/>
7
+ ) [![CI](
8
+ <https://github.com/eggplants/getcomici/actions/workflows/ci.yml/badge.svg>
9
+ )](
10
+ <https://github.com/eggplants/getcomici/actions/workflows/ci.yml>
11
+ )
12
+
13
+ [![ghcr size](
14
+ <https://ghcr-badge.egpl.dev/eggplants/getcomici/size>
15
+ )](
16
+ <https://github.com/eggplants/getcomici/pkgs/container/getcomici>
17
+ )
18
+
19
+ Retrieve and save images from manga distribution sites using [Comici+](https://comici.co.jp/business/comici-plus).
20
+
21
+ ## Valid URL Formats
22
+
23
+ - `<host>/episodes/<id>`
24
+ - e.g. <https://mangabu.jp/episodes/71f48a2c352ed>
25
+
26
+ ## Available Hosts
27
+
28
+ - <https://asacomi.jp>
29
+ - <https://bibibi-comic.com>
30
+ - <https://championcross.jp>
31
+ - <https://comic-growl.com>
32
+ - <https://comic-room-base.com>
33
+ - <https://comic.j-nbooks.jp>
34
+ - <https://comicpash.jp>
35
+ - <https://comicride.jp>
36
+ - <https://comics.manga-bang.com>
37
+ - <https://comirela.com>
38
+ - <https://ebookstore.corkagency.com>
39
+ - <https://g-comi.jp>
40
+ - <https://hanayume.com>
41
+ - <https://hayacomic.jp>
42
+ - <https://heros-web.com>
43
+ - <https://kansai.mag-garden.co.jp>
44
+ - <https://kimicomi.com>
45
+ - <https://manga-zegra.com>
46
+ - <https://mangabu.jp>
47
+ - <https://mangalt.jp>
48
+ - <https://mangaspa.nikkan-spa.jp>
49
+ - <https://namicomic.jp>
50
+ - <https://piacomic.jp>
51
+ - <https://studio.booklista.co.jp>
52
+ - <https://takecomic.jp>
53
+ - <https://younganimal.com>
54
+ - <https://youngchampion.jp>
55
+
56
+ ## Installation
57
+
58
+ ```bash
59
+ # mise via github release
60
+ mise use -g github:eggplants/getcomici
61
+
62
+ # mise via pipx
63
+ mise use -g pipx:getcomici
64
+
65
+ # pipx
66
+ pipx install getcomici
67
+
68
+ # pip
69
+ pip install getcomici
70
+ ```
71
+
72
+ ### Docker
73
+
74
+ ```bash
75
+ docker pull ghcr.io/eggplants/getcomici
76
+
77
+ docker run --rm -v "$PWD:/work" -w /work \
78
+ ghcr.io/eggplants/getcomici https://mangabu.jp/episodes/71f48a2c352ed
79
+ ```
80
+
81
+ ## CLI
82
+
83
+ ```shellsession
84
+ $ cget https://mangabu.jp/episodes/71f48a2c352ed
85
+ get: https://mangabu.jp/episodes/71f48a2c352ed
86
+ Downloading... ━━━━━━━━━━━━ 100% ( 18/18 pages ) remain: 0:00:00 spent: 0:00:02
87
+ saved: IRUKA/prologue
88
+ done.
89
+ ```
90
+
91
+ ## Library
92
+
93
+ ```python
94
+ from getcomici import Comici
95
+
96
+ comici = Comici()
97
+ next_url, save_dir, saved = comici.get(
98
+ "https://mangabu.jp/episodes/71f48a2c352ed",
99
+ save_path="out",
100
+ )
101
+ ```
102
+
103
+ ## License
104
+
105
+ [MIT License](
106
+ <https://github.com/eggplants/getcomici/blob/master/LICENSE.txt>
107
+ )
@@ -0,0 +1,36 @@
1
+ """.. include:: ../README.md""" # noqa: D415
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.metadata
6
+
7
+ from .comici import (
8
+ VALID_HOSTS,
9
+ Comici,
10
+ ComiciError,
11
+ Episode,
12
+ LoginError,
13
+ NeedPurchase,
14
+ NotAComiciPageError,
15
+ Page,
16
+ descramble,
17
+ parse_scramble,
18
+ )
19
+
20
+ try:
21
+ __version__ = importlib.metadata.version(__name__)
22
+ except importlib.metadata.PackageNotFoundError: # pragma: no cover
23
+ __version__ = "0.0.0"
24
+
25
+ __all__ = (
26
+ "VALID_HOSTS",
27
+ "Comici",
28
+ "ComiciError",
29
+ "Episode",
30
+ "LoginError",
31
+ "NeedPurchase",
32
+ "NotAComiciPageError",
33
+ "Page",
34
+ "descramble",
35
+ "parse_scramble",
36
+ )
@@ -0,0 +1,8 @@
1
+ """Support `python -m getcomici`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ main()
@@ -0,0 +1,140 @@
1
+ """Command line entry point for getcomici."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import getpass
6
+ import shutil
7
+ import sys
8
+ import warnings
9
+ from argparse import (
10
+ ArgumentDefaultsHelpFormatter,
11
+ ArgumentParser,
12
+ ArgumentTypeError,
13
+ Namespace,
14
+ RawDescriptionHelpFormatter,
15
+ )
16
+ from urllib.parse import urlparse
17
+
18
+ from . import __version__
19
+ from .comici import VALID_HOSTS, Comici, ComiciError, NeedPurchase, NotAComiciPageError
20
+
21
+
22
+ class HelpFormatter(ArgumentDefaultsHelpFormatter, RawDescriptionHelpFormatter):
23
+ """Show argument defaults while keeping the description's own line breaks."""
24
+
25
+
26
+ def available_list() -> str:
27
+ """Render the known Comici+ sites for the help epilog."""
28
+ return "known sites:\n - https://" + "\n - https://".join(VALID_HOSTS)
29
+
30
+
31
+ def check_url(url: str) -> str:
32
+ """Accept any https URL, since unlisted sites may still run the viewer.
33
+
34
+ Args:
35
+ url: The URL given on the command line.
36
+
37
+ Returns:
38
+ The URL unchanged.
39
+
40
+ Raises:
41
+ ArgumentTypeError: The URL is not https.
42
+ """
43
+ if urlparse(url).scheme != "https":
44
+ msg = f"'{url}' is not an https URL.\n{available_list()}"
45
+ raise ArgumentTypeError(msg)
46
+ return url
47
+
48
+
49
+ def parse_args(args: list[str] | None = None) -> Namespace:
50
+ """Parse the command line.
51
+
52
+ Args:
53
+ args: Arguments to parse instead of `sys.argv[1:]`. Used by the tests.
54
+
55
+ Returns:
56
+ The parsed arguments.
57
+ """
58
+ parser = ArgumentParser(
59
+ prog="getcomici",
60
+ description="Retrieve and save images from manga distribution sites using Comici+.",
61
+ epilog=available_list(),
62
+ formatter_class=lambda prog: HelpFormatter(
63
+ prog,
64
+ width=shutil.get_terminal_size(fallback=(120, 50)).columns,
65
+ max_help_position=40,
66
+ ),
67
+ )
68
+ parser.add_argument("url", type=check_url, help="episode url")
69
+ parser.add_argument("-b", "--bulk", action="store_true", help="follow every next episode")
70
+ parser.add_argument("-d", "--savedir", metavar="DIR", default=".", help="directory to save into")
71
+ parser.add_argument("-f", "--first", action="store_true", help="download only the first page")
72
+ parser.add_argument("-o", "--overwrite", action="store_true", help="download again if it exists")
73
+ parser.add_argument("-m", "--metadata", action="store_true", help="save page metadata as json")
74
+ parser.add_argument("-u", "--username", metavar="ID", help="comici id or email to log in with")
75
+ parser.add_argument("-p", "--password", metavar="PW", help="password (prompted for if -u is given without it)")
76
+ parser.add_argument("-q", "--quiet", action="store_true", help="disable console output")
77
+ parser.add_argument("-V", "--version", action="version", version=f"%(prog)s {__version__}")
78
+ return parser.parse_args(args)
79
+
80
+
81
+ def main(args: list[str] | None = None) -> None:
82
+ """Run the command."""
83
+ parsed = parse_args(args)
84
+ comici = Comici()
85
+ if parsed.username:
86
+ password = parsed.password or getpass.getpass("password: ")
87
+ try:
88
+ comici.login(parsed.url, parsed.username, password)
89
+ except ComiciError as exc:
90
+ print(f"error: {exc}", file=sys.stderr)
91
+ raise SystemExit(1) from exc
92
+ if not parsed.quiet:
93
+ print("logged in as:", parsed.username)
94
+ elif parsed.password:
95
+ print("warning: -p without -u does nothing.", file=sys.stderr)
96
+ if not parsed.quiet and not Comici.is_valid_uri(parsed.url):
97
+ print(f"warning: {urlparse(parsed.url).hostname} is not a known Comici+ site, trying anyway.")
98
+
99
+ next_url: str | None = parsed.url
100
+ first = True
101
+ try:
102
+ while next_url:
103
+ if not parsed.quiet:
104
+ print("get:", next_url)
105
+ with warnings.catch_warnings():
106
+ warnings.simplefilter("error", NeedPurchase)
107
+ try:
108
+ next_url, save_dir, saved = comici.get(
109
+ next_url,
110
+ save_path=parsed.savedir,
111
+ overwrite=parsed.overwrite,
112
+ only_first=parsed.first,
113
+ save_metadata=parsed.metadata,
114
+ print_log=not parsed.quiet,
115
+ )
116
+ except NeedPurchase as exc:
117
+ print(f"stop: '{exc.args[0]}' needs a purchase or a login.", file=sys.stderr)
118
+ break
119
+ except NotAComiciPageError:
120
+ # Locked episodes serve a purchase page with no viewer on it,
121
+ # which is where a bulk run is meant to end rather than fail.
122
+ if first:
123
+ raise
124
+ print("stop: the next episode is not readable.", file=sys.stderr)
125
+ break
126
+ if not parsed.quiet:
127
+ print("saved:" if saved else "skipped (already there):", save_dir)
128
+ if not parsed.bulk:
129
+ break
130
+ first = False
131
+ except ComiciError as exc:
132
+ print(f"error: {exc}", file=sys.stderr)
133
+ raise SystemExit(1) from exc
134
+
135
+ if not parsed.quiet:
136
+ print("done.")
137
+
138
+
139
+ if __name__ == "__main__":
140
+ main()
@@ -0,0 +1,563 @@
1
+ """Download and unscramble manga pages from sites running the Comici+ viewer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import warnings
7
+ from dataclasses import dataclass
8
+ from io import BytesIO
9
+ from pathlib import Path
10
+ from typing import TYPE_CHECKING, Any, TypedDict
11
+ from urllib.parse import urljoin, urlparse
12
+
13
+ from bs4 import BeautifulSoup
14
+ from bs4.element import Tag
15
+ from pathvalidate import sanitize_filename
16
+ from PIL import Image
17
+ from requests import Session
18
+ from requests.adapters import HTTPAdapter, Retry
19
+ from rich.progress import (
20
+ BarColumn,
21
+ MofNCompleteColumn,
22
+ Progress,
23
+ SpinnerColumn,
24
+ TaskProgressColumn,
25
+ TextColumn,
26
+ TimeElapsedColumn,
27
+ TimeRemainingColumn,
28
+ )
29
+
30
+ if TYPE_CHECKING:
31
+ from collections.abc import Sequence
32
+
33
+ HEADERS = {
34
+ "User-Agent": (
35
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
36
+ ),
37
+ "Accept-Language": "ja,en-US;q=0.9,en;q=0.8",
38
+ "sec-ch-ua": '"Google Chrome";v="131", "Chromium";v="131", "Not_A Brand";v="24"',
39
+ "sec-ch-ua-mobile": "?0",
40
+ "sec-ch-ua-platform": '"Linux"',
41
+ }
42
+
43
+ DOCUMENT_HEADERS = {
44
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
45
+ "Sec-Fetch-Dest": "document",
46
+ "Sec-Fetch-Mode": "navigate",
47
+ "Sec-Fetch-Site": "none",
48
+ "Sec-Fetch-User": "?1",
49
+ "Upgrade-Insecure-Requests": "1",
50
+ }
51
+ API_HEADERS = {
52
+ "Accept": "application/json, text/plain, */*",
53
+ "Sec-Fetch-Dest": "empty",
54
+ "Sec-Fetch-Mode": "cors",
55
+ "Sec-Fetch-Site": "same-origin",
56
+ }
57
+ IMAGE_HEADERS = {
58
+ "Accept": "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8",
59
+ "Sec-Fetch-Dest": "image",
60
+ "Sec-Fetch-Mode": "no-cors",
61
+ "Sec-Fetch-Site": "cross-site",
62
+ }
63
+
64
+ # https://comici.jp/cooperation-site
65
+ VALID_HOSTS = (
66
+ "asacomi.jp",
67
+ "bibibi-comic.com",
68
+ "championcross.jp",
69
+ "comic-growl.com",
70
+ "comic-room-base.com",
71
+ "comic.j-nbooks.jp",
72
+ "comicpash.jp",
73
+ "comicride.jp",
74
+ "comics.manga-bang.com",
75
+ "comirela.com",
76
+ "ebookstore.corkagency.com",
77
+ "g-comi.jp",
78
+ "hanayume.com",
79
+ "hayacomic.jp",
80
+ "heros-web.com",
81
+ "kansai.mag-garden.co.jp",
82
+ "kimicomi.com",
83
+ "manga-zegra.com",
84
+ "mangabu.jp",
85
+ "mangalt.jp",
86
+ "mangaspa.nikkan-spa.jp",
87
+ "namicomic.jp",
88
+ "piacomic.jp",
89
+ "studio.booklista.co.jp",
90
+ "takecomic.jp",
91
+ "younganimal.com",
92
+ "youngchampion.jp",
93
+ )
94
+
95
+ # The viewer slices every page into a COLUMNS x ROWS grid and shuffles the tiles.
96
+ COLUMNS = 4
97
+ ROWS = 4
98
+ TILES = COLUMNS * ROWS
99
+
100
+ _VIEWER_ID = "comici-viewer"
101
+
102
+ # Sites that hand their images over unscrambled still go through `descramble`,
103
+ # with a permutation that puts every tile back where it already was.
104
+ _IDENTITY_SCRAMBLE = json.dumps(list(range(TILES)))
105
+
106
+
107
+ class ComiciError(Exception):
108
+ """Base class for every error this module raises."""
109
+
110
+
111
+ class NotAComiciPageError(ComiciError):
112
+ """The fetched page carries no Comici+ viewer."""
113
+
114
+
115
+ class LoginError(ComiciError):
116
+ """The site refused the credentials."""
117
+
118
+
119
+ class NeedPurchase(Warning):
120
+ """The episode is not readable without buying it or logging in."""
121
+
122
+
123
+ class Page(TypedDict):
124
+ """One page as `book/contentsInfo` describes it."""
125
+
126
+ imageUrl: str # noqa: N815 (mirrors the API's own spelling)
127
+ scramble: str
128
+ sort: int
129
+ width: int
130
+ height: int
131
+ expiresOn: int # noqa: N815 (mirrors the API's own spelling)
132
+
133
+
134
+ @dataclass(frozen=True)
135
+ class Episode:
136
+ """What the episode page says about itself."""
137
+
138
+ url: str
139
+ viewer_id: str
140
+ api_base: str
141
+ series_title: str
142
+ episode_title: str
143
+ next_url: str | None
144
+ member_jwt: str = ""
145
+ # Pages the episode JSON carried itself, on sites that render the viewer
146
+ # client-side. None when the page had a viewer and `pages` has to ask
147
+ # `contentsInfo` for them.
148
+ inline_pages: tuple[Page, ...] | None = None
149
+
150
+
151
+ def parse_scramble(scramble: str) -> list[int]:
152
+ """Turn the API's `"[1, 5, 13, ...]"` into a list of tile indices.
153
+
154
+ Args:
155
+ scramble: The `scramble` field of a page, as the API returns it.
156
+
157
+ Returns:
158
+ One source tile index per destination tile, in column-major order.
159
+
160
+ Raises:
161
+ ComiciError: The string is not a permutation of `range(TILES)`.
162
+ """
163
+ indices = [int(part) for part in scramble.strip().strip("[]").split(",")]
164
+ if sorted(indices) != list(range(TILES)):
165
+ msg = f"{scramble!r} is not a permutation of 0..{TILES - 1}."
166
+ raise ComiciError(msg)
167
+ return indices
168
+
169
+
170
+ def descramble(image: Image.Image, scramble: Sequence[int]) -> Image.Image:
171
+ """Put a scrambled page back together.
172
+
173
+ The viewer walks the destination grid column by column and copies
174
+ `scramble[n]`-th source tile into the n-th destination slot. Tile size is
175
+ floored, so any leftover strip on the right and bottom edge is never
176
+ shuffled and is kept as-is.
177
+
178
+ Args:
179
+ image: The page exactly as the CDN serves it.
180
+ scramble: Source tile index per destination tile, from `parse_scramble`.
181
+
182
+ Returns:
183
+ A new image with the tiles back in reading order.
184
+ """
185
+ width, height = image.size
186
+ tile_width, tile_height = width // COLUMNS, height // ROWS
187
+ out = image.copy()
188
+ for dest, src in enumerate(scramble):
189
+ dest_col, dest_row = divmod(dest, ROWS)
190
+ src_col, src_row = divmod(src, ROWS)
191
+ tile = image.crop(
192
+ (
193
+ tile_width * src_col,
194
+ tile_height * src_row,
195
+ tile_width * (src_col + 1),
196
+ tile_height * (src_row + 1),
197
+ ),
198
+ )
199
+ out.paste(tile, (tile_width * dest_col, tile_height * dest_row))
200
+ return out
201
+
202
+
203
+ class Comici:
204
+ """Fetch episodes from a site running the Comici+ viewer."""
205
+
206
+ def __init__(self, session: Session | None = None) -> None:
207
+ """Build a client.
208
+
209
+ Args:
210
+ session: A session to reuse. A retrying one is made when omitted.
211
+ """
212
+ if session is None:
213
+ session = Session()
214
+ adapter = HTTPAdapter(max_retries=Retry(total=10, backoff_factor=1))
215
+ session.mount("https://", adapter)
216
+ session.mount("http://", adapter)
217
+ self._session = session
218
+ self._id_tokens: dict[str, str] = {}
219
+
220
+ @staticmethod
221
+ def is_valid_uri(url: str) -> bool:
222
+ """Report whether `url` is on a site known to run Comici+.
223
+
224
+ A `False` here is only a hint: any page carrying a Comici+ viewer works,
225
+ so `get` decides for itself once it has the HTML.
226
+
227
+ Args:
228
+ url: The URL to check.
229
+
230
+ Returns:
231
+ True when the URL is https and its host is in `VALID_HOSTS`.
232
+ """
233
+ parsed = urlparse(url)
234
+ return parsed.scheme == "https" and parsed.hostname in VALID_HOSTS
235
+
236
+ def get( # noqa: PLR0913
237
+ self,
238
+ url: str,
239
+ save_path: str | Path = ".",
240
+ *,
241
+ overwrite: bool = False,
242
+ only_first: bool = False,
243
+ save_metadata: bool = False,
244
+ print_log: bool = False,
245
+ ) -> tuple[str | None, Path, bool]:
246
+ """Download one episode and unscramble every page of it.
247
+
248
+ Args:
249
+ url: The episode URL.
250
+ save_path: Directory to build `<series>/<episode>/` under.
251
+ overwrite: Download again even if the directory already exists.
252
+ only_first: Stop after the first page.
253
+ save_metadata: Also write the raw `contentsInfo` response.
254
+ print_log: Draw a progress bar.
255
+
256
+ Returns:
257
+ The next episode's URL (or None), the directory written, and
258
+ whether anything was actually downloaded.
259
+ """
260
+ episode = self.episode_info(url)
261
+ save_dir = Path(save_path) / sanitize_filename(episode.series_title) / sanitize_filename(episode.episode_title)
262
+ if save_dir.exists() and not overwrite:
263
+ return episode.next_url, save_dir, False
264
+
265
+ pages = self.pages(episode)
266
+ if not pages:
267
+ warnings.warn(episode.episode_title, NeedPurchase, stacklevel=2)
268
+ return episode.next_url, save_dir, False
269
+
270
+ save_dir.mkdir(parents=True, exist_ok=True)
271
+ if save_metadata:
272
+ (save_dir / "metadata.json").write_text(
273
+ json.dumps(pages, indent=4, ensure_ascii=False),
274
+ encoding="utf-8",
275
+ )
276
+ self._save_pages(
277
+ pages,
278
+ save_dir,
279
+ referer=episode.url,
280
+ only_first=only_first,
281
+ print_log=print_log,
282
+ )
283
+ return episode.next_url, save_dir, True
284
+
285
+ def login(self, url: str, user_id: str, password: str) -> None:
286
+ """Sign in, so episodes the account may read become readable.
287
+
288
+ Sites run NextAuth behind `/api/auth`, with a credentials provider that
289
+ takes a Comici ID or an email address. The session cookie lands on the
290
+ shared session and the returned id token is sent with later API calls.
291
+ This grants nothing the account does not already own.
292
+
293
+ Args:
294
+ url: Any URL on the site to sign in to.
295
+ user_id: A Comici ID or the email address the account uses.
296
+ password: The account's password.
297
+
298
+ Raises:
299
+ LoginError: The site refused the credentials.
300
+ """
301
+ origin = self._origin(url)
302
+ auth = f"{origin}/api/auth"
303
+ csrf = self._session.get(f"{auth}/csrf", headers=self._headers(url, API_HEADERS), timeout=30)
304
+ csrf.raise_for_status()
305
+
306
+ res = self._session.post(
307
+ f"{auth}/callback/credentials",
308
+ data={
309
+ "id": user_id,
310
+ "password": password,
311
+ "csrfToken": csrf.json()["csrfToken"],
312
+ "callbackUrl": f"{origin}/",
313
+ "json": "true",
314
+ },
315
+ headers={**self._headers(url, API_HEADERS), "Origin": origin, "Referer": f"{origin}/"},
316
+ timeout=30,
317
+ )
318
+ res.raise_for_status()
319
+
320
+ session = self._session.get(f"{auth}/session", headers=self._headers(url, API_HEADERS), timeout=30)
321
+ session.raise_for_status()
322
+ token = (session.json() or {}).get("idToken")
323
+ if not token:
324
+ msg = f"{origin} refused the credentials for {user_id!r}."
325
+ raise LoginError(msg)
326
+ self._id_tokens[origin] = str(token)
327
+
328
+ @staticmethod
329
+ def _origin(url: str) -> str:
330
+ parsed = urlparse(url)
331
+ return f"{parsed.scheme}://{parsed.netloc}"
332
+
333
+ def _headers(self, url: str, kind: dict[str, str] | None = None) -> dict[str, str]:
334
+ """Headers for `url`, carrying the id token once its site is signed in.
335
+
336
+ Args:
337
+ url: The URL the headers are for.
338
+ kind: `DOCUMENT_HEADERS`, `API_HEADERS` or `IMAGE_HEADERS`, saying
339
+ what the request is fetching.
340
+
341
+ Returns:
342
+ The headers to send.
343
+ """
344
+ headers = {**HEADERS, **(kind or {})}
345
+ token = self._id_tokens.get(self._origin(url))
346
+ if token:
347
+ headers["Authorization"] = token
348
+ return headers
349
+
350
+ def episode_info(self, url: str) -> Episode:
351
+ """Read the viewer parameters off an episode page.
352
+
353
+ Args:
354
+ url: The episode URL.
355
+
356
+ Returns:
357
+ The parsed episode.
358
+
359
+ Raises:
360
+ NotAComiciPageError: The page carries no Comici+ viewer.
361
+ """
362
+ res = self._session.get(url, headers=self._headers(url, DOCUMENT_HEADERS), timeout=30)
363
+ res.raise_for_status()
364
+ soup = BeautifulSoup(res.content, "html.parser")
365
+
366
+ viewer = soup.find(id=_VIEWER_ID)
367
+ if not isinstance(viewer, Tag):
368
+ return self._episode_from_api(url)
369
+
370
+ viewer_id = str(viewer.attrs.get("data-comici-viewer-id", "")) or None
371
+ if viewer_id is None:
372
+ msg = f"'#{_VIEWER_ID}' on {url} carries no data-comici-viewer-id."
373
+ raise NotAComiciPageError(msg)
374
+
375
+ api_domain = str(viewer.attrs.get("data-api-domain", "/api"))
376
+ parsed = urlparse(url)
377
+ api_base = (
378
+ f"{parsed.scheme}://{parsed.netloc}{api_domain}" if api_domain.startswith("/") else f"https://{api_domain}"
379
+ )
380
+
381
+ series_title, episode_title = self._titles(soup, viewer, viewer_id)
382
+
383
+ next_id = str(viewer.attrs.get("data-next-episode-id", ""))
384
+ next_url = urljoin(url, next_id) if next_id else None
385
+
386
+ return Episode(
387
+ url=url,
388
+ viewer_id=viewer_id,
389
+ member_jwt=str(viewer.attrs.get("data-member-jwt", "")),
390
+ api_base=api_base,
391
+ series_title=series_title,
392
+ episode_title=episode_title,
393
+ next_url=next_url,
394
+ )
395
+
396
+ def pages(self, episode: Episode, member_jwt: str | None = None) -> list[Page]:
397
+ """List every page of an episode.
398
+
399
+ `contentsInfo` refuses a range wider than the episode, so the total is
400
+ asked for first and the real range fetched second.
401
+
402
+ Args:
403
+ episode: The episode to list.
404
+ member_jwt: Overrides the token the episode page carried, if any.
405
+
406
+ Returns:
407
+ The pages, in reading order. Empty when the episode is not readable.
408
+ """
409
+ if episode.inline_pages is not None:
410
+ return list(episode.inline_pages)
411
+ if member_jwt is None:
412
+ member_jwt = episode.member_jwt
413
+ total = int(self._contents_info(episode, 0, 0, member_jwt).get("totalPages") or 0)
414
+ if total <= 0:
415
+ return []
416
+ body = self._contents_info(episode, 0, total - 1, member_jwt)
417
+ pages: list[Page] = list(body.get("result") or [])
418
+ return sorted(pages, key=lambda page: page["sort"])
419
+
420
+ def _episode_from_api(self, url: str) -> Episode:
421
+ """Read an episode that renders its viewer only after hydration.
422
+
423
+ Newer sites (ebookstore.corkagency.com) ship an episode page with no
424
+ `#comici-viewer` element on it, and their `/api/episodes/{id}` hands the
425
+ page images over directly, already unscrambled, instead of a viewer id
426
+ to look up with `contentsInfo`.
427
+
428
+ Args:
429
+ url: The episode URL.
430
+
431
+ Returns:
432
+ The parsed episode, carrying its pages.
433
+
434
+ Raises:
435
+ NotAComiciPageError: The API describes no episode either.
436
+ """
437
+ parsed = urlparse(url)
438
+ episode_id = parsed.path.rstrip("/").rsplit("/", 1)[-1]
439
+ api_base = f"{parsed.scheme}://{parsed.netloc}/api"
440
+ res = self._session.get(
441
+ f"{api_base}/episodes/{episode_id}",
442
+ headers={**self._headers(url, API_HEADERS), "Referer": url},
443
+ timeout=30,
444
+ )
445
+ body = res.json() if res.ok else None
446
+ episode = body.get("episode") if isinstance(body, dict) else None
447
+ if not isinstance(episode, dict):
448
+ msg = f"no '#{_VIEWER_ID}' element on {url}, and its episode API describes none either."
449
+ raise NotAComiciPageError(msg)
450
+
451
+ series = episode.get("series") or {}
452
+ summary = episode.get("summary") or {}
453
+ next_id = str(episode.get("nextEpisodeId") or "")
454
+ return Episode(
455
+ url=url,
456
+ viewer_id=str(episode.get("id") or episode_id),
457
+ api_base=api_base,
458
+ series_title=str(series.get("name") or "").strip() or episode_id,
459
+ episode_title=str(summary.get("title") or "").strip() or episode_id,
460
+ next_url=urljoin(url, next_id) if next_id else None,
461
+ inline_pages=self._inline_pages(episode),
462
+ )
463
+
464
+ @staticmethod
465
+ def _inline_pages(episode: dict[str, Any]) -> tuple[Page, ...]:
466
+ """Turn the `content` blocks of an episode JSON into pages."""
467
+ return tuple(
468
+ Page(
469
+ imageUrl=str(node["url"]),
470
+ scramble=_IDENTITY_SCRAMBLE,
471
+ sort=index,
472
+ width=int(node.get("width") or 0),
473
+ height=int(node.get("height") or 0),
474
+ expiresOn=0,
475
+ )
476
+ for index, node in enumerate(
477
+ node
478
+ for node in episode.get("content") or []
479
+ if isinstance(node, dict) and node.get("type") == "image" and node.get("url")
480
+ )
481
+ )
482
+
483
+ def _contents_info(
484
+ self,
485
+ episode: Episode,
486
+ page_from: int,
487
+ page_to: int,
488
+ member_jwt: str = "",
489
+ ) -> dict[str, Any]:
490
+ res = self._session.get(
491
+ f"{episode.api_base}/book/contentsInfo",
492
+ params={
493
+ "user-id": member_jwt,
494
+ "comici-viewer-id": episode.viewer_id,
495
+ "page-from": page_from,
496
+ "page-to": page_to,
497
+ },
498
+ # Some sites (studio.booklista.co.jp) answer 403 without a site Referer.
499
+ headers={**self._headers(episode.url, API_HEADERS), "Referer": episode.url},
500
+ timeout=30,
501
+ )
502
+ res.raise_for_status()
503
+ body = res.json()
504
+ if not isinstance(body, dict) or "result" not in body:
505
+ msg = f"contentsInfo refused the request: {body}"
506
+ raise ComiciError(msg)
507
+ return body
508
+
509
+ @staticmethod
510
+ def _titles(soup: BeautifulSoup, viewer: Tag, viewer_id: str) -> tuple[str, str]:
511
+ """Split `og:title` -- `"<series>・<episode> | <site>"` -- into its parts."""
512
+ og = soup.find("meta", property="og:title")
513
+ heading = str(og.attrs.get("content", "")) if isinstance(og, Tag) else ""
514
+ if not heading and soup.title:
515
+ heading = soup.title.get_text()
516
+ heading = heading.rsplit(" | ", 1)[0].strip()
517
+
518
+ series, _, episode = heading.partition("・")
519
+ if not episode:
520
+ series, episode = str(viewer.attrs.get("data-share-text", "")), heading
521
+ return (series.lstrip("#").strip() or viewer_id, episode.strip() or viewer_id)
522
+
523
+ def _save_pages(
524
+ self,
525
+ pages: list[Page],
526
+ save_dir: Path,
527
+ *,
528
+ referer: str,
529
+ only_first: bool = False,
530
+ print_log: bool = False,
531
+ ) -> None:
532
+ wanted = pages[:1] if only_first else pages
533
+ width = len(str(len(wanted)))
534
+ progress = Progress(
535
+ SpinnerColumn(),
536
+ TextColumn("{task.description}"),
537
+ BarColumn(),
538
+ TaskProgressColumn(),
539
+ TextColumn("("),
540
+ MofNCompleteColumn(),
541
+ TextColumn("pages )"),
542
+ TextColumn("remain:"),
543
+ TimeRemainingColumn(),
544
+ TextColumn("spent:"),
545
+ TimeElapsedColumn(),
546
+ disable=not print_log,
547
+ )
548
+ with progress:
549
+ task = progress.add_task("[red]Downloading...", total=len(wanted))
550
+ for index, page in enumerate(wanted):
551
+ image = self._image(page, referer)
552
+ image.save(save_dir / f"{index:0{width}d}.jpg", quality=95)
553
+ progress.update(task, advance=1)
554
+
555
+ def _image(self, page: Page, referer: str) -> Image.Image:
556
+ res = self._session.get(
557
+ page["imageUrl"],
558
+ headers={**self._headers(referer, IMAGE_HEADERS), "Referer": referer},
559
+ timeout=60,
560
+ )
561
+ res.raise_for_status()
562
+ image = Image.open(BytesIO(res.content))
563
+ return descramble(image, parse_scramble(page["scramble"]))
File without changes
@@ -0,0 +1,91 @@
1
+ [build-system]
2
+ build-backend = "hatchling.build"
3
+ requires = [ "hatchling", "uv-dynamic-versioning" ]
4
+
5
+ [project]
6
+ name = "getcomici"
7
+ description = "Retrieve and save images from manga distribution sites using Comici+."
8
+ readme = "README.md"
9
+ license = { text = "MIT" }
10
+ authors = [ { name = "eggplants", email = "w10776e8w@yahoo.co.jp" } ]
11
+ requires-python = ">=3.11"
12
+ classifiers = [
13
+ "Development Status :: 3 - Alpha",
14
+ "Environment :: Console",
15
+ "License :: OSI Approved :: MIT License",
16
+ "Operating System :: OS Independent",
17
+ "Programming Language :: Python :: 3 :: Only",
18
+ "Programming Language :: Python :: 3.11",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Programming Language :: Python :: 3.13",
21
+ "Programming Language :: Python :: 3.14",
22
+ "Typing :: Typed",
23
+ ]
24
+ dynamic = [ "version" ]
25
+ dependencies = [
26
+ "beautifulsoup4>=4.12",
27
+ "pathvalidate>=3.2",
28
+ "pillow>=10.4",
29
+ "requests>=2.32",
30
+ "rich>=13.7",
31
+ ]
32
+ urls.Repository = "https://github.com/eggplants/getcomici"
33
+ scripts.cget = "getcomici.cli:main"
34
+ scripts.getcomici = "getcomici.cli:main"
35
+
36
+ [dependency-groups]
37
+ dev = [
38
+ "pymarkdownlnt>=0.9.33",
39
+ "pyproject-fmt>=2.11.1",
40
+ "pytest>=9.0.2",
41
+ "pytest-cov>=7",
42
+ "pytest-mock>=3.14",
43
+ "types-requests>=2.32",
44
+ ]
45
+ docs = [
46
+ "pdoc>=16",
47
+ ]
48
+
49
+ [tool.hatch]
50
+ version.source = "uv-dynamic-versioning"
51
+ build.targets.wheel.packages = [ "getcomici" ]
52
+ build.targets.sdist.packages = [ "getcomici" ]
53
+
54
+ [tool.uv]
55
+ python-downloads = "never"
56
+ default-groups = [
57
+ "dev",
58
+ "docs",
59
+ ]
60
+ exclude-newer = "P7D"
61
+ preview = true
62
+
63
+ [tool.ruff]
64
+ line-length = 120
65
+ format.indent-style = "space"
66
+ format.quote-style = "double"
67
+ lint.select = [ "ALL" ]
68
+ lint.ignore = [
69
+ "COM812",
70
+ "D",
71
+ ]
72
+ lint.per-file-ignores."getcomici/cli.py" = [
73
+ "T201", # `print` found
74
+ ]
75
+ lint.per-file-ignores."packaging/entrypoint.py" = [
76
+ "INP001", # Implicit namespace package (frozen entry point, not importable content)
77
+ ]
78
+ lint.per-file-ignores."tests/**" = [
79
+ "ANN", # Missing type annotations (tests/fixtures)
80
+ "ARG", # Unused arguments (pytest fixtures, lambda stubs)
81
+ "CPY001", # Missing copyright notice
82
+ "INP001", # Implicit namespace package (no __init__ in tests)
83
+ "PLR2004", # Magic value used in comparison
84
+ "S101", # Use of assert detected
85
+ ]
86
+ lint.pydocstyle.convention = "google"
87
+
88
+ [tool.uv-dynamic-versioning]
89
+ vcs = "git"
90
+ style = "semver"
91
+ fallback-version = "0.0.0"