steelforesight/panel/content-generator.js

567 lines
23 KiB
JavaScript

import * as cheerio from 'cheerio';
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125 Safari/537.36';
const STATUS_BY_JOB = new Map();
let seq = 0;
const CFG = {
ai: {
baseUrl: process.env.AI_BASE_URL || 'https://api.openai.com/v1',
apiKey: process.env.AI_API_KEY || '',
model: process.env.AI_MODEL || 'gpt-4.1-mini',
},
panel: {
baseUrl: process.env.PANEL_BASE_URL || process.env.PUBLIC_ORIGIN || 'http://localhost:3000',
username: process.env.ADMIN_USERNAME || 'admin',
password: process.env.ADMIN_PASSWORD || '',
},
systemPrompt: `You are a Persian steel-sector editorial generator.
Return JSON only.
For "At a Glance":
- short, compact, 2-3 sentences
- one takeaway
- no long analysis
For "Radar Future":
- long-form analytical article
- 4-5 paragraphs
- multiple sources and in-text citations
- intro, build-up, conclusion
- neutral, formal Persian
Do not mix the two formats.
Do not invent facts. Use only the provided news.`
};
const AT_A_GLANCE_CATEGORIES = [
{ code: 'market', label: 'بازارهای جهانی و اقتصاد کلان', query: 'فولاد بازار قیمت' },
{ code: 'tech', label: 'نوآوری و فناوری‌های تحول‌آفرین', query: 'فولاد فناوری نوآوری' },
{ code: 'commodity', label: 'مواد معدنی حیاتی و زنجیره تامین', query: 'سنگ آهن قراضه فولاد' },
{ code: 'geo', label: 'ژئوپلیتیک و امنیت اقتصادی', query: 'فولاد تعرفه تحریم ژئوپلیتیک' },
{ code: 'energy', label: 'تجارت و کسب‌وکار بین‌الملل', query: 'فولاد انرژی صادرات تجارت' },
];
const RADAR_FUTURE_CATEGORIES = [
{ label: 'بازار و زنجیره فولاد', query: 'steel market supply chain pricing tariffs' },
{ label: 'پایداری و فولاد سبز', query: 'green steel hydrogen decarbonization' },
{ label: 'ژئوپلیتیک و اقتصاد جهانی', query: 'steel geopolitics tariffs sanctions trade' },
{ label: 'فناوری و نوآوری', query: 'steel automation ai digital twin robotics' },
];
const RADAR_FUTURE_PROFILES = {
'بازار و زنجیره فولاد': 'Focus on prices, supply-demand balance, iron ore, scrap, coking coal, capacity, trade flows, logistics, inventory, margins, and steel value-chain risk.',
'پایداری و فولاد سبز': 'Focus on green steel, hydrogen DRI, EAF, emissions, CBAM, ETS, renewable power, regulation, capital expenditure, and industrial decarbonization.',
'ژئوپلیتیک و اقتصاد جهانی': 'Focus on tariffs, sanctions, industrial policy, China, US, EU, India, supply security, trade conflict, currency pressure, and geopolitical risk.',
'فناوری و نوآوری': 'Focus on AI, automation, robotics, sensors, digital twins, process control, predictive maintenance, advanced materials, and steel plant productivity.',
};
function buildSystemPrompt(kind, categoryLabel) {
if (kind === 'radarFuture') {
return `You are the senior Persian editor for the "Radar Future" section of a steel-industry think tank.
Section category: ${categoryLabel}
Category specialization: ${RADAR_FUTURE_PROFILES[categoryLabel] || 'Steel-industry foresight and market analysis.'}
Output JSON only.
Radar Future requirements:
- Write in formal Persian.
- Target length: about 900 Persian words, acceptable range 800-1000 words.
- Analytical article, not a short news brief.
- Build an introduction, evidence-led body, strategic implications, and conclusion.
- Use multiple sources. Prefer at least 3 distinct source links when provided.
- Use numbered in-text citations exactly like [1], [2], [3].
- Citation [1] must correspond to sources[0], citation [2] to sources[1], and so on.
- Every source in sources[] should be cited at least once in the text.
- Do not invent facts. Use only the provided source material.
- Do not add image blocks, chart blocks, image URLs, or captions. The editor will add images manually.
- blocks[] must contain only {"type":"text","value":"..."} objects.`;
}
return `You are the senior Persian editor for the "At a Glance" section of a steel-industry think tank.
Output JSON only.
At a Glance requirements:
- Write in formal Persian.
- Short, compact, and decision-useful.
- 2-3 sentences per item.
- One clear takeaway per item.
- No long essay.
- Do not invent facts. Use only the provided source material.`;
}
function publicJob(job) {
return {
id: job.id,
kind: job.kind,
input: job.input,
status: job.status,
step: job.step,
progress: job.progress,
message: job.message,
createdAt: job.createdAt,
startedAt: job.startedAt,
finishedAt: job.finishedAt,
error: job.error,
result: job.result,
};
}
export function getJob(id) {
return STATUS_BY_JOB.get(id) || null;
}
export function listJobs() {
return [...STATUS_BY_JOB.values()].map(publicJob);
}
function createJob(kind, input) {
const id = `gen_${Date.now()}_${++seq}`;
const job = {
id,
kind,
input,
status: 'queued',
step: 'waiting',
progress: 0,
message: 'queued',
createdAt: new Date().toISOString(),
startedAt: null,
finishedAt: null,
error: null,
result: null,
};
STATUS_BY_JOB.set(id, job);
return job;
}
function updateJob(job, patch) {
Object.assign(job, patch);
return publicJob(job);
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
function decode(s = '') {
return s
.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
.replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>')
.replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&apos;/g, "'")
.trim();
}
async function fetchWithTimeout(url, init = {}, ms = 15000) {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), ms);
try {
return await fetch(url, { ...init, signal: ctrl.signal });
} finally {
clearTimeout(t);
}
}
async function fetchNews(query) {
const url = `https://news.google.com/rss/search?q=${encodeURIComponent(query + ' when:30d')}&hl=en-US&gl=US&ceid=US:en`;
const sources = [
{ name: 'google-rss', url, init: { headers: { 'User-Agent': UA } } },
{ name: 'jina-rss', url: `https://r.jina.ai/http://${url.replace(/^https?:\/\//, '')}`, init: {} },
];
let xml = '';
let lastErr = null;
for (const src of sources) {
try {
const res = await fetchWithTimeout(src.url, src.init, 15000);
if (!res.ok) throw new Error(`news HTTP ${res.status}`);
xml = await res.text();
if (xml.trim()) break;
} catch (err) {
lastErr = new Error(`${src.name}: ${err.message || err}`);
}
}
if (!xml.trim()) throw new Error(`news fetch failed: ${lastErr?.message || 'no source returned content'}`);
const items = [...xml.matchAll(/<item>([\s\S]*?)<\/item>/g)].map((m) => {
const block = m[1];
const t = (tag) => decode(block.match(new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`))?.[1] || '');
return { title: t('title'), link: t('link'), pubDate: t('pubDate'), source: t('source') };
});
return items.filter((x) => x.title);
}
function sourceHost(url) {
try { return new URL(url).hostname.replace(/^www\./, ''); }
catch { return ''; }
}
function trimSourceText(text, max = 3500) {
return String(text || '').replace(/\s+/g, ' ').trim().slice(0, max);
}
function extractReadableText(raw, contentType = '', url = '') {
if (!raw) return null;
if (/html/i.test(contentType) || /<html|<body|<article|<!doctype/i.test(raw.slice(0, 500))) {
const $ = cheerio.load(raw);
$('script,style,noscript,svg,iframe,nav,footer,header').remove();
const title = trimSourceText($('meta[property="og:title"]').attr('content') || $('title').first().text() || $('h1').first().text() || url, 220);
const text = trimSourceText($('article').text() || $('main').text() || $('body').text());
return text ? { title, text } : null;
}
const titleMatch = raw.match(/^Title:\s*(.+)$/im);
const title = trimSourceText(titleMatch?.[1] || url, 220);
const text = trimSourceText(raw);
return text ? { title, text } : null;
}
function jinaReaderUrl(url) {
const u = String(url || '').replace(/^https?:\/\//, '');
return `https://r.jina.ai/http://${u}`;
}
async function fetchReadableUrl(url) {
const attempts = [
{ name: 'direct', url },
{ name: 'reader', url: jinaReaderUrl(url) },
];
let lastErr = null;
for (const attempt of attempts) {
try {
const res = await fetchWithTimeout(attempt.url, {
headers: {
'User-Agent': UA,
Accept: 'text/html,text/plain,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
},
});
if (!res.ok) throw new Error(`${res.status}`);
const raw = await res.text();
const parsed = extractReadableText(raw, res.headers.get('content-type') || '', url);
if (parsed?.text) return { ...parsed, url };
} catch (err) {
lastErr = new Error(`${attempt.name}: ${err.message || err}`);
}
}
throw lastErr || new Error('read failed');
}
async function collectSourceMaterials(query, sourceRows = [], maxItems = 12) {
const rows = Array.isArray(sourceRows) ? sourceRows.filter((s) => s?.url).slice(0, 80) : [];
if (!rows.length) return [];
const materials = [];
for (const src of rows) {
if (materials.length >= maxItems) break;
const host = sourceHost(src.url);
const type = src.sourceType || src.source_type || 'auto';
const label = src.label || host || src.url;
try {
if (type === 'link' || (new URL(src.url).pathname || '/') !== '/') {
const page = await fetchReadableUrl(src.url);
materials.push({ ...page, sourceLabel: label });
} else {
const hits = (await fetchNews(`${query} site:${host}`)).slice(0, 2);
for (const hit of hits) {
if (materials.length >= maxItems) break;
try {
const page = await fetchReadableUrl(hit.link);
materials.push({ ...page, title: page.title || hit.title, sourceLabel: label });
} catch {
materials.push({ title: hit.title, url: hit.link, sourceLabel: label, text: hit.title });
}
}
}
} catch {
// A large source library will always have a few blocked sites. Keep the job moving.
}
}
return materials;
}
function sourceMaterialsText(materials) {
return materials.map((m, i) => `${i + 1}. ${m.title || m.sourceLabel || 'Source'}
source: ${m.sourceLabel || ''}
url: ${m.url || ''}
excerpt: ${trimSourceText(m.text, 2200)}`).join('\n\n');
}
function uniqueSourceList(materials, max = 6) {
const seen = new Set();
const out = [];
for (const m of materials || []) {
if (!m?.url || seen.has(m.url)) continue;
seen.add(m.url);
out.push({ title: m.title || m.sourceLabel || sourceHost(m.url) || m.url, url: m.url });
if (out.length >= max) break;
}
return out;
}
function newsToMaterials(news) {
return (news || []).map((n) => ({
title: n.title,
url: n.link,
sourceLabel: n.source || sourceHost(n.link),
text: n.title,
}));
}
function textOnlyBlocks(blocks, fallbackText = '') {
const out = (Array.isArray(blocks) ? blocks : [])
.filter((b) => b?.type === 'text' && String(b.value || '').trim())
.map((b) => ({ type: 'text', value: String(b.value || '').trim() }));
if (!out.length && fallbackText) out.push({ type: 'text', value: String(fallbackText).trim() });
return out;
}
function alignCitations(blocks, sources) {
if (!blocks.length || !sources.length) return blocks;
const maxSource = sources.length;
for (const b of blocks) {
b.value = String(b.value || '').replace(/\[(\d+)\]/g, (m, n) => {
const idx = Number(n);
return idx >= 1 && idx <= maxSource ? m : '';
}).replace(/\s{2,}/g, ' ').trim();
}
const joined = blocks.map((b) => b.value).join('\n');
const cited = new Set([...joined.matchAll(/\[(\d+)\]/g)].map((m) => Number(m[1])));
const minimum = Math.min(3, maxSource);
for (let i = 1; i <= minimum; i++) {
if (cited.has(i)) continue;
const target = blocks[Math.min(i - 1, blocks.length - 1)];
target.value = `${target.value.replace(/[.。؟?]*$/, '')} [${i}].`;
}
return blocks;
}
function extractStringField(text, key) {
const rx = new RegExp(`"${key}"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)"`, 's');
const m = text.match(rx);
return m ? m[1]
.replace(/\\"/g, '"')
.replace(/\\\\/g, '\\')
.replace(/\\n/g, '\n')
.replace(/\\r/g, '\r')
.replace(/\\t/g, '\t')
.trim() : '';
}
function extractArrayField(text, key) {
const rx = new RegExp(`"${key}"\\s*:\\s*(\\[[\\s\\S]*?\\])`, 's');
const m = text.match(rx);
if (!m) return [];
try {
return JSON.parse(m[1].replace(/,(\s*[}\]])/g, '$1'));
} catch {
return [];
}
}
function salvageJson(text) {
const cleaned = String(text || '')
.replace(/^```(?:json)?\s*/i, '')
.replace(/```$/i, '')
.trim();
const obj = {
titleFa: extractStringField(cleaned, 'titleFa'),
titleEn: extractStringField(cleaned, 'titleEn'),
publishDateFa: extractStringField(cleaned, 'publishDateFa'),
publishDateEn: extractStringField(cleaned, 'publishDateEn'),
summaryFa: extractStringField(cleaned, 'summaryFa'),
summaryEn: extractStringField(cleaned, 'summaryEn'),
authorName: extractStringField(cleaned, 'authorName'),
authorAvatar: extractStringField(cleaned, 'authorAvatar'),
tags: extractArrayField(cleaned, 'tags'),
sources: extractArrayField(cleaned, 'sources'),
blocks: extractArrayField(cleaned, 'blocks'),
};
if (!obj.blocks.length && cleaned) obj.blocks = [{ type: 'text', value: cleaned.slice(0, 900) }];
return obj;
}
async function aiJson(messages, maxTokens = 6000) {
if (!CFG.ai.apiKey) throw new Error('AI_API_KEY is missing');
let res;
try {
res = await fetchWithTimeout(`${CFG.ai.baseUrl}/chat/completions`, {
method: 'POST',
headers: { Authorization: `Bearer ${CFG.ai.apiKey}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ model: CFG.ai.model, messages, max_tokens: maxTokens, temperature: 0.7 }),
}, 45000);
} catch (err) {
throw new Error(`ai fetch failed: ${err.message || err}`);
}
if (!res.ok) throw new Error(`ai ${res.status}: ${(await res.text()).slice(0, 200)}`);
const j = await res.json();
const text = String(j.choices?.[0]?.message?.content || '').trim();
const candidates = [
text,
text.replace(/^```(?:json)?\s*/i, '').replace(/```$/i, '').trim(),
];
for (const candidate of candidates) {
const s = candidate.indexOf('{');
const e = candidate.lastIndexOf('}');
if (s === -1 || e === -1) continue;
const body = candidate.slice(s, e + 1)
.replace(/,(\s*[}\]])/g, '$1')
.replace(/^\uFEFF/, '');
try {
return JSON.parse(body);
} catch {}
}
const salvage = salvageJson(text);
if (salvage.titleFa || salvage.summaryFa || salvage.blocks.length || salvage.sources.length) return salvage;
throw new Error(`ai json parse failed: ${text.slice(0, 240)}`);
}
async function login(panelBaseUrl, username, password) {
const res = await fetch(`${panelBaseUrl}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
if (!res.ok) throw new Error(`panel login failed (${res.status})`);
const cookies = res.headers.getSetCookie?.() || [];
const session = cookies.map((c) => c.split(';')[0]).find((c) => c.startsWith('session='));
if (!session) throw new Error('no session cookie returned');
return session;
}
async function createRadarDraft(panelBaseUrl, session, payload) {
const res = await fetch(`${panelBaseUrl}/api/radar`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Cookie: session },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(`radar POST ${res.status}: ${(await res.text()).slice(0, 140)}`);
return res.json();
}
async function createArticleDraft(panelBaseUrl, session, payload) {
const res = await fetch(`${panelBaseUrl}/api/articles`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Cookie: session },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(`article POST ${res.status}: ${(await res.text()).slice(0, 140)}`);
return res.json();
}
async function generateAtAGlance(job, panelBaseUrl, session) {
updateJob(job, { status: 'running', startedAt: new Date().toISOString(), step: 'fetching', progress: 10, message: 'fetching news' });
const cat = AT_A_GLANCE_CATEGORIES.find((c) => c.code === job.input.category) || AT_A_GLANCE_CATEGORIES[0];
const trusted = await collectSourceMaterials(cat.query, job.input.sources, 10);
const news = trusted.length ? [] : (await fetchNews(cat.query)).slice(0, 8);
updateJob(job, { step: 'generating', progress: 35, message: trusted.length ? `using ${trusted.length} saved source items` : `news ${news.length} items` });
const newsText = trusted.length
? sourceMaterialsText(trusted)
: news.map((n, i) => `${i + 1}. ${n.title}\nsource: ${n.link}`).join('\n\n');
const ai = await aiJson([
{ role: 'system', content: buildSystemPrompt('glance', cat.label) },
{ role: 'user', content: `Write short "At a Glance" items for category: ${cat.label}. Return JSON only: {"items":[{"titleFa":"","excerptFa":"","bodyFa":"","analysisFa":"","tags":[],"sourceUrl":""}]}. Use the saved source material/news below. Prefer exact source URLs from the material.\n\n${newsText}` },
], 7000);
const items = Array.isArray(ai?.items) ? ai.items : [];
updateJob(job, { step: 'saving', progress: 70, message: `drafting ${items.length} items` });
const dateFa = new Intl.DateTimeFormat('fa-IR-u-ca-persian', { year: 'numeric', month: 'long', day: 'numeric' }).format(new Date());
const saved = [];
for (const item of items.slice(0, 3)) {
const row = await createRadarDraft(panelBaseUrl, session, {
category: cat.code,
titleFa: String(item.titleFa || '').trim(),
excerptFa: item.excerptFa || null,
bodyFa: item.bodyFa || null,
analysisFa: item.analysisFa || null,
tags: Array.isArray(item.tags) ? item.tags : [],
source: item.sourceUrl || null,
dateFa,
status: 'draft',
});
saved.push(row.id);
}
updateJob(job, { status: 'done', step: 'done', progress: 100, message: 'finished', finishedAt: new Date().toISOString(), result: { ids: saved } });
}
async function generateRadarFuture(job, panelBaseUrl, session) {
updateJob(job, { status: 'running', startedAt: new Date().toISOString(), step: 'fetching', progress: 10, message: 'fetching news' });
const selected = job.input.category && job.input.category !== 'all'
? RADAR_FUTURE_CATEGORIES.filter((c) => c.label === job.input.category)
: RADAR_FUTURE_CATEGORIES;
const saved = [];
for (let idx = 0; idx < selected.length; idx++) {
const cat = selected[idx];
const pct = 10 + Math.round((idx / Math.max(selected.length, 1)) * 70);
updateJob(job, { step: 'fetching', progress: pct, message: `fetching sources for ${cat.label}` });
const trusted = await collectSourceMaterials(cat.query, job.input.sources, 14);
const news = trusted.length ? [] : (await fetchNews(cat.query)).slice(0, 12);
updateJob(job, { step: 'generating', progress: pct + 10, message: trusted.length ? `writing ${cat.label} from ${trusted.length} saved source items` : `writing ${cat.label} from ${news.length} news items` });
const materials = (trusted.length ? trusted : newsToMaterials(news)).slice(0, 6);
const citationSources = uniqueSourceList(materials, 6);
const newsText = sourceMaterialsText(materials);
const ai = await aiJson([
{ role: 'system', content: buildSystemPrompt('radarFuture', cat.label) },
{ role: 'user', content: `Write one long-form Radar Future article for category "${cat.label}".
Return JSON only in this exact shape:
{"titleFa":"","titleEn":"","publishDateFa":"","publishDateEn":"","summaryFa":"","summaryEn":"","authorName":"","authorAvatar":"","tags":[],"sources":[{"title":"","url":""}],"blocks":[{"type":"text","value":""}]}
Rules:
- Target about 900 Persian words, acceptable range 800-1000.
- Use 5-7 paragraph-style text blocks.
- Do not include images, image blocks, chart blocks, img fields, image URLs, or captions.
- Use multiple source mentions in the body.
- Cite facts with numbered in-text citations like [1], [2], [3].
- Citation [1] must map to sources[0], [2] to sources[1], etc.
- Keep sources[] in the same order as the citation numbers.
- Use at least 3 source links if at least 3 are provided below.
- Intro, build-up, strategic implications, conclusion.
- Keep it analytical.
- Use only facts supported by the saved source material/news.
- Keep sources array to the strongest 3-5 exact links.
- Return a single valid JSON object only. No markdown, no code fences, no explanation text.
Available source material, already numbered for citation use:
${newsText}` },
], 12000);
updateJob(job, { step: 'saving', progress: pct + 18, message: `saving ${cat.label}` });
const blocks = alignCitations(textOnlyBlocks(ai?.blocks, ai?.body || ''), citationSources);
const article = await createArticleDraft(panelBaseUrl, session, {
title: ai?.titleFa || cat.label,
category: cat.label,
type: 'radarfuture',
author: ai?.authorName || 'تحریریه',
authorRole: 'تحریریه',
authorInitial: 'ت',
publishDate: ai?.publishDateFa || new Intl.DateTimeFormat('fa-IR-u-ca-persian', { year: 'numeric', month: 'long', day: 'numeric' }).format(new Date()),
pages: 0,
price: 0,
isFree: true,
summary: ai?.summaryFa || '',
body: blocks.map((b) => b.value).join('\n\n'),
coverImage: '',
blocks,
sources: citationSources.slice(0, Math.max(3, Math.min(citationSources.length, 6))),
tags: Array.isArray(ai?.tags) ? ai.tags : [],
featured: false,
status: 'draft',
keyData: null,
type: 'radarfuture',
});
saved.push({ id: article.id, category: cat.label });
await sleep(50);
}
updateJob(job, { status: 'done', step: 'done', progress: 100, message: 'finished', finishedAt: new Date().toISOString(), result: { articles: saved } });
}
export async function runGenerator(input) {
const kind = input.kind === 'radarFuture' ? 'radarFuture' : 'glance';
const job = createJob(kind, input);
const panelBaseUrl = input.panelBaseUrl || CFG.panel.baseUrl;
const session = input.sessionCookie || await login(panelBaseUrl, input.username || CFG.panel.username, input.password || CFG.panel.password);
Promise.resolve().then(async () => {
try {
if (kind === 'radarFuture') await generateRadarFuture(job, panelBaseUrl, session);
else await generateAtAGlance(job, panelBaseUrl, session);
} catch (err) {
updateJob(job, { status: 'error', step: 'error', progress: job.progress || 0, message: String(err.message || err), finishedAt: new Date().toISOString(), error: String(err.message || err) });
}
});
return publicJob(job);
}