Files
cxyz/src/manhwasusu/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
12 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 { fetchChapterLinks, isLikelyManhwaSusuSite } = require("./chapters.js");
const { stemForManhwaSusuChapterCbz, chapterUrlToCbz, sanitizeComicDirName } =
require("./downloadChapterCbz.js");
/**
* @typedef {object} ParsedCli
* @property {boolean} [help] - Print usage and exit when true.
* @property {string|undefined} listingUrl - Series listing URL (required unless help).
* @property {boolean} listOnly - Only print the chapter menu.
* @property {boolean} allChapters - Queue every chapter after reversal to reading order.
* @property {string|undefined} selectSpec - `--select` string (1-based menu indices).
* @property {string|undefined} numbersSpec - `--numbers` string (chapter numbers from titles).
* @property {string} outDir - Output directory for `.cbz` files.
*/
/**
* @param {string[]} argv - `process.argv`.
* @returns {ParsedCli} Parsed flags and positional URL.
*/
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", "manhwasusu", "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;
}
/**
* Listing HTML is newest-first; reverse so `--select` line 1 is Chapter 1.
* @param {import("./chapters.js").ChapterLink[]} chapters - Site order.
* @returns {import("./chapters.js").ChapterLink[]} New array, oldest chapter first.
*/
function chaptersInReadingOrder(chapters) {
return [...chapters].reverse();
}
/**
* @param {string} spec - `--select` value.
* @param {number} count - Chapter count.
* @returns {number[]} Sorted unique 1-based row indices into the reversed list.
*/
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+$/u.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+)$/u.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 j = a; j <= b; j += 1) {
if (j < 1 || j > count) {
throw new Error(
`Range ${part} includes invalid index ${String(j)} (valid: 1${String(count)}).`,
);
}
seen.add(j);
}
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);
}
/**
* @param {string} title - e.g. `Chapter 22`.
* @returns {string|null} Chapter number string from title, or null.
*/
function chapterNumberFromListingTitle(title) {
const m = /^Chapter\s+([\d.]+)\s*$/iu.exec(title.trim().replace(/\s+/gu, " "));
return m ? m[1] : null;
}
/**
* @param {string} spec - `--numbers` argument.
* @returns {string[]} Ordered unique chapter number strings as entered.
*/
function parseChapterNumbersSpec(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+)$/u.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+)?$/u.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;
}
/**
* @param {string[]} orderedNums - Chapter numbers requested.
* @param {import("./chapters.js").ChapterLink[]} chapters - Ordered oldest→newest after reverse.
* @returns {Array<{ url: string, title: string, pickLabel: string, chapterNumber: string }>}
* Rows to download.
*/
function queueItemsByChapterNumbers(orderedNums, chapters) {
/** @type {Map<string, import("./chapters.js").ChapterLink>} */
const first = new Map();
for (const ch of chapters) {
const num = chapterNumberFromListingTitle(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 ManhwaSusu listing. ` +
"Use --list-only and check titles.",
);
}
return {
url: ch.url,
title: ch.title,
pickLabel: `Ch.${num}`,
chapterNumber: num,
};
});
}
/**
* @param {import("./chapters.js").ChapterLink[]} chapters - Oldest-first list.
* @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+/gu, " ").trim();
console.log(`${pad}. ${title}`);
console.log(` ${chapters[i].url}`);
}
}
/**
* @param {number[]} indices1 - Sorted 1-based indices.
* @param {import("./chapters.js").ChapterLink[]} chapters - Oldest-first.
*/
/**
* @param {number[]} indices1 - Sorted 1-based indices.
* @param {import("./chapters.js").ChapterLink[]} chapters - Oldest-first chapter list.
* @returns {Array<{ index: number, title: string, url: string, pickLabel: string }>} Queue rows.
*/
function chaptersByIndices(indices1, chapters) {
return indices1.map((idx) => ({
index: idx,
title: chapters[idx - 1].title,
url: chapters[idx - 1].url,
pickLabel: `#${String(idx)}`,
}));
}
/**
* CLI entry: fetch listing, optionally build `.cbz` archives.
* @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(`ManhwaSusu chapter queue (dedicated adapter; URLs like /read/{slug}/):
Usage:
node src/manhwasusu/downloadChaptersQueue.js <listing-url> [--list-only|-l]
node src/manhwasusu/downloadChaptersQueue.js <listing-url> --numbers|-n <spec> [--out|-o <dir>]
node src/manhwasusu/downloadChaptersQueue.js <listing-url> --select|-s <spec> [--out|-o <dir>]
node src/manhwasusu/downloadChaptersQueue.js <listing-url> --all [--out|-o <dir>]
Listing URL: series page ending in /read/{slug}/ (or chapter URL — site embeds chapter list markup).
Default --out: data/manhwasusu/comics/<comic title>/
--numbers: titles like "Chapter 22", e.g. 22,23,24 or 22-24
--select: 1-based rows on printed list after reversal (line 1 = Chapter 1)
`);
return;
}
if (!parsed.listingUrl) {
console.error(
"Usage: node src/manhwasusu/downloadChaptersQueue.js <listing-url> [options]",
);
process.exitCode = 1;
return;
}
if (!isLikelyManhwaSusuSite(parsed.listingUrl)) {
console.warn(
"Warning: URL hostname does not look like ManhwaSusu; parsers may fail if structure differs.",
);
}
let chapters;
let comicTitle;
try {
({ chapters, comicTitle } = await fetchChapterLinks(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)} ManhwaSusu chapter link(s).`);
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 --list-only).");
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 = parseChapterNumbersSpec(
/** @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 = stemForManhwaSusuChapterCbz(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 = {
chapterNumberFromListingTitle,
chaptersByIndices,
chaptersInReadingOrder,
parseChapterNumbersSpec,
parseChapterSelection,
parseCli,
printChapterMenu,
queueItemsByChapterNumbers,
};
if (require.main === module) {
main().catch((err) => {
console.error(err);
process.exitCode = 1;
});
}