16cad3fe36
- Add sharp dependency for image format conversion - Generate WebP (quality 80) and AVIF (quality 70) variants for all raster images - Render <picture> elements with srcset fallbacks (AVIF > WebP > original) - SVG images remain as <img> without picture wrapper - Update Dockerfile to install libvips for sharp, copy from public/ dir - Add nginx cache rules for .webp and .avif files - Add .gitignore for node_modules, public, dist
719 lines
46 KiB
JavaScript
719 lines
46 KiB
JavaScript
import fs from 'node:fs/promises';
|
||
import path from 'node:path';
|
||
import Parser from 'rss-parser';
|
||
import OpenAI from 'openai';
|
||
import sharp from 'sharp';
|
||
import { FEEDS } from './feeds.js';
|
||
|
||
const ROOT = path.resolve(process.cwd());
|
||
const OUT_DIR = path.join(ROOT, 'public');
|
||
const ARTICLES_DIR = path.join(OUT_DIR, 'articles');
|
||
const SECTIONS_DIR = path.join(OUT_DIR, 'sections');
|
||
const IMAGES_DIR = path.join(OUT_DIR, 'images');
|
||
const STATE_FILE = path.join(OUT_DIR, 'articles-state.json');
|
||
const IMAGE_META_FILE = path.join(OUT_DIR, 'image-meta.json');
|
||
const MAX_ITEMS_PER_FEED = Number(process.env.MAX_ITEMS_PER_FEED || 8);
|
||
const MAX_HOMEPAGE_ITEMS = Number(process.env.MAX_HOMEPAGE_ITEMS || 36);
|
||
const MODEL = process.env.OPENAI_MODEL || 'gpt-4.1-mini';
|
||
const SITE_NAME = process.env.SITE_NAME || 'Signal Ledger';
|
||
const SITE_DOMAIN = process.env.SITE_DOMAIN || 'signalledger.nl';
|
||
const SITE_URL = `https://${SITE_DOMAIN}`;
|
||
const SITE_TAGLINE = process.env.SITE_TAGLINE || 'A digital news magazine for readers who want clear reporting, context, and judgment.';
|
||
const EDITOR_NOTE = process.env.EDITOR_NOTE || 'Signal Ledger is built as a serious front page for readers who want more than headlines. We synthesize major reporting, add context, explain why events matter, and keep source attribution visible without turning the site into a mere link directory.';
|
||
const CONTACT_EMAIL = process.env.CONTACT_EMAIL || 'signalledger@jopdorp.nl';
|
||
const PARENT_COMPANY = process.env.PARENT_COMPANY || 'Jopdorp';
|
||
const ADSENSE_CLIENT = process.env.ADSENSE_CLIENT || 'ca-pub-1269854634225826';
|
||
const ADSENSE_SLOT = process.env.ADSENSE_SLOT || '7019613848';
|
||
const IMAGE_MODEL = process.env.OPENAI_IMAGE_MODEL || 'gpt-image-1';
|
||
const GENERATED_IMAGE_LIMIT = Number(process.env.GENERATED_IMAGE_LIMIT || 3);
|
||
const parser = new Parser({ timeout: 15000 });
|
||
|
||
function escapeHtml(input = '') {
|
||
return String(input)
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
|
||
function stripHtml(input = '') {
|
||
return String(input)
|
||
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
|
||
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
||
.replace(/<[^>]+>/g, ' ')
|
||
.replace(/Article URL:\s*https?:\/\/\S+/gi, ' ')
|
||
.replace(/Comments URL:\s*https?:\/\/\S+/gi, ' ')
|
||
.replace(/Points:\s*\d+/gi, ' ')
|
||
.replace(/#\s*Comments:\s*\d+/gi, ' ')
|
||
.replace(/\s+/g, ' ')
|
||
.trim();
|
||
}
|
||
|
||
function slugify(input = '') {
|
||
return String(input)
|
||
.toLowerCase()
|
||
.replace(/&/g, ' and ')
|
||
.replace(/[^a-z0-9]+/g, '-')
|
||
.replace(/^-+|-+$/g, '')
|
||
.slice(0, 90) || 'story';
|
||
}
|
||
|
||
function formatDate(date) {
|
||
return new Date(date).toLocaleString('en-US', {
|
||
dateStyle: 'medium',
|
||
timeStyle: 'short',
|
||
timeZone: 'UTC'
|
||
}) + ' UTC';
|
||
}
|
||
|
||
function xmlEscape(input = '') {
|
||
return String(input)
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
|
||
function hashString(input = '') {
|
||
let hash = 0;
|
||
for (let i = 0; i < input.length; i++) {
|
||
hash = ((hash << 5) - hash) + input.charCodeAt(i);
|
||
hash |= 0;
|
||
}
|
||
return Math.abs(hash);
|
||
}
|
||
|
||
function trimSentence(input = '', maxLength = 280) {
|
||
const cleaned = stripHtml(input)
|
||
.replace(/\s*[-–|]\s*(live|updates?|analysis|explained)\b.*$/i, '')
|
||
.replace(/\b(read more|click here|watch live)\b.*$/i, '')
|
||
.trim();
|
||
if (!cleaned) return '';
|
||
if (cleaned.length <= maxLength) return cleaned;
|
||
const slice = cleaned.slice(0, maxLength);
|
||
const stop = Math.max(slice.lastIndexOf('. '), slice.lastIndexOf('; '), slice.lastIndexOf(', '));
|
||
return `${(stop > 90 ? slice.slice(0, stop) : slice).trim().replace(/[,:;\-]+$/, '')}.`;
|
||
}
|
||
|
||
function cleanTitle(title = '') {
|
||
return stripHtml(title)
|
||
.replace(/\s+-\s+(Reuters|AP News|BBC News|BBC)$/i, '')
|
||
.replace(/\s+AP News$/i, '')
|
||
.replace(/\s+Reuters$/i, '')
|
||
.trim();
|
||
}
|
||
|
||
function titleToLowerPhrase(title = '') {
|
||
const normalized = cleanTitle(title).trim();
|
||
if (!normalized) return 'the latest development';
|
||
if (/[A-Z]{2,}/.test(normalized) || /[:?]/.test(normalized)) return normalized;
|
||
return normalized.charAt(0).toLowerCase() + normalized.slice(1);
|
||
}
|
||
|
||
function titleToSentence(title = '') {
|
||
const normalized = cleanTitle(title).trim();
|
||
if (!normalized) return 'This development';
|
||
return normalized.charAt(0).toUpperCase() + normalized.slice(1);
|
||
}
|
||
|
||
function firstClause(input = '', maxLength = 180) {
|
||
const cleaned = trimSentence(input, maxLength);
|
||
if (!cleaned) return '';
|
||
const stop = cleaned.search(/[.;:]/);
|
||
return (stop > 40 ? cleaned.slice(0, stop) : cleaned).trim().replace(/[,:;\-]+$/, '');
|
||
}
|
||
|
||
function buildFallbackLabels(item) {
|
||
if (item.category === 'Technology') {
|
||
return {
|
||
summaryLabel: 'What shipped',
|
||
stakesLabel: 'Why it matters in tech',
|
||
contextLabel: 'What it signals',
|
||
viewLabel: 'A Signal Ledger view'
|
||
};
|
||
}
|
||
if (item.category === 'Top Stories') {
|
||
return {
|
||
summaryLabel: 'The lead',
|
||
stakesLabel: 'Why this leads',
|
||
contextLabel: 'What sits behind it',
|
||
viewLabel: 'The editorial line'
|
||
};
|
||
}
|
||
return {
|
||
summaryLabel: 'What happened',
|
||
stakesLabel: 'Why it matters',
|
||
contextLabel: 'The wider picture',
|
||
viewLabel: 'The editorial line'
|
||
};
|
||
}
|
||
|
||
function buildFallbackCopy(item) {
|
||
const phrase = titleToLowerPhrase(item.title);
|
||
const sentenceTitle = titleToSentence(item.title);
|
||
const snippet = trimSentence(item.snippet, 360);
|
||
const labels = buildFallbackLabels(item);
|
||
const gist = snippet
|
||
? (snippet.split(/[.;:]/).find((part) => part.trim().length > 30) || snippet).trim()
|
||
: '';
|
||
|
||
let summary;
|
||
let whyItMatters;
|
||
let context;
|
||
let viewpoint;
|
||
|
||
if (item.category === 'Technology') {
|
||
summary = snippet
|
||
? `${phrase} matters less as a novelty item than as a signal about tools, leverage, and how technical work may be shifting. ${snippet}`
|
||
: `${phrase} looks worth tracking because technology stories matter most when they hint at a broader change in tooling, workflow, or platform power.`;
|
||
whyItMatters = `The useful test in technology coverage is whether something changes the economics of building, shipping, or operating software. This item is worth watching for what it may signal about adoption curves, competitive pressure, and the next default way of working.`;
|
||
context = gist
|
||
? `The headline is only the entry point. The more revealing layer is the operating picture underneath it: ${gist}.`
|
||
: `Technology stories become meaningful when they move from demo to default behavior. The important question here is whether this points to that kind of transition.`;
|
||
viewpoint = `Signal Ledger's technology coverage is interested less in spectacle than in durable shifts in leverage, control points, cost structures, and the habits that become normal once a tool crosses into routine use.`;
|
||
} else if (item.category === 'Top Stories') {
|
||
summary = snippet
|
||
? `${phrase} belongs near the top of the front page because it shapes the political, economic, or diplomatic weather around everything else. ${snippet}`
|
||
: `${phrase} looks like a front-page story because it carries the kind of agenda-setting force that tends to reorder the rest of the news cycle.`;
|
||
whyItMatters = `Top stories deserve the slot when they change how institutions, markets, and the public interpret what comes next. The point is not just that something happened, but that it now carries framing power over the wider day.`;
|
||
context = gist
|
||
? `Seen in context, the event matters because of the pressure it concentrates: ${gist}.`
|
||
: `The bigger question is what this development now makes easier, harder, or more urgent for the people forced to respond to it.`;
|
||
viewpoint = `Signal Ledger treats front-page stories as signals of momentum. The first fact matters, but the more valuable read is what the event may unlock, constrain, or force into the open next.`;
|
||
} else {
|
||
summary = snippet
|
||
? `${sentenceTitle} stands out less as an isolated incident than as a marker of pressure building in the wider international picture. ${snippet}`
|
||
: `${sentenceTitle} looks consequential because world news matters most when a local event starts carrying diplomatic, economic, or security implications beyond its immediate setting.`;
|
||
whyItMatters = `World coverage becomes useful when a single development begins to alter diplomatic room, economic confidence, or the risk calculations of states and institutions far beyond where the event first lands.`;
|
||
context = gist
|
||
? `Read beyond the headline, the more revealing element is the direction of travel: ${gist}.`
|
||
: `The useful frame is not only what happened, but which larger regional or international pattern the event may now be feeding.`;
|
||
viewpoint = `Signal Ledger's world coverage is interested in second-order effects: shifts in leverage, credibility, deterrence, public mood, and the room leaders still have to change course.`;
|
||
}
|
||
|
||
return {
|
||
dek: `Source: ${item.source}`,
|
||
summary,
|
||
whyItMatters,
|
||
context,
|
||
viewpoint,
|
||
sourceNote: `${item.source} reporting: ${item.link}`,
|
||
...labels
|
||
};
|
||
}
|
||
|
||
function normalizeItem(feed, item) {
|
||
return {
|
||
source: feed.source,
|
||
category: feed.category,
|
||
feedId: feed.id,
|
||
title: cleanTitle(item.title || 'Untitled'),
|
||
link: item.link || item.guid || '#',
|
||
published: item.isoDate || item.pubDate || new Date().toISOString(),
|
||
snippet: stripHtml(item.contentSnippet || item.summary || item.content || item['content:encoded'] || '').slice(0, 1600)
|
||
};
|
||
}
|
||
|
||
async function fetchFeed(feed) {
|
||
const parsed = await parser.parseURL(feed.url);
|
||
return (parsed.items || []).slice(0, MAX_ITEMS_PER_FEED).map((item) => normalizeItem(feed, item));
|
||
}
|
||
|
||
async function readJsonFile(filePath, fallback) {
|
||
try {
|
||
return JSON.parse(await fs.readFile(filePath, 'utf8'));
|
||
} catch {
|
||
return fallback;
|
||
}
|
||
}
|
||
|
||
function dedupe(items) {
|
||
const out = [];
|
||
const seen = new Set();
|
||
for (const item of items) {
|
||
if (!seen.has(item.link)) {
|
||
seen.add(item.link);
|
||
out.push(item);
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
async function enrichItems(items) {
|
||
const apiKey = process.env.OPENAI_API_KEY;
|
||
if (!apiKey) {
|
||
return items.map((item, idx) => ({
|
||
...item,
|
||
id: idx + 1,
|
||
...buildFallbackCopy(item)
|
||
}));
|
||
}
|
||
|
||
const client = new OpenAI({ apiKey });
|
||
const payload = items.map((item, idx) => ({
|
||
id: idx + 1,
|
||
source: item.source,
|
||
category: item.category,
|
||
title: item.title,
|
||
link: item.link,
|
||
published: item.published,
|
||
snippet: item.snippet
|
||
}));
|
||
|
||
const prompt = `You are writing concise, polished digital-journalism briefs for a serious magazine called Signal Ledger. Return strict JSON array only. Each object must contain: id, dek, summary, whyItMatters, context, viewpoint, sourceNote, summaryLabel, stakesLabel, contextLabel, viewLabel. Rules: write as our publication, not as a book report on another outlet. Do not start every story with phrases like "BBC reported", "Reuters reported", or "X is reporting on". Avoid formulaic constructions, repeated sentence skeletons, and filler. Use strong publication ledes, vary rhythm from story to story, and weave attribution in naturally when needed. Treat categories differently: Top Stories should feel front-page and agenda-setting, World should feel geopolitical/international, Technology should feel sharp about leverage, product direction, infrastructure, and adoption. dek should usually be short like "Source: BBC" or another compact deck. summary must be one polished paragraph, 50-110 words, never empty. The four label fields must be short, elegant, and article-specific, not generic repeated boilerplate. context and viewpoint must add interpretive value rather than restating the snippet. Do not invent facts beyond the supplied data. Items: ${JSON.stringify(payload)}`;
|
||
|
||
let parsed = [];
|
||
try {
|
||
const resp = await client.responses.create({
|
||
model: MODEL,
|
||
input: prompt,
|
||
text: { format: { type: 'text' } }
|
||
});
|
||
|
||
const text = resp.output_text || '[]';
|
||
try {
|
||
parsed = JSON.parse(text);
|
||
} catch {
|
||
const match = text.match(/\[[\s\S]*\]/);
|
||
parsed = match ? JSON.parse(match[0]) : [];
|
||
}
|
||
} catch (error) {
|
||
console.error('Falling back to deterministic copy after OpenAI enrichment failure:', error?.message || error);
|
||
}
|
||
|
||
const byId = new Map(parsed.map((entry) => [entry.id, entry]));
|
||
return items.map((item, idx) => {
|
||
const data = byId.get(idx + 1) || {};
|
||
const fallback = buildFallbackCopy(item);
|
||
return {
|
||
...item,
|
||
id: idx + 1,
|
||
dek: (data.dek && !/^source:/i.test(data.dek.trim())) ? data.dek : '',
|
||
summary: trimSentence(data.summary || '', 900) || fallback.summary,
|
||
whyItMatters: trimSentence(data.whyItMatters || '', 700) || fallback.whyItMatters,
|
||
context: trimSentence(data.context || '', 700) || fallback.context,
|
||
viewpoint: trimSentence(data.viewpoint || '', 700) || fallback.viewpoint,
|
||
sourceNote: trimSentence(data.sourceNote || '', 260) || fallback.sourceNote,
|
||
summaryLabel: trimSentence(data.summaryLabel || '', 60) || fallback.summaryLabel,
|
||
stakesLabel: trimSentence(data.stakesLabel || '', 60) || fallback.stakesLabel,
|
||
contextLabel: trimSentence(data.contextLabel || '', 60) || fallback.contextLabel,
|
||
viewLabel: trimSentence(data.viewLabel || '', 60) || fallback.viewLabel
|
||
};
|
||
});
|
||
}
|
||
|
||
function decorateItems(items, existingSlugs = []) {
|
||
const used = new Set(existingSlugs);
|
||
return items.map((item) => {
|
||
const base = slugify(item.title);
|
||
const date = new Date(item.published).toISOString().slice(0, 10);
|
||
let slug = `${base}-${date}`;
|
||
let i = 2;
|
||
while (used.has(slug)) slug = `${base}-${date}-${i++}`;
|
||
used.add(slug);
|
||
return {
|
||
...item,
|
||
slug,
|
||
articlePath: `/articles/${slug}/`,
|
||
sectionSlug: slugify(item.category),
|
||
imagePath: `/images/${slug}.png`,
|
||
imageAlt: `${item.title} — editorial image`
|
||
};
|
||
});
|
||
}
|
||
|
||
function pickPalette(item) {
|
||
const palettes = {
|
||
World: ['#14213d', '#334e68', '#7dd3fc'],
|
||
Technology: ['#111827', '#2563eb', '#60a5fa'],
|
||
'Top Stories': ['#3f1d2e', '#8b1e1e', '#f59e0b']
|
||
};
|
||
return palettes[item.category] || ['#1f2937', '#4b5563', '#94a3b8'];
|
||
}
|
||
|
||
function createIllustrationSvg(item) {
|
||
const [bg, accent, accent2] = pickPalette(item);
|
||
const h = hashString(`${item.slug}-${item.source}`);
|
||
const c1x = 120 + (h % 500);
|
||
const c1y = 140 + (h % 220);
|
||
const c2x = 700 - (h % 240);
|
||
const c2y = 90 + (h % 180);
|
||
const category = escapeHtml(item.category.toUpperCase());
|
||
const title = escapeHtml(item.title);
|
||
return `<!doctype svg><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 630" role="img" aria-label="${title}"><rect width="1200" height="630" fill="${bg}"/><circle cx="${c1x}" cy="${c1y}" r="230" fill="${accent}" opacity="0.22"/><circle cx="${c2x}" cy="${c2y}" r="180" fill="${accent2}" opacity="0.18"/><rect x="72" y="78" width="250" height="34" rx="17" fill="${accent}"/><text x="94" y="101" font-family="Inter, Arial, sans-serif" font-size="18" font-weight="700" fill="#ffffff">${category}</text><rect x="72" y="142" width="1056" height="360" rx="28" fill="rgba(255,255,255,0.08)"/></svg>`;
|
||
}
|
||
|
||
function groupByCategory(items) {
|
||
const groups = new Map();
|
||
for (const item of items) {
|
||
if (!groups.has(item.category)) groups.set(item.category, []);
|
||
groups.get(item.category).push(item);
|
||
}
|
||
return [...groups.entries()].map(([category, entries]) => ({
|
||
category,
|
||
slug: slugify(category),
|
||
items: entries.sort((a, b) => new Date(b.published) - new Date(a.published))
|
||
})).sort((a, b) => a.category.localeCompare(b.category));
|
||
}
|
||
|
||
function buildImageSearchTerms(item) {
|
||
const sourceHints = item.source === 'Hacker News' ? 'technology' : item.category;
|
||
return [
|
||
`${item.title} ${sourceHints} site:commons.wikimedia.org`,
|
||
`${item.title} ${sourceHints} Wikimedia Commons`,
|
||
`${item.category} ${item.title}`
|
||
];
|
||
}
|
||
|
||
async function findCommonsImage(item) {
|
||
const titleWords = item.title.split(/\s+/).filter(Boolean).slice(0, 8).join(' ');
|
||
const categoryHint = item.category === 'Top Stories' ? 'news' : item.category;
|
||
const url = `https://commons.wikimedia.org/w/api.php?action=query&generator=search&gsrsearch=${encodeURIComponent(`${titleWords} ${categoryHint}`)}&gsrnamespace=6&prop=imageinfo&iiprop=url|extmetadata&iiurlwidth=1400&format=json&origin=*`;
|
||
const res = await fetch(url, { headers: { 'User-Agent': 'SignalLedgerBot/1.0' } });
|
||
if (!res.ok) return null;
|
||
const data = await res.json();
|
||
const pages = Object.values(data?.query?.pages || {});
|
||
const valid = pages.find((page) => {
|
||
const meta = page?.imageinfo?.[0]?.extmetadata || {};
|
||
const license = (meta.LicenseShortName?.value || meta.UsageTerms?.value || '').toLowerCase();
|
||
return page?.imageinfo?.[0]?.thumburl && (license.includes('cc') || license.includes('public domain') || license.includes('pd') || license.includes('creative commons'));
|
||
});
|
||
if (!valid) return null;
|
||
const info = valid.imageinfo[0];
|
||
const meta = info.extmetadata || {};
|
||
return {
|
||
kind: 'commons',
|
||
sourceUrl: info.descriptionurl || info.url,
|
||
downloadUrl: info.thumburl || info.url,
|
||
credit: stripHtml(meta.Artist?.value || meta.Credit?.value || 'Wikimedia Commons'),
|
||
license: stripHtml(meta.LicenseShortName?.value || meta.UsageTerms?.value || 'Licensed via Wikimedia Commons')
|
||
};
|
||
}
|
||
|
||
async function generateImage(client, item) {
|
||
const prompt = `Create a restrained editorial illustration for a digital news magazine article. Subject: ${item.title}. Category: ${item.category}. Visual tone: serious, journalistic, clean, modern, magazine-quality, not cartoonish, no text, no logos, no watermarks. Emphasize clarity and atmosphere over literal portraiture.`;
|
||
const result = await client.images.generate({
|
||
model: IMAGE_MODEL,
|
||
prompt,
|
||
size: '1536x1024'
|
||
});
|
||
const base64 = result?.data?.[0]?.b64_json;
|
||
if (!base64) throw new Error('No image returned');
|
||
return Buffer.from(base64, 'base64');
|
||
}
|
||
|
||
async function optimizeImage(inputPath, imageMeta, slug) {
|
||
const ext = path.extname(inputPath).toLowerCase();
|
||
if (ext === '.svg') return; // Skip SVG
|
||
|
||
const base = inputPath.replace(/\.[^.]+$/, '');
|
||
const webpPath = `${base}.webp`;
|
||
const avifPath = `${base}.avif`;
|
||
|
||
try {
|
||
const inputBuffer = await fs.readFile(inputPath);
|
||
const image = sharp(inputBuffer);
|
||
|
||
// Generate WebP if not exists or source is newer
|
||
try {
|
||
await fs.access(webpPath);
|
||
} catch {
|
||
await image.clone().webp({ quality: 80, effort: 4 }).toFile(webpPath);
|
||
console.log(` Generated WebP: ${path.basename(webpPath)}`);
|
||
}
|
||
|
||
// Generate AVIF if not exists or source is newer
|
||
try {
|
||
await fs.access(avifPath);
|
||
} catch {
|
||
await image.clone().avif({ quality: 70, effort: 4 }).toFile(avifPath);
|
||
console.log(` Generated AVIF: ${path.basename(avifPath)}`);
|
||
}
|
||
|
||
if (imageMeta[slug]) {
|
||
imageMeta[slug].hasWebp = true;
|
||
imageMeta[slug].hasAvif = true;
|
||
}
|
||
} catch (error) {
|
||
console.error(`Image optimization failed for ${slug}:`, error.message);
|
||
}
|
||
}
|
||
|
||
async function ensureArticleImage(item, imageMeta, client, { tryRemote = true, allowGeneration = true } = {}) {
|
||
const existing = imageMeta[item.slug];
|
||
const ext = existing?.ext || '.png';
|
||
const outputPath = path.join(IMAGES_DIR, `${item.slug}${ext}`);
|
||
if (existing) {
|
||
item.imagePath = `/images/${item.slug}${ext}`;
|
||
item.imageCredit = existing.credit || '';
|
||
item.imageLicense = existing.license || '';
|
||
item.imageKind = existing.kind || 'generated';
|
||
item.hasWebp = existing.hasWebp || false;
|
||
item.hasAvif = existing.hasAvif || false;
|
||
// Optimize existing raster images
|
||
await optimizeImage(outputPath, imageMeta, item.slug);
|
||
return;
|
||
}
|
||
|
||
if (tryRemote) {
|
||
try {
|
||
const commons = await findCommonsImage(item);
|
||
if (commons) {
|
||
const res = await fetch(commons.downloadUrl, { headers: { 'User-Agent': 'SignalLedgerBot/1.0' } });
|
||
if (res.ok) {
|
||
const buffer = Buffer.from(await res.arrayBuffer());
|
||
const ct = res.headers.get('content-type') || 'image/jpeg';
|
||
const extFound = ct.includes('png') ? '.png' : ct.includes('webp') ? '.webp' : '.jpg';
|
||
const target = path.join(IMAGES_DIR, `${item.slug}${extFound}`);
|
||
await fs.writeFile(target, buffer);
|
||
imageMeta[item.slug] = {
|
||
kind: 'commons',
|
||
ext: extFound,
|
||
credit: commons.credit,
|
||
license: commons.license,
|
||
sourceUrl: commons.sourceUrl,
|
||
searchTerms: buildImageSearchTerms(item)
|
||
};
|
||
item.imagePath = `/images/${item.slug}${extFound}`;
|
||
item.imageCredit = commons.credit;
|
||
item.imageLicense = commons.license;
|
||
item.imageKind = 'commons';
|
||
await optimizeImage(target, imageMeta, item.slug);
|
||
item.hasWebp = imageMeta[item.slug].hasWebp || false;
|
||
item.hasAvif = imageMeta[item.slug].hasAvif || false;
|
||
return;
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('Image lookup failed for', item.slug, error);
|
||
}
|
||
}
|
||
|
||
if (allowGeneration && client) {
|
||
try {
|
||
const png = await generateImage(client, item);
|
||
await fs.writeFile(outputPath, png);
|
||
imageMeta[item.slug] = {
|
||
kind: 'generated',
|
||
ext: '.png',
|
||
credit: 'Signal Ledger illustration',
|
||
license: 'Generated for Signal Ledger',
|
||
promptBasis: item.title
|
||
};
|
||
item.imagePath = `/images/${item.slug}.png`;
|
||
item.imageCredit = 'Signal Ledger illustration';
|
||
item.imageLicense = 'Generated';
|
||
item.imageKind = 'generated';
|
||
await optimizeImage(outputPath, imageMeta, item.slug);
|
||
item.hasWebp = imageMeta[item.slug].hasWebp || false;
|
||
item.hasAvif = imageMeta[item.slug].hasAvif || false;
|
||
return;
|
||
} catch (error) {
|
||
console.error('Falling back to SVG after image generation failure for', item.slug, error?.message || error);
|
||
}
|
||
}
|
||
|
||
const svgPath = path.join(IMAGES_DIR, `${item.slug}.svg`);
|
||
await fs.writeFile(svgPath, createIllustrationSvg(item));
|
||
imageMeta[item.slug] = {
|
||
kind: 'fallback-svg',
|
||
ext: '.svg',
|
||
credit: 'Signal Ledger placeholder illustration',
|
||
license: 'Generated in-site vector artwork'
|
||
};
|
||
item.imagePath = `/images/${item.slug}.svg`;
|
||
item.imageCredit = 'Signal Ledger placeholder illustration';
|
||
item.imageLicense = 'Generated';
|
||
item.imageKind = 'fallback-svg';
|
||
item.hasWebp = false;
|
||
item.hasAvif = false;
|
||
}
|
||
|
||
function renderShell({ title, description, pathName = '/', bodyClass = '', content, generatedAt }) {
|
||
const canonical = `${SITE_URL}${pathName}`;
|
||
const adsenseHead = ADSENSE_CLIENT ? `<meta name="google-adsense-account" content="${escapeHtml(ADSENSE_CLIENT)}">\n <script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${escapeHtml(ADSENSE_CLIENT)}" crossorigin="anonymous"></script>` : '';
|
||
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${escapeHtml(title)}</title><meta name="description" content="${escapeHtml(description)}"><meta property="og:title" content="${escapeHtml(title)}"><meta property="og:description" content="${escapeHtml(description)}"><meta property="og:type" content="article"><meta property="og:url" content="${escapeHtml(canonical)}"><link rel="canonical" href="${escapeHtml(canonical)}">${adsenseHead}<link rel="icon" type="image/svg+xml" href="/favicon.svg"><link rel="alternate" type="application/rss+xml" title="${escapeHtml(SITE_NAME)} RSS" href="${SITE_URL}/feed.xml"><link rel="stylesheet" href="/styles.css"></head><body class="${escapeHtml(bodyClass)}"><div class="page"><header class="site-header"><div class="brand-bar"><a class="wordmark" href="/">${escapeHtml(SITE_NAME)}</a><p class="tagline">${escapeHtml(SITE_TAGLINE)}</p></div><nav class="main-nav"><a href="/">Home</a><a href="/archive/">Archive</a><a href="/about/">About</a><a href="/feed.xml">RSS</a></nav><div class="edition-bar">Updated ${escapeHtml(formatDate(generatedAt))}</div></header>${content}<footer class="site-footer"><div class="footer-grid"><div><h4>${escapeHtml(SITE_NAME)}</h4><p>${escapeHtml(SITE_TAGLINE)}</p></div><div><h4>Editorial model</h4><p>Signal Ledger publishes concise magazine-style briefs, context, and viewpoint grounded in clearly attributed source reporting.</p></div><div><h4>Contact</h4><p><a href="mailto:${escapeHtml(CONTACT_EMAIL)}">${escapeHtml(CONTACT_EMAIL)}</a></p><p>${escapeHtml(SITE_NAME)} is a subsidiary of ${escapeHtml(PARENT_COMPANY)}.</p></div></div></footer></div></body></html>`;
|
||
}
|
||
|
||
function renderMainAdUnit(label = 'main page ads') {
|
||
if (!ADSENSE_CLIENT || !ADSENSE_SLOT) return '';
|
||
return `<aside class="ad-unit"><div class="ad-unit-label">${escapeHtml(label)}</div><script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${escapeHtml(ADSENSE_CLIENT)}" crossorigin="anonymous"></script><ins class="adsbygoogle" style="display:block" data-ad-client="${escapeHtml(ADSENSE_CLIENT)}" data-ad-slot="${escapeHtml(ADSENSE_SLOT)}" data-ad-format="auto" data-full-width-responsive="true"></ins><script>(adsbygoogle = window.adsbygoogle || []).push({});</script></aside>`;
|
||
}
|
||
|
||
function renderImageCredit(item) {
|
||
if (!item.imageCredit && !item.imageLicense) return '';
|
||
const parts = [item.imageCredit, item.imageLicense].filter(Boolean);
|
||
return `<div class="image-credit">${escapeHtml(parts.join(' · '))}</div>`;
|
||
}
|
||
|
||
function renderPicture(item, { className = '', loading = 'lazy' } = {}) {
|
||
const ext = path.extname(item.imagePath).toLowerCase();
|
||
const base = item.imagePath.replace(/\.[^.]+$/, '');
|
||
const alt = escapeHtml(item.imageAlt);
|
||
const cls = className ? ` class="${escapeHtml(className)}"` : '';
|
||
|
||
// SVG images don't need picture element
|
||
if (ext === '.svg') {
|
||
return `<img${cls} src="${escapeHtml(item.imagePath)}" alt="${alt}" loading="${loading}">`;
|
||
}
|
||
|
||
const sources = [];
|
||
if (item.hasAvif) {
|
||
sources.push(`<source srcset="${escapeHtml(base + '.avif')}" type="image/avif">`);
|
||
}
|
||
if (item.hasWebp) {
|
||
sources.push(`<source srcset="${escapeHtml(base + '.webp')}" type="image/webp">`);
|
||
}
|
||
|
||
if (sources.length === 0) {
|
||
return `<img${cls} src="${escapeHtml(item.imagePath)}" alt="${alt}" loading="${loading}">`;
|
||
}
|
||
|
||
return `<picture>${sources.join('')}<img${cls} src="${escapeHtml(item.imagePath)}" alt="${alt}" loading="${loading}"></picture>`;
|
||
}
|
||
|
||
function renderStoryCard(item, lead = false) {
|
||
return `<article class="story-card ${lead ? 'story-card--lead' : ''}">${renderPicture(item, { className: 'story-image', loading: 'lazy' })}${renderImageCredit(item)}<div class="story-meta"><span>${escapeHtml(item.category)}</span><time datetime="${escapeHtml(item.published)}">${escapeHtml(formatDate(item.published))}</time></div><h3><a href="${escapeHtml(item.articlePath)}">${escapeHtml(item.title)}</a></h3>${item.dek ? `<p class="story-dek">${escapeHtml(item.dek)}</p>` : ''}<p>${escapeHtml(item.summary)}</p><a class="readmore" href="${escapeHtml(item.articlePath)}">Read the full brief</a></article>`;
|
||
}
|
||
|
||
function renderHome(items, groups, generatedAt) {
|
||
const lead = items[0];
|
||
const secondary = items.slice(1, 4);
|
||
const sections = groups.map((group) => `<section class="section-block"><div class="section-head"><h2><a href="/sections/${escapeHtml(group.slug)}/">${escapeHtml(group.category)}</a></h2><a href="/sections/${escapeHtml(group.slug)}/">Section index</a></div><div class="story-grid">${group.items.slice(0, 6).map((item, idx) => renderStoryCard(item, idx === 0)).join('')}</div></section>`).join('');
|
||
const content = `<main><section class="hero"><div class="hero-copy"><p class="eyebrow">Digital news magazine</p><h1>${escapeHtml(SITE_NAME)}</h1><p class="hero-tagline">${escapeHtml(SITE_TAGLINE)}</p><p class="hero-note">${escapeHtml(EDITOR_NOTE)}</p><div class="hero-stats"><div><strong>${items.length}</strong><span>live briefs</span></div><div><strong>${groups.length}</strong><span>sections</span></div><div><strong>${escapeHtml(formatDate(generatedAt).replace(' UTC', ''))}</strong><span>edition timestamp</span></div></div></div><aside class="hero-panel">${lead ? `<div class="eyebrow">Lead brief</div>${renderPicture(lead, { className: 'hero-image' })}${renderImageCredit(lead)}<div class="story-meta story-meta--hero"><span class="meta-pill">${escapeHtml(lead.category)}</span><time datetime="${escapeHtml(lead.published)}">${escapeHtml(formatDate(lead.published))}</time></div><h2><a href="${escapeHtml(lead.articlePath)}">${escapeHtml(lead.title)}</a></h2>${lead.dek ? `<p class="story-dek">${escapeHtml(lead.dek)}</p>` : ''}<p>${escapeHtml(lead.summary)}</p><a class="readmore" href="${escapeHtml(lead.articlePath)}">Open lead story</a>` : '<p>No lead story available yet.</p>'}</aside></section><section class="strip">${secondary.map((item) => `<article class="mini-card">${renderPicture(item, { className: 'story-image', loading: 'lazy' })}${renderImageCredit(item)}<div class="story-meta"><span class="meta-pill">${escapeHtml(item.category)}</span></div><h3><a href="${escapeHtml(item.articlePath)}">${escapeHtml(item.title)}</a></h3>${item.dek ? `<p>${escapeHtml(item.dek)}</p>` : ''}</article>`).join('')}</section>${renderMainAdUnit('main page ads')}${sections}</main>`;
|
||
return renderShell({ title: `${SITE_NAME} — ${SITE_TAGLINE}`, description: SITE_TAGLINE, pathName: '/', bodyClass: 'home', content, generatedAt });
|
||
}
|
||
|
||
function renderArticle(item, related, generatedAt) {
|
||
const sourceHref = item.link && item.link !== '#' ? `<p><a href="${escapeHtml(item.link)}" rel="noreferrer noopener">Read the original reporting</a></p>` : '';
|
||
const relatedInline = related.length ? `<section><h2>Where this fits in Signal Ledger</h2><p>This story sits alongside related Signal Ledger coverage that helps frame the broader pattern.</p><ul class="article-links">${related.slice(0, 3).map((other) => `<li><a href="${escapeHtml(other.articlePath)}">${escapeHtml(other.title)}</a></li>`).join('')}</ul></section>` : '';
|
||
const content = `<main class="article-layout"><article class="article">${renderPicture(item, { className: 'article-image' })}${renderImageCredit(item)}<div class="article-kicker">${escapeHtml(item.category)}</div><h1>${escapeHtml(item.title)}</h1>${item.dek ? `<p class="article-dek">${escapeHtml(item.dek)}</p>` : ''}<div class="article-meta">Published <time datetime="${escapeHtml(item.published)}">${escapeHtml(formatDate(item.published))}</time></div><section><h2>${escapeHtml(item.summaryLabel)}</h2><p>${escapeHtml(item.summary)}</p></section><section><h2>${escapeHtml(item.stakesLabel)}</h2><p>${escapeHtml(item.whyItMatters)}</p></section><section><h2>${escapeHtml(item.contextLabel)}</h2><p>${escapeHtml(item.context)}</p></section>${relatedInline}<section><h2>${escapeHtml(item.viewLabel)}</h2><p>${escapeHtml(item.viewpoint)}</p></section><section class="source-note"><h2>Source note</h2><p>${escapeHtml(item.sourceNote)}</p>${sourceHref}</section></article><aside class="sidebar">${renderMainAdUnit('article page ads')}<div class="sidebar-box"><h3>Related coverage</h3>${related.map((other) => `<p><a href="${escapeHtml(other.articlePath)}">${escapeHtml(other.title)}</a></p>`).join('') || '<p>No related items yet.</p>'}</div><div class="sidebar-box"><h3>Magazine index</h3><p><a href="/archive/">Browse archive</a></p><p><a href="/sections/${escapeHtml(item.sectionSlug)}/">More ${escapeHtml(item.category)}</a></p></div></aside></main>`;
|
||
return renderShell({ title: `${item.title} — ${SITE_NAME}`, description: item.summary || SITE_TAGLINE, pathName: item.articlePath, bodyClass: 'article-page', content, generatedAt });
|
||
}
|
||
|
||
function renderSectionPage(group, generatedAt) {
|
||
const content = `<main><section class="section-hero"><p class="eyebrow">Section</p><h1>${escapeHtml(group.category)}</h1><p>Magazine briefs, analysis, and context from the ${escapeHtml(group.category)} desk.</p></section>${renderMainAdUnit('section page ads')}<section class="story-grid">${group.items.map((item, idx) => renderStoryCard(item, idx === 0)).join('')}</section></main>`;
|
||
return renderShell({ title: `${group.category} — ${SITE_NAME}`, description: `${group.category} coverage from ${SITE_NAME}`, pathName: `/sections/${group.slug}/`, bodyClass: 'section-page', content, generatedAt });
|
||
}
|
||
|
||
function renderArchive(items, generatedAt) {
|
||
const content = `<main><section class="section-hero"><p class="eyebrow">Archive</p><h1>All stories</h1><p>A running index of current Signal Ledger briefs.</p></section><div class="archive-list">${items.map((item) => `<article class="archive-item"><div><h3><a href="${escapeHtml(item.articlePath)}">${escapeHtml(item.title)}</a></h3><p>${escapeHtml(item.dek)}</p></div><div class="archive-meta"><span>${escapeHtml(item.category)}</span><span>${escapeHtml(item.source)}</span><time datetime="${escapeHtml(item.published)}">${escapeHtml(formatDate(item.published))}</time></div></article>`).join('')}</div></main>`;
|
||
return renderShell({ title: `Archive — ${SITE_NAME}`, description: `Archive of ${SITE_NAME} briefs and magazine stories.`, pathName: '/archive/', bodyClass: 'archive-page', content, generatedAt });
|
||
}
|
||
|
||
function renderAbout(generatedAt) {
|
||
const content = `<main><section class="section-hero"><p class="eyebrow">About</p><h1>About ${escapeHtml(SITE_NAME)}</h1><p>${escapeHtml(EDITOR_NOTE)}</p></section><section class="article prose"><h2>Editorial vision</h2><p>${escapeHtml(SITE_NAME)} aims to sit between the raw newswire and the over-heated opinion economy. We publish compact, readable briefs with context, judgment, and a magazine voice.</p><h2>How stories are made</h2><p>Each story is grounded in attributed reporting from established publishers, then rewritten into a distinct Signal Ledger brief with analysis, framing, and context for readers who want understanding rather than repetition.</p><h2>Contact</h2><p>Editorial and business contact: <a href="mailto:${escapeHtml(CONTACT_EMAIL)}">${escapeHtml(CONTACT_EMAIL)}</a>.</p><p>${escapeHtml(SITE_NAME)} is a subsidiary of ${escapeHtml(PARENT_COMPANY)}.</p><h2>What is coming next</h2><p>Planned expansions include author bylines, topic pages, and daily briefing edition pages.</p></section></main>`;
|
||
return renderShell({ title: `About — ${SITE_NAME}`, description: `About the editorial concept behind ${SITE_NAME}.`, pathName: '/about/', bodyClass: 'about-page', content, generatedAt });
|
||
}
|
||
|
||
function renderRss(items, generatedAt) {
|
||
return `<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"><channel><title>${xmlEscape(SITE_NAME)}</title><link>${xmlEscape(SITE_URL)}</link><description>${xmlEscape(SITE_TAGLINE)}</description><language>en-us</language><lastBuildDate>${new Date(generatedAt).toUTCString()}</lastBuildDate>${items.map((item) => `<item><title>${xmlEscape(item.title)}</title><link>${xmlEscape(`${SITE_URL}${item.articlePath}`)}</link><guid>${xmlEscape(`${SITE_URL}${item.articlePath}`)}</guid><pubDate>${new Date(item.published).toUTCString()}</pubDate><description>${xmlEscape(item.summary)}</description><category>${xmlEscape(item.category)}</category></item>`).join('')}</channel></rss>`;
|
||
}
|
||
|
||
function renderSitemap(items, groups) {
|
||
const urls = [`${SITE_URL}/`, `${SITE_URL}/archive/`, `${SITE_URL}/about/`, ...groups.map((g) => `${SITE_URL}/sections/${g.slug}/`), ...items.map((item) => `${SITE_URL}${item.articlePath}`)];
|
||
return `<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${urls.map((url) => `<url><loc>${xmlEscape(url)}</loc></url>`).join('')}</urlset>`;
|
||
}
|
||
|
||
async function writeCss() {
|
||
const css = `:root { --bg: #f3ede3; --paper: #fffdf8; --ink: #161616; --muted: #5d594f; --line: #ddd3c5; --accent: #8b1e1e; --accent-dark: #5f1414; --navy: #1f2a37; --shadow: rgba(16, 24, 40, 0.08); } * { box-sizing: border-box; } body { margin: 0; font-family: Georgia, 'Times New Roman', serif; color: var(--ink); background: linear-gradient(180deg, #efe6d7, var(--bg)); overflow-x: hidden; } a { color: inherit; text-decoration: none; } a:hover { color: var(--accent); } .page { width: 100%; max-width: 1240px; margin: 0 auto; padding: 0 18px 48px; } .site-header { padding: 24px 0 18px; border-bottom: 2px solid var(--navy); } .brand-bar { display: flex; justify-content: space-between; gap: 24px; align-items: end; min-width: 0; } .wordmark { font-size: clamp(2.5rem, 5vw, 4.8rem); font-weight: 700; line-height: .95; overflow-wrap: anywhere; } .tagline { margin: 0; max-width: 620px; color: var(--muted); font-family: Inter, Arial, sans-serif; } .main-nav { display: flex; gap: 18px; flex-wrap: wrap; padding-top: 16px; font-family: Inter, Arial, sans-serif; } .main-nav a { color: var(--navy); font-weight: 700; } .edition-bar { margin-top: 12px; color: var(--muted); font-family: Inter, Arial, sans-serif; font-size: .92rem; } .hero, .section-hero { display: grid; grid-template-columns: 1.2fr .9fr; gap: 22px; padding-top: 28px; min-width: 0; } .hero-copy, .hero-panel, .story-card, .mini-card, .sidebar-box, .article, .archive-item, .prose, .ad-unit { background: var(--paper); border: 1px solid var(--line); box-shadow: 0 12px 30px var(--shadow); min-width: 0; overflow: hidden; } .hero-copy, .hero-panel, .article, .prose { padding: 24px; } .eyebrow, .article-kicker { text-transform: uppercase; letter-spacing: .12em; font-family: Inter, Arial, sans-serif; font-size: .75rem; color: var(--muted); } .hero h1, .section-hero h1 { margin: 8px 0 12px; font-size: clamp(2rem, 4vw, 3.6rem); } .hero-tagline, .hero-note, .story-dek, .article-dek { color: #403933; } .strip { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-top: 18px; } .mini-card, .story-card { padding: 18px; } .story-image, .hero-image, .article-image { width: 100%; display: block; border-radius: 16px; border: 1px solid var(--line); background: #e9e2d6; } .story-image { aspect-ratio: 1.91 / 1; object-fit: cover; margin-bottom: 14px; } .hero-image { aspect-ratio: 1.91 / 1; object-fit: cover; margin: 6px 0 10px; } .article-image { aspect-ratio: 1.91 / 1; object-fit: cover; margin-bottom: 10px; } .image-credit { margin-bottom: 14px; font-family: Inter, Arial, sans-serif; font-size: .8rem; color: var(--muted); } .story-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr)); gap: 18px; } .section-block { margin-top: 34px; } .section-head { display: flex; justify-content: space-between; gap: 16px; align-items: baseline; border-bottom: 1px solid var(--line); margin-bottom: 16px; padding-bottom: 10px; } .section-head h2 { margin: 0; font-size: 2rem; } .story-meta, .archive-meta, .article-meta { display: flex; gap: 10px; flex-wrap: wrap; font-family: Inter, Arial, sans-serif; font-size: .82rem; color: var(--muted); } .story-card h3, .mini-card h3 { margin: 10px 0 10px; font-size: 1.3rem; line-height: 1.25; overflow-wrap: anywhere; } .story-card--lead { border-top: 4px solid var(--accent); } .readmore { display: inline-block; margin-top: 14px; color: var(--accent-dark); font-family: Inter, Arial, sans-serif; font-weight: 700; } .article-layout { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 320px); gap: 20px; padding-top: 28px; } .article h1 { font-size: clamp(2.2rem, 4vw, 3.8rem); margin: 10px 0 12px; overflow-wrap: anywhere; } .article section + section { margin-top: 22px; } .article p, .prose p { line-height: 1.7; color: #38332d; overflow-wrap: anywhere; } .article-links { margin: 10px 0 0 18px; color: #38332d; } .article-links li + li { margin-top: 8px; } .source-note { border-top: 1px solid var(--line); padding-top: 14px; } .source-note h2 { font-size: 1rem; font-family: Inter, Arial, sans-serif; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); } .sidebar { display: grid; gap: 16px; align-content: start; min-width: 0; } .sidebar-box { padding: 18px; } .archive-list { display: grid; gap: 14px; padding-top: 22px; } .archive-item { padding: 18px; display: grid; grid-template-columns: 1.4fr .8fr; gap: 16px; } .site-footer { border-top: 2px solid var(--navy); margin-top: 42px; padding-top: 22px; } .footer-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; } .footer-grid h4 { margin: 0 0 8px; font-family: Inter, Arial, sans-serif; } .footer-grid p { margin: 0; color: var(--muted); line-height: 1.6; } .ad-unit { padding: 16px; overflow: hidden; } .ad-unit-label { margin-bottom: 8px; text-transform: uppercase; letter-spacing: .12em; font-family: Inter, Arial, sans-serif; font-size: .72rem; color: var(--muted); } .adsbygoogle { max-width: 100%; } @media (max-width: 980px) { .hero, .section-hero, .article-layout, .strip, .footer-grid, .archive-item { grid-template-columns: 1fr; } .brand-bar { flex-direction: column; align-items: start; } } @media (max-width: 640px) { .page { padding: 0 12px 28px; } .section-head { flex-direction: column; align-items: start; } .wordmark { font-size: 2.1rem; } .hero-copy, .hero-panel, .story-card, .mini-card, .sidebar-box, .article, .archive-item, .prose, .ad-unit { padding: 14px; } }`;
|
||
await fs.writeFile(path.join(OUT_DIR, 'styles.css'), css);
|
||
}
|
||
|
||
async function main() {
|
||
await fs.mkdir(OUT_DIR, { recursive: true });
|
||
await fs.mkdir(ARTICLES_DIR, { recursive: true });
|
||
await fs.mkdir(SECTIONS_DIR, { recursive: true });
|
||
await fs.mkdir(IMAGES_DIR, { recursive: true });
|
||
await fs.mkdir(path.join(OUT_DIR, 'archive'), { recursive: true });
|
||
await fs.mkdir(path.join(OUT_DIR, 'about'), { recursive: true });
|
||
|
||
const existingArticles = await readJsonFile(STATE_FILE, { articles: [] });
|
||
const existingList = Array.isArray(existingArticles.articles) ? existingArticles.articles : [];
|
||
const existingByLink = new Map(existingList.map((item) => [item.link, item]));
|
||
const imageMeta = await readJsonFile(IMAGE_META_FILE, {});
|
||
|
||
const settled = await Promise.allSettled(FEEDS.map(fetchFeed));
|
||
const fetched = settled.flatMap((result, index) => result.status === 'fulfilled' ? result.value : (console.error(`Feed failed: ${FEEDS[index].source} ${FEEDS[index].url}`, result.reason), []));
|
||
const freshByLink = new Map(dedupe(fetched).map((item) => [item.link, item]));
|
||
const corpusBase = [...freshByLink.values(), ...existingList.filter((item) => !freshByLink.has(item.link))].sort((a, b) => new Date(b.published) - new Date(a.published));
|
||
|
||
const rewritten = await enrichItems(corpusBase.map((item) => ({
|
||
source: item.source,
|
||
category: item.category,
|
||
feedId: item.feedId,
|
||
title: item.title,
|
||
link: item.link,
|
||
published: item.published,
|
||
snippet: item.snippet
|
||
})));
|
||
|
||
const finalCorpus = rewritten.map((item) => {
|
||
const existing = existingByLink.get(item.link);
|
||
if (!existing) return item;
|
||
return {
|
||
...item,
|
||
slug: existing.slug,
|
||
articlePath: existing.articlePath,
|
||
sectionSlug: existing.sectionSlug || slugify(item.category),
|
||
imagePath: existing.imagePath || `/images/${existing.slug}.png`,
|
||
imageAlt: existing.imageAlt || `${item.title} — editorial image`
|
||
};
|
||
});
|
||
|
||
const decoratedNew = decorateItems(finalCorpus.filter((item) => !item.slug), existingList.map((item) => item.slug));
|
||
const decoratedNewByLink = new Map(decoratedNew.map((item) => [item.link, item]));
|
||
const corpus = finalCorpus.map((item) => decoratedNewByLink.get(item.link) || item).sort((a, b) => new Date(b.published) - new Date(a.published));
|
||
const homepageItems = corpus.slice(0, MAX_HOMEPAGE_ITEMS);
|
||
const homeGroups = groupByCategory(homepageItems);
|
||
const allGroups = groupByCategory(corpus);
|
||
const generatedAt = new Date();
|
||
|
||
await fs.copyFile(path.join(ROOT, 'src', 'favicon.svg'), path.join(OUT_DIR, 'favicon.svg'));
|
||
const client = process.env.OPENAI_API_KEY ? new OpenAI({ apiKey: process.env.OPENAI_API_KEY }) : null;
|
||
let generatedCount = 0;
|
||
const featuredSlugs = new Set(homepageItems.slice(0, 8).map((item) => item.slug));
|
||
for (const item of corpus) {
|
||
const isFeatured = featuredSlugs.has(item.slug);
|
||
const beforeKind = imageMeta[item.slug]?.kind || null;
|
||
await ensureArticleImage(item, imageMeta, client, {
|
||
tryRemote: isFeatured,
|
||
allowGeneration: isFeatured && generatedCount < GENERATED_IMAGE_LIMIT
|
||
});
|
||
if (!beforeKind && imageMeta[item.slug]?.kind === 'generated') generatedCount += 1;
|
||
}
|
||
await fs.writeFile(IMAGE_META_FILE, JSON.stringify(imageMeta, null, 2));
|
||
|
||
await writeCss();
|
||
await fs.writeFile(path.join(OUT_DIR, 'index.html'), renderHome(homepageItems, homeGroups, generatedAt));
|
||
await fs.writeFile(path.join(OUT_DIR, 'archive', 'index.html'), renderArchive(corpus, generatedAt));
|
||
await fs.writeFile(path.join(OUT_DIR, 'about', 'index.html'), renderAbout(generatedAt));
|
||
|
||
for (const group of allGroups) {
|
||
await fs.mkdir(path.join(SECTIONS_DIR, group.slug), { recursive: true });
|
||
await fs.writeFile(path.join(SECTIONS_DIR, group.slug, 'index.html'), renderSectionPage(group, generatedAt));
|
||
}
|
||
|
||
for (const item of corpus) {
|
||
const related = corpus.filter((other) => other.slug !== item.slug && (other.category === item.category || other.source === item.source)).slice(0, 4);
|
||
const dir = path.join(ARTICLES_DIR, item.slug);
|
||
await fs.mkdir(dir, { recursive: true });
|
||
await fs.writeFile(path.join(dir, 'index.html'), renderArticle(item, related, generatedAt));
|
||
}
|
||
|
||
await fs.writeFile(STATE_FILE, JSON.stringify({ generatedAt: generatedAt.toISOString(), articles: corpus }, null, 2));
|
||
await fs.writeFile(path.join(OUT_DIR, 'feed.json'), JSON.stringify({ siteName: SITE_NAME, siteTagline: SITE_TAGLINE, generatedAt: generatedAt.toISOString(), items: homepageItems }, null, 2));
|
||
await fs.writeFile(path.join(OUT_DIR, 'feed.xml'), renderRss(homepageItems, generatedAt));
|
||
await fs.writeFile(path.join(OUT_DIR, 'sitemap.xml'), renderSitemap(corpus, allGroups));
|
||
await fs.writeFile(path.join(OUT_DIR, 'robots.txt'), `User-agent: *\nAllow: /\nSitemap: ${SITE_URL}/sitemap.xml\n`);
|
||
await fs.writeFile(path.join(OUT_DIR, 'ads.txt'), `google.com, pub-1269854634225826, DIRECT, f08c47fec0942fa0\n`);
|
||
console.log(`Built ${corpus.length} stories into ${OUT_DIR}`);
|
||
}
|
||
|
||
main().catch((error) => {
|
||
console.error(error);
|
||
process.exit(1);
|
||
});
|