import fs from "fs"; import http from "http"; import path from "path"; import { fileURLToPath } from "url"; import { execSync } from "child_process"; import crypto from "crypto"; import WebSocket from "ws"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __filename = fileURLToPath(import.meta.url); const isDirectRun = process.argv[1] ? path.resolve(process.argv[1]) === __filename : false; const configPath = path.join(__dirname, "config.json"); const rawCfg = fs.existsSync(configPath) ? JSON.parse(fs.readFileSync(configPath, "utf-8")) : {}; const CONFIG = { league: "Runes of Aldur", itemName: "The Desperate Alliance", itemType: "Vase Relic", maxPrice: 5, priceCurrency: "divine", ...rawCfg, poeSessionId: process.env.POESESSID || rawCfg.poeSessionId || "", poeToken: process.env.POETOKEN || rawCfg.poeToken || "", }; const mode = process.argv.includes("--serve") ? "serve" : process.argv.includes("--live") ? "live" : "full"; const SEEN_FILE = path.join(__dirname, "seen.json"); function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } let lastPoeRequestAt = 0; let poeRequestQueue = Promise.resolve(); async function waitForPoeSlot() { const sinceLast = Date.now() - lastPoeRequestAt; if (sinceLast < 1200) await sleep(1200 - sinceLast); lastPoeRequestAt = Date.now(); } function queuePoeRequest(task) { const queued = poeRequestQueue.then(task, task); poeRequestQueue = queued.catch(() => {}); return queued; } function fetchRetry(url, opts, retries = 10) { return queuePoeRequest(() => fetchRetryQueued(url, opts, retries)); } function parseRateLimitWaitMs(body) { try { const message = JSON.parse(body)?.error?.message || ""; const match = message.match(/wait\s+(\d+)\s+seconds/i); return match ? parseInt(match[1], 10) * 1000 + 1000 : 0; } catch { return 0; } } async function fetchRetryQueued(url, opts, retries) { for (let attempt = 0; attempt < retries; attempt++) { await waitForPoeSlot(); const res = await fetch(url, opts); if (res.ok) return res; const body = await res.text(); if (res.status === 429) { let wait = parseRateLimitWaitMs(body); if (!wait) wait = Math.min(5000 * (1.5 ** attempt) + Math.random() * 2000, 60000); const sec = Math.round(wait / 1000); if (sec > 300) { log(`⏳ Rate limit — надо ждать ${sec}s (>5m), выкидываю`, "red"); throw new Error(`Rate limit: ${sec}s. Подожди ${Math.ceil(sec / 60)} мин и попробуй снова`); } log(`⏳ Rate limit — ждём ${sec}s (attempt ${attempt + 1}/${retries})`, "yellow"); await sleep(wait); continue; } if (res.status >= 500) { const delay = Math.min(5000 * (1.5 ** attempt), 60000); log(`HTTP ${res.status} — retry ${attempt + 1}/${retries} in ${Math.round(delay / 1000)}s`, "yellow"); await sleep(delay); continue; } throw new Error(`HTTP ${res.status}: ${body.slice(0, 200)}`); } throw new Error(`Fetch failed after ${retries} retries`); } function publicConfig(cfg) { return { league: cfg.league, itemName: cfg.itemName, itemType: cfg.itemType, maxPrice: cfg.maxPrice, priceCurrency: cfg.priceCurrency, maxResults: cfg.maxResults, }; } function validateConfig(input) { const allowedCurrencies = new Set(["divine", "exalted", "chaos"]); const cfg = { league: String(input?.league || "").trim(), itemName: String(input?.itemName || "").trim(), itemType: String(input?.itemType || "").trim(), maxPrice: Number(input?.maxPrice), priceCurrency: String(input?.priceCurrency || "").toLowerCase(), }; if (!cfg.league || cfg.league.length > 100) throw Object.assign(new Error("Invalid league"), { statusCode: 400 }); if (!cfg.itemName || cfg.itemName.length > 200) throw Object.assign(new Error("Invalid item name"), { statusCode: 400 }); if (!cfg.itemType || cfg.itemType.length > 200) throw Object.assign(new Error("Invalid item type"), { statusCode: 400 }); if (!Number.isFinite(cfg.maxPrice) || cfg.maxPrice < 0) throw Object.assign(new Error("Invalid max price"), { statusCode: 400 }); if (!allowedCurrencies.has(cfg.priceCurrency)) throw Object.assign(new Error("Invalid currency"), { statusCode: 400 }); return cfg; } function readJsonBody(req, maxBytes = 32 * 1024) { return new Promise((resolve, reject) => { let body = ""; let size = 0; let tooLarge = false; req.on("data", chunk => { size += chunk.length; if (size > maxBytes) { tooLarge = true; return; } body += chunk; }); req.on("end", () => { if (tooLarge) { reject(Object.assign(new Error("Request body too large"), { statusCode: 413 })); return; } try { resolve(JSON.parse(body || "{}")); } catch { reject(Object.assign(new Error("Invalid JSON"), { statusCode: 400 })); } }); req.on("error", reject); }); } function escapeHtml(value) { return String(value ?? "") .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function parseLiveMessage(message) { const nestedResult = message?.message?.result; const result = message?.result; const ids = Array.isArray(message?.new) ? message.new : Array.isArray(nestedResult) ? nestedResult : Array.isArray(result) ? result : []; const token = typeof result === "string" ? result : typeof nestedResult === "string" ? nestedResult : ""; return { auth: message?.auth === true, ids, token, count: Number.isFinite(Number(message?.count)) ? Number(message.count) : 0, }; } function extractLiveIds(message) { return parseLiveMessage(message).ids; } function ts() { return new Date().toLocaleTimeString(); } function log(msg, color = "") { const c = { green: "\x1b[32m", yellow: "\x1b[33m", red: "\x1b[31m", cyan: "\x1b[36m", reset: "\x1b[0m" }; console.log(`${c.cyan}[${ts()}]${c.reset} ${c[color] || ""}${msg}${c.reset}`); } function copyToClipboard(text) { try { const escaped = text.replace(/'/g, "''"); execSync(`powershell -command "Set-Clipboard -Value '${escaped}'"`, { shell: "cmd.exe", timeout: 3000 }); return true; } catch { return false; } } function loadSeen() { try { if (fs.existsSync(SEEN_FILE)) return new Set(JSON.parse(fs.readFileSync(SEEN_FILE, "utf-8"))); } catch {} return new Set(); } function saveSeen(ids) { fs.writeFileSync(SEEN_FILE, JSON.stringify([...ids])); } function headers(extra) { const h = { "Content-Type": "application/json", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", Accept: "*/*", "X-Requested-With": "XMLHttpRequest", "Sec-Fetch-Dest": "empty", "Sec-Fetch-Mode": "cors", "Sec-Fetch-Site": "same-origin", "Referer": "https://www.pathofexile.com/trade2/", }; const cookies = []; if (CONFIG.poeSessionId) cookies.push(`POESESSID=${CONFIG.poeSessionId}`); if (CONFIG.poeToken) cookies.push(`POETOKEN=${CONFIG.poeToken}`); if (cookies.length) h.Cookie = cookies.join("; "); if (extra) Object.assign(h, extra); return h; } function wsHeaders() { const cookies = []; if (CONFIG.poeSessionId) cookies.push(`POESESSID=${CONFIG.poeSessionId}`); if (CONFIG.poeToken) cookies.push(`POETOKEN=${CONFIG.poeToken}`); return { "Cache-Control": "no-cache", Pragma: "no-cache", Origin: "https://www.pathofexile.com", ...(cookies.length ? { Cookie: cookies.join("; ") } : {}), "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", }; } async function search(override) { const c = override || CONFIG; const league = c.league || CONFIG.league || "Runes of Aldur"; const url = `https://www.pathofexile.com/api/trade2/search/poe2/${encodeURIComponent(league)}`; const body = { query: { status: { option: "available" }, name: c.itemName, type: c.itemType, filters: { trade_filters: { filters: { price: { option: c.priceCurrency } } } }, }, sort: { price: "asc" }, }; const res = await fetchRetry(url, { method: "POST", headers: headers(), body: JSON.stringify(body) }); return await res.json(); } const tradeTokens = new Map(); function rememberTradeTokens(items) { for (const item of items) { const id = item.id || item.listing?.id || ""; if (!id) continue; const tokens = {}; if (item.listing?.whisper_token) tokens.whisperToken = item.listing.whisper_token; if (item.listing?.hideout_token) tokens.hideoutToken = item.listing.hideout_token; if (tokens.whisperToken || tokens.hideoutToken) tradeTokens.set(id, tokens); } } async function fetchItems(ids, queryId) { if (!ids.length) return []; const allItems = []; for (let i = 0; i < ids.length; i += 10) { const batch = ids.slice(i, i + 10); const url = `https://www.pathofexile.com/api/trade2/fetch/${batch.join(",")}?query=${encodeURIComponent(queryId)}`; const res = await fetchRetry(url, { headers: headers() }); const data = await res.json(); rememberTradeTokens(data.result || []); allItems.push(...(data.result || [])); await sleep(300); } return allItems; } async function fetchLivePackage(token, queryId) { if (!token) return []; const url = `https://www.pathofexile.com/api/trade2/fetch/${encodeURIComponent(token)}?query=${encodeURIComponent(queryId)}&realm=poe2`; const res = await fetchRetry(url, { headers: headers() }); const data = await res.json(); const items = data.result || []; rememberTradeTokens(items); return items; } function extractPrice(item) { const listing = item.listing || {}; const p = listing.price; if (!p || p.amount == null) return null; const account = listing.account?.name || ""; const whisper = listing.whisper || ""; const seller = account || (whisper.startsWith("@") ? whisper.split(" ")[0].replace("@", "") : ""); const itemId = item.id || listing.id || ""; const cached = tradeTokens.get(itemId) || {}; return { amount: Number(p.amount), currency: (p.currency || "").toLowerCase(), whisper, seller, id: itemId, hideoutToken: listing.hideout_token || cached.hideoutToken || "", whisperToken: listing.whisper_token || cached.whisperToken || "", token: listing.hideout_token || cached.hideoutToken || "", }; } function matchesPrice(price, c) { if (!price) return false; return price.amount <= c.maxPrice && price.currency === c.priceCurrency; } // ─── MODES: full, live, serve ─────────────────────────────────────── async function runFull() { log("=== FULL SCAN ===", "green"); log(`${CONFIG.itemName} (${CONFIG.itemType}) ≤ ${CONFIG.maxPrice} ${CONFIG.priceCurrency} | ${CONFIG.league}`, "cyan"); try { const data = await search(CONFIG); const qid = data.id; const ids = (data.result || []).slice(0, 200); log(`Всего: ${data.total}, проверяю ${ids.length} дешёвых`, "cyan"); if (!ids.length) { log("Нет лотов", "yellow"); return; } const items = await fetchItems(ids, qid); const found = []; for (const item of items) { const p = extractPrice(item); if (matchesPrice(p, CONFIG)) found.push(p); } found.splice(20); if (!found.length) { let cheapest = "?"; for (const it of items) { const p = extractPrice(it); if (p) { cheapest = `${p.amount} ${p.currency}`; break; } } log(`Нет лотов ≤ ${CONFIG.maxPrice} ${CONFIG.priceCurrency} (самый дешёвый: ${cheapest})`, "yellow"); return; } log(`--- ${found.length} лотов ≤ ${CONFIG.maxPrice} ${CONFIG.priceCurrency} ---`, "green"); for (const p of found) { const url = `https://www.pathofexile.com/trade2/search/poe2/${encodeURIComponent(CONFIG.league)}/${qid}`; log(`${p.seller} - ${p.amount} ${p.currency}`, ""); log(` ${url}`, "cyan"); if (p.whisper) log(` ${p.whisper.slice(0, 120)}`, "green"); } const s = new Set(); for (const p of found) { if (p.id) s.add(p.id); } saveSeen(s); } catch (err) { log(`Error: ${err.message}`, "red"); } } async function runLive() { let seen = loadSeen(); log("=== LIVE ===", "green"); log(`${CONFIG.itemName} (${CONFIG.itemType}) ≤ ${CONFIG.maxPrice} ${CONFIG.priceCurrency} | ${CONFIG.league}`, "cyan"); let qid; let initialSearch; try { initialSearch = await search(CONFIG); qid = initialSearch.id; log(`Known: ${seen.size} items`, "cyan"); } catch (err) { log(`Search failed: ${err.message}`, "red"); process.exit(1); } const wsUrl = `wss://www.pathofexile.com/api/trade2/live/poe2/${encodeURIComponent(CONFIG.league)}/${qid}`; async function handleNewItems(items) { for (const item of items) { const price = extractPrice(item); const itemId = price?.id || item.id || item.listing?.id || ""; if (itemId && seen.has(itemId)) continue; if (itemId) seen.add(itemId); if (!price || !matchesPrice(price, CONFIG)) continue; const url = `https://www.pathofexile.com/trade2/search/poe2/${encodeURIComponent(CONFIG.league)}/${qid}`; log(`[NEW] ${price.seller} - ${price.amount} ${price.currency}`, "yellow"); log(` ${url}`, "cyan"); if (price.whisper) { log(` ${price.whisper.slice(0, 120)}`, "green"); copyToClipboard(price.whisper); } for (let i = 0; i < 3; i++) setTimeout(() => process.stdout.write("\x07"), i * 250); } saveSeen(seen); if (seen.size > 10000) { const a = [...seen]; seen = new Set(a.slice(-5000)); saveSeen(seen); } } async function handleNewIds(newIds) { const unknown = newIds.filter(id => !seen.has(id)); if (!unknown.length) return; const items = await fetchItems(unknown, qid); await handleNewItems(items); } if (initialSearch.result) { for (const id of initialSearch.result) seen.add(id); saveSeen(seen); } let cliItemQueue = Promise.resolve(); function queueCliIds(ids) { cliItemQueue = cliItemQueue.then(() => handleNewIds(ids)).catch(err => log(`Live item error: ${err.message}`, "red")); } function queueCliToken(token) { cliItemQueue = cliItemQueue .then(async () => handleNewItems(await fetchLivePackage(token, qid))) .catch(err => log(`Live package error: ${err.message}`, "red")); } let cliReconnAttempt = 0; function cliConnectWs() { const ws = new WebSocket(wsUrl, [], { headers: wsHeaders(), handshakeTimeout: 15000 }); ws.on("open", () => { cliReconnAttempt = 0; log("Live WS connected", "green"); }); ws.on("message", async (data) => { try { const msg = parseLiveMessage(JSON.parse(typeof data === "string" ? data : data.toString())); if (msg.auth) return; if (msg.token) queueCliToken(msg.token); else if (msg.ids.length) queueCliIds(msg.ids); } catch (e) { log(`Live message error: ${e.message}`, "red"); } }); ws.on("close", (code, reason) => { log(`Live WS closed: code=${code} ${reason || ""}`, "yellow"); const d = Math.min(3000 * Math.pow(1.5, cliReconnAttempt), 30000); cliReconnAttempt++; setTimeout(cliConnectWs, d); }); ws.on("error", (err) => log(`Live WS error: ${err.message}`, "red")); } cliConnectWs(); setInterval(async () => { try { const d = await search(CONFIG); if (d.result) queueCliIds(d.result); } catch (e) { log(`Live polling error: ${e.message}`, "yellow"); } }, 30000); process.stdin.resume(); } // ─── SERVE MODE ───────────────────────────────────────────────────── if (isDirectRun) log(`Config path: ${configPath}, hasSession: ${!!CONFIG.poeSessionId}`, "cyan"); const scannerToken = crypto.randomBytes(32).toString("hex"); function serveHTML() { const c = CONFIG; return ` POE2 Scanner

POE2 Scanner ${escapeHtml(c.league)}

Автопомощь Secure
Ready
Local scanner | session: ${CONFIG.poeSessionId ? "yes" : "no"}
SellerPriceAction
`; } function runServe() { const PORT = 3999; let liveWs = null; let liveFallbackPolling = null; let reconnectTimer = null; let reconnectAttempt = 0; let refreshingQid = false; let liveWsQid = null; let liveWsCfg = null; let liveRunning = false; let liveSeen = loadSeen(); let liveItemQueue = Promise.resolve(); let sseClients = []; let hrClients = []; let currentCfg = publicConfig(CONFIG); function broadcastSSE(data) { sseClients = sseClients.filter(c => { try { c.write(`data: ${JSON.stringify(data)}\n\n`); return true; } catch { return false; } }); } function queueLiveIds(ids, queryId = liveWsQid) { liveItemQueue = liveItemQueue .then(() => processLiveIds(ids, queryId)) .catch(e => log("Live item processing failed: " + (e?.message || e), "red")); return liveItemQueue; } function queueLiveToken(token, queryId = liveWsQid) { liveItemQueue = liveItemQueue .then(async () => processLiveItems(await fetchLivePackage(token, queryId), queryId)) .catch(e => log("Live package processing failed: " + (e?.message || e), "red")); return liveItemQueue; } async function processLiveIds(ids, queryId) { if (!liveRunning || !queryId || !liveWsCfg) return; const unknown = [...new Set(ids)].filter(id => !liveSeen.has(id)); if (!unknown.length) return; const items = await fetchItems(unknown, queryId); await processLiveItems(items, queryId); } async function processLiveItems(items, queryId) { if (!liveRunning || !queryId || !liveWsCfg) return; const newItems = []; for (const item of items) { const p = extractPrice(item); const itemId = p?.id || item.id || item.listing?.id || ""; if (itemId && liveSeen.has(itemId)) continue; if (itemId) liveSeen.add(itemId); if (p && matchesPrice(p, liveWsCfg)) newItems.push({ ...p, qid: queryId }); } if (liveSeen.size > 10000) liveSeen = new Set([...liveSeen].slice(-5000)); saveSeen(liveSeen); if (newItems.length) { log(`Live matched ${newItems.length} new item(s)`, "green"); broadcastSSE({ type: "new", items: newItems }); for (let i = 0; i < 3; i++) setTimeout(() => process.stdout.write("\x07"), i * 250); } } function stopFallbackPolling() { if (liveFallbackPolling) { clearInterval(liveFallbackPolling); liveFallbackPolling = null; log("Fallback polling stopped", "cyan"); } } function startFallbackPolling() { if (!liveRunning || !liveWsQid || !liveWsCfg) return; if (liveWs && liveWs.readyState <= WebSocket.OPEN) { stopFallbackPolling(); return; } if (liveFallbackPolling) return; log("WS down — fallback polling 30s", "yellow"); broadcastSSE({ type: "status", text: "WS down, fallback polling every 30s" }); liveFallbackPolling = setInterval(async () => { if (!liveRunning) { stopFallbackPolling(); return; } if (liveWs && liveWs.readyState <= WebSocket.OPEN) { stopFallbackPolling(); return; } try { const d = await search(liveWsCfg); if (d.result) await queueLiveIds(d.result, d.id); } catch (e) { log("Fallback polling failed: " + (e?.message || e), "yellow"); } }, 30000); } function connectLiveWs() { if (!liveRunning || !liveWsQid) return; const wsUrl = `wss://www.pathofexile.com/api/trade2/live/poe2/${encodeURIComponent(liveWsCfg.league)}/${liveWsQid}`; log("Connecting WS: " + wsUrl, "cyan"); try { const ws = new WebSocket(wsUrl, [], { headers: wsHeaders(), handshakeTimeout: 15000 }); liveWs = ws; ws.on("open", () => { if (liveWs !== ws || !liveRunning) { ws.close(); return; } reconnectAttempt = 0; stopFallbackPolling(); log("Live WebSocket connected", "green"); broadcastSSE({ type: "status", text: "Live!" }); }); ws.on("message", msg => { if (liveWs !== ws || !liveRunning) return; try { const liveMessage = parseLiveMessage(JSON.parse(typeof msg === "string" ? msg : msg.toString())); if (liveMessage.auth) return; if (liveMessage.token) { log(`Live package received: ${liveMessage.count || "?"} item(s)`, "cyan"); queueLiveToken(liveMessage.token, liveWsQid); } else if (liveMessage.ids.length) { queueLiveIds(liveMessage.ids, liveWsQid); } } catch (e) { log("Live message error: " + (e?.message || e), "red"); } }); ws.on("close", (code, reason) => { if (liveWs !== ws) return; liveWs = null; const msg = reason ? `WS closed: ${reason} (${code})` : `WS closed: code=${code}`; log(msg, "red"); broadcastSSE({ type: "status", text: msg }); if (liveRunning && !refreshingQid) { startFallbackPolling(); scheduleReconnect(); } }); ws.on("error", err => { if (liveWs !== ws) return; if (err?.message?.includes("404") || err?.message?.includes("Unexpected server response: 404")) { if (refreshingQid) return; log("WS qid expired, fetching new qid...", "yellow"); refreshingQid = true; try { ws.close(); } catch {} refreshQidAndReconnect(); } else { log("WS error: " + (err?.message || err), "red"); } }); } catch (e) { log("WS constructor failed: " + e.message, "red"); liveWs = null; startFallbackPolling(); scheduleReconnect(); } } function scheduleReconnect() { if (!liveRunning) return; if (reconnectTimer) clearTimeout(reconnectTimer); const delay = Math.min(3000 * Math.pow(1.5, reconnectAttempt), 30000); reconnectAttempt++; log(`WS reconnect in ${Math.round(delay)}ms (attempt ${reconnectAttempt})`, "yellow"); broadcastSSE({ type: "status", text: `Reconnecting in ${Math.round(delay)}ms...` }); reconnectTimer = setTimeout(() => { reconnectTimer = null; if (!liveRunning) return; connectLiveWs(); }, delay); } async function refreshQidAndReconnect() { if (!liveRunning) return; try { const data = await search(liveWsCfg); liveWsQid = data.id; log("New qid: " + liveWsQid, "green"); broadcastSSE({ type: "status", text: `New search ID obtained` }); refreshingQid = false; if (data.result) await queueLiveIds(data.result, data.id); connectLiveWs(); } catch (e) { refreshingQid = false; log("Failed to refresh qid: " + e.message, "red"); startFallbackPolling(); scheduleReconnect(); } } // hotreload при изменении scanner.mjs fs.watch(__filename, () => { log("Hotreload: file changed, reloading browsers", "yellow"); for (const c of hrClients) { try { c.write(`data: reload\n\n`); } catch {} } }); const server = http.createServer(async (req, res) => { const [pathname] = req.url.split("?"); const corsHeaders = { "X-Content-Type-Options": "nosniff", "Referrer-Policy": "no-referrer" }; if (req.method === "OPTIONS") { res.writeHead(204, corsHeaders); res.end(); return; } if (req.method === "POST" && req.headers["x-scanner-token"] !== scannerToken) { res.writeHead(403, { ...corsHeaders, "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Forbidden" })); return; } // Serve HTML if (pathname === "/" || pathname === "/index.html") { res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store, no-cache, must-revalidate", "Pragma": "no-cache" }); res.end(serveHTML()); return; } // API: config if (pathname === "/api/config" && req.method === "GET") { res.writeHead(200, { ...corsHeaders, "Content-Type": "application/json" }); res.end(JSON.stringify({ ...publicConfig(currentCfg), hasSession: !!CONFIG.poeSessionId })); return; } // API: search if (pathname === "/api/search" && req.method === "POST") { try { const cfg = validateConfig(await readJsonBody(req)); Object.assign(CONFIG, cfg); currentCfg = cfg; const data = await search(cfg); const qid = data.id; const ids = (data.result || []).slice(0, 200); const items = ids.length ? await fetchItems(ids, qid) : []; const found = []; for (const item of items) { const p = extractPrice(item); if (matchesPrice(p, cfg)) found.push({ ...p, qid }); } found.splice(20); const s = new Set(); for (const f of found) { if (f.id) s.add(f.id); } saveSeen(s); res.writeHead(200, { ...corsHeaders, "Content-Type": "application/json" }); res.end(JSON.stringify({ items: found, total: data.total || ids.length })); } catch (err) { res.writeHead(err.statusCode || 500, { ...corsHeaders, "Content-Type": "application/json" }); res.end(JSON.stringify({ error: err.message })); } return; } // API: live start if (pathname === "/api/live/start" && req.method === "POST") { try { const cfg = validateConfig(await readJsonBody(req)); Object.assign(CONFIG, cfg); currentCfg = cfg; if (liveRunning) { res.writeHead(200, { ...corsHeaders, "Content-Type": "application/json" }); res.end(JSON.stringify({ status: "already running" })); return; } const data = await search(cfg); liveWsQid = data.id; liveWsCfg = cfg; liveSeen = loadSeen(); for (const id of (data.result || [])) liveSeen.add(id); saveSeen(liveSeen); liveRunning = true; reconnectAttempt = 0; refreshingQid = false; if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } connectLiveWs(); broadcastSSE({ type: "status", text: `Cached ${liveSeen.size} known items. Live starting...` }); res.writeHead(200, { ...corsHeaders, "Content-Type": "application/json" }); res.end(JSON.stringify({ status: "ok" })); } catch (err) { liveRunning = false; liveWsQid = null; liveWsCfg = null; res.writeHead(err.statusCode || 500, { ...corsHeaders, "Content-Type": "application/json" }); res.end(JSON.stringify({ error: err.message })); } return; } // API: live stop if (pathname === "/api/live/stop" && req.method === "POST") { liveRunning = false; refreshingQid = false; stopFallbackPolling(); if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } if (liveWs) { try { liveWs.close(); } catch {} liveWs = null; } liveWsQid = null; liveWsCfg = null; res.writeHead(200, { ...corsHeaders, "Content-Type": "application/json" }); res.end(JSON.stringify({ status: "stopped" })); return; } // API: secure — отправляет POST /api/trade2/whisper if (pathname === "/api/secure" && req.method === "POST") { try { const { token } = await readJsonBody(req); if (!token) { log("Secure: no token", "red"); res.writeHead(400, { ...corsHeaders, "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "hideout token is empty — нужно перезайти на страницу поиска" })); return; } log("Secure: sending whisper request", "cyan"); const ac = new AbortController(); const to = setTimeout(() => { ac.abort(); log("Secure: PoE API timeout 15s", "red"); }, 15000); let apiRes; try { apiRes = await queuePoeRequest(async () => { await waitForPoeSlot(); return fetch("https://www.pathofexile.com/api/trade2/whisper", { method: "POST", headers: { ...headers(), "Content-Type": "application/json" }, body: JSON.stringify({ token: String(token) }), signal: ac.signal, }); }); } finally { clearTimeout(to); } const text = await apiRes.text(); let json; try { json = JSON.parse(text); } catch { json = { error: { message: `PoE вернул не-JSON (${apiRes.status}): ${text.slice(0, 200)}` } }; } log(`Secure response: ${apiRes.status} ${json?.error?.message || "OK"}`, "cyan"); res.writeHead(apiRes.status, { ...corsHeaders, "Content-Type": "application/json" }); res.end(JSON.stringify(json)); } catch (err) { log(`Secure error: ${err.message}`, "red"); res.writeHead(err.statusCode || 500, { ...corsHeaders, "Content-Type": "application/json" }); res.end(JSON.stringify({ error: err.message })); } return; } // SSE stream if (pathname === "/api/live/stream") { res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive", ...corsHeaders }); res.write(`data: ${JSON.stringify({ type: "status", text: liveRunning ? "Live active" : "Ready" })}\n\n`); sseClients.push(res); req.on("close", () => { sseClients = sseClients.filter(c => c !== res); }); return; } // Hotreload SSE if (pathname === "/api/hotreload") { res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" }); res.write("data: connected\n\n"); hrClients.push(res); req.on("close", () => { hrClients = hrClients.filter(c => c !== res); }); return; } if (pathname === "/sound.mp3") { const filePath$ = path.join(__dirname, "sound.mp3"); try { const content = fs.readFileSync(filePath$); res.writeHead(200, { "Content-Type": "audio/mpeg", "Cache-Control": "no-cache", "Content-Length": content.length }); res.end(content); } catch { res.writeHead(404); res.end(); } return; } res.writeHead(404); res.end("Not found"); }); server.on("error", (err) => { if (err.code === "EADDRINUSE") { log(`Port ${PORT} is already in use. Stop the old scanner and try again.`, "red"); } else { throw err; } }); server.listen(PORT, "127.0.0.1", () => { log(`Server: http://localhost:${PORT}`, "green"); }); } // ─── MAIN ────────────────────────────────────────────────────────── if (isDirectRun) { if (mode === "serve") { runServe(); } else if (mode === "live") { runLive(); } else { runFull(); } } export { escapeHtml, extractLiveIds, extractPrice, fetchLivePackage, matchesPrice, parseLiveMessage, parseRateLimitWaitMs, publicConfig, serveHTML, validateConfig };