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.
This commit is contained in:
2026-08-03 07:35:37 +07:00
commit 3799ad59f8
35 changed files with 8809 additions and 0 deletions
+426
View File
@@ -0,0 +1,426 @@
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;
});
}