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.
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
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,
|
||||
};
|
||||
Reference in New Issue
Block a user