568 lines
23 KiB
JavaScript
568 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(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
.replace(/"/g, '"').replace(/'/g, "'").replace(/'/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":[],"sourceTitle":"","sourceUrl":""}]}. Use the saved source material/news below. Prefer exact source URLs from the material. sourceTitle is the publisher/site name, for example Reuters.\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.sourceTitle || item.source || null,
|
|
sourceUrl: 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);
|
|
}
|