mirror of
https://github.com/tiennm99/miti99.git
synced 2026-09-19 06:21:08 +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.
95 lines
3.0 KiB
JavaScript
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 "" (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,
|
|
});
|
|
}
|