const path = require("path"); const { fetchWpMangaChapterLinks } = require("./mangareadFetchChapters.js"); const { chapterUrlToCbz, sanitizeComicDirName, stemForCbzFilename } = require("./downloadChapterCbz.js"); /** * @typedef {object} ParsedCli * @property {boolean} [help] - When set from `--help`, print usage and exit. * @property {string|undefined} listingUrl - Page containing `li.wp-manga-chapter` links. * @property {boolean} listOnly - Print numbered chapters and exit. * @property {boolean} allChapters - Queue every chapter in list order. * @property {string|undefined} selectSpec - Comma-separated **list indices** on the printed menu (oldest first: row 1 ≈ Chapter 1), e.g. `1,3,5-7`. * @property {string|undefined} numbersSpec - Comma-separated **chapter numbers** from titles, e.g. `22,23,24` or `22-24`. * @property {string} outDir - Directory for `.cbz` files. */ /** * Parses CLI arguments after `node .../downloadChaptersQueue.js`. * @param {string[]} argv - `process.argv`. * @returns {ParsedCli} Parsed options. */ function parseCli(argv) { /** @type {ParsedCli} */ const result = { help: false, listingUrl: undefined, listOnly: false, allChapters: false, selectSpec: undefined, numbersSpec: undefined, outDir: path.join(process.cwd(), "data", "comics"), }; for (let i = 2; i < argv.length; i += 1) { const a = argv[i]; if (a === "--help" || a === "-h") { result.help = true; } else if (a === "--list-only" || a === "-l") { result.listOnly = true; } else if (a === "--all") { result.allChapters = true; } else if (a === "--select" || a === "-s") { const next = argv[i + 1]; if (next === undefined || next.startsWith("-")) { throw new Error("Missing value for --select"); } result.selectSpec = next; i += 1; } else if (a === "--numbers" || a === "-n") { const next = argv[i + 1]; if (next === undefined || next.startsWith("-")) { throw new Error("Missing value for --numbers"); } result.numbersSpec = next; i += 1; } else if (a === "--out" || a === "-o") { const next = argv[i + 1]; if (next === undefined || next.startsWith("-")) { throw new Error("Missing value for --out"); } result.outDir = path.resolve(next); i += 1; } else if (!a.startsWith("-")) { if (result.listingUrl) { throw new Error(`Unexpected extra argument: ${a}`); } result.listingUrl = a; } else { throw new Error(`Unknown option: ${a}`); } } return result; } /** * WP‑Manga lists chapters **newest first** in the HTML. We reverse so the numbered * menu and `--select` indices match reading order (line 1 ≈ Chapter 1). * @param {import("./mangareadFetchChapters.js").ChapterLink[]} chapters - Fetched in site order. * @returns {import("./mangareadFetchChapters.js").ChapterLink[]} New array, oldest chapter first. */ function chaptersInReadingOrder(chapters) { return [...chapters].reverse(); } /** * Builds sorted unique 1-based indices from a spec like `1`, `1,3`, `5-10,2`. * @param {string} spec - Selection string. * @param {number} count - Number of chapters (max index). * @returns {number[]} Ascending unique indices in [1, count]. */ function parseChapterSelection(spec, count) { if (count <= 0) { throw new Error("Chapter list is empty; cannot select indices."); } const trimmed = spec.trim(); if (trimmed.length === 0) { throw new Error("--select value is empty."); } /** @type {Set} */ const seen = new Set(); for (const rawPart of trimmed.split(",")) { const part = rawPart.trim(); if (part.length === 0) { continue; } if (/^\d+$/.test(part)) { const n = Number.parseInt(part, 10); if (n < 1 || n > count) { throw new Error( `Index ${String(n)} is out of range (valid: 1–${String(count)}).`, ); } seen.add(n); continue; } const rangeMatch = /^(\d+)\s*-\s*(\d+)$/.exec(part); if (rangeMatch) { const lo = Number.parseInt(rangeMatch[1], 10); const hi = Number.parseInt(rangeMatch[2], 10); const a = Math.min(lo, hi); const b = Math.max(lo, hi); for (let i = a; i <= b; i += 1) { if (i < 1 || i > count) { throw new Error( `Range ${part} includes invalid index ${String(i)} (valid: 1–${String(count)}).`, ); } seen.add(i); } continue; } throw new Error(`Invalid --select segment: "${part}"`); } if (seen.size === 0) { throw new Error("No indices parsed from --select."); } return [...seen].sort((x, y) => x - y); } /** * Parses "Chapter 22" / "chapter 160.5" style titles from WP‑Manga lists. * @param {string} title - Anchor text. * @returns {string|null} Numeric suffix or null. */ function chapterNumberFromWpTitle(title) { const m = /^Chapter\s+([\d.]+)\s*$/iu.exec(title.trim().replace(/\s+/g, " ")); return m ? m[1] : null; } /** * Expands `"22,23,24"` or `"22-24"` into ordered chapter-number strings. * Integer ranges only (22-24 → 22,23,24). Use explicit `160.5` for decimals. * @param {string} spec - User `--numbers` argument. * @returns {string[]} Ordered unique chapter numbers to resolve. */ function parseMangaChapterNumbersSpec(spec) { const trimmed = spec.trim(); if (trimmed.length === 0) { throw new Error("--numbers value is empty."); } /** @type {string[]} */ const ordered = []; /** @type {Set} */ const seen = new Set(); for (const rawPart of trimmed.split(",")) { const part = rawPart.trim(); if (part.length === 0) { continue; } const rangeMatch = /^(\d+)\s*-\s*(\d+)$/.exec(part); if (rangeMatch) { const lo = Number.parseInt(rangeMatch[1], 10); const hi = Number.parseInt(rangeMatch[2], 10); const a = Math.min(lo, hi); const b = Math.max(lo, hi); for (let n = a; n <= b; n += 1) { const k = String(n); if (!seen.has(k)) { seen.add(k); ordered.push(k); } } continue; } if (/^\d+(\.\d+)?$/.test(part)) { if (!seen.has(part)) { seen.add(part); ordered.push(part); } continue; } throw new Error(`Invalid --numbers segment: "${part}"`); } if (ordered.length === 0) { throw new Error("No chapter numbers parsed from --numbers."); } return ordered; } /** * Resolves chapter rows by numeric title (e.g. Chapter 22). * @param {string[]} orderedNums - From {@link parseMangaChapterNumbersSpec}. * @param {import("./mangareadFetchChapters.js").ChapterLink[]} chapters - Full list. * @returns {Array<{ url: string, title: string, pickLabel: string, chapterNumber: string }>} Queue rows with URLs and labels. */ function queueItemsByChapterNumbers(orderedNums, chapters) { /** @type {Map} */ const first = new Map(); for (const ch of chapters) { const num = chapterNumberFromWpTitle(ch.title); if (num !== null && !first.has(num)) { first.set(num, ch); } } return orderedNums.map((num) => { const ch = first.get(num); if (!ch) { throw new Error( `No chapter titled "Chapter ${num}" in the listing.` + " Use --list-only and check spelling (decimals like 160.5 must be listed explicitly).", ); } return { url: ch.url, title: ch.title, pickLabel: `Ch.${num}`, chapterNumber: num, }; }); } /** * Prints numbered chapters for manual `--select` choice. * @param {import("./mangareadFetchChapters.js").ChapterLink[]} chapters - Chapter rows. * @returns {void} */ function printChapterMenu(chapters) { const digits = String(chapters.length).length; for (let i = 0; i < chapters.length; i += 1) { const n = i + 1; const pad = String(n).padStart(digits, " "); const title = chapters[i].title.replace(/\s+/g, " ").trim(); console.log(`${pad}. ${title}`); console.log(` ${chapters[i].url}`); } } /** * @param {number[]} indices1 - Sorted unique 1-based indices. * @param {import("./mangareadFetchChapters.js").ChapterLink[]} chapters - Full list. * @returns {Array<{ url: string, title: string, index: number, pickLabel: string }>} One item per requested index. */ function chaptersByIndices(indices1, chapters) { return indices1.map((idx) => ({ index: idx, title: chapters[idx - 1].title, url: chapters[idx - 1].url, pickLabel: `#${String(idx)}`, })); } /** * Fetches chapter list, queues selected chapters, downloads one `.cbz` at a time. * @returns {Promise} */ async function main() { let parsed; try { parsed = parseCli(process.argv); } catch (err) { console.error(err instanceof Error ? err.message : err); process.exitCode = 1; return; } if (parsed.help) { console.log(`Usage: node src/downloadChaptersQueue.js [--list-only|-l] node src/downloadChaptersQueue.js --numbers|-n [--out|-o ] node src/downloadChaptersQueue.js --select|-s [--out|-o ] node src/downloadChaptersQueue.js --all [--out|-o ] Any chapter URL for that manga usually works as (same HTML includes the chapter list). --numbers spec: chapter numbers from link titles ("Chapter 22"), e.g. 22,23,24 or 22-24 Decimals (e.g. 160.5) must be listed explicitly; integer ranges expand to whole numbers only. --select spec: 1-based row numbers on the **printed list** (oldest first; row 1 is Chapter 1), e.g. 1,3,5-10. This is the menu index, not a substring of the title (use --numbers for that). Chapters are processed in list order for --select/--all, or in the order given for --numbers. Default --out: data/comics// (under current working directory).`); return; } if (!parsed.listingUrl) { console.error( "Usage: node src/downloadChaptersQueue.js [options]\nTry --help for details.", ); process.exitCode = 1; return; } let chapters; let comicTitle; try { ({ chapters, comicTitle } = await fetchWpMangaChapterLinks(parsed.listingUrl)); chapters = chaptersInReadingOrder(chapters); } catch (err) { console.error(err instanceof Error ? err.message : err); process.exitCode = 1; return; } console.log(`Found ${String(chapters.length)} chapter link(s) on listing page.`); printChapterMenu(chapters); if (parsed.listOnly) { return; } const wantsAll = parsed.allChapters; const wantsSelect = Boolean(parsed.selectSpec); const wantsNumbers = Boolean(parsed.numbersSpec); const pickModes = [wantsAll, wantsSelect, wantsNumbers].filter(Boolean).length; if (pickModes === 0) { console.error( "Choose: --numbers , --select , or --all (or use --list-only to exit after listing).", ); process.exitCode = 1; return; } if (pickModes > 1) { console.error("Use only one of --all, --select, or --numbers."); process.exitCode = 1; return; } /** @type {Array<{ url: string, title: string, pickLabel: string }>} */ let queue; try { if (wantsAll) { const indices = Array.from({ length: chapters.length }, (_, i) => i + 1); queue = chaptersByIndices(indices, chapters); } else if (wantsSelect) { const indices = parseChapterSelection( /** @type {string} */ (parsed.selectSpec), chapters.length, ); queue = chaptersByIndices(indices, chapters); } else { const orderedNums = parseMangaChapterNumbersSpec( /** @type {string} */ (parsed.numbersSpec), ); queue = queueItemsByChapterNumbers(orderedNums, chapters); } } catch (err) { console.error(err instanceof Error ? err.message : err); process.exitCode = 1; return; } const outDir = path.join(parsed.outDir, sanitizeComicDirName(comicTitle)); console.log(`Comic: ${comicTitle}`); console.log(`Output directory: ${outDir}`); let failedCount = 0; let done = 0; for (const item of queue) { done += 1; const stem = stemForCbzFilename(item.url); const targetPath = path.join(outDir, `${stem}.cbz`); console.log( `\n[${String(done)}/${String(queue.length)}] ${item.pickLabel} ${item.title}`, ); console.log(` → ${item.url}`); try { const { imageCount, outputPath } = await chapterUrlToCbz(item.url, targetPath); console.log(` ✓ ${String(imageCount)} page(s) → ${outputPath}`); } catch (err) { console.error(` ✗ ${err instanceof Error ? err.message : err}`); failedCount += 1; } } if (failedCount > 0) { console.error( `\n${String(failedCount)} chapter(s) failed (${String(queue.length - failedCount)} succeeded).`, ); process.exitCode = 1; } } module.exports = { chapterNumberFromWpTitle, chaptersByIndices, chaptersInReadingOrder, parseChapterSelection, parseCli, parseMangaChapterNumbersSpec, printChapterMenu, queueItemsByChapterNumbers, }; if (require.main === module) { main().catch((err) => { console.error(err); process.exitCode = 1; }); }