fix: Node proxy on 3000 → Python dashboard on 8000
run is Node-based, so we need a Node entrypoint. server.js: 1) pip install -r requirements.txt (idempotent, --break-system-packages) 2) spawn python3 -m src.dashboard.run on PORT 8000 3) http proxy 3000 → 8000 This makes the dashboard visible at /proxy/3000/.
This commit is contained in:
parent
8d527c2f1c
commit
c632aa41a2
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"name": "price-agent",
|
"name": "agromarket-price-agent",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "price-agent"
|
"name": "agromarket-price-agent"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "price-agent",
|
"name": "agromarket-price-agent",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
"scripts": { "start": "node server.js" }
|
"scripts": { "start": "node server.js" }
|
||||||
|
|||||||
352
server.js
352
server.js
@ -1,332 +1,44 @@
|
|||||||
const http = require("http");
|
const http = require("http");
|
||||||
const fs = require("fs");
|
const { spawn } = require("child_process");
|
||||||
const path = require("path");
|
|
||||||
|
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
const DATA = path.join(__dirname, "data.json");
|
const DASH_PORT = 8000;
|
||||||
const AGENT = require("./agent");
|
|
||||||
|
|
||||||
function readData() {
|
// 1) Устанавливаем Python-зависимости (идемпотентно)
|
||||||
try { return JSON.parse(fs.readFileSync(DATA, "utf8")); }
|
function setupDeps() {
|
||||||
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) => {
|
return new Promise((resolve) => {
|
||||||
let s = "";
|
const py = spawn("python3", ["-m", "pip", "install", "--break-system-packages", "-q", "-r", "requirements.txt"], {
|
||||||
req.on("data", (c) => (s += c));
|
cwd: __dirname, stdio: "pipe"
|
||||||
req.on("end", () => { try { resolve(JSON.parse(s || "{}")); } catch (_) { resolve({}); } });
|
});
|
||||||
|
py.stdout.on("data", () => {});
|
||||||
|
py.stderr.on("data", (d) => console.error("[setup]", d.toString().trim()));
|
||||||
|
py.on("close", (code) => {
|
||||||
|
console.log(`[setup] pip exit ${code}`);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
function json(res, obj, code) {
|
|
||||||
res.writeHead(code || 200, { "Content-Type": "application/json; charset=utf-8" });
|
|
||||||
res.end(JSON.stringify(obj));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Агенты ----
|
setupDeps().then(startProxy);
|
||||||
// Сбор «на сегодня»: находит последнюю дату в истории (вчера или сегодня),
|
|
||||||
// считает изменение к ней и дописывает сегодняшние курсы/погоду.
|
|
||||||
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() {
|
async function startProxy() {
|
||||||
const d = readData();
|
// 2) Запускаем Python-дашборд
|
||||||
if (d.quotes.length) return d;
|
const py = spawn("python3", ["-m", "src.dashboard.run"], {
|
||||||
const days = 30;
|
cwd: __dirname, stdio: "inherit",
|
||||||
const base = { USD: 527.4, EUR: 601.2, RUB: 6.21, CNY: 71.3 };
|
env: { ...process.env, PORT: String(DASH_PORT), DASHBOARD_HOST: "0.0.0.0" }
|
||||||
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;
|
py.on("error", (e) => { console.error("[proxy]", e.message); process.exit(1); });
|
||||||
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 {
|
const proxy = http.createServer((req, res) => {
|
||||||
kpis,
|
const opts = { host: "127.0.0.1", port: DASH_PORT, path: req.url, method: req.method,
|
||||||
banner: {
|
headers: { ...req.headers, host: `127.0.0.1:${DASH_PORT}` } };
|
||||||
tone: "info",
|
const up = http.request(opts, (upRes) => { res.writeHead(upRes.statusCode, upRes.headers); upRes.pipe(res); });
|
||||||
text: "Данные " + lastDate + (d.meta && d.meta.demo ? " · демонстрационные данные первой закладки — агент обновит их из живых источников" : ""),
|
up.on("error", () => { res.writeHead(502); res.end("Dashboard booting… retry in 2s"); });
|
||||||
action: "Обновить сейчас"
|
req.pipe(up);
|
||||||
},
|
});
|
||||||
latest: cur,
|
|
||||||
series,
|
proxy.listen(PORT, () => console.log(`[proxy] ${PORT} → ${DASH_PORT}`));
|
||||||
weatherNow: weatherNow.map((w) => ({ city: w.city, max: w.max, min: w.min, rain: w.rain })),
|
|
||||||
alerts,
|
function shutdown() { py.kill("SIGTERM"); proxy.close(() => process.exit(0)); setTimeout(() => process.exit(0), 2000).unref(); }
|
||||||
meta: d.meta,
|
process.on("SIGTERM", shutdown); process.on("SIGINT", shutdown);
|
||||||
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();
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user