Files
miti99/scripts/newsletter/html-text.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

182 lines
5.5 KiB
JavaScript

// HTML / RSS text-extraction helpers for find-substack-post, ported from
// html_text.go. Pure string functions — no network, no fs. A real parser
// (cheerio) replaces the hand-rolled regexes the Go version used.
import * as cheerio from "cheerio";
/**
* loadXml parses an XML fragment (RSS item, sitemap) with CDATA recognition.
* @param {string} src
* @returns {cheerio.CheerioAPI}
*/
export function loadXml(src) {
return cheerio.load(src, { xmlMode: true }, false);
}
/**
* loadHtml parses an HTML blob. CDATA markers are stripped first: RSS carries
* post HTML inside CDATA, and the HTML parser would otherwise swallow it as a
* bogus comment.
* @param {string} src
* @returns {cheerio.CheerioAPI}
*/
export function loadHtml(src) {
return cheerio.load(src.split("<![CDATA[").join("").split("]]>").join(""));
}
/**
* rawInner returns an element's serialized inner content with any CDATA wrapper
* removed — the same substring the Go regexes captured, so entity handling can
* stay identical instead of being applied twice.
* @param {cheerio.CheerioAPI} $
* @param {cheerio.Cheerio<any>} el
* @returns {string}
*/
function rawInner($, el) {
const html = $.html(el);
const start = html.indexOf(">") + 1;
const end = html.lastIndexOf("</");
if (start <= 0 || end < start) return "";
let inner = html.slice(start, end);
if (inner.startsWith("<![CDATA[")) inner = inner.slice(9);
if (inner.endsWith("]]>")) inner = inner.slice(0, -3);
return inner;
}
/**
* decodeEntities decodes HTML entities (named, numeric, hex) and trims.
* @param {string} s
* @returns {string}
*/
export function decodeEntities(s) {
if (s === "") return "";
return cheerio.load(s, null, false).text().trim();
}
/**
* collapseWhitespace squeezes runs of whitespace to one space and trims.
* @param {string} s
* @returns {string}
*/
function collapseWhitespace(s) {
return s.replace(/\s+/g, " ").trim();
}
/**
* stripTags removes markup, decodes entities, and collapses whitespace.
* @param {string} s
* @returns {string}
*/
export function stripTags(s) {
if (s === "") return "";
return collapseWhitespace(cheerio.load(s, null, false).text());
}
/**
* itemTitle pulls the first <title> (CDATA or plain) from an RSS <item> chunk.
* @param {string} item
* @returns {string}
*/
export function itemTitle(item) {
const $ = loadXml(item);
const el = $("title").first();
if (el.length === 0) return "";
return decodeEntities(rawInner($, el));
}
/**
* itemLink pulls the first <link> (CDATA or plain) from an RSS <item> chunk.
* Entities are left as stored — the link goes straight into a post.
* @param {string} item
* @returns {string}
*/
export function itemLink(item) {
const $ = loadXml(item);
const el = $("link").first();
if (el.length === 0) return "";
return rawInner($, el).trim();
}
/**
* extractCandidates pulls candidate topic titles from a post's TOC bullet list.
* ByteByteGo does not attach captions to images — the topic titles live only in
* the "in this issue" bullets, and image→title cannot be mapped automatically
* (sponsor/video items interleave), so these are surfaced for the user to pick
* from. Light filtering keeps the list short: dedupe, drop sub-point
* explanations and over-long lines.
* @param {string} htmlSrc
* @returns {string[]}
*/
export function extractCandidates(htmlSrc) {
const $ = loadHtml(htmlSrc);
/** @type {Set<string>} */
const seen = new Set();
/** @type {string[]} */
const out = [];
$("li").each((_i, el) => {
const text = collapseWhitespace($(el).text());
if (text === "") return;
// Titles are short; long lines are sub-point explanations. Count code
// points, not UTF-16 units, so an emoji does not count double.
const n = [...text].length;
if (n < 6 || n > 70) return;
const key = text.toLowerCase();
if (seen.has(key)) return; // content is duplicated in the page
seen.add(key);
out.push(text);
});
return out;
}
/**
* captionForUuid returns the <figcaption> text of the <figure> containing the
* UUID. Cover images live in <enclosure> (no figure) → "".
* @param {string} src
* @param {string} id
* @returns {string}
*/
export function captionForUuid(src, id) {
if (id === "") return "";
const $ = loadHtml(src);
let target = null;
$("*").each((_i, el) => {
if (target !== null) return false;
for (const v of Object.values(el.attribs ?? {})) {
if (typeof v === "string" && v.includes(id)) {
target = el;
return false;
}
}
for (const child of el.children ?? []) {
if (child.type === "text" && String(child.data).includes(id)) {
target = el;
return false;
}
}
return undefined;
});
if (target === null) return "";
const figure = $(target).closest("figure");
if (figure.length === 0) return "";
const caption = figure.find("figcaption").first();
if (caption.length === 0) return "";
return collapseWhitespace(caption.text());
}
/**
* postTitleFromHtml extracts a post title from server-rendered post HTML
* (og:title preferred, then <h1>, then <title>).
* @param {string} htmlSrc
* @returns {string}
*/
export function postTitleFromHtml(htmlSrc) {
const $ = loadHtml(htmlSrc);
const og = $('meta[property="og:title"]').first().attr("content");
if (og !== undefined && og !== "") return og.trim();
const h1 = $("h1").first();
if (h1.length > 0) return collapseWhitespace(h1.text());
const title = $("title").first();
if (title.length > 0) return title.text().trim();
return "";
}