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 - ``. * @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} */ 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, };