152 lines
7.0 KiB
JavaScript
152 lines
7.0 KiB
JavaScript
// 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(/ | | /g, ' ')
|
||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/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) })
|