mirror of
https://github.com/tiennm99/miti99.git
synced 2026-08-03 22:24:18 +00:00
Extract newsletter scripts to a neutral scripts/newsletter/ engine and add side-by-side support for all three AI coding tools off one source of truth. - Move 9 scripts + substack config from .claude/skills/**/scripts to scripts/newsletter/ (history preserved); fix PROJECT_ROOT depth and cross-require/config paths; repoint all SKILL.md invocations - Add canonical AGENTS.md; reduce CLAUDE.md to an @AGENTS.md import - Add opencode.json (permissions, no MCP); skills auto-discovered in place - Add codex/prompts/*.md (6 prompts) + copy installers (install.sh/.ps1) - Add docs/multi-tool-usage.md (setup, per-tool invocation, teardown) + README pointer
92 lines
3.5 KiB
JavaScript
92 lines
3.5 KiB
JavaScript
// HTML / RSS text-extraction helpers for find-substack-post.js.
|
|
// Pure string functions (no network, no fs) — kept separate so the crawler
|
|
// stays focused on fetch/search flow. CommonJS so plain `node` works.
|
|
|
|
// Decode the HTML entities that appear in Substack titles/captions, including
|
|
// numeric (') and hex (—) forms. & is decoded last-ish but
|
|
// before nothing re-encodes it; ordering here is safe for our inputs.
|
|
function decodeEntities(s) {
|
|
return s
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, '"')
|
|
.replace(/�?39;|'/g, "'")
|
|
.replace(/ /g, " ")
|
|
.replace(/&#x([0-9a-fA-F]+);/g, (_, n) => String.fromCodePoint(parseInt(n, 16)))
|
|
.replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(+n))
|
|
.trim();
|
|
}
|
|
|
|
function stripTags(html) {
|
|
return decodeEntities(html.replace(/<[^>]+>/g, "")).replace(/\s+/g, " ").trim();
|
|
}
|
|
|
|
// Pull the first <title> (CDATA or plain) and <link> from an RSS <item> chunk.
|
|
function itemTitle(item) {
|
|
const m = item.match(/<title>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?<\/title>/);
|
|
return m ? decodeEntities(m[1].trim()) : "";
|
|
}
|
|
function itemLink(item) {
|
|
const m = item.match(/<link>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?<\/link>/);
|
|
return m ? m[1].trim() : "";
|
|
}
|
|
|
|
// Extract 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. We can't reliably map image→title automatically (sponsor /
|
|
// video items interleave and break positional order), so we surface these
|
|
// candidates for the user to pick from. Light filtering keeps the list short:
|
|
// dedupe, drop sub-point explanations ("Term: long sentence") and over-long
|
|
// lines. The correct title is always present; the user selects it.
|
|
function extractCandidates(html) {
|
|
const seen = new Set();
|
|
const out = [];
|
|
const re = /<li[^>]*>\s*(?:<p[^>]*>)?([\s\S]*?)(?:<\/p>)?\s*<\/li>/g;
|
|
let m;
|
|
while ((m = re.exec(html))) {
|
|
const text = stripTags(m[1]);
|
|
if (!text) continue;
|
|
if (text.length < 6 || text.length > 70) continue; // titles are short; long lines are sub-point explanations
|
|
const key = text.toLowerCase();
|
|
if (seen.has(key)) continue; // content is duplicated in the page
|
|
seen.add(key);
|
|
out.push(text);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// If the UUID sits inside a <figure>…</figure>, return that figure's
|
|
// <figcaption> text. Cover images live in <enclosure> (no figure) → "".
|
|
function captionForUuid(item, id) {
|
|
const at = item.indexOf(id);
|
|
if (at === -1) return "";
|
|
const figStart = item.lastIndexOf("<figure", at);
|
|
if (figStart === -1) return "";
|
|
const figEnd = item.indexOf("</figure>", at);
|
|
if (figEnd === -1) return "";
|
|
const figure = item.slice(figStart, figEnd);
|
|
const cap = figure.match(/<figcaption[^>]*>([\s\S]*?)<\/figcaption>/);
|
|
return cap ? stripTags(cap[1]) : "";
|
|
}
|
|
|
|
// Extract a post title from server-rendered post HTML (og:title preferred).
|
|
function postTitleFromHtml(html) {
|
|
const og = html.match(/<meta[^>]+property=["']og:title["'][^>]+content=["']([^"']+)["']/i);
|
|
if (og) return decodeEntities(og[1]);
|
|
const h1 = html.match(/<h1[^>]*>([\s\S]*?)<\/h1>/i);
|
|
if (h1) return stripTags(h1[1]);
|
|
const t = html.match(/<title>([\s\S]*?)<\/title>/i);
|
|
return t ? decodeEntities(t[1].trim()) : "";
|
|
}
|
|
|
|
module.exports = {
|
|
decodeEntities,
|
|
stripTags,
|
|
itemTitle,
|
|
itemLink,
|
|
extractCandidates,
|
|
captionForUuid,
|
|
postTitleFromHtml,
|
|
};
|