Docs/Skills and code/skill video-script-production
.claude/skills/video-script-production/scripts/fetch-articles.mjs
What the file says about itself
S1b: save pre-existing articles and documents about the topic as plain text, so the research is in the repo. node fetch-articles.js --dir research/<slug>/sources --urls urls.txt [--pause 1500] node fetch-articles.js --dir…
#!/usr/bin/env node
// S1b: save pre-existing articles and documents about the topic as plain text, so the research is in the repo.
//
// node fetch-articles.js --dir research/<slug>/sources --urls urls.txt [--pause 1500]
// node fetch-articles.js --dir research/<slug>/sources --url https://... --url https://...
//
// urls.txt: one URL per line; text after a space is a note ("why this source"); lines starting with # are skipped.
// Writes <dir>/NN-<host>.txt (header: URL, fetched date, HTTP status, length; then the text) and <dir>/INDEX.md.
// PDFs are saved as-is (<dir>/NN-<host>.pdf) with a stub .txt; read them with the Read tool (pages option).
// Pages that block scripted fetches (403, a short body, a consent wall) are listed in INDEX.md as "blocked": open them
// with the browser tools or WebFetch and mark the fact [V-search] until you have read the page.
// It saves what the page says. It does not judge it: a saved article is a lead until FACTS.md cites it with a status.
import fs from 'node:fs';
import path from 'node:path';
const args = process.argv.slice(2);
const opt = (k, d) => { const i = args.indexOf('--' + k); return i >= 0 ? args[i + 1] : d; };
const all = (k) => args.flatMap((a, i) => (a === '--' + k ? [args[i + 1]] : []));
const dir = opt('dir');
if (!dir) { console.error('usage: fetch-articles.js --dir <folder> (--urls file | --url U ...) [--pause ms]'); process.exit(2); }
const pause = Number(opt('pause', 1500));
let entries = all('url').map((u) => ({ url: u, note: '' }));
const f = opt('urls');
if (f) {
for (const line of fs.readFileSync(f, 'utf8').split(/\r?\n/)) {
const s = line.trim();
if (!s || s.startsWith('#')) continue;
const sp = s.indexOf(' ');
entries.push(sp < 0 ? { url: s, note: '' } : { url: s.slice(0, sp), note: s.slice(sp + 1).trim() });
}
}
fs.mkdirSync(dir, { recursive: true });
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36';
const decode = (s) => s
.replace(/ /g, ' ').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
.replace(/'|’|‘/g, "'").replace(/”|“/g, '"').replace(/₹/g, 'Rs ')
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n)));
function htmlToText(html) {
let h = html.replace(/<(script|style|noscript|svg|nav|footer|header|form|iframe)[\s\S]*?<\/\1>/gi, ' ');
const title = (h.match(/<title[^>]*>([\s\S]*?)<\/title>/i) || [])[1] || '';
const art = h.match(/<article[\s\S]*?<\/article>/i);
if (art && art[0].length > 1500) h = art[0];
h = h.replace(/<\/(p|div|h[1-6]|li|tr|blockquote|section)>/gi, '\n').replace(/<br\s*\/?>/gi, '\n').replace(/<[^>]+>/g, ' ');
const text = decode(h).split(/\n/).map((l) => l.replace(/\s+/g, ' ').trim()).filter((l) => l.length > 0).join('\n');
return { title: decode(title).replace(/\s+/g, ' ').trim(), text };
}
const index = [];
let n = 0;
for (const e of entries) {
n += 1;
const host = (() => { try { return new URL(e.url).hostname.replace(/^www\./, ''); } catch { return 'bad-url'; } })();
const base = `${String(n).padStart(2, '0')}-${host.replace(/[^a-z0-9.-]/gi, '_')}`;
const row = { n, url: e.url, note: e.note, file: '', status: '', chars: 0, title: '' };
try {
const r = await fetch(e.url, { headers: { 'user-agent': UA, accept: 'text/html,application/pdf,*/*', 'accept-language': 'en-IN,en;q=0.9' }, redirect: 'follow' });
row.status = String(r.status);
const type = r.headers.get('content-type') || '';
if (!r.ok) { row.file = 'blocked'; }
else if (/pdf/i.test(type) || /\.pdf($|\?)/i.test(e.url)) {
fs.writeFileSync(path.join(dir, base + '.pdf'), Buffer.from(await r.arrayBuffer()));
row.file = base + '.pdf'; row.title = 'PDF (read it with the Read tool)';
} else {
const { title, text } = htmlToText(await r.text());
row.title = title; row.chars = text.length;
if (text.length < 1200) row.file = 'blocked'; // a consent wall, a paywall or a script-only page
else {
row.file = base + '.txt';
fs.writeFileSync(path.join(dir, row.file), `URL: ${e.url}\nFetched: ${new Date().toISOString().slice(0, 10)}\nHTTP: ${r.status}\nTitle: ${title}\nNote: ${e.note}\nChars: ${text.length}\n---\n${text}\n`);
}
}
} catch (err) { row.status = 'error'; row.file = 'blocked'; row.title = String(err.message || err).slice(0, 80); }
console.log(`${String(n).padStart(2)} ${row.file === 'blocked' ? 'BLOCKED' : 'saved '} ${row.status} ${row.chars} ${e.url.slice(0, 90)}`);
index.push(row);
await new Promise((res) => setTimeout(res, pause));
}
const md = ['# Saved sources', '', `Fetched ${new Date().toISOString().slice(0, 10)} by fetch-articles.js. "blocked" = not saved; open it another way and keep the fact at [V-search] until you have read it.`, '',
'| # | file | HTTP | chars | title | note | URL |', '|---|---|---|---|---|---|---|',
...index.map((r) => `| ${r.n} | ${r.file} | ${r.status} | ${r.chars} | ${r.title.replace(/\|/g, '/').slice(0, 70)} | ${r.note.replace(/\|/g, '/')} | ${r.url} |`), ''];
const prev = path.join(dir, 'INDEX.md');
fs.writeFileSync(prev, md.join('\n'));
console.log(`saved ${index.filter((r) => r.file !== 'blocked').length} of ${index.length}; index: ${prev}`);
Shown as it is in the repository at build time (2026-11-03); the home directory appears as <home>. If this page and the file differ, the file is right. Back to the catalogue.