From 3177d872234e2bdae26aad413b86cb737afacbd4 Mon Sep 17 00:00:00 2001 From: yoshaman Date: Wed, 29 Jul 2026 21:03:59 +0300 Subject: [PATCH] Initial PoE2 scanner --- .gitignore | 7 + AGENTS.md | 112 ++++ AccessibilityClickHelper.cs | 189 ++++++ AccessibilityClickHelper.exe | Bin 0 -> 8704 bytes README.md | 85 +++ build-accessibility-helper.bat | 9 + config.example.json | 10 + full-scan.bat | 6 + launch.vbs | 3 + live.bat | 8 + package-lock.json | 34 + package.json | 12 + run-web.bat | 7 + run.bat | 21 + scanner.mjs | 1138 ++++++++++++++++++++++++++++++++ scanner.test.mjs | 105 +++ sound.mp3 | Bin 0 -> 122390 bytes 17 files changed, 1746 insertions(+) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 AccessibilityClickHelper.cs create mode 100644 AccessibilityClickHelper.exe create mode 100644 README.md create mode 100644 build-accessibility-helper.bat create mode 100644 config.example.json create mode 100644 full-scan.bat create mode 100644 launch.vbs create mode 100644 live.bat create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 run-web.bat create mode 100644 run.bat create mode 100644 scanner.mjs create mode 100644 scanner.test.mjs create mode 100644 sound.mp3 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2b8a574 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +config.json +startup.json +seen.json +*.log +server.err + diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..6cdedb6 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,112 @@ +# PoE 2 Scanner — Session Summary (27 Jul 2026) + +## Hardening work log (29 Jul 2026) + +- Удалены реальные `POESESSID` из bat-файлов, `config.json` очищен. +- Удалён `startup.json` и код, который записывал в него полный конфиг. +- Добавлены `.gitignore` и `config.example.json`. +- Сервер слушает только `127.0.0.1`; CORS wildcard удалён. +- Все POST-эндпоинты защищены случайным токеном Web UI. +- `/api/config` больше не возвращает секреты и абсолютный путь. +- Исправлены конфликт DOM id для league и затенение модуля `path` в `/sound.mp3`. +- Добавлены валидация конфигурации, лимит тела запроса и безопасный текстовый вывод UI. +- Запросы к PoE сериализованы через общую очередь с паузой 1,2 секунды. +- Live сначала кеширует текущие ID, затем открывает WebSocket. +- Обработка live-ID и WS reconnect сериализована; старый сокет не может затереть новый. +- Сервер больше не завершает произвольный процесс на порту 3999. +- Добавлены `node:test`, `npm test` и `npm run check`. + +## Что было сделано / починено + +### WGAP work log (27 Jul 2026 — Nachmittagssitzung) + +- **`Referer` header** — добавлен `"Referer": "https://www.pathofexile.com/trade2/"` в `headers()` (строка 100). Без него Cloudflare мог ебать. +- **WS message format fix** — PoE 2 Live WS шлёт `{"message":{"result":["id1","id2"]}}`, а не `{"new":[...]}`. Пофикшено в двух местах: + - Сервер `connectLiveWs` (строка 613): теперь разбирает `m.new || m.message?.result || []`. + - CLI `runLive` (строка 253): то же самое. +- **`cacheReady` выпилен с фронтенда** — переменная блокировала показ новых лотов до прихода "Cached N items" из SSE. Убрана целиком (строка 487, 494, 497). +- **Secure / Whisper работает** — подтверждено: `POST /api/trade2/whisper` возвращает `{"success":true}`, PoE телепортит в зайдаут продавца. Работает только когда персонаж в игре. + +### Open issues (27 Jul 2026) + +- **Full Scan** (🔍) в serve-mode — зависает на "Scanning..." или возвращает пустой результат (не тестили при чистом рейт-лимите). +- **Live не показывает реаль-time лоты** — фикс WS message формата должен починить, но не тестили (рейт-лимит). + +### Auth & API +- **POETOKEN (JWT) auth**: `headers()` шлёт `POETOKEN` в Cookie вместе с `POESESSID`. +- **hideout_token extraction**: `extractPrice()` возвращает `listing.hideout_token` как `hideoutToken`. +- **`/api/secure`**: прокси `POST /api/trade2/whisper` с JWT и полными куками — PoE возвращает `code:2` (нужно быть в игре). +- **WS headers**: `wsHeaders()` шлёт `POESESSID`+`POETOKEN` в Cookie, `Origin`, `User-Agent`, `Cache-Control`, `Pragma`. Без `Accept`, `Content-Type`, `Referer`, `X-Requested-With`. +- **PoE 2 Live WebSocket**: URL `wss://.../api/trade2/live/poe2/{league}/{qid}`. Подтверждён кодом POEFlip (Swift, URLSessionWebSocketTask). Не требует пингов — PoE не шлёт пинг/понг, сервер дропает через ~10с (1006). + +### Rate limit (НОВОЕ) +- **Рейт-лимит PoE**: возвращает HTTP 429 с `{"error":{"code":3,"message":"Rate limit exceeded; Please wait N seconds before trying again."}}`. +- **`fetchRetry()` переписана**: + - Читает `wait N seconds` из ответа и ждёт реальное время. + - Если N > 300 (5 мин) — сразу кидает `Rate limit: Ns. Подожди X мин...` + - Если N ≤ 300 — ждёт и ретраит (до 10 попыток). + - На 5xx — exponential backoff до 60s. + - **Глобальный cooldown 1.2с** между любыми запросами к PoE API. +- **`curl.exe` Cloudflare block**: curl без `cf_clearance` получает Cloudflare 403/429. Node.js `fetch()` работает нормально. + +### Live mode (НОВОЕ) +- **Кнопка Live больше не виснет на "⏳":** `fetchItems()` с начальными 200 ID вынесен ПОСЛЕ `res.end()`. Ответ приходит сразу, кача начальных предметов идёт в фоне. +- **SSE стрим**: шлёт статусы (`Live!`, `Cached N known items`, ошибки). +- **Autoreconnect**: exponential backoff (3s×1.5^attempt, max 30s), рефреш qid при 404. + +### Фронтенд +- **Double-click fix**: кнопка Live дизейблится сразу на клик — `⏳` + `Starting...`. +- **Secure button**: `fetch('/api/secure', {token})` вместо мёртвого `poe2://`. +- **Ошибки**: отображаются в статус-баре (красный текст). +- **Sound**: `new Audio('/sound.mp3').play()` вместо `\x07`. + +### Баги +- CORS: переменная переименована `headers`→`corsHeaders` чтоб не затенять глобальную `headers()`. +- WS: убран пинг (PoE дропает сам), добавлен автоматический реконнект. +- JSON parse on server: исправлена отправка `Content-Length` (предыдущий баг с curl был из-за отсутствия `req.end()`). + +## Как это работает +1. Search → PoE API возвращает `listing.hideout_token` (JWT) **только** когда в запросе есть `POESESSID` + `POETOKEN`. +2. User жмёт Secure → фронтенд шлёт `POST /api/secure {token}`. +3. Сервер проксит `POST /api/trade2/whisper {token}` с полными куками. +4. PoE API отвечает `{"error":{"code":2,"message":"Your account must be in-game..."}}` — ожидаемо, работает только в игре. +5. Live: WS `wss://.../api/trade2/live/poe2/{league}/{qid}` + polling fallback 30s. + +## Известные проблемы +- **Рейт-лимит PoE** (~1 req/s на аккаунт). При превышении — ждать N минут. После долгого тестирования может быть 25-30 мин. +- **WS дропается через ~10с** (код 1006) — сервер PoE сам закрывает. Autoreconnect держит соединение. Если WS не взлетает (Cloudflare), работает polling раз в 30с. +- **`curl.exe`** к PoE API не работает — Cloudflare блокирует. Только через Node.js `fetch()` или браузер. +- **`cf_clearance`** кука в WS не передаётся — если Cloudflare начинает дропать WSS, переходим на polling. + +## Config +- `config.json`: `poeSessionId` (POESESSID) + `poeToken` (POETOKEN JWT) +- `seen.json`: cache of seen item IDs +- Никакого `startup.json` — удалён, не нужен + +## Как запускать +```powershell +# Full scan (консоль) +node scanner.mjs + +# Serve mode (http://localhost:3999) +node scanner.mjs --serve + +# Live mode (консоль) +node scanner.mjs --live +``` + +## Ключевые файлы +- `scanner.mjs` — main scanner + HTTP server (всё в одном) +- `config.json` — настройки + авторизация +- `launch.vbs` — скрытый запуск через полные пути +- `AGENTS.md` — этот файл +- `sound.mp3` — звук уведомления + +## Сессионный ритуал +При старте сессии с агентом **в первую очередь** читать папку `C:\Users\shama\Desktop\skills\pohuy\`: +- `SKILL.md` — базовый режим +- `references/slovar.md` — словарь +- `references/sceny.md` — шкала и сцены + +Вести себя в стиле **ultra**-режима скилла `pohuy` (идиоматический русский мат, инженерная лексика, плотность ultra). +Не сползать обратно в вежливость. Выключение: «нормальный режим» / «хватит материться». diff --git a/AccessibilityClickHelper.cs b/AccessibilityClickHelper.cs new file mode 100644 index 0000000..876d9fc --- /dev/null +++ b/AccessibilityClickHelper.cs @@ -0,0 +1,189 @@ +using System; +using System.Drawing; +using System.Runtime.InteropServices; +using System.Windows.Forms; + +internal static class NativeMethods +{ + internal const int WM_HOTKEY = 0x0312; + internal const int HOTKEY_ID = 1; + internal const uint KEYEVENTF_KEYUP = 0x0002; + internal const byte VK_CONTROL = 0x11; + internal const uint MOUSEEVENTF_LEFTDOWN = 0x0002; + internal const uint MOUSEEVENTF_LEFTUP = 0x0004; + + [DllImport("user32.dll")] + internal static extern bool RegisterHotKey(IntPtr hWnd, int id, uint modifiers, uint key); + + [DllImport("user32.dll")] + internal static extern bool UnregisterHotKey(IntPtr hWnd, int id); + + [DllImport("user32.dll")] + internal static extern void keybd_event(byte virtualKey, byte scanCode, uint flags, UIntPtr extraInfo); + + [DllImport("user32.dll")] + internal static extern void mouse_event(uint flags, uint dx, uint dy, uint data, UIntPtr extraInfo); +} + +internal sealed class HelperForm : Form +{ + private const int DwellMilliseconds = 100; + private const int MovementThreshold = 6; + + private readonly Timer timer; + private readonly Button toggleButton; + private readonly Label statusLabel; + private Point anchor; + private DateTime stableSince; + private DateTime cooldownUntil; + private bool enabled; + private bool clickLocked; + + internal HelperForm() + { + Text = "PoE2 Accessibility Click Helper"; + ClientSize = new Size(500, 250); + MinimumSize = new Size(500, 250); + StartPosition = FormStartPosition.CenterScreen; + TopMost = true; + BackColor = Color.FromArgb(15, 15, 24); + ForeColor = Color.White; + Font = new Font("Segoe UI", 12F); + + var title = new Label { + Text = "Помощник Ctrl + Left Click", + Font = new Font("Segoe UI", 18F, FontStyle.Bold), + ForeColor = Color.FromArgb(220, 190, 100), + AutoSize = false, + TextAlign = ContentAlignment.MiddleCenter, + Bounds = new Rectangle(15, 15, 470, 45) + }; + + toggleButton = new Button { + Text = "ВКЛЮЧИТЬ (F8)", + Font = new Font("Segoe UI", 18F, FontStyle.Bold), + BackColor = Color.FromArgb(40, 110, 65), + ForeColor = Color.White, + FlatStyle = FlatStyle.Flat, + Bounds = new Rectangle(60, 70, 380, 70) + }; + toggleButton.Click += delegate { Toggle(); }; + + statusLabel = new Label { + Text = "ВЫКЛЮЧЕНО — кликов не будет", + Font = new Font("Segoe UI", 12F, FontStyle.Bold), + ForeColor = Color.FromArgb(255, 100, 100), + AutoSize = false, + TextAlign = ContentAlignment.MiddleCenter, + Bounds = new Rectangle(20, 148, 460, 35) + }; + + var help = new Label { + Text = "Наведи курсор и удерживай 0,1с. Один Ctrl+Click, затем сдвинь курсор.\nF8 — мгновенно включить или выключить.", + ForeColor = Color.FromArgb(190, 190, 200), + AutoSize = false, + TextAlign = ContentAlignment.MiddleCenter, + Bounds = new Rectangle(20, 185, 460, 50) + }; + + Controls.Add(title); + Controls.Add(toggleButton); + Controls.Add(statusLabel); + Controls.Add(help); + + timer = new Timer { Interval = 20 }; + timer.Tick += OnTimerTick; + timer.Start(); + + anchor = Cursor.Position; + stableSince = DateTime.UtcNow; + } + + protected override void OnHandleCreated(EventArgs e) + { + base.OnHandleCreated(e); + NativeMethods.RegisterHotKey(Handle, NativeMethods.HOTKEY_ID, 0, (uint)Keys.F8); + } + + protected override void OnHandleDestroyed(EventArgs e) + { + NativeMethods.UnregisterHotKey(Handle, NativeMethods.HOTKEY_ID); + base.OnHandleDestroyed(e); + } + + protected override void WndProc(ref Message message) + { + if (message.Msg == NativeMethods.WM_HOTKEY && message.WParam.ToInt32() == NativeMethods.HOTKEY_ID) { + Toggle(); + return; + } + base.WndProc(ref message); + } + + private void Toggle() + { + enabled = !enabled; + clickLocked = false; + cooldownUntil = DateTime.MinValue; + anchor = Cursor.Position; + stableSince = DateTime.UtcNow; + toggleButton.Text = enabled ? "ВЫКЛЮЧИТЬ (F8)" : "ВКЛЮЧИТЬ (F8)"; + toggleButton.BackColor = enabled ? Color.FromArgb(135, 45, 45) : Color.FromArgb(40, 110, 65); + statusLabel.Text = enabled ? "ВКЛЮЧЕНО — удержание 0,1с" : "ВЫКЛЮЧЕНО — кликов не будет"; + statusLabel.ForeColor = enabled ? Color.FromArgb(90, 230, 125) : Color.FromArgb(255, 100, 100); + } + + private void OnTimerTick(object sender, EventArgs e) + { + if (!enabled) return; + + DateTime now = DateTime.UtcNow; + Point current = Cursor.Position; + int dx = current.X - anchor.X; + int dy = current.Y - anchor.Y; + bool moved = (dx * dx + dy * dy) >= MovementThreshold * MovementThreshold; + + if (moved) { + anchor = current; + stableSince = now; + } + + if (clickLocked) { + if (now < cooldownUntil) { + double remaining = Math.Ceiling((cooldownUntil - now).TotalMilliseconds / 100.0) / 10.0; + statusLabel.Text = "Пауза " + remaining.ToString("0.0") + "с"; + return; + } + clickLocked = false; + stableSince = now; + statusLabel.Text = "ВКЛЮЧЕНО — готов к следующему клику"; + } + + if (Bounds.Contains(current)) return; + if ((now - stableSince).TotalMilliseconds < DwellMilliseconds) return; + + clickLocked = true; + cooldownUntil = now.AddSeconds(3); + PerformControlClick(); + statusLabel.Text = "CTRL + CLICK выполнен — пауза 3,0с"; + } + + private static void PerformControlClick() + { + NativeMethods.keybd_event(NativeMethods.VK_CONTROL, 0, 0, UIntPtr.Zero); + NativeMethods.mouse_event(NativeMethods.MOUSEEVENTF_LEFTDOWN, 0, 0, 0, UIntPtr.Zero); + NativeMethods.mouse_event(NativeMethods.MOUSEEVENTF_LEFTUP, 0, 0, 0, UIntPtr.Zero); + NativeMethods.keybd_event(NativeMethods.VK_CONTROL, 0, NativeMethods.KEYEVENTF_KEYUP, UIntPtr.Zero); + } +} + +internal static class Program +{ + [STAThread] + private static void Main() + { + Application.EnableVisualStyles(); + Application.SetCompatibleTextRenderingDefault(false); + Application.Run(new HelperForm()); + } +} diff --git a/AccessibilityClickHelper.exe b/AccessibilityClickHelper.exe new file mode 100644 index 0000000000000000000000000000000000000000..60a724e131bf0cad0b6282127e985a1ce0d05b02 GIT binary patch literal 8704 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 c1fT8Szy})<|2_Z3x-=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..79cedaa --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "name": "poe2-scanner", + "private": true, + "type": "module", + "scripts": { + "check": "node --check scanner.mjs", + "test": "node --test" + }, + "dependencies": { + "ws": "^8.21.1" + } +} diff --git a/run-web.bat b/run-web.bat new file mode 100644 index 0000000..e287a2b --- /dev/null +++ b/run-web.bat @@ -0,0 +1,7 @@ +@echo off +cd /d "%~dp0" +echo Starting POE2 Scanner Web UI... +echo This window will close automatically. +start launch.vbs +timeout /t 2 /nobreak >nul +exit \ No newline at end of file diff --git a/run.bat b/run.bat new file mode 100644 index 0000000..7d24909 --- /dev/null +++ b/run.bat @@ -0,0 +1,21 @@ +@echo off +cd /d "%~dp0" +echo POE2 Scanner +echo ============ +echo 1. Full Scan (console) +echo 2. Live Mode (console) +echo 3. Web UI (opens browser, no console) +echo. +choice /c 123 /n /m "Select [1] Full [2] Live [3] Web: " +if errorlevel 3 goto web +if errorlevel 2 goto live +node scanner.mjs --full +goto end +:live +node scanner.mjs --live +goto end +:web +start launch.vbs +goto end +:end +if not "%1"=="/q" pause diff --git a/scanner.mjs b/scanner.mjs new file mode 100644 index 0000000..1f2a7ac --- /dev/null +++ b/scanner.mjs @@ -0,0 +1,1138 @@ +import fs from "fs"; +import http from "http"; +import path from "path"; +import { fileURLToPath } from "url"; +import { execSync } from "child_process"; +import crypto from "crypto"; +import WebSocket from "ws"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const __filename = fileURLToPath(import.meta.url); +const isDirectRun = process.argv[1] ? path.resolve(process.argv[1]) === __filename : false; +const configPath = path.join(__dirname, "config.json"); + +const rawCfg = fs.existsSync(configPath) ? JSON.parse(fs.readFileSync(configPath, "utf-8")) : {}; +const CONFIG = { + league: "Runes of Aldur", + itemName: "The Desperate Alliance", + itemType: "Vase Relic", + maxPrice: 5, + priceCurrency: "divine", + ...rawCfg, + poeSessionId: process.env.POESESSID || rawCfg.poeSessionId || "", + poeToken: process.env.POETOKEN || rawCfg.poeToken || "", +}; + +const mode = process.argv.includes("--serve") ? "serve" + : process.argv.includes("--live") ? "live" + : "full"; + +const SEEN_FILE = path.join(__dirname, "seen.json"); + +function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } + +let lastPoeRequestAt = 0; +let poeRequestQueue = Promise.resolve(); + +async function waitForPoeSlot() { + const sinceLast = Date.now() - lastPoeRequestAt; + if (sinceLast < 1200) await sleep(1200 - sinceLast); + lastPoeRequestAt = Date.now(); +} + +function queuePoeRequest(task) { + const queued = poeRequestQueue.then(task, task); + poeRequestQueue = queued.catch(() => {}); + return queued; +} + +function fetchRetry(url, opts, retries = 10) { + return queuePoeRequest(() => fetchRetryQueued(url, opts, retries)); +} + +function parseRateLimitWaitMs(body) { + try { + const message = JSON.parse(body)?.error?.message || ""; + const match = message.match(/wait\s+(\d+)\s+seconds/i); + return match ? parseInt(match[1], 10) * 1000 + 1000 : 0; + } catch { + return 0; + } +} + +async function fetchRetryQueued(url, opts, retries) { + for (let attempt = 0; attempt < retries; attempt++) { + await waitForPoeSlot(); + const res = await fetch(url, opts); + if (res.ok) return res; + const body = await res.text(); + if (res.status === 429) { + let wait = parseRateLimitWaitMs(body); + if (!wait) wait = Math.min(5000 * (1.5 ** attempt) + Math.random() * 2000, 60000); + const sec = Math.round(wait / 1000); + if (sec > 300) { + log(`⏳ Rate limit — надо ждать ${sec}s (>5m), выкидываю`, "red"); + throw new Error(`Rate limit: ${sec}s. Подожди ${Math.ceil(sec / 60)} мин и попробуй снова`); + } + log(`⏳ Rate limit — ждём ${sec}s (attempt ${attempt + 1}/${retries})`, "yellow"); + await sleep(wait); + continue; + } + if (res.status >= 500) { + const delay = Math.min(5000 * (1.5 ** attempt), 60000); + log(`HTTP ${res.status} — retry ${attempt + 1}/${retries} in ${Math.round(delay / 1000)}s`, "yellow"); + await sleep(delay); + continue; + } + throw new Error(`HTTP ${res.status}: ${body.slice(0, 200)}`); + } + throw new Error(`Fetch failed after ${retries} retries`); +} + +function publicConfig(cfg) { + return { + league: cfg.league, + itemName: cfg.itemName, + itemType: cfg.itemType, + maxPrice: cfg.maxPrice, + priceCurrency: cfg.priceCurrency, + maxResults: cfg.maxResults, + }; +} + +function validateConfig(input) { + const allowedCurrencies = new Set(["divine", "exalted", "chaos"]); + const cfg = { + league: String(input?.league || "").trim(), + itemName: String(input?.itemName || "").trim(), + itemType: String(input?.itemType || "").trim(), + maxPrice: Number(input?.maxPrice), + priceCurrency: String(input?.priceCurrency || "").toLowerCase(), + }; + if (!cfg.league || cfg.league.length > 100) throw Object.assign(new Error("Invalid league"), { statusCode: 400 }); + if (!cfg.itemName || cfg.itemName.length > 200) throw Object.assign(new Error("Invalid item name"), { statusCode: 400 }); + if (!cfg.itemType || cfg.itemType.length > 200) throw Object.assign(new Error("Invalid item type"), { statusCode: 400 }); + if (!Number.isFinite(cfg.maxPrice) || cfg.maxPrice < 0) throw Object.assign(new Error("Invalid max price"), { statusCode: 400 }); + if (!allowedCurrencies.has(cfg.priceCurrency)) throw Object.assign(new Error("Invalid currency"), { statusCode: 400 }); + return cfg; +} + +function readJsonBody(req, maxBytes = 32 * 1024) { + return new Promise((resolve, reject) => { + let body = ""; + let size = 0; + let tooLarge = false; + req.on("data", chunk => { + size += chunk.length; + if (size > maxBytes) { + tooLarge = true; + return; + } + body += chunk; + }); + req.on("end", () => { + if (tooLarge) { + reject(Object.assign(new Error("Request body too large"), { statusCode: 413 })); + return; + } + try { + resolve(JSON.parse(body || "{}")); + } catch { + reject(Object.assign(new Error("Invalid JSON"), { statusCode: 400 })); + } + }); + req.on("error", reject); + }); +} + +function escapeHtml(value) { + return String(value ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function parseLiveMessage(message) { + const nestedResult = message?.message?.result; + const result = message?.result; + const ids = Array.isArray(message?.new) + ? message.new + : Array.isArray(nestedResult) + ? nestedResult + : Array.isArray(result) + ? result + : []; + const token = typeof result === "string" + ? result + : typeof nestedResult === "string" + ? nestedResult + : ""; + return { + auth: message?.auth === true, + ids, + token, + count: Number.isFinite(Number(message?.count)) ? Number(message.count) : 0, + }; +} + +function extractLiveIds(message) { + return parseLiveMessage(message).ids; +} +function ts() { return new Date().toLocaleTimeString(); } +function log(msg, color = "") { + const c = { green: "\x1b[32m", yellow: "\x1b[33m", red: "\x1b[31m", cyan: "\x1b[36m", reset: "\x1b[0m" }; + console.log(`${c.cyan}[${ts()}]${c.reset} ${c[color] || ""}${msg}${c.reset}`); +} + +function copyToClipboard(text) { + try { + const escaped = text.replace(/'/g, "''"); + execSync(`powershell -command "Set-Clipboard -Value '${escaped}'"`, { shell: "cmd.exe", timeout: 3000 }); + return true; + } catch { return false; } +} + +function loadSeen() { + try { if (fs.existsSync(SEEN_FILE)) return new Set(JSON.parse(fs.readFileSync(SEEN_FILE, "utf-8"))); } catch {} + return new Set(); +} +function saveSeen(ids) { fs.writeFileSync(SEEN_FILE, JSON.stringify([...ids])); } + +function headers(extra) { + const h = { + "Content-Type": "application/json", + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + Accept: "*/*", + "X-Requested-With": "XMLHttpRequest", + "Sec-Fetch-Dest": "empty", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Site": "same-origin", + "Referer": "https://www.pathofexile.com/trade2/", + }; + const cookies = []; + if (CONFIG.poeSessionId) cookies.push(`POESESSID=${CONFIG.poeSessionId}`); + if (CONFIG.poeToken) cookies.push(`POETOKEN=${CONFIG.poeToken}`); + if (cookies.length) h.Cookie = cookies.join("; "); + if (extra) Object.assign(h, extra); + return h; +} + +function wsHeaders() { + const cookies = []; + if (CONFIG.poeSessionId) cookies.push(`POESESSID=${CONFIG.poeSessionId}`); + if (CONFIG.poeToken) cookies.push(`POETOKEN=${CONFIG.poeToken}`); + return { + "Cache-Control": "no-cache", + Pragma: "no-cache", + Origin: "https://www.pathofexile.com", + ...(cookies.length ? { Cookie: cookies.join("; ") } : {}), + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + }; +} + +async function search(override) { + const c = override || CONFIG; + const league = c.league || CONFIG.league || "Runes of Aldur"; + const url = `https://www.pathofexile.com/api/trade2/search/poe2/${encodeURIComponent(league)}`; + const body = { + query: { + status: { option: "available" }, + name: c.itemName, + type: c.itemType, + filters: { trade_filters: { filters: { price: { option: c.priceCurrency } } } }, + }, + sort: { price: "asc" }, + }; + const res = await fetchRetry(url, { method: "POST", headers: headers(), body: JSON.stringify(body) }); + return await res.json(); +} + +const tradeTokens = new Map(); + +function rememberTradeTokens(items) { + for (const item of items) { + const id = item.id || item.listing?.id || ""; + if (!id) continue; + const tokens = {}; + if (item.listing?.whisper_token) tokens.whisperToken = item.listing.whisper_token; + if (item.listing?.hideout_token) tokens.hideoutToken = item.listing.hideout_token; + if (tokens.whisperToken || tokens.hideoutToken) tradeTokens.set(id, tokens); + } +} + +async function fetchItems(ids, queryId) { + if (!ids.length) return []; + const allItems = []; + for (let i = 0; i < ids.length; i += 10) { + const batch = ids.slice(i, i + 10); + const url = `https://www.pathofexile.com/api/trade2/fetch/${batch.join(",")}?query=${encodeURIComponent(queryId)}`; + const res = await fetchRetry(url, { headers: headers() }); + const data = await res.json(); + rememberTradeTokens(data.result || []); + allItems.push(...(data.result || [])); + await sleep(300); + } + return allItems; +} + +async function fetchLivePackage(token, queryId) { + if (!token) return []; + const url = `https://www.pathofexile.com/api/trade2/fetch/${encodeURIComponent(token)}?query=${encodeURIComponent(queryId)}&realm=poe2`; + const res = await fetchRetry(url, { headers: headers() }); + const data = await res.json(); + const items = data.result || []; + rememberTradeTokens(items); + return items; +} + +function extractPrice(item) { + const listing = item.listing || {}; + const p = listing.price; + if (!p || p.amount == null) return null; + const account = listing.account?.name || ""; + const whisper = listing.whisper || ""; + const seller = account || (whisper.startsWith("@") ? whisper.split(" ")[0].replace("@", "") : ""); + const itemId = item.id || listing.id || ""; + const cached = tradeTokens.get(itemId) || {}; + return { + amount: Number(p.amount), currency: (p.currency || "").toLowerCase(), + whisper, seller, id: itemId, + hideoutToken: listing.hideout_token || cached.hideoutToken || "", + whisperToken: listing.whisper_token || cached.whisperToken || "", + token: listing.hideout_token || cached.hideoutToken || "", + }; +} + +function matchesPrice(price, c) { + if (!price) return false; + return price.amount <= c.maxPrice && price.currency === c.priceCurrency; +} + +// ─── MODES: full, live, serve ─────────────────────────────────────── + +async function runFull() { + log("=== FULL SCAN ===", "green"); + log(`${CONFIG.itemName} (${CONFIG.itemType}) ≤ ${CONFIG.maxPrice} ${CONFIG.priceCurrency} | ${CONFIG.league}`, "cyan"); + try { + const data = await search(CONFIG); + const qid = data.id; + const ids = (data.result || []).slice(0, 200); + log(`Всего: ${data.total}, проверяю ${ids.length} дешёвых`, "cyan"); + if (!ids.length) { log("Нет лотов", "yellow"); return; } + const items = await fetchItems(ids, qid); + const found = []; + for (const item of items) { const p = extractPrice(item); if (matchesPrice(p, CONFIG)) found.push(p); } + found.splice(20); + if (!found.length) { + let cheapest = "?"; + for (const it of items) { const p = extractPrice(it); if (p) { cheapest = `${p.amount} ${p.currency}`; break; } } + log(`Нет лотов ≤ ${CONFIG.maxPrice} ${CONFIG.priceCurrency} (самый дешёвый: ${cheapest})`, "yellow"); + return; + } + log(`--- ${found.length} лотов ≤ ${CONFIG.maxPrice} ${CONFIG.priceCurrency} ---`, "green"); + for (const p of found) { + const url = `https://www.pathofexile.com/trade2/search/poe2/${encodeURIComponent(CONFIG.league)}/${qid}`; + log(`${p.seller} - ${p.amount} ${p.currency}`, ""); + log(` ${url}`, "cyan"); + if (p.whisper) log(` ${p.whisper.slice(0, 120)}`, "green"); + } + const s = new Set(); for (const p of found) { if (p.id) s.add(p.id); } saveSeen(s); + } catch (err) { log(`Error: ${err.message}`, "red"); } +} + +async function runLive() { + let seen = loadSeen(); + log("=== LIVE ===", "green"); + log(`${CONFIG.itemName} (${CONFIG.itemType}) ≤ ${CONFIG.maxPrice} ${CONFIG.priceCurrency} | ${CONFIG.league}`, "cyan"); + let qid; + let initialSearch; + try { initialSearch = await search(CONFIG); qid = initialSearch.id; log(`Known: ${seen.size} items`, "cyan"); } + catch (err) { log(`Search failed: ${err.message}`, "red"); process.exit(1); } + + const wsUrl = `wss://www.pathofexile.com/api/trade2/live/poe2/${encodeURIComponent(CONFIG.league)}/${qid}`; + async function handleNewItems(items) { + for (const item of items) { + const price = extractPrice(item); const itemId = price?.id || item.id || item.listing?.id || ""; + if (itemId && seen.has(itemId)) continue; + if (itemId) seen.add(itemId); + if (!price || !matchesPrice(price, CONFIG)) continue; + const url = `https://www.pathofexile.com/trade2/search/poe2/${encodeURIComponent(CONFIG.league)}/${qid}`; + log(`[NEW] ${price.seller} - ${price.amount} ${price.currency}`, "yellow"); + log(` ${url}`, "cyan"); + if (price.whisper) { log(` ${price.whisper.slice(0, 120)}`, "green"); copyToClipboard(price.whisper); } + for (let i = 0; i < 3; i++) setTimeout(() => process.stdout.write("\x07"), i * 250); + } + saveSeen(seen); + if (seen.size > 10000) { const a = [...seen]; seen = new Set(a.slice(-5000)); saveSeen(seen); } + } + + async function handleNewIds(newIds) { + const unknown = newIds.filter(id => !seen.has(id)); + if (!unknown.length) return; + const items = await fetchItems(unknown, qid); + await handleNewItems(items); + } + + if (initialSearch.result) { for (const id of initialSearch.result) seen.add(id); saveSeen(seen); } + let cliItemQueue = Promise.resolve(); + function queueCliIds(ids) { + cliItemQueue = cliItemQueue.then(() => handleNewIds(ids)).catch(err => log(`Live item error: ${err.message}`, "red")); + } + function queueCliToken(token) { + cliItemQueue = cliItemQueue + .then(async () => handleNewItems(await fetchLivePackage(token, qid))) + .catch(err => log(`Live package error: ${err.message}`, "red")); + } + let cliReconnAttempt = 0; + function cliConnectWs() { + const ws = new WebSocket(wsUrl, [], { headers: wsHeaders(), handshakeTimeout: 15000 }); + ws.on("open", () => { cliReconnAttempt = 0; log("Live WS connected", "green"); }); + ws.on("message", async (data) => { + try { + const msg = parseLiveMessage(JSON.parse(typeof data === "string" ? data : data.toString())); + if (msg.auth) return; + if (msg.token) queueCliToken(msg.token); + else if (msg.ids.length) queueCliIds(msg.ids); + } catch (e) { log(`Live message error: ${e.message}`, "red"); } + }); + ws.on("close", (code, reason) => { + log(`Live WS closed: code=${code} ${reason || ""}`, "yellow"); + const d = Math.min(3000 * Math.pow(1.5, cliReconnAttempt), 30000); + cliReconnAttempt++; + setTimeout(cliConnectWs, d); + }); + ws.on("error", (err) => log(`Live WS error: ${err.message}`, "red")); + } + cliConnectWs(); + setInterval(async () => { try { const d = await search(CONFIG); if (d.result) queueCliIds(d.result); } catch (e) { log(`Live polling error: ${e.message}`, "yellow"); } }, 30000); + process.stdin.resume(); +} + +// ─── SERVE MODE ───────────────────────────────────────────────────── + +if (isDirectRun) log(`Config path: ${configPath}, hasSession: ${!!CONFIG.poeSessionId}`, "cyan"); + +const scannerToken = crypto.randomBytes(32).toString("hex"); + +function serveHTML() { + const c = CONFIG; + return ` + + + + +POE2 Scanner + + + +

