const { CHAPTER_PATHNAME_PATTERN, COMICLAND_API_ORIGIN, COMICLAND_HOSTS, LISTING_PATHNAME_PATTERN, } = require("./constants.js"); const { fetchComicLandJson, unwrapApiData } = require("./io.js"); /** * @typedef {object} ChapterLink * @property {string} url - Absolute chapter reader URL. * @property {string} title - Chapter label (trimmed). */ /** * @param {string} pathname - URL pathname. * @returns {string} Path without trailing slashes. */ function stripTrailingSlash(pathname) { return pathname.replace(/\/+$/u, "") || "/"; } /** * @param {string} pathname - URL pathname. * @returns {boolean} Whether this path is a ComicLand chapter reader URL. */ function isChapterPathname(pathname) { return CHAPTER_PATHNAME_PATTERN.test(stripTrailingSlash(pathname)); } /** * @param {string} pathname - URL pathname. * @returns {boolean} Whether this path is a ComicLand series listing URL. */ function isListingPathname(pathname) { return LISTING_PATHNAME_PATTERN.test(stripTrailingSlash(pathname)); } /** * @param {string} url - Any http(s) URL. * @returns {boolean} True when hostname is ComicLand (hint only). */ function isLikelyComicLandSite(url) { try { const { hostname } = new URL(url); const host = hostname.toLowerCase(); return COMICLAND_HOSTS.some((h) => h === host) || host.includes("comicland"); } catch { return false; } } /** * Series slug from `/comic/{slug}` or `/comic/{slug}/chapter/{index}`. * @param {string} pageUrl - Series or chapter URL. * @returns {string} Slug segment, or `"comic"`. */ function seriesSlugFromListingUrl(pageUrl) { try { const { pathname } = new URL(pageUrl); const parts = pathname.split("/").filter(Boolean); const comicIdx = parts.indexOf("comic"); if (comicIdx !== -1 && parts[comicIdx + 1] !== undefined) { return parts[comicIdx + 1]; } } catch { /* invalid URL */ } return "comic"; } /** * @param {string} pageUrl - Series or chapter URL. * @returns {{ origin: string, slug: string, chapterIndex: string|null }} Parsed parts. */ function parseComicLandUrl(pageUrl) { const u = new URL(pageUrl); const parts = u.pathname.split("/").filter(Boolean); const comicIdx = parts.indexOf("comic"); const slug = comicIdx !== -1 && parts[comicIdx + 1] !== undefined ? parts[comicIdx + 1] : "comic"; let chapterIndex = null; const chapterIdx = parts.indexOf("chapter"); if (chapterIdx !== -1 && parts[chapterIdx + 1] !== undefined) { chapterIndex = parts[chapterIdx + 1]; } return { origin: u.origin, slug, chapterIndex }; } /** * @param {string} siteOrigin - e.g. `https://comicland.org`. * @param {string} slug - Series slug. * @param {string|number} chapterIndex - API `chapter_index`. * @returns {string} Canonical reader URL (no trailing slash). */ function chapterUrlFromParts(siteOrigin, slug, chapterIndex) { const origin = siteOrigin.replace(/\/+$/u, ""); return `${origin}/comic/${slug}/chapter/${String(chapterIndex)}`; } /** * @param {unknown} rawIndex - API `chapter_index`. * @returns {string} Index as used in the reader URL. */ function chapterIndexString(rawIndex) { if (typeof rawIndex === "number" && Number.isFinite(rawIndex)) { return Number.isInteger(rawIndex) ? String(rawIndex) : String(rawIndex); } if (typeof rawIndex === "string" && rawIndex.trim()) { return rawIndex.trim(); } throw new Error("ComicLand chapter is missing chapter_index"); } /** * GET `/api/comic/detail?slug=` and return mapped chapter links (oldest first). * @param {string} listingPageUrl - e.g. `https://comicland.org/comic/{slug}` * @returns {Promise<{ chapters: ChapterLink[], comicTitle: string }>} Chapter rows and series title. */ async function fetchChapterLinks(listingPageUrl) { const { origin, slug } = parseComicLandUrl(listingPageUrl); const apiUrl = `${COMICLAND_API_ORIGIN}/api/comic/detail?slug=${encodeURIComponent(slug)}`; const payload = await fetchComicLandJson(apiUrl); const data = unwrapApiData(payload, "comic/detail"); const comicTitle = typeof data.title === "string" && data.title.trim() ? data.title.trim() : slug; const rawChapters = Array.isArray(data.chapters) ? data.chapters : []; /** @type {ChapterLink[]} */ const chapters = []; for (const row of rawChapters) { if (!row || typeof row !== "object") { continue; } let index; try { index = chapterIndexString(row.chapter_index); } catch { continue; } const title = typeof row.title === "string" && row.title.trim() ? row.title.trim().replace(/\s+/gu, " ") : `Chapter ${index}`; chapters.push({ url: chapterUrlFromParts(origin, slug, index), title, }); } chapters.sort((a, b) => { const na = Number.parseFloat(String(new URL(a.url).pathname.split("/").pop())); const nb = Number.parseFloat(String(new URL(b.url).pathname.split("/").pop())); if (Number.isFinite(na) && Number.isFinite(nb) && na !== nb) { return na - nb; } return a.url.localeCompare(b.url); }); return { chapters, comicTitle }; } module.exports = { chapterUrlFromParts, fetchChapterLinks, isChapterPathname, isLikelyComicLandSite, isListingPathname, parseComicLandUrl, seriesSlugFromListingUrl, };