← back to Allnewsdaily
scripts/short/fetch-stock.mjs
130 lines
#!/usr/bin/env node
// fetch-stock.mjs — download ONE commercially-safe stock image per story for the Short's card
// backgrounds. Source: Openverse (no API key), filtered to license=cc0,pdm (public domain / CC0)
// so it's commercial-use safe with NO attribution required. Best-effort: always exits 0; a story
// with no usable image just falls back to the gradient card in render-short.js.
// Writes data/short/img/{intro,<beatN>,outro}.jpg. TK-11342 / TK-11343 (reduce template sameness).
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const DIR = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(DIR, '../..');
const DATA = path.join(ROOT, 'data', 'short');
const IMG = path.join(DATA, 'img');
const LOGO = path.join(DATA, 'logo');
const OPENVERSE = 'https://api.openverse.org/v1/images/';
const STOP = new Set(('a,an,the,of,to,in,on,for,and,or,but,after,before,as,at,by,with,from,into,over,under,is,are,was,were,' +
'be,been,has,have,had,will,would,could,should,say,says,said,report,reports,reported,new,amid,than,that,this,these,those,' +
'it,its,their,his,her,they,dozens,several,many,more,most,who,what,when,where,why,how,about,against,between,during,without,' +
'first,second,two,three,four,five,near,off,out,up,down,per,via,plan,plans,set,gets,get').split(','));
function queryFor(headline) {
const caps = (headline.match(/\b[A-Z][a-zA-Z]{2,}/g) || []).map((w) => w.toLowerCase()).filter((w) => !STOP.has(w));
const words = headline.toLowerCase().replace(/[^a-z0-9\s]/g, ' ').split(/\s+/).filter((w) => w.length > 3 && !STOP.has(w));
const terms = [...new Set([...caps, ...words])].slice(0, 3);
return terms.join(' ') || 'breaking news';
}
async function tfetch(url, opts = {}, ms = 9000) {
const ac = new AbortController(); const to = setTimeout(() => ac.abort(), ms);
try { return await fetch(url, { ...opts, signal: ac.signal, headers: { 'user-agent': 'allnewsdaily-short/1.0', ...(opts.headers || {}) } }); }
finally { clearTimeout(to); }
}
async function downloadFirstUsable(results, outPath) {
for (const it of results) {
for (const src of [it.url, it.thumbnail]) { // try original, then the Openverse-proxied thumbnail
if (!src) continue;
try {
const r = await tfetch(src, {}, 10000);
if (!r.ok) continue;
const ct = (r.headers.get('content-type') || '').split(';')[0].trim();
if (!/image\//.test(ct) || ct === 'image/svg+xml') continue; // skip svg — not a raster image; IM will
// content-sniff it as vector and try to render any embedded <text> via its font engine, which can
// crash the whole render on an unresolvable font-family (see TK-11458).
const buf = Buffer.from(await r.arrayBuffer());
if (buf.length < 6000) continue; // skip tiny/broken
fs.writeFileSync(outPath, buf);
return { ok: true, bytes: buf.length };
} catch { /* try next src */ }
}
}
return { ok: false };
}
// Rotating generic news imagery so a CC0-sparse headline still gets a (thematic) background,
// and consecutive fallbacks differ instead of repeating the same photo.
const GENERIC = ['news studio broadcast', 'city skyline', 'government capitol building', 'world globe earth',
'newspaper headlines', 'crowd people street', 'flags government', 'stock market chart', 'satellite earth night'];
function domainOf(link) { try { return new URL(link).hostname.replace(/^www\./, ''); } catch { return null; } }
const EXT = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/x-icon': 'ico', 'image/vnd.microsoft.icon': 'ico', 'image/svg+xml': 'svg', 'image/webp': 'webp', 'image/gif': 'gif' };
// Each network's real logo: Clearbit (best) → DuckDuckGo icon proxy → Google favicons (always returns something).
async function grabLogo(domain, outBase) {
if (!domain) return { ok: false, note: 'no domain' };
const sources = [
`https://logo.clearbit.com/${domain}?size=256`,
`https://icons.duckduckgo.com/ip3/${domain}.ico`,
`https://www.google.com/s2/favicons?sz=256&domain=${domain}`,
];
for (const src of sources) {
try {
const r = await tfetch(src, {}, 8000);
if (!r.ok) continue;
const ct = (r.headers.get('content-type') || '').split(';')[0].trim();
if (!/image\//.test(ct) || ct === 'image/svg+xml') continue; // skip svg (IM raster handling varies)
const buf = Buffer.from(await r.arrayBuffer());
if (buf.length < 400) continue;
const out = `${outBase}.${EXT[ct] || 'png'}`;
fs.writeFileSync(out, buf);
return { ok: true, domain, via: src.split('/')[2], bytes: buf.length };
} catch { /* next source */ }
}
return { ok: false, domain, note: 'no logo source hit' };
}
async function grabWithFallback(primaryQuery, outPath, idx) {
let r = await grab(primaryQuery, outPath); if (r.ok) return { ...r, used: primaryQuery };
const t1 = primaryQuery.split(' ')[0];
if (t1 && t1 !== primaryQuery) { r = await grab(t1, outPath); if (r.ok) return { ...r, used: t1 }; }
const g = GENERIC[idx % GENERIC.length];
r = await grab(g, outPath); if (r.ok) return { ...r, used: g + ' (generic)' };
r = await grab('news', outPath); return r.ok ? { ...r, used: 'news (generic)' } : { ok: false };
}
async function grab(query, outPath) {
try {
const url = `${OPENVERSE}?q=${encodeURIComponent(query)}&license=cc0,pdm&size=large&page_size=8&mature=false`;
const r = await tfetch(url);
if (!r.ok) return { ok: false, note: `openverse HTTP ${r.status}` };
const j = await r.json();
const res = await downloadFirstUsable(j.results || [], outPath);
return res.ok ? { ok: true, query, ...res } : { ok: false, note: 'no usable result', query };
} catch (e) { return { ok: false, note: e.message }; }
}
(async () => {
const storiesDoc = JSON.parse(fs.readFileSync(path.join(DATA, 'stories.json'), 'utf8'));
for (const dir of [IMG, LOGO]) { fs.rmSync(dir, { recursive: true, force: true }); fs.mkdirSync(dir, { recursive: true }); }
const imgJobs = [];
imgJobs.push(grabWithFallback('news studio broadcast', path.join(IMG, 'intro.jpg'), 0).then((r) => ['img:intro', r]));
storiesDoc.stories.forEach((s, i) => imgJobs.push(grabWithFallback(queryFor(s.headline), path.join(IMG, `${s.n}.jpg`), i + 1).then((r) => [`img:beat${s.n}`, r])));
imgJobs.push(grabWithFallback('world globe earth', path.join(IMG, 'outro.jpg'), 8).then((r) => ['img:outro', r]));
// one real network logo per story (Clearbit → DuckDuckGo → Google favicons)
const logoJobs = storiesDoc.stories.map((s) => grabLogo(domainOf(s.link), path.join(LOGO, String(s.n))).then((r) => [`logo:${s.outlet}`, r]));
const results = await Promise.all([...imgJobs, ...logoJobs]);
let img = 0, logo = 0;
for (const [tag, r] of results) {
if (r.ok) { if (tag.startsWith('logo')) logo++; else img++; }
console.log(` ${tag}: ${r.ok ? '✓ ' + (r.used || r.via || r.domain || '') : '✗ ' + (r.note || 'fail')}`);
}
console.log(`[fetch-stock] ${img} stock images (CC0/public-domain) + ${logo}/${storiesDoc.stories.length} network logos.`);
process.exit(0); // best-effort — never block the pipeline
})().catch((e) => { console.error('[fetch-stock] non-fatal:', e.message); process.exit(0); });