chto-ty-takoe-na-baze-kakih/server.js

333 lines
14 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.

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();