scanner/scanner.mjs
2026-07-29 21:03:59 +03:00

1139 lines
46 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
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 `<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>POE2 Scanner</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:'Segoe UI',system-ui,sans-serif;background:#0a0a0f;color:#e0e0e0;padding:20px;min-height:100vh}
h1{font-size:22px;color:#c8a655;margin-bottom:16px}
h1 span{color:#888;font-size:14px}
.controls{display:flex;gap:10px;flex-wrap:wrap;align-items:end;margin-bottom:16px}
.field{display:flex;flex-direction:column;gap:3px}
.field label{font-size:11px;color:#888;text-transform:uppercase;letter-spacing:.5px}
.field input,.field select{padding:6px 10px;background:#1a1a2e;border:1px solid #333;border-radius:4px;color:#e0e0e0;font-size:13px;min-width:90px}
.field input:focus,.field select:focus{outline:none;border-color:#c8a655}
.btn{padding:8px 18px;border:none;border-radius:4px;cursor:pointer;font-size:13px;font-weight:600;transition:.2s}
.btn-primary{background:#c8a655;color:#0a0a0f}.btn-primary:hover{background:#dbb86a}
.btn-live{background:#2a6e3f;color:#fff}.btn-live:hover{background:#388a51}
.btn-live.active{background:#6e2a2a;animation:pulse 1.5s infinite}
.btn-stop{background:#5a2a2a;color:#fff}.btn-stop:hover{background:#7a3a3a}
.btn-scan{background:#2a4a6e;color:#fff}.btn-scan:hover{background:#3a5a8e}
@keyframes pulse{0%{opacity:1}50%{opacity:.6}100%{opacity:1}}
.status{color:#888;font-size:13px;margin-bottom:12px;padding:8px 12px;background:#111;border-radius:4px;min-height:20px}
.status .count{color:#c8a655;font-weight:700}
.status .new{color:#4caf50;font-weight:700}
table{width:100%;border-collapse:collapse;font-size:13px}
th{text-align:left;padding:8px 10px;border-bottom:1px solid #333;color:#888;font-size:11px;text-transform:uppercase;letter-spacing:.5px;position:sticky;top:0;background:#0a0a0f}
td{padding:8px 10px;border-bottom:1px solid #1a1a2e;color:#ccc}
tr:hover td{background:#12121f}
tr.new td{background:#1a3a2a}
.price{color:#c8a655;font-weight:600}
.seller{color:#e0e0e0}
.btn-buy{padding:4px 12px;background:#1a4a2e;border:none;border-radius:3px;color:#fff;cursor:pointer;font-size:11px;margin:0 2px}
.btn-buy:hover{background:#2a6e3f}
.btn-wisp{padding:4px 10px;background:#2a3a5e;border:none;border-radius:3px;color:#fff;cursor:pointer;font-size:11px;margin:0 2px}
.btn-wisp:hover{background:#3a4a7e}
.btn-travel{padding:4px 10px;background:#5a3a2a;border:none;border-radius:3px;color:#fff;cursor:pointer;font-size:11px;margin:0 2px}
.btn-travel:hover{background:#7a4a3a}
.btn-delay{padding:4px 10px;background:#55451f;border:none;border-radius:3px;color:#fff;cursor:pointer;font-size:11px;margin:0 2px}
.btn-delay:hover{background:#75612c}.btn-delay:disabled{opacity:.65;cursor:default}
.assist-control{display:flex;align-items:center;gap:8px;padding:5px 10px;background:#141420;border:1px solid #333;border-radius:5px}
.assist-control span{font-size:12px;color:#bbb}
.switch{position:relative;display:inline-block;width:44px;height:24px}
.switch input{opacity:0;width:0;height:0}
.slider{position:absolute;cursor:pointer;inset:0;background:#555;border-radius:24px;transition:.2s}
.slider:before{content:"";position:absolute;width:18px;height:18px;left:3px;bottom:3px;background:#fff;border-radius:50%;transition:.2s}
.switch input:checked+.slider{background:#2a8a4a}
.switch input:checked+.slider:before{transform:translateX(20px)}
.toast{position:fixed;bottom:20px;right:20px;background:#1a3a2a;border:1px solid #2a5a3a;padding:12px 20px;border-radius:6px;font-size:13px;z-index:999;animation:fadeIn .3s;max-width:400px}
.toast strong{color:#4caf50}
@keyframes fadeIn{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}
.hidden{display:none}
.live-badge{display:inline-block;width:8px;height:8px;border-radius:50%;background:#555;margin-right:6px;vertical-align:middle}
.live-badge.active{background:#4caf50;animation:pulse 1.5s infinite}
.sess-indicator{display:inline-block;width:10px;height:10px;border-radius:50%;margin-left:8px;vertical-align:middle;transition:.3s}
.sess-on{background:#4caf50;box-shadow:0 0 6px #4caf50}
.sess-off{background:#ff5252;box-shadow:0 0 6px #ff5252}
</style>
</head>
<body>
<h1>POE2 Scanner <span id="leagueLabel">${escapeHtml(c.league)}</span><span class="sess-indicator ${c.poeSessionId ? 'sess-on' : 'sess-off'}" id="sess" title="${c.poeSessionId ? 'POESESSID: есть' : 'POESESSID: НЕТ'}"></span></h1>
<div class="controls">
<div class="field"><label>Item</label><input id="itemName" value="${escapeHtml(c.itemName)}"></div>
<div class="field"><label>Type</label><input id="itemType" value="${escapeHtml(c.itemType)}"></div>
<div class="field"><label>Max Price</label><input id="maxPrice" value="${escapeHtml(c.maxPrice)}" type="number" min="0" step="0.5"></div>
<div class="field"><label>Currency</label><select id="currency"><option ${c.priceCurrency==='divine'?'selected':''}>divine</option><option ${c.priceCurrency==='exalted'?'selected':''}>exalted</option><option ${c.priceCurrency==='chaos'?'selected':''}>chaos</option></select></div>
<div class="field"><label>League</label><input id="leagueInput" value="${escapeHtml(c.league)}"></div>
<button class="btn btn-scan" onclick="doSearch()">🔍 Full Scan</button>
<button class="btn btn-live" id="liveBtn" onclick="toggleLive()">🟢 Live</button>
<div class="assist-control">
<label class="switch" title="Автоматически нажимать Secure через 0.5 секунды у каждого нового Live-лота">
<input id="assistToggle" type="checkbox">
<span class="slider"></span>
</label>
<span>Автопомощь Secure</span>
</div>
</div>
<div class="status" id="status">Ready</div>
<div id="debug" style="font-size:11px;color:#555;margin-bottom:8px;word-break:break-all">Local scanner | session: ${CONFIG.poeSessionId ? "yes" : "no"}</div>
<div id="tableWrap" style="max-height:70vh;overflow-y:auto">
<table><thead><tr><th>Seller</th><th>Price</th><th>Action</th></tr></thead><tbody id="results"></tbody></table>
</div>
<div id="toast" class="toast hidden"></div>
<script>
const CSRF_TOKEN = ${JSON.stringify(scannerToken)};
let liveActive = false;
let eventSource = null;
let liveTradeIds = new Set();
let autoSecureAssist = localStorage.getItem('autoSecureAssist') === 'on';
function getCfg() { return {
itemName: document.getElementById('itemName').value,
itemType: document.getElementById('itemType').value,
maxPrice: Number.isFinite(parseFloat(document.getElementById('maxPrice').value)) ? parseFloat(document.getElementById('maxPrice').value) : 5,
priceCurrency: document.getElementById('currency').value,
league: document.getElementById('leagueInput').value,
}; }
function status(msg, cls='') {
const el = document.getElementById('status');
el.textContent = msg;
el.style.color = cls === 'green' ? '#4caf50' : cls === 'red' ? '#ff5252' : '#888';
}
function toast(msg) {
const el = document.getElementById('toast');
el.textContent = msg; el.classList.remove('hidden');
clearTimeout(el._t); el._t = setTimeout(() => el.classList.add('hidden'), 5000);
}
function beep() {
try { new Audio('/sound.mp3').play(); } catch(e) {}
}
function tradeKey(item) {
return item.id || [item.qid, item.seller, item.amount, item.currency].join('|');
}
function resetLiveResults() {
liveTradeIds.clear();
document.getElementById('results').innerHTML = '';
}
function enqueueTrade(item) {
const key = tradeKey(item);
if (liveTradeIds.has(key)) return false;
liveTradeIds.add(key);
const row = addRow(item, true);
if (autoSecureAssist) row.querySelector('.btn-delay').click();
return true;
}
function secureTravel(token, seller, btn) {
if (!token) { toast('Travel unavailable — у этого лота нет hideout token'); return; }
const orig = btn.textContent;
btn.textContent = '⏳';
btn.disabled = true;
fetch('/api/secure', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-Scanner-Token': CSRF_TOKEN},
body: JSON.stringify({token: token})
}).then(function(r) {
return r.text().then(function(t) {
try { return JSON.parse(t); } catch(e) { return { error: { message: 'Server returned non-JSON: ' + t.slice(0, 100) } }; }
});
}).then(function(d) {
if (typeof d.error === 'string') { toast('Error: ' + d.error); return }
if (d.error && d.error.message) { toast('Error: ' + d.error.message); return }
if (d.error) { toast('Error code ' + d.error.code); return }
toast("✅ Secure sent! Teleporting to " + seller + "'s hideout...");
}).catch(function(e) {
toast('Request failed: ' + e.message + ' — сервер не отвечает?');
}).finally(function() {
setTimeout(function() { btn.textContent = orig; btn.disabled = false }, 3000);
});
}
function addRow(item, isNew) {
const tbody = document.getElementById('results');
const tr = document.createElement('tr');
if (isNew) tr.className = 'new';
const seller = item.seller || '';
const tradeUrl = 'https://www.pathofexile.com/trade2/search/poe2/' + encodeURIComponent(getCfg().league) + '/' + item.qid;
var token = item.hideoutToken || item.token || '';
tr.innerHTML = '<td class="seller">' + escapeHtml(seller) + '</td>'
+ '<td class="price">' + item.amount + ' ' + item.currency + '</td>'
+ '<td nowrap>'
+ '<button class="btn-wisp" data-w="' + escapeHtml(item.id) + '" data-q="' + escapeHtml(item.qid) + '">💬 Whisper</button>'
+ '<button class="btn-travel" data-token="' + escapeHtml(token) + '" data-tseller="' + escapeHtml(seller) + '">🔒 Secure</button>'
+ '<button class="btn-delay">⏱ Secure через 0.5с</button>'
+ '<a href="' + tradeUrl + '" target="_blank"><button class="btn-buy">🔗 Купить</button></a>'
+ '</td>';
// whisper
tr.querySelector('.btn-wisp').onclick = function() {
var id = this.getAttribute('data-w');
var w = window._whispers && window._whispers[id];
if (!w) { toast('Whisper unavailable'); return }
navigator.clipboard.writeText(w).then(function() {
var orig = this.textContent; this.textContent = '✅ Done'; this.style.background = '#1a4a2e';
var me = this; setTimeout(function() { me.textContent = orig; me.style.background = '' }, 2000);
}.bind(this)).catch(function() {
var ta = document.createElement('textarea'); ta.value = w; document.body.appendChild(ta); ta.select();
document.execCommand('copy'); document.body.removeChild(ta);
});
};
// Один и тот же Secure для Full Scan и Live.
tr.querySelector('.btn-travel').onclick = function() {
var token = this.getAttribute('data-token');
var s = this.getAttribute('data-tseller');
secureTravel(token, s, this);
};
tr.querySelector('.btn-delay').onclick = function() {
var delayBtn = this;
var secureBtn = tr.querySelector('.btn-travel');
delayBtn.disabled = true;
delayBtn.textContent = '⏱ 0.5с';
setTimeout(function() {
delayBtn.textContent = '✅ Secure';
secureBtn.click();
setTimeout(function() {
delayBtn.textContent = '⏱ Secure через 0.5с';
delayBtn.disabled = false;
}, 3000);
}, 500);
};
tbody.appendChild(tr);
return tr;
}
function copyWisp(text, btn) {
if (!text) { toast('No whisper — missing from listing'); return }
navigator.clipboard.writeText(text).then(() => {
const orig = btn.textContent;
btn.textContent = '✅ Copied';
btn.style.background = '#1a4a2e';
setTimeout(() => { btn.textContent = orig; btn.style.background = '' }, 2000);
}).catch(() => {
const ta = document.createElement('textarea'); ta.value = text; document.body.appendChild(ta); ta.select();
document.execCommand('copy'); document.body.removeChild(ta);
toast('Whisper copied!');
});
}
function escapeHtml(s) { return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;'); }
async function doSearch() {
document.getElementById('results').innerHTML = '';
const cfg = getCfg();
status('Scanning...', '');
try {
const r = await fetch('/api/search', { method: 'POST', headers: {'Content-Type':'application/json', 'X-Scanner-Token': CSRF_TOKEN}, body: JSON.stringify(cfg) });
const d = await r.json();
if (d.error) { status('Error: ' + d.error, 'red'); return; }
window._whispers = {}; for (const it of d.items) { if (it.id && it.whisper) window._whispers[it.id] = it.whisper }
const tbody = document.getElementById('results');
for (const item of d.items) { addRow(item, false); }
status(d.items.length + ' items \u2264 ' + getCfg().maxPrice + ' ' + getCfg().priceCurrency
+ ' (total ' + d.total + ' listings)', 'green');
} catch(e) { status('Error: ' + e.message, 'red'); }
}
function toggleLive() {
const btn = document.getElementById('liveBtn');
if (!liveActive) {
btn.disabled = true;
btn.textContent = '⏳';
status('Starting...', 'green');
fetch('/api/live/start', { method: 'POST', headers: {'Content-Type':'application/json', 'X-Scanner-Token': CSRF_TOKEN}, body: JSON.stringify(getCfg()) })
.then(r => r.json()).then(d => {
if (d.error) { status('Live error: ' + d.error, 'red'); btn.disabled = false; btn.textContent = '🟢 Live'; return; }
liveActive = true;
btn.textContent = '⏹ Stop Live';
btn.disabled = false;
btn.classList.add('active');
resetLiveResults();
status('Live connected. Waiting for new items...', 'green');
connectSSE();
}).catch(e => { status('Error: ' + e.message, 'red'); btn.disabled = false; btn.textContent = '🟢 Live'; });
} else {
fetch('/api/live/stop', { method: 'POST', headers: {'X-Scanner-Token': CSRF_TOKEN} }).catch(()=>{});
if (eventSource) { eventSource.close(); eventSource = null; }
liveActive = false;
resetLiveResults();
btn.textContent = '🟢 Live';
btn.classList.remove('active');
status('Live stopped', '');
}
}
function connectSSE() {
if (eventSource) eventSource.close();
window._whispers = {};
eventSource = new EventSource('/api/live/stream');
eventSource.onmessage = function(e) {
try {
const d = JSON.parse(e.data);
if (d.type === 'status') {
status(d.text, '');
} else if (d.type === 'new') {
let added = 0;
for (const item of d.items) {
if (item.id && item.whisper) window._whispers[item.id] = item.whisper;
if (enqueueTrade(item)) added++;
}
if (added) {
beep();
toast('Новых лотов: ' + added);
status('Live active. Лотов в таблице: ' + liveTradeIds.size, 'green');
}
}
} catch(e) {}
};
eventSource.onerror = function() {
if (liveActive) setTimeout(connectSSE, 3000);
};
}
fetch('/api/config').then(r=>r.json()).then(d=>{
const el = document.getElementById('sess');
if (d.hasSession) { el.className = 'sess-indicator sess-on'; el.title = 'POESESSID: есть' }
else { el.className = 'sess-indicator sess-off'; el.title = 'POESESSID: НЕТ — API будет банить' }
document.getElementById('debug').textContent = 'Local scanner | session: ' + (d.hasSession ? 'yes' : 'no') + ' | league: ' + d.league;
});
const assistToggle = document.getElementById('assistToggle');
assistToggle.checked = autoSecureAssist;
assistToggle.onchange = function() {
autoSecureAssist = this.checked;
localStorage.setItem('autoSecureAssist', autoSecureAssist ? 'on' : 'off');
toast(autoSecureAssist ? '✅ Автопомощь Secure включена' : 'Автопомощь Secure выключена');
};
new EventSource('/api/hotreload').onmessage = function(e) {
if (e.data === 'reload') {
toast('🔄 Hotreload: code changed, reloading...');
setTimeout(function() { location.reload() }, 500);
}
};
</script>
</body>
</html>`;
}
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 };