- 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.
398 lines
11 KiB
JavaScript
398 lines
11 KiB
JavaScript
const dns = require("dns");
|
|
const fs = require("fs");
|
|
const https = require("https");
|
|
const net = require("net");
|
|
const path = require("path");
|
|
const axios = require("axios");
|
|
|
|
const USER_AGENT =
|
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " +
|
|
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
|
|
|
|
/**
|
|
* @typedef {object} ParsedCli
|
|
* @property {boolean} help - Print usage and exit when true.
|
|
* @property {string|undefined} url - Absolute page URL.
|
|
* @property {string|undefined} outPath - Optional file to write the body to.
|
|
* @property {number} timeoutMs - Axios timeout in milliseconds.
|
|
* @property {string|null} domain - Optional Host header / logical hostname.
|
|
*/
|
|
|
|
/**
|
|
* Parse CLI args: `node scripts/fetch-page.js <url> [--domain <host>] [--out <file>] [--timeout <ms>]`.
|
|
* @param {string[]} argv - `process.argv.slice(2)`.
|
|
* @returns {ParsedCli} Parsed flags and positional URL.
|
|
*/
|
|
function parseCli(argv) {
|
|
/** @type {ParsedCli} */
|
|
const result = {
|
|
help: false,
|
|
url: undefined,
|
|
outPath: undefined,
|
|
timeoutMs: 15_000,
|
|
domain: null,
|
|
};
|
|
|
|
for (let i = 0; i < argv.length; i += 1) {
|
|
const arg = argv[i];
|
|
if (arg === "-h" || arg === "--help") {
|
|
result.help = true;
|
|
continue;
|
|
}
|
|
|
|
if (arg === "--domain" || arg === "-d") {
|
|
result.domain = argv[i + 1];
|
|
i += 1;
|
|
continue;
|
|
}
|
|
|
|
if (arg === "--out" || arg === "-o") {
|
|
result.outPath = argv[i + 1];
|
|
i += 1;
|
|
continue;
|
|
}
|
|
if (arg.startsWith("--out=")) {
|
|
result.outPath = arg.slice("--out=".length);
|
|
continue;
|
|
}
|
|
if (arg === "--timeout") {
|
|
result.timeoutMs = Number(argv[i + 1]);
|
|
i += 1;
|
|
continue;
|
|
}
|
|
if (arg.startsWith("--timeout=")) {
|
|
result.timeoutMs = Number(arg.slice("--timeout=".length));
|
|
continue;
|
|
}
|
|
if (!arg.startsWith("-") && result.url === undefined) {
|
|
result.url = arg;
|
|
continue;
|
|
}
|
|
throw new Error(`Unknown argument: ${arg}`);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Write a diagnostic line to stderr so stdout stays the page body.
|
|
* @param {string} line - Message.
|
|
* @returns {void}
|
|
*/
|
|
function log(line) {
|
|
console.error(line);
|
|
}
|
|
|
|
/**
|
|
* Flatten Axios or Node headers into a plain object.
|
|
* @param {unknown} headers - AxiosHeaders, IncomingHttpHeaders, or plain object.
|
|
* @returns {Record<string, string>} Header names mapped to string values.
|
|
*/
|
|
function plainHeaders(headers) {
|
|
if (!headers || typeof headers !== "object") {
|
|
return {};
|
|
}
|
|
const raw =
|
|
typeof /** @type {{ toJSON?: () => unknown }} */ (headers).toJSON ===
|
|
"function"
|
|
? /** @type {{ toJSON: () => unknown }} */ (headers).toJSON()
|
|
: headers;
|
|
/** @type {Record<string, string>} */
|
|
const out = {};
|
|
for (const [key, value] of Object.entries(raw || {})) {
|
|
if (value == null) {
|
|
continue;
|
|
}
|
|
out[key] = Array.isArray(value) ? value.join(", ") : String(value);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Log HTTP headers, one per line.
|
|
* @param {unknown} headers - Response or request headers.
|
|
* @returns {void}
|
|
*/
|
|
function logHeaders(headers) {
|
|
const entries = Object.entries(plainHeaders(headers));
|
|
if (entries.length === 0) {
|
|
log(" (no headers)");
|
|
return;
|
|
}
|
|
for (const [key, value] of entries) {
|
|
log(` ${key}: ${value}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve A/AAAA records and OS lookup for a hostname; log the query.
|
|
* @param {string} hostname - Name or IP to resolve.
|
|
* @param {string} [label] - Extra context, e.g. "connect" or "Host header".
|
|
* @returns {Promise<void>}
|
|
*/
|
|
async function logDnsQuery(hostname, label) {
|
|
const tag = label ? ` (${label})` : "";
|
|
if (net.isIP(hostname)) {
|
|
log(`dns query${tag}: ${hostname}`);
|
|
log(` skipped — host is already an IP (IPv${String(net.isIP(hostname))})`);
|
|
return;
|
|
}
|
|
|
|
const servers = dns.getServers();
|
|
log(`dns query${tag}: ${hostname}`);
|
|
log(` servers: ${servers.join(", ")}`);
|
|
|
|
const started = Date.now();
|
|
const [a, aaaa, lookup] = await Promise.all([
|
|
dns.promises.resolve4(hostname, { ttl: true }).catch((err) => err),
|
|
dns.promises.resolve6(hostname, { ttl: true }).catch((err) => err),
|
|
dns.promises.lookup(hostname, { all: true }).catch((err) => err),
|
|
]);
|
|
const elapsed = Date.now() - started;
|
|
|
|
if (a instanceof Error) {
|
|
log(` A: ${a.message}`);
|
|
} else {
|
|
for (const rec of a) {
|
|
log(` A: ${rec.address} ttl=${String(rec.ttl)}`);
|
|
}
|
|
if (a.length === 0) {
|
|
log(" A: (empty)");
|
|
}
|
|
}
|
|
|
|
if (aaaa instanceof Error) {
|
|
log(` AAAA: ${aaaa.message}`);
|
|
} else {
|
|
for (const rec of aaaa) {
|
|
log(` AAAA: ${rec.address} ttl=${String(rec.ttl)}`);
|
|
}
|
|
if (aaaa.length === 0) {
|
|
log(" AAAA: (empty)");
|
|
}
|
|
}
|
|
|
|
if (lookup instanceof Error) {
|
|
log(` lookup (getaddrinfo): ${lookup.message}`);
|
|
} else {
|
|
for (const rec of lookup) {
|
|
log(` lookup (getaddrinfo): ${rec.address} IPv${String(rec.family)}`);
|
|
}
|
|
if (lookup.length === 0) {
|
|
log(" lookup (getaddrinfo): (empty)");
|
|
}
|
|
}
|
|
|
|
log(` elapsed: ${String(elapsed)}ms`);
|
|
}
|
|
|
|
/**
|
|
* Agent `lookup` that logs the hostname Node actually resolves for the socket.
|
|
* @param {string} hostname - Name passed by http/https.
|
|
* @param {import("dns").LookupOptions} options - dns.lookup options.
|
|
* @param {(...args: unknown[]) => void} callback - Node lookup callback.
|
|
* @returns {void}
|
|
*/
|
|
function loggedLookup(hostname, options, callback) {
|
|
const started = Date.now();
|
|
dns.lookup(hostname, options, (err, address, family) => {
|
|
const elapsed = Date.now() - started;
|
|
if (err) {
|
|
log(
|
|
`dns lookup (socket): ${hostname} failed in ${String(elapsed)}ms: ${err.message}`,
|
|
);
|
|
} else {
|
|
const addrs = Array.isArray(address)
|
|
? address
|
|
.map((row) => `${row.address} IPv${String(row.family)}`)
|
|
.join(", ")
|
|
: `${String(address)} IPv${String(family)}`;
|
|
log(
|
|
`dns lookup (socket): ${hostname} -> ${addrs} (${String(elapsed)}ms)`,
|
|
);
|
|
}
|
|
callback(err, address, family);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Log axios failure details (TLS handshake, no response, or HTTP error).
|
|
* @param {unknown} err - Caught error.
|
|
* @returns {void}
|
|
*/
|
|
function logAxiosError(err) {
|
|
const ax = /** @type {import("axios").AxiosError} */ (err);
|
|
log("response: (failed)");
|
|
if (ax.code) {
|
|
log(` code: ${ax.code}`);
|
|
}
|
|
log(` message: ${ax instanceof Error ? ax.message : String(ax)}`);
|
|
|
|
const syscall =
|
|
ax && typeof ax === "object" && "syscall" in ax
|
|
? /** @type {{ syscall?: string }} */ (ax).syscall
|
|
: undefined;
|
|
const address =
|
|
ax && typeof ax === "object" && "address" in ax
|
|
? /** @type {{ address?: string }} */ (ax).address
|
|
: undefined;
|
|
const port =
|
|
ax && typeof ax === "object" && "port" in ax
|
|
? /** @type {{ port?: number }} */ (ax).port
|
|
: undefined;
|
|
if (syscall) {
|
|
log(` syscall: ${syscall}`);
|
|
}
|
|
if (address != null) {
|
|
log(` peer: ${address}${port != null ? `:${String(port)}` : ""}`);
|
|
}
|
|
|
|
if (ax.response) {
|
|
log(
|
|
` http status: ${String(ax.response.status)} ${ax.response.statusText || ""}`.trim(),
|
|
);
|
|
log(" http headers:");
|
|
logHeaders(ax.response.headers);
|
|
const body = ax.response.data;
|
|
if (typeof body === "string" && body.length > 0) {
|
|
const preview = body.length > 500 ? `${body.slice(0, 500)}…` : body;
|
|
log(` http body preview: ${preview}`);
|
|
}
|
|
return;
|
|
}
|
|
|
|
log(" (no HTTP response — connection or TLS failed before headers)");
|
|
if (ax.code === "EPROTO" || /ssl|tls|handshake/iu.test(String(ax.message))) {
|
|
log(
|
|
" hint: HTTPS to an IP uses that IP as TLS SNI unless servername is set; CDNs often send alert 40",
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* GET a page URL and return the response body as text.
|
|
* @param {string} url - Absolute page URL.
|
|
* @param {number} timeoutMs - Axios timeout.
|
|
* @param {string|null} domain - Optional Host header / logical hostname.
|
|
* @returns {Promise<{ status: number, contentType: string, body: string }>} HTTP status, content-type, and body text.
|
|
*/
|
|
async function fetchPage(url, timeoutMs, domain) {
|
|
const parsedUrl = new URL(url);
|
|
const connectHost = parsedUrl.hostname;
|
|
const hostHeader = domain || parsedUrl.host;
|
|
const sni = domain && net.isIP(connectHost) ? domain : connectHost;
|
|
|
|
await logDnsQuery(connectHost, "connect");
|
|
if (domain && domain !== connectHost) {
|
|
await logDnsQuery(domain, "Host / --domain");
|
|
}
|
|
|
|
log("request:");
|
|
log(` GET ${url}`);
|
|
log(` connect host: ${connectHost}`);
|
|
log(` Host header: ${hostHeader}`);
|
|
log(` tls servername (SNI): ${sni}`);
|
|
|
|
const httpsAgent = new https.Agent({
|
|
lookup: loggedLookup,
|
|
servername: sni,
|
|
});
|
|
|
|
let data;
|
|
let status;
|
|
let headers;
|
|
try {
|
|
({ data, status, headers } = await axios.get(url, {
|
|
responseType: "text",
|
|
timeout: timeoutMs,
|
|
httpsAgent,
|
|
validateStatus: (s) => s >= 200 && s < 300,
|
|
headers: {
|
|
Accept:
|
|
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
"Accept-Language": "en-US,en;q=0.9",
|
|
"User-Agent": USER_AGENT,
|
|
Host: hostHeader,
|
|
},
|
|
}));
|
|
} catch (err) {
|
|
logAxiosError(err);
|
|
throw err;
|
|
}
|
|
|
|
log(`response: ${String(status)}`);
|
|
logHeaders(headers);
|
|
|
|
return {
|
|
status,
|
|
contentType: String(headers["content-type"] || ""),
|
|
body: typeof data === "string" ? data : String(data ?? ""),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Fetch a website page with axios and print the body (or write it to a file).
|
|
*
|
|
* Usage:
|
|
* node scripts/fetch-page.js <url> [--domain <host>] [--out <file>] [--timeout <ms>]
|
|
* @returns {Promise<void>}
|
|
*/
|
|
async function main() {
|
|
let parsed;
|
|
try {
|
|
parsed = parseCli(process.argv.slice(2));
|
|
} catch (err) {
|
|
console.error(err instanceof Error ? err.message : err);
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
if (parsed.help || !parsed.url) {
|
|
console.error(
|
|
"Usage: node scripts/fetch-page.js <url> [--domain <host>] [--out <file>] [--timeout <ms>]",
|
|
);
|
|
if (!parsed.help) {
|
|
process.exitCode = 1;
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!Number.isFinite(parsed.timeoutMs) || parsed.timeoutMs <= 0) {
|
|
console.error("--timeout must be a positive number of milliseconds");
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
let page;
|
|
try {
|
|
page = await fetchPage(parsed.url, parsed.timeoutMs, parsed.domain);
|
|
} catch (err) {
|
|
console.error(err instanceof Error ? err.message : err);
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
log(`bytes: ${String(Buffer.byteLength(page.body, "utf8"))}`);
|
|
|
|
if (parsed.outPath) {
|
|
const abs = path.resolve(parsed.outPath);
|
|
fs.writeFileSync(abs, page.body, "utf8");
|
|
log(`wrote: ${abs}`);
|
|
return;
|
|
}
|
|
|
|
process.stdout.write(page.body);
|
|
if (!page.body.endsWith("\n")) {
|
|
process.stdout.write("\n");
|
|
}
|
|
}
|
|
|
|
if (require.main === module) {
|
|
main().catch((err) => {
|
|
console.error(err);
|
|
process.exitCode = 1;
|
|
});
|
|
}
|
|
|
|
module.exports = { fetchPage, main, parseCli };
|