Initial PoE2 scanner
This commit is contained in:
commit
3177d87223
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
config.json
|
||||
startup.json
|
||||
seen.json
|
||||
*.log
|
||||
server.err
|
||||
|
||||
112
AGENTS.md
Normal file
112
AGENTS.md
Normal 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
189
AccessibilityClickHelper.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
BIN
AccessibilityClickHelper.exe
Normal file
BIN
AccessibilityClickHelper.exe
Normal file
Binary file not shown.
85
README.md
Normal file
85
README.md
Normal 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` больше не создаётся.
|
||||
9
build-accessibility-helper.bat
Normal file
9
build-accessibility-helper.bat
Normal 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
10
config.example.json
Normal 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
6
full-scan.bat
Normal 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
3
launch.vbs
Normal 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
8
live.bat
Normal 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
34
package-lock.json
generated
Normal 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
12
package.json
Normal 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
7
run-web.bat
Normal 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
21
run.bat
Normal 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
1138
scanner.mjs
Normal file
File diff suppressed because it is too large
Load Diff
105
scanner.test.mjs
Normal file
105
scanner.test.mjs
Normal 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>'&`), ""><script>'&");
|
||||
});
|
||||
|
||||
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));
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user