Files
miti99/scripts/newsletter/find-substack-post.js
T
tiennm99 fb292d444d feat(newsletter): port the engine to JavaScript
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.
2026-09-18 16:23:58 +07:00

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