Files
cxyz/src/comicland/chapters.js
T
nsrb a7c6058540 Add cxyz Android project structure and initial configuration
- Introduced a new Flutter project for the cxyz Android manga/comic reader, including essential files such as pubspec.yaml, AndroidManifest.xml, and Gradle configuration.
- Added .gitignore files to exclude unnecessary files and directories for both the main project and Android-specific files.
- Implemented initial README.md with project description, features, setup instructions, and API details for ComicLand, MangaRead, and MangaTown.
- Configured analysis options and dependencies in pubspec.yaml to support Flutter development.
- Established a pnpm workspace and lockfile for dependency management.
- Included various Android resources such as icons, styles, and launch backgrounds for a complete application setup.
2026-08-29 17:50:31 +07:00

178 lines
5.2 KiB
JavaScript

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