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,
};