mirror of
https://github.com/tiennm99/miti99.git
synced 2026-09-19 00:23:12 +00:00
Translates the seven-subcommand engine to Node ESM, one module per former Go file, invoked as `node scripts/newsletter <command>` from the repo root. The Go implementation stays in place for now so parity can be measured against it. Hand-rolled HTML and XML regexes give way to cheerio, which removes the manual string surgery in caption extraction and covers RSS and sitemap XML through xmlMode without a second parser. fetch-via-defuddle gains a local extraction stage ahead of the defuddle.md proxy; the proxy stays, because fetching from a third IP is the whole point when this machine's IP is the blocked one. Both stages now emit the same YAML frontmatter plus body, and an extraction that looks like a bot challenge counts as a local failure so the proxy still runs. Behaviour is preserved where it is load-bearing rather than where it is merely idiomatic: query strings are rebuilt by string surgery so surviving parameters keep their original order and encoding, duplicate detection stays an index loop with byte-offset boundary checks so a prefix of a stored URL is not a false match, empty optional fields are omitted rather than emitted as "", a deep-crawl miss reports cutoff as null rather than dropping the key, bullet filtering counts code points, tag counts keep their six-column alignment, and a malformed percent sequence falls back to the raw substring. The printer lives in its own module so a command module never imports the dispatcher: index.js runs main() at module scope, so that cycle would execute the CLI as a side effect of any import. Missing dependencies report one actionable line instead of a module-resolution stack trace, and a reader that closes early exits quietly instead of raising EPIPE.
340 lines
9.9 KiB
JavaScript
340 lines
9.9 KiB
JavaScript
// Shared URL helpers, ported from url_utils.go. Owned by the add-url router;
|
|
// reused by the other subcommands.
|
|
|
|
import { readdirSync, readFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
|
|
/** Exact-match tracking params; any key starting with utm_ is also dropped. */
|
|
const EXACT_TRACKING = new Set([
|
|
"fbclid", "gclid", "msclkid", "mc_eid",
|
|
"aid", "ref", "ref_src", "ref_url", "source", "s",
|
|
"ck_subscriber_id", "igshid", "yclid", "vero_id",
|
|
]);
|
|
|
|
/**
|
|
* contentDir is repo-root relative (invocation contract: run from repo root).
|
|
* @returns {string}
|
|
*/
|
|
export function contentDir() {
|
|
return join("content", "post");
|
|
}
|
|
|
|
/**
|
|
* cleanUrl removes common tracking parameters. Surviving query pairs are kept
|
|
* verbatim (no re-encoding) and in their original order — URLSearchParams would
|
|
* normalize percent-encoding (%7E → ~, + → %20), which must not leak into
|
|
* stored clean_url values. Unparseable / non-absolute input is returned
|
|
* untouched rather than corrupted.
|
|
* @param {string} raw
|
|
* @returns {string}
|
|
*/
|
|
export function cleanUrl(raw) {
|
|
let u;
|
|
try {
|
|
u = new URL(raw);
|
|
} catch {
|
|
return raw;
|
|
}
|
|
if (!u.protocol || !u.host) return raw;
|
|
// WHATWG URL serializes an empty path as "/" for special schemes; force it
|
|
// for the rest so cleaned URLs keep one shape.
|
|
if (u.pathname === "") {
|
|
try {
|
|
u.pathname = "/";
|
|
} catch {
|
|
/* opaque path — leave as parsed */
|
|
}
|
|
}
|
|
|
|
let kept = "";
|
|
const query = u.search.slice(1);
|
|
if (query !== "") {
|
|
const parts = [];
|
|
for (const pair of query.split("&")) {
|
|
if (pair === "") continue;
|
|
const eq = pair.indexOf("=");
|
|
const key = (eq === -1 ? pair : pair.slice(0, eq)).toLowerCase();
|
|
if (key.startsWith("utm_") || EXACT_TRACKING.has(key)) continue;
|
|
parts.push(pair);
|
|
}
|
|
kept = parts.join("&");
|
|
}
|
|
|
|
const hash = u.hash;
|
|
u.search = "";
|
|
u.hash = "";
|
|
return u.toString() + (kept === "" ? "" : "?" + kept) + hash;
|
|
}
|
|
|
|
// --- Substack image helpers (shared by add-url routing and detect-image-source) ---
|
|
|
|
const SUBSTACK_IMAGE_HOSTS = new Set([
|
|
"substackcdn.com",
|
|
"substack-post-media.s3.amazonaws.com",
|
|
]);
|
|
|
|
const UUID_PATTERN = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
|
|
const IMAGE_UUID_RE = new RegExp(`images(?:%2F|/)(${UUID_PATTERN})`, "i");
|
|
const UUID_EXACT_RE = new RegExp(`^${UUID_PATTERN}$`, "i");
|
|
|
|
/**
|
|
* isSubstackImage reports a Substack-hosted image (CDN wrapper or raw S3),
|
|
* regardless of file extension.
|
|
* @param {string} target
|
|
* @returns {boolean}
|
|
*/
|
|
export function isSubstackImage(target) {
|
|
let host = "";
|
|
try {
|
|
host = new URL(target).host.toLowerCase();
|
|
} catch {
|
|
/* not an absolute URL — fall through to the substring check */
|
|
}
|
|
return SUBSTACK_IMAGE_HOSTS.has(host) || target.toLowerCase().includes("substack-post-media");
|
|
}
|
|
|
|
/**
|
|
* substackImageUuid extracts the stable image identity: the S3 image UUID under
|
|
* public/images/<uuid>, with raw (/) or percent-encoded (%2F) separators.
|
|
* @param {string} target
|
|
* @returns {string} the lowercased uuid, or "" when absent
|
|
*/
|
|
export function substackImageUuid(target) {
|
|
const m = IMAGE_UUID_RE.exec(target);
|
|
return m === null ? "" : m[1].toLowerCase();
|
|
}
|
|
|
|
/**
|
|
* isUuid reports whether a bare identity is a Substack image uuid.
|
|
* @param {string} s
|
|
* @returns {boolean}
|
|
*/
|
|
export function isUuid(s) {
|
|
return UUID_EXACT_RE.test(s);
|
|
}
|
|
|
|
/**
|
|
* Some sites carry the resource identity in a query param, not the path
|
|
* (e.g. YouTube /watch?v=ID). Preserve the identity param for those hosts so
|
|
* dedup does not collapse every video onto the same bare URL.
|
|
*/
|
|
const IDENTITY_PARAMS = new Map([
|
|
["youtube.com", "v"],
|
|
["www.youtube.com", "v"],
|
|
["m.youtube.com", "v"],
|
|
]);
|
|
|
|
/**
|
|
* trimTrailingSlash removes at most one trailing "/".
|
|
* @param {string} s
|
|
* @returns {string}
|
|
*/
|
|
function trimTrailingSlash(s) {
|
|
return s.endsWith("/") ? s.slice(0, -1) : s;
|
|
}
|
|
|
|
/**
|
|
* bareUrl reduces a URL to a stable identity for duplicate detection:
|
|
* - Substack image → its S3 UUID (transform/size variants share one identity)
|
|
* - YouTube → scheme+host+path + the v= video id
|
|
* - everything else → scheme + host + path
|
|
* @param {string} target
|
|
* @returns {string}
|
|
*/
|
|
export function bareUrl(target) {
|
|
if (isSubstackImage(target)) {
|
|
const uuid = substackImageUuid(target);
|
|
if (uuid !== "") return uuid;
|
|
}
|
|
let u = null;
|
|
try {
|
|
u = new URL(target);
|
|
} catch {
|
|
/* unparseable — fall back to crude string surgery below */
|
|
}
|
|
if (u === null || !u.protocol || !u.host) {
|
|
return trimTrailingSlash(target.split("?")[0]);
|
|
}
|
|
const host = u.host.toLowerCase();
|
|
let bare = trimTrailingSlash(u.protocol + "//" + host + u.pathname);
|
|
const idParam = IDENTITY_PARAMS.get(host);
|
|
if (idParam !== undefined) {
|
|
const v = u.searchParams.get(idParam);
|
|
if (v !== null && v !== "") bare += "?" + idParam + "=" + v;
|
|
}
|
|
return bare;
|
|
}
|
|
|
|
/**
|
|
* fetchTextOk GETs target and returns the body on a 2xx response, "" on any
|
|
* error, non-2xx status, or timeout. Redirects are followed.
|
|
* @param {string} target
|
|
* @param {number} timeoutMs
|
|
* @param {string} [userAgent]
|
|
* @returns {Promise<string>}
|
|
*/
|
|
export async function fetchTextOk(target, timeoutMs, userAgent = "") {
|
|
try {
|
|
const headers = {};
|
|
if (userAgent !== "") headers["user-agent"] = userAgent;
|
|
const res = await fetch(target, {
|
|
headers,
|
|
redirect: "follow",
|
|
signal: AbortSignal.timeout(timeoutMs),
|
|
});
|
|
if (res.status < 200 || res.status >= 300) return "";
|
|
return await res.text();
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* checkAccessibility HEADs the URL and returns the final HTTP status code as a
|
|
* string, or "000" on network error / timeout.
|
|
* @param {string} target
|
|
* @returns {Promise<string>}
|
|
*/
|
|
export async function checkAccessibility(target) {
|
|
try {
|
|
const res = await fetch(target, {
|
|
method: "HEAD",
|
|
redirect: "follow",
|
|
signal: AbortSignal.timeout(10_000),
|
|
});
|
|
return String(res.status);
|
|
} catch {
|
|
return "000";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* collectMarkdown recursively collects *.md files under dir (the content tree is
|
|
* small). Entries are visited in lexical order so callers that depend on
|
|
* first-seen ordering stay deterministic. Missing or unreadable directories
|
|
* yield nothing.
|
|
* @param {string} dir
|
|
* @returns {string[]}
|
|
*/
|
|
export function collectMarkdown(dir) {
|
|
/** @type {string[]} */
|
|
const acc = [];
|
|
walkMarkdown(dir, acc);
|
|
return acc;
|
|
}
|
|
|
|
/**
|
|
* @param {string} dir
|
|
* @param {string[]} acc
|
|
* @returns {void}
|
|
*/
|
|
function walkMarkdown(dir, acc) {
|
|
let entries;
|
|
try {
|
|
entries = readdirSync(dir, { withFileTypes: true });
|
|
} catch {
|
|
return; // skip unreadable entries
|
|
}
|
|
entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
for (const e of entries) {
|
|
const p = join(dir, e.name);
|
|
if (e.isDirectory()) walkMarkdown(p, acc);
|
|
else if (e.name.toLowerCase().endsWith(".md")) acc.push(p);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* uuidBoundaryOk: the char after a UUID match must not extend the hex id, so
|
|
* <uuid>.png (cover image), <uuid>_WxH and <uuid>) all match.
|
|
* @param {string} text
|
|
* @param {number} end
|
|
* @returns {boolean}
|
|
*/
|
|
export function uuidBoundaryOk(text, end) {
|
|
if (end >= text.length) return true;
|
|
const c = text[end];
|
|
return !((c >= "0" && c <= "9") || (c >= "a" && c <= "f") || (c >= "A" && c <= "F"));
|
|
}
|
|
|
|
const URL_DELIMITERS = `)]"'?#<>_&,`;
|
|
const URL_WHITESPACE = " \t\n\r\f\v";
|
|
|
|
/**
|
|
* urlBoundaryOk: an optional trailing slash (bareUrl strips it, stored URLs may
|
|
* keep it), then a path/punctuation delimiter, whitespace, or end of text — so
|
|
* /p/foo does not match a stored /p/foo-bar. The '>' delimiter covers URLs
|
|
* stored in markdown autolink form <https://…>, which older posts use.
|
|
* @param {string} text
|
|
* @param {number} end
|
|
* @returns {boolean}
|
|
*/
|
|
export function urlBoundaryOk(text, end) {
|
|
let at = end;
|
|
if (at < text.length && text[at] === "/") at++;
|
|
if (at >= text.length) return true;
|
|
const c = text[at];
|
|
if (URL_WHITESPACE.includes(c)) return true;
|
|
return URL_DELIMITERS.includes(c);
|
|
}
|
|
|
|
/**
|
|
* hasBoundaryMatch scans every occurrence of needle and applies the boundary
|
|
* check in code. Kept as an index loop rather than one lookahead regex: the
|
|
* byte-offset checks (optional trailing slash, delimiter set) are what stop a
|
|
* needle that is merely a PREFIX of a stored string from matching.
|
|
* @param {string} text
|
|
* @param {string} needle
|
|
* @param {boolean} isUuidNeedle
|
|
* @returns {boolean}
|
|
*/
|
|
export function hasBoundaryMatch(text, needle, isUuidNeedle) {
|
|
for (let from = 0; ; ) {
|
|
const i = text.indexOf(needle, from);
|
|
if (i === -1) return false;
|
|
const end = i + needle.length;
|
|
if (isUuidNeedle ? uuidBoundaryOk(text, end) : urlBoundaryOk(text, end)) return true;
|
|
from = i + 1;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* checkDuplicate reports whether a URL identity already exists in the stored
|
|
* markdown, boundary-aware so a needle that is merely a PREFIX of a stored
|
|
* longer string is NOT a false duplicate.
|
|
* @param {string} target
|
|
* @param {string} dir
|
|
* @returns {boolean}
|
|
*/
|
|
export function checkDuplicate(target, dir) {
|
|
const needle = bareUrl(target);
|
|
if (needle === "") return false;
|
|
const uuidNeedle = isUuid(needle);
|
|
for (const file of collectMarkdown(dir)) {
|
|
let text;
|
|
try {
|
|
text = readFileSync(file, "utf8");
|
|
} catch {
|
|
continue;
|
|
}
|
|
if (text.includes(needle) && hasBoundaryMatch(text, needle, uuidNeedle)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
const IMAGE_EXT_RE = /\.(png|jpg|jpeg|gif|webp|svg|avif|heic|heif|bmp|tiff?)(\?.*)?$/;
|
|
const VIDEO_EXT_RE = /\.(mp4|webm|mov|avi|mkv)(\?.*)?$/;
|
|
const DOCUMENT_EXT_RE = /\.(pdf|docx?|xlsx?|pptx?)(\?.*)?$/;
|
|
|
|
/**
|
|
* classifyType classifies a URL by file extension.
|
|
* @param {string} target
|
|
* @returns {"image"|"video"|"document"|"article"}
|
|
*/
|
|
export function classifyType(target) {
|
|
const lower = target.toLowerCase();
|
|
if (IMAGE_EXT_RE.test(lower)) return "image";
|
|
if (VIDEO_EXT_RE.test(lower)) return "video";
|
|
if (DOCUMENT_EXT_RE.test(lower)) return "document";
|
|
return "article";
|
|
}
|