- 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.
181 lines
4.9 KiB
JavaScript
181 lines
4.9 KiB
JavaScript
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,
|
|
};
|