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
+180
View File
@@ -0,0 +1,180 @@
const { parse } = require("node-html-parser");
const { CHAPTER_PATHNAME_PATTERN, MANHWASUSU_HOSTS } = require("./constants.js");
const { fetchManhwaSusuHtml } = require("./io.js");
/**
* @typedef {object} ChapterLink
* @property {string} url - Absolute chapter URL.
* @property {string} title - Chapter label (trimmed).
*/
/**
* @param {string} pathname - URL pathname.
* @returns {boolean} Whether this path is a ManhwaSusu chapter reader URL.
*/
function isChapterPathname(pathname) {
const p = pathname.replace(/\/+$/u, "");
return CHAPTER_PATHNAME_PATTERN.test(p);
}
/**
* @param {string} url - Any http(s) URL.
* @returns {boolean} True when hostname matches known ManhwaSusu hosts (hint only).
*/
function isLikelyManhwaSusuSite(url) {
try {
const { hostname } = new URL(url);
return MANHWASUSU_HOSTS.some((h) => h === hostname.toLowerCase());
} catch {
return false;
}
}
/**
* Skip duplicate “First / Last chapter” buttons; the full list still contains the same targets.
* @param {import("node-html-parser").HTMLElement} anchor - `<a>`.
* @returns {boolean} True when the link text is only “First chapter” or “Last chapter”.
*/
function isFirstLastNavOnlyLink(anchor) {
const t = (anchor.textContent || "").trim().replace(/\s+/gu, " ");
return /^first chapter$/iu.test(t) || /^last chapter$/iu.test(t);
}
/**
* Parses chapter anchors from a series / listing HTML document.
* @param {string} html - Raw HTML.
* @param {string} pageUrl - Base URL for resolving relative `href`s.
* @returns {ChapterLink[]} Unique links in **document order** (site lists newest first).
*/
function extractChapterLinksFromListingHtml(html, pageUrl) {
const root = parse(html);
const base = new URL(pageUrl);
/** @type {Map<string, ChapterLink>} */
const byUrl = new Map();
for (const a of root.querySelectorAll("a[href]")) {
const href = (a.getAttribute("href") || "").trim();
if (href.length === 0 || href.startsWith("#")) {
continue;
}
let abs;
try {
abs = new URL(href, base).href;
} catch {
continue;
}
const { pathname } = new URL(abs);
if (!isChapterPathname(pathname)) {
continue;
}
if (isFirstLastNavOnlyLink(a)) {
continue;
}
if (byUrl.has(abs)) {
continue;
}
let title = "";
const pEl = a.querySelector("p");
if (pEl) {
title = (pEl.textContent || "").trim().replace(/\s+/gu, " ");
}
if (title.length === 0) {
title = (a.textContent || "").trim().replace(/\s+/gu, " ");
}
title = title
.replace(/\s+\d+\s*(min|hour|day|week|mth|month|year)s?\s+ago$/iu, "")
.trim();
const fallback = pathname.split("/").filter(Boolean).pop() || "chapter";
byUrl.set(abs, { url: abs, title: title.length > 0 ? title : fallback });
}
return [...byUrl.values()];
}
/**
* @param {string} html - Series listing HTML.
* @returns {string|null} Comic title when found in markup.
*/
function extractComicTitleFromListingHtml(html) {
const root = parse(html);
const stripSiteSuffix = (raw) =>
raw.replace(/\s*-\s*ManhwaSusu\s*$/iu, "").trim().replace(/\s+/gu, " ");
const og = root.querySelector('meta[property="og:title"]');
if (og) {
const t = stripSiteSuffix(og.getAttribute("content") || "");
if (t.length > 0) {
return t;
}
}
const titleEl = root.querySelector("title");
if (titleEl) {
const t = stripSiteSuffix(titleEl.textContent || "");
if (t.length > 0) {
return t;
}
}
for (const h1 of root.querySelectorAll("h1")) {
const t = (h1.textContent || "").trim().replace(/\s+/gu, " ");
if (
t.length > 0 &&
!/^ManhwaSusu$/iu.test(t) &&
!/chapter\s*list/iu.test(t)
) {
return t;
}
}
return null;
}
/**
* @param {string} pageUrl - Series or chapter URL under `/read/{slug}/`.
* @returns {string} Series slug segment, or `"comic"`.
*/
function seriesSlugFromListingUrl(pageUrl) {
try {
const { pathname } = new URL(pageUrl);
const parts = pathname.split("/").filter(Boolean);
const readIdx = parts.indexOf("read");
if (readIdx !== -1 && parts[readIdx + 1] !== undefined) {
return parts[readIdx + 1];
}
} catch {
/* invalid URL */
}
return "comic";
}
/**
* @param {string} listingPageUrl - e.g. `https://manhwasusu.com/read/{slug}/`
* @returns {Promise<{ chapters: ChapterLink[], comicTitle: string }>} Chapter rows and series title.
*/
async function fetchChapterLinks(listingPageUrl) {
const html = await fetchManhwaSusuHtml(listingPageUrl);
const chapters = extractChapterLinksFromListingHtml(html, listingPageUrl);
const comicTitle =
extractComicTitleFromListingHtml(html) || seriesSlugFromListingUrl(listingPageUrl);
return { chapters, comicTitle };
}
module.exports = {
extractChapterLinksFromListingHtml,
extractComicTitleFromListingHtml,
fetchChapterLinks,
isChapterPathname,
isFirstLastNavOnlyLink,
isLikelyManhwaSusuSite,
seriesSlugFromListingUrl,
};
+55
View File
@@ -0,0 +1,55 @@
/**
* ManhwaSusu-specific URLs and DOM selectors. Adjust here if routes or markup change.
* @see https://manhwasusu.com/read/{slug}/ — series + chapter index
*/
/** Hostnames that this adapter is intended for (for friendly checks only). */
const MANHWASUSU_HOSTS = /** @type {const} */ (["manhwasusu.com", "www.manhwasusu.com"]);
/** Canonical site origin used as Referer for CDN panel downloads. */
const MANHWASUSU_ORIGIN = "https://manhwasusu.com/";
/** Default sample listing used by the fetch CLI when no URL is overridden. */
const DEFAULT_LISTING_URL = "https://manhwasusu.com/read/switch-on-season-2/";
/** Path shape: `/read/{seriesSlug}/chapter-{id}/` (SSR uses trailing slash optional). */
const CHAPTER_PATHNAME_PATTERN = /^\/read\/[^/]+\/chapter-[^/]+$/iu;
/**
* Reader lazily fills `src` from `data-src`; class `imgku` wraps panel images on chapter pages.
* @type {readonly [string, ...string[]]}
*/
const READER_IMG_SELECTORS = ["img.imgku"];
/**
* Browser-like headers for CDN image GETs (captured from manhwasusu.com reader → manhwature.com).
* Referer must be the site origin; chapter URLs are often rejected by the CDN.
* @type {Readonly<Record<string, string>>}
*/
const CDN_IMAGE_HEADERS = {
Accept: "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9,id;q=0.8",
"Cache-Control": "no-cache",
Pragma: "no-cache",
"Sec-CH-UA":
'"Chromium";v="146", "Not-A.Brand";v="24", "Microsoft Edge";v="146"',
"Sec-CH-UA-Mobile": "?0",
"Sec-CH-UA-Platform": '"Windows"',
"Sec-Fetch-Dest": "image",
"Sec-Fetch-Mode": "no-cors",
"Sec-Fetch-Site": "cross-site",
"Sec-Fetch-Storage-Access": "active",
Referer: MANHWASUSU_ORIGIN,
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
"(KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.0.0",
};
module.exports = {
CDN_IMAGE_HEADERS,
CHAPTER_PATHNAME_PATTERN,
DEFAULT_LISTING_URL,
MANHWASUSU_HOSTS,
MANHWASUSU_ORIGIN,
READER_IMG_SELECTORS,
};
+169
View File
@@ -0,0 +1,169 @@
const fs = require("fs/promises");
const path = require("path");
const os = require("os");
const AdmZip = require("adm-zip");
const { downloadChapterImages } = require("./images.js");
const { extractComicTitleFromListingHtml, seriesSlugFromListingUrl } = require("./chapters.js");
const { fetchManhwaSusuHtml } = require("./io.js");
/**
* `/read/my-series/chapter-12/` → `my-series-chapter-12`.
* @param {string} chapterPageUrl - Full chapter URL.
* @returns {string} Suggested `.cbz` stem.
*/
function stemForManhwaSusuChapterCbz(chapterPageUrl) {
try {
const { pathname } = new URL(chapterPageUrl);
const segs = pathname.replace(/\/+$/u, "").split("/").filter(Boolean);
const readIdx = segs.indexOf("read");
if (readIdx !== -1 && segs.length >= readIdx + 3) {
const slug = segs[readIdx + 1];
const chapSeg = segs[readIdx + 2];
return `${slug}-${chapSeg}`;
}
if (segs.length >= 2) {
return segs.slice(-2).join("-");
}
return segs.at(-1) || "chapter";
} catch {
return "chapter";
}
}
/**
* @param {string} stem - File base segment.
* @returns {string} Sanitized stem.
*/
function sanitizeFsStem(stem) {
const cleaned = stem.replace(/[^a-z0-9._-]+/giu, "_").replace(/^_+|_+$/gu, "");
return cleaned.length > 0 ? cleaned.slice(0, 180) : "chapter";
}
/**
* @param {string} name - Human-readable comic title.
* @returns {string} Filesystem-safe directory name.
*/
function sanitizeComicDirName(name) {
const collapsed = name.trim().replace(/\s+/gu, " ");
const cleaned = [...collapsed]
.filter((ch) => {
const code = ch.charCodeAt(0);
if (code < 32 || code === 127) {
return false;
}
return !/[<>:"/\\|?*]/.test(ch);
})
.join("")
.replace(/\.+$/g, "")
.trim();
return cleaned.length > 0 ? cleaned.slice(0, 180) : "comic";
}
/**
* @param {string|undefined} cliOut - Positional output path/dir.
* @param {string} defaultStem - When `cliOut` is omitted or directory-like.
* @param {string} [comicTitle] - When set, default output goes under this subdirectory.
* @returns {string} Absolute `.cbz` path.
*/
function resolveCliOutputPath(cliOut, defaultStem, comicTitle) {
const stem = sanitizeFsStem(defaultStem);
const baseDir =
typeof comicTitle === "string" && comicTitle.trim().length > 0
? path.join(
process.cwd(),
"data",
"manhwasusu",
"comics",
sanitizeComicDirName(comicTitle),
)
: path.join(process.cwd(), "data", "manhwasusu", "comics");
if (cliOut === undefined || cliOut.trim() === "") {
return path.join(baseDir, `${stem}.cbz`);
}
const trimmed = cliOut.trim();
const abs = path.resolve(trimmed);
if (abs.toLowerCase().endsWith(".cbz")) {
return abs;
}
return path.join(abs, `${stem}.cbz`);
}
/**
* Downloads ManhwaSusu chapter panels to temp storage, packs `.cbz`, removes temp folder.
* @param {string} chapterUrl - Chapter reader URL.
* @param {string} outputCbzPath - Destination `.cbz`.
* @returns {Promise<{ imageCount: number, outputPath: string }>} Page count and written archive path.
*/
async function chapterUrlToCbz(chapterUrl, outputCbzPath) {
const absOut = outputCbzPath.toLowerCase().endsWith(".cbz")
? path.resolve(outputCbzPath)
: `${path.resolve(outputCbzPath)}.cbz`;
const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cxyz-manhwasusu-chapter-"));
try {
const saved = await downloadChapterImages(chapterUrl, tmpRoot);
if (saved.length === 0) {
throw new Error("No chapter images were found for this ManhwaSusu URL.");
}
await fs.mkdir(path.dirname(absOut), { recursive: true });
const zip = new AdmZip();
for (const { filePath } of saved) {
zip.addLocalFile(filePath, "", path.basename(filePath));
}
zip.writeZip(absOut);
return { imageCount: saved.length, outputPath: absOut };
} finally {
await fs.rm(tmpRoot, { recursive: true, force: true }).catch(() => {});
}
}
/**
* CLI: `node src/manhwasusu/downloadChapterCbz.js <chapterUrl> [.cbz|dir]`
* @returns {Promise<void>}
*/
async function main() {
const chapterUrl = process.argv[2];
if (!chapterUrl || chapterUrl.startsWith("-")) {
console.error(
"Usage: node src/manhwasusu/downloadChapterCbz.js <chapterUrl> [output.cbz|outputDirectory]",
);
process.exitCode = 1;
return;
}
const stem = stemForManhwaSusuChapterCbz(chapterUrl);
let comicTitle = seriesSlugFromListingUrl(chapterUrl);
try {
const html = await fetchManhwaSusuHtml(chapterUrl);
comicTitle = extractComicTitleFromListingHtml(html) || comicTitle;
} catch {
/* keep slug fallback */
}
const target = resolveCliOutputPath(process.argv[3], stem, comicTitle);
try {
const { imageCount, outputPath } = await chapterUrlToCbz(chapterUrl, target);
console.log(`Wrote ${String(imageCount)} page(s) → ${outputPath}`);
} catch (err) {
console.error(err instanceof Error ? err.message : err);
process.exitCode = 1;
}
}
module.exports = {
chapterUrlToCbz,
resolveCliOutputPath,
sanitizeComicDirName,
sanitizeFsStem,
stemForManhwaSusuChapterCbz,
};
if (require.main === module) {
main();
}
+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;
});
}
+45
View File
@@ -0,0 +1,45 @@
const fs = require("fs/promises");
const path = require("path");
const { DEFAULT_LISTING_URL } = require("./constants.js");
const { extractChapterLinksFromListingHtml } = require("./chapters.js");
const { fetchManhwaSusuHtml } = require("./io.js");
const OUTPUT_HTML = path.join(
__dirname,
"..",
"..",
"data",
"manhwasusu",
"debug-listing.html",
);
/**
* CLI: snapshot listing HTML + print chapter JSON. Optional `MANHWASUSU_LISTING_URL` env overrides default.
* @returns {Promise<void>}
*/
async function main() {
const pageUrl = process.env.MANHWASUSU_LISTING_URL || DEFAULT_LISTING_URL;
const html = await fetchManhwaSusuHtml(pageUrl);
await fs.mkdir(path.dirname(OUTPUT_HTML), { recursive: true });
await fs.writeFile(OUTPUT_HTML, html, "utf8");
const chapters = extractChapterLinksFromListingHtml(html, pageUrl);
console.log(`Source: ${pageUrl}`);
console.log(`Saved HTML: ${OUTPUT_HTML}`);
console.log(
`Found ${String(chapters.length)} chapter link(s) (newest-first on page; reverse for reading order):`,
);
console.log(JSON.stringify(chapters, null, 2));
}
if (require.main === module) {
main().catch((err) => {
console.error(err);
process.exitCode = 1;
});
}
module.exports = { main };
+100
View File
@@ -0,0 +1,100 @@
const fs = require("fs/promises");
const path = require("path");
const axios = require("axios");
const { parse } = require("node-html-parser");
const { READER_IMG_SELECTORS } = require("./constants.js");
const {
extensionFromContentType,
extensionFromImageUrl,
fetchManhwaSusuHtml,
imageGetConfig,
pickReaderImgSrc,
slugFromChapterUrl,
} = require("./io.js");
/**
* Collects panel URLs from chapter HTML (lazy `data-src` on reader images).
* @param {string} html - Chapter document.
* @param {string} baseHref - Used to resolve relative attributes.
* @returns {string[]} Absolute image URLs in DOM order.
*/
function collectChapterImageUrls(html, baseHref) {
const root = parse(html);
const baseUrl = new URL(baseHref);
const selector = READER_IMG_SELECTORS.join(", ");
/** @type {string[]} */
const urls = [];
for (const img of root.querySelectorAll(selector)) {
const raw = pickReaderImgSrc(img);
if (!raw) {
continue;
}
try {
urls.push(new URL(raw, baseUrl).href);
} catch {
/* skip bad href */
}
}
return urls;
}
/**
* @typedef {object} SavedChapterImage
* @property {string} imageUrl - Remote URL that was downloaded.
* @property {string} filePath - Path of the saved file.
*/
/**
* Downloads reader panels into `data/manhwasusu/chapters/{slug}/` by default.
* @param {string} chapterPageUrl - Full `/read/{slug}/chapter-N/` URL.
* @param {string} [outDir] - Override output directory.
* @returns {Promise<SavedChapterImage[]>} One record per saved panel file.
*/
async function downloadChapterImages(chapterPageUrl, outDir) {
const html = await fetchManhwaSusuHtml(chapterPageUrl);
const urls = collectChapterImageUrls(html, chapterPageUrl);
const targetDir =
typeof outDir === "string" && outDir.trim().length > 0
? path.resolve(outDir.trim())
: path.join(
path.join(__dirname, "..", ".."),
"data",
"manhwasusu",
"chapters",
slugFromChapterUrl(chapterPageUrl),
);
await fs.mkdir(targetDir, { recursive: true });
const cfg = imageGetConfig(chapterPageUrl);
/** @type {SavedChapterImage[]} */
const saved = [];
let index = 0;
for (const imageUrl of urls) {
index += 1;
const { data, headers } = await axios.get(imageUrl, cfg);
const ct = headers["content-type"];
let ext = extensionFromImageUrl(imageUrl);
if (ext.length === 0) {
ext = extensionFromContentType(typeof ct === "string" ? ct : "") || ".bin";
}
const filename = `page-${String(index).padStart(4, "0")}${ext}`;
const filePath = path.join(targetDir, filename);
await fs.writeFile(filePath, Buffer.from(data));
saved.push({ imageUrl, filePath });
}
return saved;
}
module.exports = {
collectChapterImageUrls,
downloadChapterImages,
};
+88
View File
@@ -0,0 +1,88 @@
/**
* ManhwaSusu adapter: HTTP, listing/chapter parsing, image download, and CBZ CLIs live under this folder.
* Tweak {@link ./constants.js} when routes or reader markup change.
*/
const {
CDN_IMAGE_HEADERS,
CHAPTER_PATHNAME_PATTERN,
DEFAULT_LISTING_URL,
MANHWASUSU_HOSTS,
MANHWASUSU_ORIGIN,
READER_IMG_SELECTORS,
} = require("./constants.js");
const {
extractChapterLinksFromListingHtml,
extractComicTitleFromListingHtml,
fetchChapterLinks,
isChapterPathname,
isFirstLastNavOnlyLink,
isLikelyManhwaSusuSite,
seriesSlugFromListingUrl,
} = require("./chapters.js");
const {
collectChapterImageUrls,
downloadChapterImages,
} = require("./images.js");
const {
DEFAULT_GET_HTML,
extensionFromContentType,
extensionFromImageUrl,
fetchManhwaSusuHtml,
imageGetConfig,
pickReaderImgSrc,
slugFromChapterUrl,
} = require("./io.js");
const {
chapterUrlToCbz,
resolveCliOutputPath,
sanitizeComicDirName,
sanitizeFsStem,
stemForManhwaSusuChapterCbz,
} = require("./downloadChapterCbz.js");
module.exports = {
CDN_IMAGE_HEADERS,
CHAPTER_PATHNAME_PATTERN,
DEFAULT_GET_HTML,
DEFAULT_LISTING_URL,
MANHWASUSU_HOSTS,
MANHWASUSU_ORIGIN,
READER_IMG_SELECTORS,
chapterUrlToCbz,
collectChapterImageUrls,
/** @deprecated Use {@link collectChapterImageUrls} */
collectManhwaSusuChapterImageUrls: collectChapterImageUrls,
downloadChapterImages,
/** @deprecated Use {@link downloadChapterImages} */
downloadManhwaSusuChapterImages: downloadChapterImages,
extensionFromContentType,
extensionFromImageUrl,
extractChapterLinksFromListingHtml,
extractComicTitleFromListingHtml,
/** @deprecated Use {@link extractChapterLinksFromListingHtml} */
extractManhwaSusuChapters: extractChapterLinksFromListingHtml,
fetchChapterLinks,
fetchManhwaSusuHtml,
/** @deprecated Use {@link fetchChapterLinks} */
fetchManhwaSusuChapterLinks: fetchChapterLinks,
imageGetConfig,
isChapterPathname,
isFirstLastNavOnlyLink,
/** @deprecated Use {@link isFirstLastNavOnlyLink} */
isFirstLastChapterNavLink: isFirstLastNavOnlyLink,
isLikelyManhwaSusuSite,
/** @deprecated Use {@link isChapterPathname} */
isManhwaSusuChapterPath: isChapterPathname,
pickReaderImgSrc,
resolveCliOutputPath,
sanitizeComicDirName,
sanitizeFsStem,
seriesSlugFromListingUrl,
slugFromChapterUrl,
stemForManhwaSusuChapterCbz,
};
+141
View File
@@ -0,0 +1,141 @@
const path = require("path");
const axios = require("axios");
const { fetchHtmlWithOptionalFlareSolverr } = require("../flaresolverr.js");
const { CDN_IMAGE_HEADERS, MANHWASUSU_ORIGIN } = require("./constants.js");
/** @type {import("axios").AxiosRequestConfig} */
const DEFAULT_GET_HTML = {
responseType: "text",
timeout: 30_000,
headers: {
Accept:
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9,id;q=0.8",
Referer: MANHWASUSU_ORIGIN,
"User-Agent": CDN_IMAGE_HEADERS["User-Agent"],
},
validateStatus: (s) => s >= 200 && s < 300,
};
/**
* @param {string} pageUrl - URL to load.
* @returns {Promise<string>} Response body text.
*/
async function fetchManhwaSusuHtml(pageUrl) {
return fetchHtmlWithOptionalFlareSolverr(pageUrl, DEFAULT_GET_HTML);
}
/**
* Chooses a usable Lazy-load / CDN attribute from a reader `<img>` (same order as many WPstyle lazy themes).
* @param {import("node-html-parser").HTMLElement} img - Image element.
* @returns {string|null} First non-placeholder image URL, or null.
*/
function pickReaderImgSrc(img) {
/** @type {(string|null)[]} */
const attrs = [
img.getAttribute("data-src"),
img.getAttribute("data-lazy-src"),
img.getAttribute("data-original"),
img.getAttribute("data-full-url"),
img.getAttribute("src"),
];
for (const raw of attrs) {
if (raw && typeof raw === "string") {
const t = raw.trim();
if (
t.length > 0 &&
!t.startsWith("data:") &&
t !== "/readerarea.svg" &&
!t.endsWith("readerarea.svg")
) {
return t;
}
}
}
return null;
}
/**
* @param {string} contentType - `Content-Type` header value.
* @returns {string} File extension including dot, or empty string.
*/
function extensionFromContentType(contentType) {
if (!contentType || typeof contentType !== "string") {
return "";
}
const ct = contentType.split(";")[0].trim().toLowerCase();
/** @type {Record<string, string>} */
const map = {
"image/jpeg": ".jpg",
"image/jpg": ".jpg",
"image/pjpeg": ".jpg",
"image/png": ".png",
"image/webp": ".webp",
"image/gif": ".gif",
"image/avif": ".avif",
"image/svg+xml": ".svg",
};
return map[ct] || "";
}
/**
* @param {string} imageUrl - Absolute image URL.
* @returns {string} Lowercase extension with dot, or empty string.
*/
function extensionFromImageUrl(imageUrl) {
try {
const p = new URL(imageUrl).pathname;
const ext = path.extname(p).toLowerCase();
if (ext.length > 0 && ext.length <= 8 && /^\.[a-z0-9]+$/u.test(ext)) {
return ext;
}
} catch {
/* invalid URL */
}
return "";
}
/**
* @param {string} pageUrl - Chapter URL (directory name fragment).
* @returns {string} Single filesystem-safe segment.
*/
function slugFromChapterUrl(pageUrl) {
try {
const { pathname } = new URL(pageUrl);
const parts = pathname.split("/").filter(Boolean);
const last = parts[parts.length - 1] || "chapter";
const safe = last.replace(/[^a-z0-9._-]+/giu, "_").slice(0, 96);
return safe.length > 0 ? safe : "chapter";
} catch {
return "chapter";
}
}
/**
* Axios config for CDN panel bytes (browser-like headers from manhwasusu reader).
* @param {string} [_referrerChapterUrl] - Kept for call-site compatibility; CDN expects site-origin Referer.
* @returns {import("axios").AxiosRequestConfig} GET config for binary image responses.
*/
function imageGetConfig(_referrerChapterUrl) {
return {
responseType: "arraybuffer",
timeout: 120_000,
maxContentLength: 50 * 1024 * 1024,
maxBodyLength: 50 * 1024 * 1024,
headers: { ...CDN_IMAGE_HEADERS },
validateStatus: (s) => s >= 200 && s < 300,
};
}
module.exports = {
DEFAULT_GET_HTML,
extensionFromContentType,
extensionFromImageUrl,
fetchManhwaSusuHtml,
imageGetConfig,
pickReaderImgSrc,
slugFromChapterUrl,
};