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:
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 WP‑style 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,
|
||||
};
|
||||
Reference in New Issue
Block a user