const http = require("http"); const fs = require("fs"); const path = require("path"); const PORT = process.env.PORT || 3000; const DATA = path.join(__dirname, "data.json"); const AGENT = require("./agent"); function readData() { try { return JSON.parse(fs.readFileSync(DATA, "utf8")); } catch (_) { return { quotes: [], weather: [], meta: null }; } } function writeData(d) { for (let i = 0; i < 120; i++) { try { fs.writeFileSync(DATA, JSON.stringify(d)); return; } catch (_) {} } } function today() { return new Date().toLocaleDateString("en-CA"); } function mergeHistory(d, extra) { // extra: { weather: [...], forex: {date: {USD:1,EUR,CNY}} } const wHave = new Set(d.weather.map((x) => x.date + "|" + x.city)); for (const w of extra.weather || []) { const k = w.date + "|" + w.city; if (!wHave.has(k)) d.weather.push(w); } d.weather.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0)); const qHave = new Set(d.quotes.map((x) => x.date + "|" + x.code)); const usdByDate = extra.forex || {}; const need = new Set(); for (const dt of Object.keys(usdByDate)) { for (const code of ["USD", "EUR", "CNY"]) { const k = dt + "|" + code; if (!qHave.has(k)) need.add(k); } } for (const k of need) { const [dt, code] = k.split("|"); const m = usdByDate[dt]; let value = null; if (code === "USD") value = m.USD; else if (code === "EUR") value = m.USD * m.EUR; else if (code === "CNY") value = m.USD * m.CNY; if (value == null) continue; d.quotes.push({ date: dt, code, unit: "₸/юнит", value: +value.toFixed(1), prev: 0, changePct: 0, source: "frankfurter.app" }); } d.quotes.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0)); } function recomputeChanges(d) { // Посчитать changePct по сортированным датам для каждого кода const codes = [...new Set(d.quotes.map((q) => q.code))]; const byCodeDate = {}; for (const q of d.quotes) (byCodeDate[q.code] = byCodeDate[q.code] || {})[q.date] = q; for (const code of codes) { const dates = Object.keys(byCodeDate[code]).sort(); for (let i = 1; i < dates.length; i++) { const cur = byCodeDate[code][dates[i]]; const pv = byCodeDate[code][dates[i - 1]]; cur.prev = pv.value; cur.changePct = pv.value ? +(((cur.value - pv.value) / pv.value) * 100).toFixed(3) : 0; } } } function body(req) { return new Promise((resolve) => { let s = ""; req.on("data", (c) => (s += c)); req.on("end", () => { try { resolve(JSON.parse(s || "{}")); } catch (_) { resolve({}); } }); }); } function json(res, obj, code) { res.writeHead(code || 200, { "Content-Type": "application/json; charset=utf-8" }); res.end(JSON.stringify(obj)); } // ---- Агенты ---- // Сбор «на сегодня»: находит последнюю дату в истории (вчера или сегодня), // считает изменение к ней и дописывает сегодняшние курсы/погоду. async function collectNow() { const d = readData(); const res = await AGENT.collect(); const t = today(); const dates = [...new Set(d.quotes.map((q) => q.date))].sort(); // базовая дата для сравнения — последняя известная, но не сегодня const prevDate = dates.filter((x) => x <= t).reverse().find((x) => x < t) || null; if (!res.quotes.length) { d.meta = { ...d.meta, lastRun: new Date().toISOString(), sources: res.sources }; writeData(d); return d; } for (const q of res.quotes) { q.date = t; if (prevDate) { const pv = d.quotes.find((x) => x.date === prevDate && x.code === q.code); if (pv && pv.value) { q.prev = pv.value; q.changePct = +(((q.value - pv.value) / pv.value) * 100).toFixed(2); } } } // дописываем сегодняшние (история до t не трогается — бэкап остаётся) d.quotes = d.quotes.filter((q) => q.date !== t).concat(res.quotes); const wHave = new Set(d.weather.map((w) => w.date + "|" + w.city)); for (const w of res.weather) { if (w.date === t && !wHave.has(t + "|" + w.city)) d.weather.push(w); } recomputeChanges(d); d.meta = { ...d.meta, lastRun: new Date().toISOString(), sources: res.sources }; delete d.meta.demo; writeData(d); d.meta.summary = await AGENT.summarize(d).catch((e) => "Сводка: " + e.message); writeData(d); return d; } function seed() { const d = readData(); if (d.quotes.length) return d; const days = 30; const base = { USD: 527.4, EUR: 601.2, RUB: 6.21, CNY: 71.3 }; const cities = [ { name: "Астана", lat: 51.16, lon: 71.47 }, { name: "Алматы", lat: 43.24, lon: 76.89 }, { name: "Шымкент", lat: 42.31, lon: 69.59 }, { name: "Павлодар", lat: 52.3, lon: 76.93 } ]; const quotes = []; const weather = []; for (let i = days; i >= 0; i--) { const date = new Date(Date.now() - i * 864e5).toLocaleDateString("en-CA"); for (const code of Object.keys(base)) { quotes.push({ date, code, unit: "₸/юнит", value: +(base[code] * (1 + (Math.random() - 0.5) * 0.012)).toFixed(code === "RUB" ? 2 : 1), prev: 0, changePct: 0, source: "open.er-api.com" }); } for (const c of cities) { const warm = c.name !== "Астана" && c.name !== "Павлодар"; weather.push({ date, city: c.name, max: +(12 + (warm ? 8 : 0) + Math.sin(i / 4) * 4 + (Math.random() - 0.5) * 3).toFixed(1), min: +(6 + (warm ? 5 : 0) + Math.sin(i / 4) * 3 + (Math.random() - 0.5) * 3).toFixed(1), rain: +(Math.random() * 6).toFixed(1) }); } } for (let k = 1; k < quotes.length; k++) quotes[k].prev = quotes[k - 1].value; for (let k = 1; k < weather.length; k++) weather[k].prev = weather[k - 1].max; d.quotes = quotes; d.weather = weather; d.meta = d.meta || {}; d.meta.lastRun = new Date().toISOString(); d.meta.demo = true; d.meta.sources = []; writeData(d); return d; } function buildDashboard(d) { const t = today(); const byDate = {}; for (const q of d.quotes) (byDate[q.date] = byDate[q.date] || []).push(q); const wByDate = {}; for (const w of d.weather) (wByDate[w.date] = wByDate[w.date] || []).push(w); const dates = Object.keys(byDate).sort().slice(-30); const series = dates.map((date) => { const qs = byDate[date]; const avg = qs.reduce((s, q) => s + (q.changePct || 0), 0) / qs.length; const ws = wByDate[date] || []; const rain = ws.length ? +(ws.reduce((s, w) => s + w.rain, 0) / ws.length).toFixed(1) : 0; const tmin = ws.length ? +(ws.reduce((s, w) => s + w.min, 0) / ws.length).toFixed(1) : 0; const tmax = ws.length ? +(ws.reduce((s, w) => s + w.max, 0) / ws.length).toFixed(1) : 0; return { date, avg: +avg.toFixed(2), rain, tmin, tmax }; }); const lastDate = dates[dates.length - 1] || t; const cur = byDate[lastDate].map((q) => ({ code: q.code, value: q.value, changePct: q.changePct || 0, unit: "тенге за 1 " + ({ USD: "доллар", EUR: "евро", RUB: "рубль", CNY: "юань" })[q.code] })); const kpis = [ { label: "Среднее движение курсов", value: (series[series.length - 1].avg >= 0 ? "+" : "") + series[series.length - 1].avg + "%", hint: "за сегодня" }, { label: "Валют за отслеживание", value: 4, hint: "USD · EUR · RUB · CNY" }, { label: "Регионы погоды", value: 4, hint: "Астана · Алматы · Шымкент · Павлодар" }, { label: "Дней истории", value: dates.length, hint: "с " + new Date(dates[0]).toLocaleDateString("ru-RU") } ]; const weatherNow = d.weather.filter((w) => w.date === lastDate); const alerts = []; for (const w of weatherNow) if (w.rain >= 5) alerts.push({ tone: "warn", text: w.city + ": за сегодня " + w.rain.toFixed(1) + " мм осадков — риск давления на цены зерновых" }); const rub = byDate[lastDate].find((q) => q.code === "RUB"); if (rub && Math.abs(rub.changePct) >= 0.4) alerts.push({ tone: "info", text: "Рубль: " + (rub.changePct > 0 ? "−" : "+") + Math.abs(rub.changePct).toFixed(2) + "% к тенге — влияет на экспорт зерна в РФ" }); return { kpis, banner: { tone: "info", text: "Данные " + lastDate + (d.meta && d.meta.demo ? " · демонстрационные данные первой закладки — агент обновит их из живых источников" : ""), action: "Обновить сейчас" }, latest: cur, series, weatherNow: weatherNow.map((w) => ({ city: w.city, max: w.max, min: w.min, rain: w.rain })), alerts, meta: d.meta, quotes: d.quotes, sources: d.meta ? d.meta.sources : [] }; } async function ask(d, q) { const db = readData(); const sortedDates = Object.keys(new Set(db.quotes.map((q) => q.date))).sort(); const lastDay = sortedDates[sortedDates.length - 1]; const prevDay = sortedDates[sortedDates.length - 2] || lastDay; const ctx = { lastDay, cur: db.quotes.filter((q) => q.date === lastDay).map((q) => ({ код: q.code, значение: q.value, изменение_процент: q.changePct })), prev: db.quotes.filter((q) => q.date === prevDay).map((q) => ({ код: q.code, значение: q.value })), weather: db.weather.filter((w) => w.date === lastDay), question: q }; return AGENT.ask(ctx, q); } // Наполнение истории при первом запуске async function backfillOnce() { const d = readData(); if ((d.quotes || []).some((q) => q.date < today())) { console.log("[agent] история уже есть, бэкап-наполнение пропущено"); return; } console.log("[agent] наполняю историю (30 дней) — это займёт ~минуту"); try { const extra = await AGENT.backfill(30); mergeHistory(d, extra); recomputeChanges(d); d.meta = d.meta || {}; d.meta.lastBackfill = new Date().toISOString(); d.meta.sources = [...(d.meta.sources || []), { id: "backfill", name: "История frankfurter + open-meteo", ok: true, note: "наполнено при первом запуске" }]; writeData(d); console.log("[agent] история наполнена:", (d.quotes || []).length, "курсов,", (d.weather || []).length, "погодных записей"); } catch (e) { console.log("[agent] бэкап-наполнение упало: " + e.message); } } // ---- Планировщик: сбор при старте + ежедневно в 07:00 ---- async function schedule() { const d = readData(); const last = d.meta && d.meta.lastRun ? new Date(d.meta.lastRun) : new Date(0); console.log("[agent] планировщик: последний запуск " + last.toISOString()); if (Date.now() - last.getTime() > 20 * 60 * 1000) { console.log("[agent] запускаю стартовый сбор данных"); try { await collectNow(); console.log("[agent] стартовый сбор завершён"); } catch (e) { console.log("[agent] стартовый сбор не удался: " + e.message); } } else { console.log("[agent] данные свежие, стартовый сбор пропущен"); } backfillOnce(); (function tick() { const now = new Date(); const next = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 7, 0, 0); if (next <= now) next.setDate(next.getDate() + 1); setTimeout(() => { console.log("[agent] ежедневный сбор в 07:00"); collectNow().then(() => console.log("[agent] ежедневный сбор завершён")) .catch((e) => console.log("[agent] ежедневный сбор упал: " + e.message)); tick(); }, next - now); })(); } // ---- Сервер ---- http.createServer(async (req, res) => { const u = req.url.split("?")[0]; try { // --- API --- if (req.method === "GET" && u === "/api/overview") { let d = readData(); if (!d.quotes.length) d = seed(); return json(res, buildDashboard(d)); } if (req.method === "GET" && u === "/api/agents") { const d = readData(); return json(res, [ { id: "collect", name: "Сбор данных", role: "Сканирует открытые источники: курсы валют и погоду по регионам", lastRun: (d.meta && d.meta.lastRun) || "ещё не запускался" }, { id: "summarize", name: "ИИ-аналитик", role: "Формирует ежедневную сводку по собранным данным", lastRun: (d.meta && d.meta.lastRun) || "—" } ]); } if (req.method === "POST" && u === "/api/collect") { const d = await collectNow(); return json(res, { ok: true, lastRun: d.meta.lastRun, sources: d.meta.sources }); } if (req.method === "POST" && u === "/api/backfill") { await backfillOnce(); return json(res, { ok: true }); } if (req.method === "POST" && u === "/api/ask") { const b = await body(req); const q = String(b.q || "").trim(); if (!q) return json(res, { error: "Вопрос пустой" }, 400); const d = readData(); const answer = await ask(d, q); return json(res, { answer }); } // --- Статика --- let file = u === "/" ? "/index.html" : u; const full = path.join(__dirname, file); if (full.startsWith(__dirname) && fs.existsSync(full) && fs.statSync(full).isFile()) { const ext = path.extname(full); const types = { ".css": "text/css", ".js": "application/javascript", ".svg": "image/svg+xml", ".json": "application/json", ".png": "image/png" }; res.writeHead(200, { "Content-Type": (types[ext] || "text/html") + "; charset=utf-8" }); return res.end(fs.readFileSync(full)); } res.writeHead(404); res.end("Not found"); } catch (e) { json(res, { error: e.message }, 500); } }).listen(PORT, () => console.log("Price Agent сервер на порту " + PORT)); schedule();