Files
cxyz/src/downloadChaptersQueue.js
T
nsrb 3799ad59f8 Add initial project structure with Docker support and chapter downloading functionality
- Created .dockerignore and .gitignore files to exclude unnecessary files from Docker builds.
- Added Dockerfile for multi-stage build setup, including dependencies and production environment configuration.
- Introduced docker-compose.yml for service orchestration, including FlareSolverr and the main application.
- Implemented core functionality for downloading manga chapters, including utilities for handling file paths and chapter metadata.
- Added ESLint configuration for code quality and consistency.
- Included JSDoc configuration for documentation generation.
- Established initial package.json and package-lock.json with necessary dependencies for the project.
2026-08-03 07:35:37 +07:00

427 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
/**
* WPManga 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<number>} */
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 WPManga 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<string>} */
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<string, import("./mangareadFetchChapters.js").ChapterLink>} */
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<void>}
*/
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 <listing-page-url> [--list-only|-l]
node src/downloadChaptersQueue.js <listing-page-url> --numbers|-n <spec> [--out|-o <dir>]
node src/downloadChaptersQueue.js <listing-page-url> --select|-s <spec> [--out|-o <dir>]
node src/downloadChaptersQueue.js <listing-page-url> --all [--out|-o <dir>]
Any chapter URL for that manga usually works as <listing-page-url> (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/<comic title>/ (under current working directory).`);
return;
}
if (!parsed.listingUrl) {
console.error(
"Usage: node src/downloadChaptersQueue.js <listing-page-url> [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 <spec>, --select <spec>, 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;
});
}