mirror of
https://github.com/tiennm99/miti99.git
synced 2026-09-19 04:22:33 +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.
232 lines
7.0 KiB
JavaScript
232 lines
7.0 KiB
JavaScript
// Find which Substack post embeds a given image UUID, and extract a label.
|
|
// Usage: node scripts/newsletter find-substack-post --uuid <uuid> [--deep]
|
|
// Output on hit: JSON { found:true, source, publication, postTitle, postUrl, caption, candidates }
|
|
// Output on miss: JSON { found:false } (RSS) or { found:false, source:"sitemap", scanned, budget, cutoff } (--deep)
|
|
//
|
|
// Strategy: RSS feed first (fast, ~recent weeks). With --deep, fall back to a
|
|
// heavier sitemap crawl up to ~3 months back — opt-in because it fetches many
|
|
// posts. A Substack CDN URL does not encode its publication, so we search each
|
|
// publication listed in config/substack-publications.json, read at runtime so
|
|
// editing the JSON takes effect immediately.
|
|
|
|
import { readFileSync } from "node:fs";
|
|
import { parseArgs } from "node:util";
|
|
import { printJson } from "./json-out.js";
|
|
import { fetchTextOk } from "./url-utils.js";
|
|
import {
|
|
captionForUuid,
|
|
extractCandidates,
|
|
itemLink,
|
|
itemTitle,
|
|
loadXml,
|
|
postTitleFromHtml,
|
|
} from "./html-text.js";
|
|
|
|
/** Total post fetches allowed across ALL publications during a --deep crawl. */
|
|
const DEEP_FETCH_BUDGET = 40;
|
|
|
|
/**
|
|
* loadPublications reads the publication list, falling back to the default on
|
|
* any read or parse failure.
|
|
* @returns {string[]}
|
|
*/
|
|
export function loadPublications() {
|
|
try {
|
|
const raw = readFileSync(new URL("./config/substack-publications.json", import.meta.url), "utf8");
|
|
const pubs = JSON.parse(raw);
|
|
if (Array.isArray(pubs) && pubs.length > 0) return pubs;
|
|
} catch {
|
|
/* fall through */
|
|
}
|
|
return ["blog.bytebytego.com"];
|
|
}
|
|
|
|
/**
|
|
* fetchPage: body text with a browser-ish UA, or "" on any error.
|
|
* @param {string} target
|
|
* @returns {Promise<string>}
|
|
*/
|
|
function fetchPage(target) {
|
|
return fetchTextOk(target, 10_000, "Mozilla/5.0");
|
|
}
|
|
|
|
const RFC3339_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/i;
|
|
const DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
|
|
/**
|
|
* parseLastmod accepts only the two layouts the Go engine accepted — RFC3339
|
|
* and a bare date — so a loosely formatted stamp is skipped rather than
|
|
* silently reinterpreted in local time.
|
|
* @param {string} s
|
|
* @returns {Date|null}
|
|
*/
|
|
export function parseLastmod(s) {
|
|
if (!RFC3339_RE.test(s) && !DATE_ONLY_RE.test(s)) return null;
|
|
const t = new Date(s);
|
|
return Number.isNaN(t.getTime()) ? null : t;
|
|
}
|
|
|
|
/**
|
|
* cutoffDate returns the ~3-months-back boundary for the deep crawl.
|
|
* @returns {Date}
|
|
*/
|
|
function cutoffDate() {
|
|
const d = new Date();
|
|
d.setUTCMonth(d.getUTCMonth() - 3);
|
|
return d;
|
|
}
|
|
|
|
/**
|
|
* @typedef {{found: true, source: string, publication: string, postTitle: string,
|
|
* postUrl: string, caption: string, candidates: string[]}} PostHit
|
|
*/
|
|
|
|
/**
|
|
* searchSitemap is the deep fallback: crawl the sitemap back ~3 months, fetch
|
|
* posts most-recent-first (up to maxFetch from the shared budget), and look for
|
|
* the UUID. Heavier than RSS — only used on RSS miss.
|
|
* @param {string} publication
|
|
* @param {string} id
|
|
* @param {number} maxFetch
|
|
* @returns {Promise<{hit: PostHit|null, scanned: number, cutoff: string}>} cutoff is "" when the sitemap itself could not be fetched
|
|
*/
|
|
async function searchSitemap(publication, id, maxFetch) {
|
|
const xml = await fetchPage("https://" + publication + "/sitemap.xml");
|
|
if (xml === "") return { hit: null, scanned: 0, cutoff: "" };
|
|
|
|
const cutoffTime = cutoffDate();
|
|
const cutoff = cutoffTime.toISOString().slice(0, 10);
|
|
|
|
const $ = loadXml(xml);
|
|
/** @type {{url: string, when: Date}[]} */
|
|
const candidates = [];
|
|
$("url").each((_i, el) => {
|
|
const loc = $(el).children("loc").first().text();
|
|
const lastmod = $(el).children("lastmod").first().text();
|
|
if (loc === "" || lastmod === "") return;
|
|
if (!loc.includes("/p/")) return; // posts only
|
|
const when = parseLastmod(lastmod);
|
|
if (when === null || when < cutoffTime) return;
|
|
candidates.push({ url: loc, when });
|
|
});
|
|
candidates.sort((a, b) => b.when.getTime() - a.when.getTime());
|
|
|
|
let scanned = 0;
|
|
for (const c of candidates.slice(0, maxFetch)) {
|
|
scanned++;
|
|
const html = await fetchPage(c.url);
|
|
if (html === "" || !html.includes(id)) continue;
|
|
return {
|
|
hit: {
|
|
found: true,
|
|
source: "sitemap",
|
|
publication,
|
|
postTitle: postTitleFromHtml(html),
|
|
postUrl: c.url,
|
|
caption: captionForUuid(html, id),
|
|
candidates: extractCandidates(html),
|
|
},
|
|
scanned,
|
|
cutoff,
|
|
};
|
|
}
|
|
return { hit: null, scanned, cutoff };
|
|
}
|
|
|
|
/**
|
|
* searchRss looks for the uuid in a publication's recent feed items.
|
|
* @param {string} publication
|
|
* @param {string} id
|
|
* @returns {Promise<PostHit|null>}
|
|
*/
|
|
async function searchRss(publication, id) {
|
|
const xml = await fetchPage("https://" + publication + "/feed");
|
|
if (xml === "") return null;
|
|
const $ = loadXml(xml);
|
|
const items = $("item").toArray();
|
|
for (const el of items) {
|
|
const item = $.html(el);
|
|
if (!item.includes(id)) continue;
|
|
return {
|
|
found: true,
|
|
source: "rss",
|
|
publication,
|
|
postTitle: itemTitle(item),
|
|
postUrl: itemLink(item),
|
|
caption: captionForUuid(item, id),
|
|
candidates: extractCandidates(item),
|
|
};
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* @param {string[]} args
|
|
* @returns {Promise<void>}
|
|
*/
|
|
export async function runFindSubstackPost(args) {
|
|
let values;
|
|
try {
|
|
({ values } = parseArgs({
|
|
args,
|
|
options: { uuid: { type: "string" }, deep: { type: "boolean" } },
|
|
allowPositionals: false,
|
|
}));
|
|
} catch (err) {
|
|
process.stderr.write(String(err?.message ?? err) + "\n");
|
|
process.stderr.write(
|
|
"Usage: node scripts/newsletter find-substack-post --uuid <uuid> [--deep]\n",
|
|
);
|
|
process.exit(2);
|
|
}
|
|
|
|
const uuid = values.uuid ?? "";
|
|
if (uuid === "") {
|
|
process.stderr.write(
|
|
"Usage: node scripts/newsletter find-substack-post --uuid <uuid> [--deep]\n",
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
const publications = loadPublications();
|
|
for (const pub of publications) {
|
|
const hit = await searchRss(pub, uuid);
|
|
if (hit !== null) {
|
|
printJson(hit);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Deep fallback: sitemap crawl up to ~3 months back, sharing one global fetch
|
|
// budget across all publications so coverage can't blow up as the
|
|
// publications list grows.
|
|
if (values.deep === true) {
|
|
let totalScanned = 0;
|
|
/** @type {string|null} */
|
|
let lastCutoff = null;
|
|
for (const pub of publications) {
|
|
const remaining = DEEP_FETCH_BUDGET - totalScanned;
|
|
if (remaining <= 0) break;
|
|
const { hit, scanned, cutoff } = await searchSitemap(pub, uuid, remaining);
|
|
totalScanned += scanned;
|
|
if (cutoff !== "") lastCutoff = cutoff;
|
|
if (hit !== null) {
|
|
printJson(hit);
|
|
return;
|
|
}
|
|
}
|
|
// cutoff is null (not omitted) when no sitemap could be fetched — the skill
|
|
// distinguishes "crawled and missed" from "could not crawl".
|
|
printJson({
|
|
found: false,
|
|
source: "sitemap",
|
|
scanned: totalScanned,
|
|
budget: DEEP_FETCH_BUDGET,
|
|
cutoff: lastCutoff,
|
|
});
|
|
return;
|
|
}
|
|
|
|
printJson({ found: false });
|
|
}
|