Files
miti99/scripts/newsletter/post-stats.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

95 lines
3.0 KiB
JavaScript

// Count the entries already present in a newsletter post, so a handler can
// report a running tally after each insertion.
// Usage: node scripts/newsletter post-stats <path/to/index.md>
// Outputs: JSON { post, newsletter, articles, images, videos, documents, total }
import { readFileSync } from "node:fs";
import { printJson } from "./json-out.js";
import { NEWSLETTER_NUM_RE } from "./find-newsletter-number.js";
// Entry shapes, per the Bonus format in the shared post mechanics:
//
// articles "## [Title](url)" (level-2 heading, main content)
// images "![label](url)" (under **Images:**)
// videos "[Title](url)" (under **Videos:**)
// documents "[PDF: title](url)" (under **Documents:**)
const ARTICLE_HEADING_RE = /^##\s+\[/;
const BONUS_HEADING_RE = /^###\s+Bonus\b/;
const SUBSECTION_RE = /^\*\*(Images|Videos|Documents):\*\*/;
const IMAGE_ENTRY_RE = /^!\[/;
const LINK_ENTRY_RE = /^\[/;
/**
* countPostEntries walks the post once. Article headings are counted anywhere
* outside Bonus; asset entries are attributed to whichever subsection is open.
* @param {string} content
* @returns {{articles: number, images: number, videos: number, documents: number, total: number}}
*/
export function countPostEntries(content) {
let articles = 0;
let images = 0;
let videos = 0;
let documents = 0;
let inBonus = false;
let subsection = "";
for (const raw of content.split("\n")) {
const line = raw.trim();
if (BONUS_HEADING_RE.test(line)) {
inBonus = true;
subsection = "";
continue;
}
const sub = SUBSECTION_RE.exec(line);
if (sub !== null) {
subsection = sub[1];
continue;
}
if (ARTICLE_HEADING_RE.test(line)) {
articles++;
continue;
}
if (!inBonus) continue;
if (subsection === "Images") {
if (IMAGE_ENTRY_RE.test(line)) images++;
} else if (subsection === "Videos") {
// A direct video file entry looks the same as a YouTube entry;
// both belong to the Videos tally.
if (LINK_ENTRY_RE.test(line)) videos++;
} else if (subsection === "Documents") {
if (LINK_ENTRY_RE.test(line)) documents++;
}
}
return { articles, images, videos, documents, total: articles + images + videos + documents };
}
/**
* @param {string[]} args
* @returns {Promise<void>}
*/
export async function runPostStats(args) {
if (args.length < 1) {
process.stderr.write("usage: post-stats <path/to/index.md>\n");
process.exit(1);
}
const path = args[0];
let content;
try {
content = readFileSync(path, "utf8");
} catch (err) {
process.stderr.write("read post: " + String(err?.message ?? err) + "\n");
process.exit(1);
}
const counted = countPostEntries(content);
const m = NEWSLETTER_NUM_RE.exec(content);
printJson({
post: path,
newsletter: m === null ? 0 : Number.parseInt(m[1], 10),
articles: counted.articles,
images: counted.images,
videos: counted.videos,
documents: counted.documents,
total: counted.total,
});
}