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:
Elshat 2026-09-24 10:31:45 +00:00
parent 8d527c2f1c
commit c632aa41a2
3 changed files with 35 additions and 323 deletions

4
package-lock.json generated
View File

@ -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"
} }
} }
} }

View File

@ -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
View File

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