From c632aa41a2c6b3693268267eb9f58b9640a94bfe Mon Sep 17 00:00:00 2001 From: Elshat Date: Thu, 24 Sep 2026 10:31:45 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20Node=20proxy=20on=203000=20=E2=86=92=20P?= =?UTF-8?q?ython=20dashboard=20on=208000?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/. --- package-lock.json | 4 +- package.json | 2 +- server.js | 352 +++++----------------------------------------- 3 files changed, 35 insertions(+), 323 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5ad2416..b8c49e2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,10 +1,10 @@ { - "name": "price-agent", + "name": "agromarket-price-agent", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "price-agent" + "name": "agromarket-price-agent" } } } diff --git a/package.json b/package.json index 9e92ba8..d93b03f 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "price-agent", + "name": "agromarket-price-agent", "private": true, "type": "commonjs", "scripts": { "start": "node server.js" } diff --git a/server.js b/server.js index 65ee313..76e09de 100644 --- a/server.js +++ b/server.js @@ -1,332 +1,44 @@ const http = require("http"); -const fs = require("fs"); -const path = require("path"); +const { spawn } = require("child_process"); const PORT = process.env.PORT || 3000; -const DATA = path.join(__dirname, "data.json"); -const AGENT = require("./agent"); +const DASH_PORT = 8000; -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) { +// 1) Устанавливаем Python-зависимости (идемпотентно) +function setupDeps() { return new Promise((resolve) => { - let s = ""; - req.on("data", (c) => (s += c)); - req.on("end", () => { try { resolve(JSON.parse(s || "{}")); } catch (_) { resolve({}); } }); + const py = spawn("python3", ["-m", "pip", "install", "--break-system-packages", "-q", "-r", "requirements.txt"], { + cwd: __dirname, stdio: "pipe" + }); + 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)); -} -// ---- Агенты ---- -// Сбор «на сегодня»: находит последнюю дату в истории (вчера или сегодня), -// считает изменение к ней и дописывает сегодняшние курсы/погоду. -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; -} +setupDeps().then(startProxy); -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 }; +async function startProxy() { + // 2) Запускаем Python-дашборд + const py = spawn("python3", ["-m", "src.dashboard.run"], { + cwd: __dirname, stdio: "inherit", + env: { ...process.env, PORT: String(DASH_PORT), DASHBOARD_HOST: "0.0.0.0" } }); - 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) + "% к тенге — влияет на экспорт зерна в РФ" }); + py.on("error", (e) => { console.error("[proxy]", e.message); process.exit(1); }); - 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 : [] - }; + const proxy = http.createServer((req, res) => { + const opts = { host: "127.0.0.1", port: DASH_PORT, path: req.url, method: req.method, + headers: { ...req.headers, host: `127.0.0.1:${DASH_PORT}` } }; + const up = http.request(opts, (upRes) => { res.writeHead(upRes.statusCode, upRes.headers); upRes.pipe(res); }); + up.on("error", () => { res.writeHead(502); res.end("Dashboard booting… retry in 2s"); }); + req.pipe(up); + }); + + proxy.listen(PORT, () => console.log(`[proxy] ${PORT} → ${DASH_PORT}`)); + + function shutdown() { py.kill("SIGTERM"); proxy.close(() => process.exit(0)); setTimeout(() => process.exit(0), 2000).unref(); } + process.on("SIGTERM", shutdown); process.on("SIGINT", shutdown); } - -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();