mirror of
https://github.com/tiennm99/blog.git
synced 2026-10-11 03:13:10 +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.
89 lines
3.1 KiB
JavaScript
89 lines
3.1 KiB
JavaScript
// Newsletter engine for the mt-* skills — one entry point, one subcommand per
|
|
// task. Invoked from the repo root:
|
|
//
|
|
// node scripts/newsletter <command> [args]
|
|
//
|
|
// Repo-relative paths (content/post) resolve from the working directory, so the
|
|
// repo-root invocation contract from AGENTS.md still applies.
|
|
|
|
const USAGE = `Usage: node scripts/newsletter <command> [args]
|
|
|
|
Commands:
|
|
add-url <url> classify + dedup a URL, emit JSON route
|
|
find-newsletter-number print the next newsletter number
|
|
list-existing-tags tag frequencies, most-used first (top 40)
|
|
detect-image-source <url> detect Substack image + uuid
|
|
find-substack-post --uuid <uuid> [--deep] find the post embedding an image uuid
|
|
fetch-via-defuddle <url> fallback fetch (local defuddle, then defuddle.md)
|
|
post-stats <path/to/index.md> count the post's articles/images/videos/documents
|
|
`;
|
|
|
|
// A reader that closes early (`… | head -3`) makes the next write fail with
|
|
// EPIPE, which Node surfaces as an unhandled error event. Stop quietly instead
|
|
// of printing a stack trace over the user's terminal.
|
|
process.stdout.on("error", (err) => {
|
|
if (err.code === "EPIPE") process.exit(0);
|
|
throw err;
|
|
});
|
|
|
|
/** @returns {void} */
|
|
function usage() {
|
|
process.stderr.write(USAGE);
|
|
}
|
|
|
|
/**
|
|
* Command modules are imported lazily so a missing node_modules reports the one
|
|
* actionable fix instead of a module-resolution stack trace.
|
|
* @param {string} spec
|
|
* @returns {Promise<Record<string, any>>}
|
|
*/
|
|
async function loadCommand(spec) {
|
|
try {
|
|
return await import(spec);
|
|
} catch (err) {
|
|
if (err !== null && typeof err === "object" && err.code === "ERR_MODULE_NOT_FOUND") {
|
|
process.stderr.write(
|
|
"newsletter engine: dependencies missing — run 'npm ci' from the repo root\n",
|
|
);
|
|
process.exit(1);
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/** @type {Record<string, {module: string, fn: string}>} */
|
|
const COMMANDS = {
|
|
"add-url": { module: "./add-url.js", fn: "runAddUrl" },
|
|
"find-newsletter-number": { module: "./find-newsletter-number.js", fn: "runFindNewsletterNumber" },
|
|
"list-existing-tags": { module: "./list-existing-tags.js", fn: "runListExistingTags" },
|
|
"detect-image-source": { module: "./detect-image-source.js", fn: "runDetectImageSource" },
|
|
"find-substack-post": { module: "./find-substack-post.js", fn: "runFindSubstackPost" },
|
|
"fetch-via-defuddle": { module: "./fetch-via-defuddle.js", fn: "runFetchViaDefuddle" },
|
|
"post-stats": { module: "./post-stats.js", fn: "runPostStats" },
|
|
};
|
|
|
|
/** @returns {Promise<void>} */
|
|
async function main() {
|
|
const argv = process.argv.slice(2);
|
|
if (argv.length < 1) {
|
|
usage();
|
|
process.exit(1);
|
|
}
|
|
const [name, ...args] = argv;
|
|
const entry = COMMANDS[name];
|
|
if (entry === undefined) {
|
|
process.stderr.write(`unknown command: ${name}\n`);
|
|
usage();
|
|
process.exit(1);
|
|
}
|
|
const mod = await loadCommand(entry.module);
|
|
await mod[entry.fn](args);
|
|
}
|
|
|
|
try {
|
|
await main();
|
|
} catch (err) {
|
|
process.stderr.write("newsletter engine: " + String(err?.stack ?? err) + "\n");
|
|
process.exit(1);
|
|
}
|