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 `
| Seller | Price | Action |
|---|