1139 lines
46 KiB
JavaScript
1139 lines
46 KiB
JavaScript
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, """)
|
||
.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 `<!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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,'''); }
|
||
|
||
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 };
|
||
|