POE2 Scanner ${escapeHtml(c.league)}

+
+
+
+
+
+
+ + +
+ + Автопомощь Secure +
+
+
Ready
+
Local scanner | session: ${CONFIG.poeSessionId ? "yes" : "no"}
+
+
SellerPriceAction
+
+ + + +`; +} + +function runServe() { + const PORT = 3999; + let liveWs = null; + let liveFallbackPolling = null; + let reconnectTimer = null; + let reconnectAttempt = 0; + let refreshingQid = false; + let liveWsQid = null; + let liveWsCfg = null; + let liveRunning = false; + let liveSeen = loadSeen(); + let liveItemQueue = Promise.resolve(); + let sseClients = []; + let hrClients = []; + let currentCfg = publicConfig(CONFIG); + + function broadcastSSE(data) { + sseClients = sseClients.filter(c => { + try { + c.write(`data: ${JSON.stringify(data)}\n\n`); + return true; + } catch { + return false; + } + }); + } + + function queueLiveIds(ids, queryId = liveWsQid) { + liveItemQueue = liveItemQueue + .then(() => processLiveIds(ids, queryId)) + .catch(e => log("Live item processing failed: " + (e?.message || e), "red")); + return liveItemQueue; + } + + function queueLiveToken(token, queryId = liveWsQid) { + liveItemQueue = liveItemQueue + .then(async () => processLiveItems(await fetchLivePackage(token, queryId), queryId)) + .catch(e => log("Live package processing failed: " + (e?.message || e), "red")); + return liveItemQueue; + } + + async function processLiveIds(ids, queryId) { + if (!liveRunning || !queryId || !liveWsCfg) return; + const unknown = [...new Set(ids)].filter(id => !liveSeen.has(id)); + if (!unknown.length) return; + const items = await fetchItems(unknown, queryId); + await processLiveItems(items, queryId); + } + + async function processLiveItems(items, queryId) { + if (!liveRunning || !queryId || !liveWsCfg) return; + const newItems = []; + for (const item of items) { + const p = extractPrice(item); + const itemId = p?.id || item.id || item.listing?.id || ""; + if (itemId && liveSeen.has(itemId)) continue; + if (itemId) liveSeen.add(itemId); + if (p && matchesPrice(p, liveWsCfg)) newItems.push({ ...p, qid: queryId }); + } + if (liveSeen.size > 10000) liveSeen = new Set([...liveSeen].slice(-5000)); + saveSeen(liveSeen); + if (newItems.length) { + log(`Live matched ${newItems.length} new item(s)`, "green"); + broadcastSSE({ type: "new", items: newItems }); + for (let i = 0; i < 3; i++) setTimeout(() => process.stdout.write("\x07"), i * 250); + } + } + + function stopFallbackPolling() { + if (liveFallbackPolling) { + clearInterval(liveFallbackPolling); + liveFallbackPolling = null; + log("Fallback polling stopped", "cyan"); + } + } + + function startFallbackPolling() { + if (!liveRunning || !liveWsQid || !liveWsCfg) return; + if (liveWs && liveWs.readyState <= WebSocket.OPEN) { stopFallbackPolling(); return; } + if (liveFallbackPolling) return; + log("WS down — fallback polling 30s", "yellow"); + broadcastSSE({ type: "status", text: "WS down, fallback polling every 30s" }); + liveFallbackPolling = setInterval(async () => { + if (!liveRunning) { stopFallbackPolling(); return; } + if (liveWs && liveWs.readyState <= WebSocket.OPEN) { stopFallbackPolling(); return; } + try { + const d = await search(liveWsCfg); + if (d.result) await queueLiveIds(d.result, d.id); + } catch (e) { + log("Fallback polling failed: " + (e?.message || e), "yellow"); + } + }, 30000); + } + + function connectLiveWs() { + if (!liveRunning || !liveWsQid) return; + const wsUrl = `wss://www.pathofexile.com/api/trade2/live/poe2/${encodeURIComponent(liveWsCfg.league)}/${liveWsQid}`; + log("Connecting WS: " + wsUrl, "cyan"); + + try { + const ws = new WebSocket(wsUrl, [], { headers: wsHeaders(), handshakeTimeout: 15000 }); + liveWs = ws; + + ws.on("open", () => { + if (liveWs !== ws || !liveRunning) { + ws.close(); + return; + } + reconnectAttempt = 0; + stopFallbackPolling(); + log("Live WebSocket connected", "green"); + broadcastSSE({ type: "status", text: "Live!" }); + }); + + ws.on("message", msg => { + if (liveWs !== ws || !liveRunning) return; + try { + const liveMessage = parseLiveMessage(JSON.parse(typeof msg === "string" ? msg : msg.toString())); + if (liveMessage.auth) return; + if (liveMessage.token) { + log(`Live package received: ${liveMessage.count || "?"} item(s)`, "cyan"); + queueLiveToken(liveMessage.token, liveWsQid); + } else if (liveMessage.ids.length) { + queueLiveIds(liveMessage.ids, liveWsQid); + } + } catch (e) { + log("Live message error: " + (e?.message || e), "red"); + } + }); + + ws.on("close", (code, reason) => { + if (liveWs !== ws) return; + liveWs = null; + const msg = reason ? `WS closed: ${reason} (${code})` : `WS closed: code=${code}`; + log(msg, "red"); + broadcastSSE({ type: "status", text: msg }); + if (liveRunning && !refreshingQid) { + startFallbackPolling(); + scheduleReconnect(); + } + }); + + ws.on("error", err => { + if (liveWs !== ws) return; + if (err?.message?.includes("404") || err?.message?.includes("Unexpected server response: 404")) { + if (refreshingQid) return; + log("WS qid expired, fetching new qid...", "yellow"); + refreshingQid = true; + try { ws.close(); } catch {} + refreshQidAndReconnect(); + } else { + log("WS error: " + (err?.message || err), "red"); + } + }); + } catch (e) { + log("WS constructor failed: " + e.message, "red"); + liveWs = null; + startFallbackPolling(); + scheduleReconnect(); + } + } + + function scheduleReconnect() { + if (!liveRunning) return; + if (reconnectTimer) clearTimeout(reconnectTimer); + const delay = Math.min(3000 * Math.pow(1.5, reconnectAttempt), 30000); + reconnectAttempt++; + log(`WS reconnect in ${Math.round(delay)}ms (attempt ${reconnectAttempt})`, "yellow"); + broadcastSSE({ type: "status", text: `Reconnecting in ${Math.round(delay)}ms...` }); + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + if (!liveRunning) return; + connectLiveWs(); + }, delay); + } + + async function refreshQidAndReconnect() { + if (!liveRunning) return; + try { + const data = await search(liveWsCfg); + liveWsQid = data.id; + log("New qid: " + liveWsQid, "green"); + broadcastSSE({ type: "status", text: `New search ID obtained` }); + refreshingQid = false; + if (data.result) await queueLiveIds(data.result, data.id); + connectLiveWs(); + } catch (e) { + refreshingQid = false; + log("Failed to refresh qid: " + e.message, "red"); + startFallbackPolling(); + scheduleReconnect(); + } + } + + // hotreload при изменении scanner.mjs + fs.watch(__filename, () => { + log("Hotreload: file changed, reloading browsers", "yellow"); + for (const c of hrClients) { try { c.write(`data: reload\n\n`); } catch {} } + }); + + const server = http.createServer(async (req, res) => { + const [pathname] = req.url.split("?"); + const corsHeaders = { "X-Content-Type-Options": "nosniff", "Referrer-Policy": "no-referrer" }; + if (req.method === "OPTIONS") { res.writeHead(204, corsHeaders); res.end(); return; } + if (req.method === "POST" && req.headers["x-scanner-token"] !== scannerToken) { + res.writeHead(403, { ...corsHeaders, "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Forbidden" })); + return; + } + + // Serve HTML + if (pathname === "/" || pathname === "/index.html") { + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store, no-cache, must-revalidate", "Pragma": "no-cache" }); + res.end(serveHTML()); + return; + } + + // API: config + if (pathname === "/api/config" && req.method === "GET") { + res.writeHead(200, { ...corsHeaders, "Content-Type": "application/json" }); + res.end(JSON.stringify({ ...publicConfig(currentCfg), hasSession: !!CONFIG.poeSessionId })); + return; + } + + // API: search + if (pathname === "/api/search" && req.method === "POST") { + try { + const cfg = validateConfig(await readJsonBody(req)); + Object.assign(CONFIG, cfg); + currentCfg = cfg; + const data = await search(cfg); + const qid = data.id; + const ids = (data.result || []).slice(0, 200); + const items = ids.length ? await fetchItems(ids, qid) : []; + const found = []; + for (const item of items) { + const p = extractPrice(item); + if (matchesPrice(p, cfg)) found.push({ ...p, qid }); + } + found.splice(20); + const s = new Set(); for (const f of found) { if (f.id) s.add(f.id); } saveSeen(s); + res.writeHead(200, { ...corsHeaders, "Content-Type": "application/json" }); + res.end(JSON.stringify({ items: found, total: data.total || ids.length })); + } catch (err) { + res.writeHead(err.statusCode || 500, { ...corsHeaders, "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: err.message })); + } + return; + } + + // API: live start + if (pathname === "/api/live/start" && req.method === "POST") { + try { + const cfg = validateConfig(await readJsonBody(req)); + Object.assign(CONFIG, cfg); + currentCfg = cfg; + if (liveRunning) { + res.writeHead(200, { ...corsHeaders, "Content-Type": "application/json" }); + res.end(JSON.stringify({ status: "already running" })); + return; + } + const data = await search(cfg); + liveWsQid = data.id; + liveWsCfg = cfg; + liveSeen = loadSeen(); + for (const id of (data.result || [])) liveSeen.add(id); + saveSeen(liveSeen); + liveRunning = true; + reconnectAttempt = 0; + refreshingQid = false; + if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } + connectLiveWs(); + broadcastSSE({ type: "status", text: `Cached ${liveSeen.size} known items. Live starting...` }); + res.writeHead(200, { ...corsHeaders, "Content-Type": "application/json" }); + res.end(JSON.stringify({ status: "ok" })); + } catch (err) { + liveRunning = false; + liveWsQid = null; + liveWsCfg = null; + res.writeHead(err.statusCode || 500, { ...corsHeaders, "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: err.message })); + } + return; + } + + // API: live stop + if (pathname === "/api/live/stop" && req.method === "POST") { + liveRunning = false; + refreshingQid = false; + stopFallbackPolling(); + if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } + if (liveWs) { try { liveWs.close(); } catch {} liveWs = null; } + liveWsQid = null; + liveWsCfg = null; + res.writeHead(200, { ...corsHeaders, "Content-Type": "application/json" }); + res.end(JSON.stringify({ status: "stopped" })); + return; + } + + // API: secure — отправляет POST /api/trade2/whisper + if (pathname === "/api/secure" && req.method === "POST") { + try { + const { token } = await readJsonBody(req); + if (!token) { + log("Secure: no token", "red"); + res.writeHead(400, { ...corsHeaders, "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "hideout token is empty — нужно перезайти на страницу поиска" })); + return; + } + log("Secure: sending whisper request", "cyan"); + const ac = new AbortController(); + const to = setTimeout(() => { ac.abort(); log("Secure: PoE API timeout 15s", "red"); }, 15000); + let apiRes; + try { + apiRes = await queuePoeRequest(async () => { + await waitForPoeSlot(); + return fetch("https://www.pathofexile.com/api/trade2/whisper", { + method: "POST", + headers: { ...headers(), "Content-Type": "application/json" }, + body: JSON.stringify({ token: String(token) }), + signal: ac.signal, + }); + }); + } finally { clearTimeout(to); } + const text = await apiRes.text(); + let json; + try { json = JSON.parse(text); } catch { json = { error: { message: `PoE вернул не-JSON (${apiRes.status}): ${text.slice(0, 200)}` } }; } + log(`Secure response: ${apiRes.status} ${json?.error?.message || "OK"}`, "cyan"); + res.writeHead(apiRes.status, { ...corsHeaders, "Content-Type": "application/json" }); + res.end(JSON.stringify(json)); + } catch (err) { + log(`Secure error: ${err.message}`, "red"); + res.writeHead(err.statusCode || 500, { ...corsHeaders, "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: err.message })); + } + return; + } + + // SSE stream + if (pathname === "/api/live/stream") { + res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive", ...corsHeaders }); + res.write(`data: ${JSON.stringify({ type: "status", text: liveRunning ? "Live active" : "Ready" })}\n\n`); + sseClients.push(res); + req.on("close", () => { sseClients = sseClients.filter(c => c !== res); }); + return; + } + + // Hotreload SSE + if (pathname === "/api/hotreload") { + res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" }); + res.write("data: connected\n\n"); + hrClients.push(res); + req.on("close", () => { hrClients = hrClients.filter(c => c !== res); }); + return; + } + + if (pathname === "/sound.mp3") { + const filePath$ = path.join(__dirname, "sound.mp3"); + try { + const content = fs.readFileSync(filePath$); + res.writeHead(200, { "Content-Type": "audio/mpeg", "Cache-Control": "no-cache", "Content-Length": content.length }); + res.end(content); + } catch { res.writeHead(404); res.end(); } + return; + } + + res.writeHead(404); res.end("Not found"); + }); + + server.on("error", (err) => { + if (err.code === "EADDRINUSE") { + log(`Port ${PORT} is already in use. Stop the old scanner and try again.`, "red"); + } else { throw err; } + }); + + server.listen(PORT, "127.0.0.1", () => { + log(`Server: http://localhost:${PORT}`, "green"); + }); +} + +// ─── MAIN ────────────────────────────────────────────────────────── + +if (isDirectRun) { + if (mode === "serve") { + runServe(); + } else if (mode === "live") { + runLive(); + } else { + runFull(); + } +} + +export { escapeHtml, extractLiveIds, extractPrice, fetchLivePackage, matchesPrice, parseLiveMessage, parseRateLimitWaitMs, publicConfig, serveHTML, validateConfig }; + diff --git a/scanner.test.mjs b/scanner.test.mjs new file mode 100644 index 0000000..b02a5b1 --- /dev/null +++ b/scanner.test.mjs @@ -0,0 +1,105 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + escapeHtml, + extractLiveIds, + extractPrice, + matchesPrice, + parseLiveMessage, + parseRateLimitWaitMs, + publicConfig, + serveHTML, + validateConfig, +} from "./scanner.mjs"; + +test("parses both supported live message formats", () => { + assert.deepEqual(extractLiveIds({ new: ["a"] }), ["a"]); + assert.deepEqual(extractLiveIds({ message: { result: ["b", "c"] } }), ["b", "c"]); + assert.deepEqual(extractLiveIds({ message: {} }), []); +}); + +test("parses the current PoE live JWT package format", () => { + assert.deepEqual(parseLiveMessage({ auth: true }), { auth: true, ids: [], token: "", count: 0 }); + assert.deepEqual( + parseLiveMessage({ result: "signed.jwt.package", count: 2 }), + { auth: false, ids: [], token: "signed.jwt.package", count: 2 }, + ); +}); + +test("parses the PoE rate-limit wait time with a safety second", () => { + const body = JSON.stringify({ error: { message: "Rate limit exceeded; Please wait 12 seconds before trying again." } }); + assert.equal(parseRateLimitWaitMs(body), 13000); + assert.equal(parseRateLimitWaitMs("not json"), 0); +}); + +test("extracts listing price and secure tokens", () => { + const result = extractPrice({ + id: "item-1", + listing: { + price: { amount: 4, currency: "Divine" }, + account: { name: "Seller" }, + whisper: "@Seller hello", + hideout_token: "hideout-token", + }, + }); + assert.equal(result.amount, 4); + assert.equal(result.currency, "divine"); + assert.equal(result.seller, "Seller"); + assert.equal(result.token, "hideout-token"); +}); + +test("does not treat a whisper token as a hideout travel token", () => { + const result = extractPrice({ + id: "item-2", + listing: { + price: { amount: 5, currency: "divine" }, + whisper_token: "whisper-only-token", + }, + }); + assert.equal(result.hideoutToken, ""); + assert.equal(result.whisperToken, "whisper-only-token"); + assert.equal(result.token, ""); +}); + +test("matches only the requested price and currency", () => { + assert.equal(matchesPrice({ amount: 5, currency: "divine" }, { maxPrice: 5, priceCurrency: "divine" }), true); + assert.equal(matchesPrice({ amount: 6, currency: "divine" }, { maxPrice: 5, priceCurrency: "divine" }), false); + assert.equal(matchesPrice({ amount: 1, currency: "chaos" }, { maxPrice: 5, priceCurrency: "divine" }), false); +}); + +test("validates browser configuration and allows zero price", () => { + const cfg = validateConfig({ + league: "Test League", + itemName: "Test Item", + itemType: "Test Type", + maxPrice: 0, + priceCurrency: "DIVINE", + poeSessionId: "must-not-pass-through", + }); + assert.equal(cfg.maxPrice, 0); + assert.equal(cfg.priceCurrency, "divine"); + assert.equal("poeSessionId" in cfg, false); + assert.throws(() => validateConfig({ ...cfg, maxPrice: -1 }), /Invalid max price/); +}); + +test("public config and HTML escaping do not leak dangerous values", () => { + const cfg = publicConfig({ + league: "Test", + itemName: "Item", + itemType: "Type", + maxPrice: 5, + priceCurrency: "divine", + poeSessionId: "secret", + poeToken: "secret", + }); + assert.equal("poeSessionId" in cfg, false); + assert.equal("poeToken" in cfg, false); + assert.equal(escapeHtml(`">