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.
- getcomici-0.0.2/.gitignore +12 -0
- getcomici-0.0.2/LICENSE.txt +21 -0
- getcomici-0.0.2/PKG-INFO +133 -0
- getcomici-0.0.2/README.md +107 -0
- getcomici-0.0.2/getcomici/__init__.py +36 -0
- getcomici-0.0.2/getcomici/__main__.py +8 -0
- getcomici-0.0.2/getcomici/cli.py +140 -0
- getcomici-0.0.2/getcomici/comici.py +563 -0
- getcomici-0.0.2/getcomici/py.typed +0 -0
- getcomici-0.0.2/pyproject.toml +91 -0
|
@@ -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.
|
getcomici-0.0.2/PKG-INFO
ADDED
|
@@ -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
|
+
[](
|
|
32
|
+
<https://pypi.org/project/getcomici/>
|
|
33
|
+
) [](
|
|
36
|
+
<https://github.com/eggplants/getcomici/actions/workflows/ci.yml>
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
[](
|
|
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
|
+
[](
|
|
6
|
+
<https://pypi.org/project/getcomici/>
|
|
7
|
+
) [](
|
|
10
|
+
<https://github.com/eggplants/getcomici/actions/workflows/ci.yml>
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
[](
|
|
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,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"
|