- 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.
427 lines
13 KiB
JavaScript
427 lines
13 KiB
JavaScript
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<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 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<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;
|
||
});
|
||
}
|