steelforesight/frontend/scripts/generate-drafts.mjs

152 lines
7.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Auto-draft generator for رادار آینده / در یک نگاه.
// Scrapes recent Iranian steel news per section → Gemini (via Liara) writes
// Persian analytical items → pushes them to the panel as DRAFTS for review.
//
// node scripts/generate-drafts.mjs
//
// Config + tone live in ./gen.config.mjs (gitignored). Drafts stay hidden from
// the public site until you press «انتشار» in the CMS.
import CFG from './gen.config.mjs'
// allow pointing at a local panel for testing without editing the config
const PANEL = {
baseUrl: process.env.PANEL_BASE || CFG.panel.baseUrl,
username: process.env.PANEL_USER || CFG.panel.username,
password: process.env.PANEL_PASS || CFG.panel.password,
}
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36'
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
// section → { backend category code, scrape query, page label }
const CATEGORIES = [
{ code: 'market', label: 'بازار و زنجیره فولاد', query: 'قیمت بازار فولاد' },
{ code: 'energy', label: 'پایداری و فولاد سبز', query: 'فولاد سبز محیط زیست انرژی' },
{ code: 'geo', label: 'ژئوپلیتیک و اقتصاد جهانی', query: 'تعرفه صادرات فولاد اقتصاد جهانی' },
{ code: 'tech', label: 'فناوری و نوآوری', query: 'فناوری نوآوری فولاد' },
]
const NEWS_SITES = ['https://www.chilanonline.com', 'https://felezatonline.ir', 'https://madanmedia.ir', 'https://www.madannews.ir']
// ---------- helpers ----------
const decode = (s) =>
s.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(+n))
.replace(/&#xA0;|&#160;|&nbsp;/g, ' ')
.replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/g, "'")
.trim()
const stripTags = (s) => decode(s.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' '))
const tag = (b, n) => { const m = b.match(new RegExp(`<${n}[^>]*>([\\s\\S]*?)<\\/${n}>`, 'i')); return m ? m[1] : '' }
async function get(url, timeout = 18000) {
const ac = new AbortController()
const t = setTimeout(() => ac.abort(), timeout)
try { const r = await fetch(url, { headers: { 'User-Agent': UA }, redirect: 'follow', signal: ac.signal }); return r.ok ? await r.text() : '' }
catch { return '' } finally { clearTimeout(t) }
}
function parseRss(xml) {
return [...xml.matchAll(/<item>([\s\S]*?)<\/item>/g)].map((m) => {
const b = m[1]
return { title: decode(tag(b, 'title')), url: decode(tag(b, 'link')).trim(), date: tag(b, 'pubDate').trim(), excerpt: stripTags(tag(b, 'description')).slice(0, 260) }
})
}
async function scrape(query) {
const all = []
for (const base of NEWS_SITES) {
const xml = await get(`${base}/?s=${encodeURIComponent(query)}&feed=rss2`)
if (xml) all.push(...parseRss(xml))
await sleep(300)
}
const seen = new Set()
return all
.filter((it) => it.title && it.url && !seen.has(it.title) && seen.add(it.title))
.sort((a, b) => (Date.parse(b.date) || 0) - (Date.parse(a.date) || 0))
.slice(0, 8)
}
async function panelLogin() {
const res = await fetch(`${PANEL.baseUrl}/api/auth/login`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: PANEL.username, password: PANEL.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
}
function parseItems(text) {
let t = text.replace(/```json|```/g, '').trim()
const s = t.indexOf('{'), e = t.lastIndexOf('}')
if (s === -1 || e === -1) return []
try { return JSON.parse(t.slice(s, e + 1)).items || [] } catch { return [] }
}
async function generate(cat, news) {
const newsText = news.map((n, i) => `${i + 1}. ${n.title}\n منبع: ${n.url}\n ${n.excerpt}`).join('\n\n')
const user = `دسته: «${cat.label}»
از میان خبرهای زیر، ${CFG.perCategory} آیتم تحلیلیِ مستقل و متفاوت برای این دسته بنویس. هر آیتم باید یک رویداد/موضوع واقعی از خبرها را پوشش دهد.
خروجی را «فقط» به‌صورت JSON و دقیقاً با این ساختار بده (بدون هیچ توضیح اضافه):
{"items":[{"titleFa":"تیتر کوتاه","excerptFa":"خلاصهٔ یک تا دو جمله‌ای","bodyFa":"متن اصلی ۳ تا ۵ پاراگراف","analysisFa":"تحلیل راهبردی یک پاراگراف","tags":["برچسب","برچسب"],"sourceUrl":"نشانی منبع مرتبط"}]}
خبرها:
${newsText}`
const res = await fetch(`${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: [{ role: 'system', content: CFG.systemPrompt }, { role: 'user', content: user }], max_tokens: 8000, temperature: 0.7 }),
})
if (!res.ok) throw new Error(`gemini ${res.status}: ${(await res.text()).slice(0, 200)}`)
const j = await res.json()
return parseItems(j.choices?.[0]?.message?.content || '')
}
async function createDraft(session, cat, item, dateFa) {
const body = {
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',
}
if (!body.titleFa) return null
const res = await fetch(`${PANEL.baseUrl}/api/radar`, {
method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: session },
body: JSON.stringify(body),
})
if (!res.ok) { console.log(` ✗ POST failed ${res.status}: ${(await res.text()).slice(0, 120)}`); return null }
return (await res.json()).id
}
async function main() {
const dateFa = new Intl.DateTimeFormat('fa-IR-u-ca-persian', { year: 'numeric', month: 'long', day: 'numeric' }).format(new Date())
console.log(`Login to panel…`)
const session = await panelLogin()
let total = 0
for (const cat of CATEGORIES) {
console.log(`\n${cat.label} (${cat.code})`)
const news = await scrape(cat.query)
console.log(` scraped ${news.length} articles`)
if (!news.length) { console.log(' (no news, skipping)'); continue }
const items = await generate(cat, news)
console.log(` gemini returned ${items.length} items`)
for (const it of items) {
const id = await createDraft(session, cat, it, dateFa)
if (id) { total++; console.log(` ✓ draft ${id}${String(it.titleFa).slice(0, 50)}`) }
}
}
console.log(`\nDone. ${total} drafts created. Review & publish them in the panel → «در یک نگاه».`)
}
main().catch((e) => { console.error('FATAL:', e.message); process.exit(1) })