Initial PoE2 scanner

This commit is contained in:
yoshaman 2026-07-29 21:03:59 +03:00
commit 3177d87223
17 changed files with 1746 additions and 0 deletions

7
.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
node_modules/
config.json
startup.json
seen.json
*.log
server.err

112
AGENTS.md Normal file
View File

@ -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).
Не сползать обратно в вежливость. Выключение: «нормальный режим» / «хватит материться».

189
AccessibilityClickHelper.cs Normal file
View File

@ -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());
}
}

Binary file not shown.

85
README.md Normal file
View File

@ -0,0 +1,85 @@
# PoE 2 Scanner
Локальный сканер торговли Path of Exile 2. Поддерживает разовый поиск, консольный Live и Web UI с SSE-уведомлениями.
## Настройка
1. Скопируйте `config.example.json` в `config.json`.
2. Укажите лигу, предмет и максимальную цену.
3. Добавьте свежие `POESESSID` и `POETOKEN` в локальный `config.json` либо передайте их через переменные окружения.
`config.json` содержит секреты и исключён из Git. Не отправляйте его другим людям и не кладите в публичные архивы.
Пример безопасной структуры:
```json
{
"league": "Runes of Aldur",
"itemName": "The Desperate Alliance",
"itemType": "Vase Relic",
"maxPrice": 5,
"priceCurrency": "divine",
"maxResults": 500,
"poeSessionId": "",
"poeToken": ""
}
```
Переменные окружения `POESESSID` и `POETOKEN` имеют приоритет над значениями из файла.
## Запуск
```powershell
node scanner.mjs
node scanner.mjs --live
node scanner.mjs --serve
```
Web UI будет доступен только на этом компьютере:
```text
http://127.0.0.1:3999
```
Также доступны:
- `full-scan.bat` — разовый поиск.
- `live.bat` — консольный Live.
- `launch.vbs` — скрытый запуск Web UI.
- `run.bat` — меню режимов.
## Проверка
```powershell
npm test
npm run check
```
## Как устроен Live
1. Сканер создаёт поисковый запрос и запоминает уже существующие объявления.
2. Подключается к PoE Live WebSocket.
3. Новые ID последовательно загружаются через PoE API.
4. При разрыве WebSocket включается polling раз в 30 секунд.
5. При устаревшем search ID сканер получает новый и переподключается.
Все HTTP-запросы к PoE идут через одну очередь с минимальным интервалом 1,2 секунды. При HTTP 429 сканер читает указанное PoE время ожидания.
## Локальный API
Сервер слушает только `127.0.0.1`. Изменяющие состояние POST-запросы защищены случайным токеном, который получает Web UI.
- `GET /api/config`
- `POST /api/search`
- `POST /api/live/start`
- `POST /api/live/stop`
- `POST /api/secure`
- `GET /api/live/stream`
## Файлы состояния
- `config.json` — локальные настройки и авторизация.
- `seen.json` — кеш просмотренных объявлений.
- `sound.mp3` — звук уведомления.
`startup.json` больше не создаётся.

View File

@ -0,0 +1,9 @@
@echo off
cd /d "%~dp0"
"C:\Windows\Microsoft.NET\Framework64\v4.0.30319\csc.exe" /nologo /target:winexe /optimize+ /out:AccessibilityClickHelper.exe /reference:System.dll /reference:System.Drawing.dll /reference:System.Windows.Forms.dll AccessibilityClickHelper.cs
if errorlevel 1 (
echo Build failed.
pause
exit /b 1
)
echo Built AccessibilityClickHelper.exe

10
config.example.json Normal file
View File

@ -0,0 +1,10 @@
{
"league": "Runes of Aldur",
"itemName": "The Desperate Alliance",
"itemType": "Vase Relic",
"maxPrice": 5,
"priceCurrency": "divine",
"maxResults": 500,
"poeSessionId": "",
"poeToken": ""
}

6
full-scan.bat Normal file
View File

@ -0,0 +1,6 @@
@echo off
cd /d "%~dp0"
echo POE2 Scanner — Full Scan
echo =========================
node scanner.mjs --full
pause

3
launch.vbs Normal file
View File

@ -0,0 +1,3 @@
CreateObject("WScript.Shell").Run "cmd /q /c ""C:\Program Files\nodejs\node.exe"" C:\Users\shama\poe2-scanner\scanner.mjs --serve", 0, False
WScript.Sleep 2000
CreateObject("WScript.Shell").Run "http://localhost:3999", 1, False

8
live.bat Normal file
View File

@ -0,0 +1,8 @@
@echo off
cd /d "%~dp0"
echo POE2 Scanner — Live Mode
echo =========================
echo Close this window to stop.
echo.
node scanner.mjs --live
pause

34
package-lock.json generated Normal file
View File

@ -0,0 +1,34 @@
{
"name": "poe2-scanner",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "poe2-scanner",
"dependencies": {
"ws": "^8.21.1"
}
},
"node_modules/ws": {
"version": "8.21.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
"integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}

12
package.json Normal file
View File

@ -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"
}
}

7
run-web.bat Normal file
View File

@ -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

21
run.bat Normal file
View File

@ -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

1138
scanner.mjs Normal file

File diff suppressed because it is too large Load Diff

105
scanner.test.mjs Normal file
View File

@ -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(`"><script>'&`), "&quot;&gt;&lt;script&gt;&#39;&amp;");
});
test("generated browser script has valid JavaScript syntax", () => {
const html = serveHTML();
const script = html.match(/<script>([\s\S]*?)<\/script>/)?.[1];
assert.ok(script);
assert.doesNotThrow(() => new Function(script));
});

BIN
sound.mp3 Normal file

Binary file not shown.