- JavaScript 50.6%
- Python 49.4%
| .gitignore | ||
| CLAUDE.md | ||
| download.js | ||
| download.py | ||
| images_to_pdf.js | ||
| images_to_pdf.py | ||
| LICENSE | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
| requirements.txt | ||
comizy.io downloader
Downloads chapters of a comic from comizy.io as images, organized into per-chapter folders.
Two equivalent implementations are included — pick whichever you have installed. Both use the same API flow and produce the same output.
Note: this is an unofficial tool, not affiliated with or endorsed by comizy.io. See Legal / responsible use before running it.
Usage
Node:
node download.js <comic-url> [output-dir] [options]
Python:
python download.py <comic-url> [output-dir] [options]
Both accept the same options — run either with -h / --help for the full list:
| Option | Meaning |
|---|---|
--chapters <selector> |
Download a subset of chapters (default: all) |
--group <n> |
Combine every n chapters into a single folder |
-h, --help |
Show usage and exit |
--chapters addresses chapters by position in reading order (1 = first chapter, regardless of its
slug/number):
--chapters 5— a single chapter--chapters 1-10— an inclusive range--chapters 1,3,5-7— a comma-separated mix of singles and ranges--chapters latest:5— the most recent 5 chapters
Example:
node download.js https://comizy.io/<comic-name> output --chapters 1-10
python download.py https://comizy.io/<comic-name> output --chapters latest:5
Output structure:
output/
001_Chapter 1/
01.webp
02.webp
...
002_Chapter 1.1/
01.webp
...
Chapter folders are numbered in reading order (by chapter number, not by slug), and images within
each chapter are numbered in the order the site returns them. Folder numbers reflect the chapter's
position across the full chapter list even when --chapters is used to download a subset — e.g.
--chapters latest:2 on a 103-chapter comic produces folders 102_... and 103_..., not 01_...
and 02_....
Grouping chapters into one folder
--group <n> puts every n chapters into a single folder instead of one folder per chapter, which
is handy for producing one PDF per batch of chapters rather than per chapter:
node download.js https://comizy.io/<comic-name> output --group 5
python download.py https://comizy.io/<comic-name> output --group 5
output/
001-005/
001_01.webp <- chapter 1, page 1
001_02.webp
...
005_03.webp <- chapter 5, page 3
006-010/
006_01.webp
...
Inside a grouped folder each filename is prefixed with the chapter's position, so pages stay in
reading order across the whole folder and can't collide between chapters. Group boundaries are
computed over the full chapter list, so they don't shift when --chapters selects a subset — a
given chapter always lands in the same group folder.
Requirements
- Node.js 18+ (uses the built-in
fetch), or - Python 3.8+ (stdlib only —
urllib+concurrent.futures, nopip installneeded)
Both scripts use a 20-second timeout per HTTP request (with a few retries) so a stalled connection doesn't hang the whole run, and to avoid hammering the site with indefinitely-retried requests.
If a download times out mid-body, the request is automatically retried in smaller Range-limited
pieces, which works around network paths that stall on large responses. This is transparent — you may
just notice the first image of a run taking a few seconds longer.
Converting a chapter to PDF
images_to_pdf takes a folder of images (e.g. one of the chapter folders produced above) and
combines them into a single PDF, in natural sort order (2.jpg before 10.jpg).
Node (requires npm install — uses sharp + pdf-lib):
npm install
node images_to_pdf.js <input-dir> [output.pdf] [options]
Python (requires pip install -r requirements.txt — uses Pillow):
pip install -r requirements.txt
python images_to_pdf.py <input-dir> [output.pdf] [options]
Both accept the same options:
| Option | Meaning |
|---|---|
-o, --output-dir <dir> |
Where to write the PDF when output.pdf is omitted (default: output) |
-q, --quality <1-100> |
JPEG quality (default: 75) |
-w, --max-width <px> |
Downscale pages wider than this (default: 0, no downscaling) |
-g, --grayscale |
Encode pages as grayscale — smaller, but only sane for B/W comics |
-r, --recursive |
Write one PDF per immediate subdirectory containing images |
-h, --help |
Show usage and exit |
If output.pdf is omitted the PDF is named after the input folder and written into output/
(override with -o). --recursive turns a whole download into one PDF per chapter (or per group)
in a single pass:
node images_to_pdf.js output -r
python images_to_pdf.py output -r
About PDF size
Pages are embedded as JPEG, which is PDF's native photographic format (DCTDecode). PDF has no
WebP support at all — there is no filter for it in the format, so the WebP files comizy.io serves
cannot be stored as-is and must be re-encoded no matter what. Since WebP is the more efficient
format of the two, a straight conversion at default quality lands near or slightly above the total
size of the source images; that's a property of the formats, not a bug in the conversion.
If you need a smaller file, --quality and --max-width are the levers — dropping to -q 60 or
capping width with -w 800 both make a substantial difference. --grayscale helps too, but only use
it on comics that are actually black and white.
The Node script produces noticeably smaller files than the Python one at identical settings, because
sharp bundles mozjpeg while Pillow uses standard libjpeg.
Both are valid PDFs at the requested quality; if file size matters most to you, prefer the Node
script.
How it works
comizy.io is a Next.js app backed by a separate API host (api.comizy.io). No login or cookies are
needed to read public chapters — just a browser-like User-Agent and Referer header. The flow,
reverse-engineered from a browser HAR capture:
-
Fetch the comic page —
GET https://comizy.io/<slug>The HTML contains a<script id="__NEXT_DATA__">blob with:buildId— the current Next.js build id, needed to construct data-fetch URLsprops.pageProps.initialManga.id— the comic's internal id (a short opaque string)
-
List chapters —
GET https://api.comizy.io/titles/<mangaId>/chaptersReturnsdata.chapters, an array of{ id, slug, name, number, ... }.numberis the correct reading order; the human-readablename/slug(e.g. "Chapter 1.1") doesn't sort correctly as a string, so chapters are sorted bynumberbefore downloading. -
Get chapter images —
GET https://comizy.io/_next/data/<buildId>/<slug>/<chapterSlug>.json(with headerx-nextjs-data: 1) This is Next.js's own data endpoint for the chapter reader page. The response'spageProps.initialChapter.imagesis an ordered array of full CDN image URLs (hosted on*.cmzcdn.org), already in reading order. -
Download each image directly from its CDN URL with a
Referer: https://comizy.io/header.
No API key, auth token, or Cloudflare challenge-solving was needed for any of the above — confirmed by hitting each endpoint directly outside the browser.
Caveats / things that could break this
buildIdis tied to the current Next.js deployment. If comizy.io redeploys, old build ids stop working and the script needs to re-fetch a fresh comic page each run (which it does) — this isn't cached across runs, so it self-heals automatically.- This was reverse-engineered from a single comic. Other comics are expected to follow the same API shape since it's the same site-wide Next.js app, but haven't all been verified.
- If comizy.io adds Cloudflare bot-challenge pages or requires auth for some content in the future, this script does not handle solving challenges or logging in.
- The Python version sends
Accept-Encoding: gzip, deflateand manually decompresses the response. Cloudflare stalls (hangs, never times out on its own) requests fromurllib's default client when that header is absent —fetchin Node sends it automatically, buturllibdoesn't, so it's set explicitly and the body is gunzipped by hand. - The
Rangeretry fallback depends on the CDN honouring range requests, which it currently does (Accept-Ranges: bytes). If that changed, networks that stall on large responses would have no workaround — the scripts report the failure rather than hanging, but can't get around it.
Security
- No credentials required or stored. All endpoints used here are public; the scripts never send or read cookies, tokens, or login state.
- No dynamic code execution. Nothing is
eval'd and no shell commands are spawned — all network responses (HTML/JSON) are parsed with a regex extraction + standard JSON parsing, never executed. - TLS verification is on in both
fetch(Node) andurlopen(Python) and is never disabled. - Filenames are constrained. Every file written to disk is named from a zero-padded numeric index plus a sanitized chapter title (path separators and other filesystem-unsafe characters are stripped) — chapter/image data from the API cannot be used to write outside the output directory.
- Trust boundary: you are pointing this tool at a URL and letting it fetch whatever images that
site's API returns for it. That's inherent to what a downloader does, not a bug — but as with any
tool that fetches remote content, only point it at sites you trust, and treat the
output/directory contents as content from that site, not vetted by this tool. - If you find an actual security issue in this code (not "the site could serve something malicious" — that's the trust boundary above, but a genuine bug in the scripts themselves), please open an issue.
Legal / responsible use
This project is an unofficial client for comizy.io's public web API — it is not affiliated with, endorsed by, or sponsored by comizy.io. It automates the same requests a browser makes when reading a comic on the site; it does not bypass paywalls, DRM, or access controls.
You are responsible for how you use this tool. Respect comizy.io's Terms of Service and applicable copyright law in your jurisdiction — this includes not redistributing downloaded content you don't have the rights to, and downloading only for personal, offline use where that is legally permitted. The authors of this repository assume no liability for misuse.
Contributing
Issues and PRs are welcome. If comizy.io changes its site (new build system, different API shape, Cloudflare challenges, etc.) and one of the caveats above starts happening in practice, a bug report with the failing URL and error output is the most useful thing you can attach.