Initial commit: POE2 automated trade bot
Monitors pathofexile.com/trade2 for new listings, travels to seller hideouts, buys items from public stash tabs, and stores them. Includes persistent C# OCR daemon for fast screen capture + Windows native OCR, web dashboard for managing trade links and settings, and full game automation via Win32 SendInput. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
41d174195e
28 changed files with 6449 additions and 0 deletions
22
.env.example
Normal file
22
.env.example
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# POE2 Trade Bot Configuration
|
||||
|
||||
# Path to POE2 Client.txt log file
|
||||
POE2_LOG_PATH=C:\Program Files (x86)\Steam\steamapps\common\Path of Exile 2\logs\Client.txt
|
||||
|
||||
# POE2 game window title (used to find/focus the window)
|
||||
POE2_WINDOW_TITLE=Path of Exile 2
|
||||
|
||||
# Playwright persistent browser data directory
|
||||
BROWSER_USER_DATA_DIR=./browser-data
|
||||
|
||||
# Trade URLs (comma-separated) - can also add via dashboard
|
||||
# TRADE_URLS=https://www.pathofexile.com/trade2/search/poe2/Fate%20of%20the%20Vaal/LgLZ6MBGHn/live
|
||||
|
||||
# Dashboard port
|
||||
DASHBOARD_PORT=3000
|
||||
|
||||
# Timeouts (milliseconds)
|
||||
TRAVEL_TIMEOUT_MS=15000
|
||||
STASH_SCAN_TIMEOUT_MS=10000
|
||||
WAIT_FOR_MORE_ITEMS_MS=20000
|
||||
BETWEEN_TRADES_DELAY_MS=5000
|
||||
13
.gitignore
vendored
Normal file
13
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
config.json
|
||||
browser-data/
|
||||
*.log
|
||||
debug-screenshots/
|
||||
eng.traineddata
|
||||
.claude/
|
||||
|
||||
# OcrDaemon build output
|
||||
tools/OcrDaemon/bin/
|
||||
tools/OcrDaemon/obj/
|
||||
2888
package-lock.json
generated
Normal file
2888
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
32
package.json
Normal file
32
package.json
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
{
|
||||
"name": "poe2trade",
|
||||
"version": "1.0.0",
|
||||
"description": "POE2 trade bot - automated item purchasing via trade site monitoring",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"dev": "tsx src/index.ts",
|
||||
"build": "tsc",
|
||||
"build:daemon": "dotnet build tools/OcrDaemon -c Release",
|
||||
"start": "node dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"chokidar": "^4.0.3",
|
||||
"clipboard-sys": "^1.2.0",
|
||||
"commander": "^13.1.0",
|
||||
"dotenv": "^16.4.7",
|
||||
"express": "^5.2.1",
|
||||
"koffi": "^2.9.2",
|
||||
"pino": "^9.6.0",
|
||||
"pino-pretty": "^13.0.0",
|
||||
"playwright": "^1.50.1",
|
||||
"ws": "^8.19.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^22.13.1",
|
||||
"@types/ws": "^8.18.1",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
38
src/config.ts
Normal file
38
src/config.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import dotenv from 'dotenv';
|
||||
import type { Config } from './types.js';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
function env(key: string, fallback?: string): string {
|
||||
const val = process.env[key];
|
||||
if (val !== undefined) return val;
|
||||
if (fallback !== undefined) return fallback;
|
||||
throw new Error(`Missing required environment variable: ${key}`);
|
||||
}
|
||||
|
||||
function envInt(key: string, fallback: number): number {
|
||||
const val = process.env[key];
|
||||
return val ? parseInt(val, 10) : fallback;
|
||||
}
|
||||
|
||||
export function loadConfig(cliUrls?: string[]): Config {
|
||||
const envUrls = process.env['TRADE_URLS']
|
||||
? process.env['TRADE_URLS'].split(',').map((u) => u.trim())
|
||||
: [];
|
||||
|
||||
const tradeUrls = cliUrls && cliUrls.length > 0 ? cliUrls : envUrls;
|
||||
|
||||
return {
|
||||
tradeUrls,
|
||||
poe2LogPath: env(
|
||||
'POE2_LOG_PATH',
|
||||
'C:\\Program Files (x86)\\Steam\\steamapps\\common\\Path of Exile 2\\logs\\Client.txt',
|
||||
),
|
||||
poe2WindowTitle: env('POE2_WINDOW_TITLE', 'Path of Exile 2'),
|
||||
browserUserDataDir: env('BROWSER_USER_DATA_DIR', './browser-data'),
|
||||
travelTimeoutMs: envInt('TRAVEL_TIMEOUT_MS', 15000),
|
||||
stashScanTimeoutMs: envInt('STASH_SCAN_TIMEOUT_MS', 10000),
|
||||
waitForMoreItemsMs: envInt('WAIT_FOR_MORE_ITEMS_MS', 20000),
|
||||
betweenTradesDelayMs: envInt('BETWEEN_TRADES_DELAY_MS', 5000),
|
||||
};
|
||||
}
|
||||
187
src/dashboard/BotController.ts
Normal file
187
src/dashboard/BotController.ts
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
import { EventEmitter } from 'events';
|
||||
import { logger } from '../util/logger.js';
|
||||
import type { ConfigStore, SavedLink } from './ConfigStore.js';
|
||||
|
||||
export interface TradeLink {
|
||||
id: string;
|
||||
url: string;
|
||||
name: string;
|
||||
label: string;
|
||||
active: boolean;
|
||||
addedAt: string;
|
||||
}
|
||||
|
||||
export interface BotStatus {
|
||||
paused: boolean;
|
||||
state: string;
|
||||
links: TradeLink[];
|
||||
tradesCompleted: number;
|
||||
tradesFailed: number;
|
||||
uptime: number;
|
||||
settings: {
|
||||
poe2LogPath: string;
|
||||
poe2WindowTitle: string;
|
||||
travelTimeoutMs: number;
|
||||
waitForMoreItemsMs: number;
|
||||
betweenTradesDelayMs: number;
|
||||
};
|
||||
}
|
||||
|
||||
export class BotController extends EventEmitter {
|
||||
private paused = false;
|
||||
private links: Map<string, TradeLink> = new Map();
|
||||
private _state = 'IDLE';
|
||||
private tradesCompleted = 0;
|
||||
private tradesFailed = 0;
|
||||
private startTime = Date.now();
|
||||
private store: ConfigStore;
|
||||
|
||||
constructor(store: ConfigStore) {
|
||||
super();
|
||||
this.store = store;
|
||||
this.paused = store.settings.paused;
|
||||
}
|
||||
|
||||
get isPaused(): boolean {
|
||||
return this.paused;
|
||||
}
|
||||
|
||||
get state(): string {
|
||||
return this._state;
|
||||
}
|
||||
|
||||
set state(s: string) {
|
||||
this._state = s;
|
||||
this.emit('state-change', s);
|
||||
}
|
||||
|
||||
pause(): void {
|
||||
this.paused = true;
|
||||
this.store.setPaused(true);
|
||||
logger.info('Bot paused');
|
||||
this.emit('paused');
|
||||
}
|
||||
|
||||
resume(): void {
|
||||
this.paused = false;
|
||||
this.store.setPaused(false);
|
||||
logger.info('Bot resumed');
|
||||
this.emit('resumed');
|
||||
}
|
||||
|
||||
addLink(url: string, name: string = ''): TradeLink {
|
||||
url = this.stripLive(url);
|
||||
const id = this.extractId(url);
|
||||
const label = this.extractLabel(url);
|
||||
// Check if we have saved state for this link
|
||||
const savedLink = this.store.links.find((l) => l.url === url);
|
||||
const link: TradeLink = {
|
||||
id,
|
||||
url,
|
||||
name: name || savedLink?.name || '',
|
||||
label,
|
||||
active: savedLink?.active !== undefined ? savedLink.active : true,
|
||||
addedAt: new Date().toISOString(),
|
||||
};
|
||||
this.links.set(id, link);
|
||||
this.store.addLink(url, link.name);
|
||||
logger.info({ id, url, name: link.name, active: link.active }, 'Trade link added');
|
||||
this.emit('link-added', link);
|
||||
return link;
|
||||
}
|
||||
|
||||
removeLink(id: string): void {
|
||||
const link = this.links.get(id);
|
||||
this.links.delete(id);
|
||||
if (link) {
|
||||
this.store.removeLink(link.url);
|
||||
} else {
|
||||
this.store.removeLinkById(id);
|
||||
}
|
||||
logger.info({ id }, 'Trade link removed');
|
||||
this.emit('link-removed', id);
|
||||
}
|
||||
|
||||
toggleLink(id: string, active: boolean): void {
|
||||
const link = this.links.get(id);
|
||||
if (!link) return;
|
||||
link.active = active;
|
||||
this.store.updateLinkById(id, { active });
|
||||
logger.info({ id, active }, `Trade link ${active ? 'activated' : 'deactivated'}`);
|
||||
this.emit('link-toggled', { id, active, link });
|
||||
}
|
||||
|
||||
updateLinkName(id: string, name: string): void {
|
||||
const link = this.links.get(id);
|
||||
if (!link) return;
|
||||
link.name = name;
|
||||
this.store.updateLinkById(id, { name });
|
||||
}
|
||||
|
||||
isLinkActive(searchId: string): boolean {
|
||||
const link = this.links.get(searchId);
|
||||
return link ? link.active : false;
|
||||
}
|
||||
|
||||
getLinks(): TradeLink[] {
|
||||
return Array.from(this.links.values());
|
||||
}
|
||||
|
||||
recordTradeSuccess(): void {
|
||||
this.tradesCompleted++;
|
||||
this.emit('trade-completed');
|
||||
}
|
||||
|
||||
recordTradeFailure(): void {
|
||||
this.tradesFailed++;
|
||||
this.emit('trade-failed');
|
||||
}
|
||||
|
||||
getStatus(): BotStatus {
|
||||
const s = this.store.settings;
|
||||
return {
|
||||
paused: this.paused,
|
||||
state: this._state,
|
||||
links: this.getLinks(),
|
||||
tradesCompleted: this.tradesCompleted,
|
||||
tradesFailed: this.tradesFailed,
|
||||
uptime: Date.now() - this.startTime,
|
||||
settings: {
|
||||
poe2LogPath: s.poe2LogPath,
|
||||
poe2WindowTitle: s.poe2WindowTitle,
|
||||
travelTimeoutMs: s.travelTimeoutMs,
|
||||
waitForMoreItemsMs: s.waitForMoreItemsMs,
|
||||
betweenTradesDelayMs: s.betweenTradesDelayMs,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
getStore(): ConfigStore {
|
||||
return this.store;
|
||||
}
|
||||
|
||||
private stripLive(url: string): string {
|
||||
return url.replace(/\/live\/?$/, '');
|
||||
}
|
||||
|
||||
private extractId(url: string): string {
|
||||
const parts = url.split('/');
|
||||
return parts[parts.length - 1] || url;
|
||||
}
|
||||
|
||||
private extractLabel(url: string): string {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
const parts = urlObj.pathname.split('/').filter(Boolean);
|
||||
const poe2Idx = parts.indexOf('poe2');
|
||||
if (poe2Idx >= 0 && parts.length > poe2Idx + 2) {
|
||||
const league = decodeURIComponent(parts[poe2Idx + 1]);
|
||||
const searchId = parts[poe2Idx + 2];
|
||||
return `${league} / ${searchId}`;
|
||||
}
|
||||
} catch {
|
||||
// fallback
|
||||
}
|
||||
return url.substring(0, 60);
|
||||
}
|
||||
}
|
||||
129
src/dashboard/ConfigStore.ts
Normal file
129
src/dashboard/ConfigStore.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { logger } from '../util/logger.js';
|
||||
|
||||
export interface SavedLink {
|
||||
url: string;
|
||||
name: string;
|
||||
active: boolean;
|
||||
addedAt: string;
|
||||
}
|
||||
|
||||
export interface SavedSettings {
|
||||
paused: boolean;
|
||||
links: SavedLink[];
|
||||
poe2LogPath: string;
|
||||
poe2WindowTitle: string;
|
||||
browserUserDataDir: string;
|
||||
travelTimeoutMs: number;
|
||||
stashScanTimeoutMs: number;
|
||||
waitForMoreItemsMs: number;
|
||||
betweenTradesDelayMs: number;
|
||||
dashboardPort: number;
|
||||
}
|
||||
|
||||
const DEFAULTS: SavedSettings = {
|
||||
paused: false,
|
||||
links: [],
|
||||
poe2LogPath: 'C:\\Program Files (x86)\\Steam\\steamapps\\common\\Path of Exile 2\\logs\\Client.txt',
|
||||
poe2WindowTitle: 'Path of Exile 2',
|
||||
browserUserDataDir: './browser-data',
|
||||
travelTimeoutMs: 15000,
|
||||
stashScanTimeoutMs: 10000,
|
||||
waitForMoreItemsMs: 20000,
|
||||
betweenTradesDelayMs: 5000,
|
||||
dashboardPort: 3000,
|
||||
};
|
||||
|
||||
export class ConfigStore {
|
||||
private filePath: string;
|
||||
private data: SavedSettings;
|
||||
|
||||
constructor(configPath?: string) {
|
||||
this.filePath = configPath || path.resolve('config.json');
|
||||
this.data = this.load();
|
||||
}
|
||||
|
||||
private load(): SavedSettings {
|
||||
if (!existsSync(this.filePath)) {
|
||||
logger.info({ path: this.filePath }, 'No config.json found, using defaults');
|
||||
return { ...DEFAULTS };
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = readFileSync(this.filePath, 'utf-8');
|
||||
const parsed = JSON.parse(raw) as Partial<SavedSettings>;
|
||||
const merged = { ...DEFAULTS, ...parsed };
|
||||
// Migrate old links: add name/active fields, strip /live from URLs
|
||||
merged.links = merged.links.map((l) => ({
|
||||
url: l.url.replace(/\/live\/?$/, ''),
|
||||
name: l.name || '',
|
||||
active: l.active !== undefined ? l.active : true,
|
||||
addedAt: l.addedAt || new Date().toISOString(),
|
||||
}));
|
||||
logger.info({ path: this.filePath, linkCount: merged.links.length }, 'Loaded config.json');
|
||||
return merged;
|
||||
} catch (err) {
|
||||
logger.warn({ err, path: this.filePath }, 'Failed to read config.json, using defaults');
|
||||
return { ...DEFAULTS };
|
||||
}
|
||||
}
|
||||
|
||||
save(): void {
|
||||
try {
|
||||
writeFileSync(this.filePath, JSON.stringify(this.data, null, 2), 'utf-8');
|
||||
} catch (err) {
|
||||
logger.error({ err, path: this.filePath }, 'Failed to save config.json');
|
||||
}
|
||||
}
|
||||
|
||||
get settings(): SavedSettings {
|
||||
return this.data;
|
||||
}
|
||||
|
||||
get links(): SavedLink[] {
|
||||
return this.data.links;
|
||||
}
|
||||
|
||||
addLink(url: string, name: string = ''): void {
|
||||
url = url.replace(/\/live\/?$/, '');
|
||||
if (this.data.links.some((l) => l.url === url)) return;
|
||||
this.data.links.push({ url, name, active: true, addedAt: new Date().toISOString() });
|
||||
this.save();
|
||||
}
|
||||
|
||||
removeLink(url: string): void {
|
||||
this.data.links = this.data.links.filter((l) => l.url !== url);
|
||||
this.save();
|
||||
}
|
||||
|
||||
removeLinkById(id: string): void {
|
||||
this.data.links = this.data.links.filter((l) => {
|
||||
const parts = l.url.split('/');
|
||||
return parts[parts.length - 1] !== id;
|
||||
});
|
||||
this.save();
|
||||
}
|
||||
|
||||
updateLinkById(id: string, updates: { name?: string; active?: boolean }): SavedLink | null {
|
||||
const link = this.data.links.find((l) => {
|
||||
const parts = l.url.split('/');
|
||||
return parts[parts.length - 1] === id;
|
||||
});
|
||||
if (!link) return null;
|
||||
if (updates.name !== undefined) link.name = updates.name;
|
||||
if (updates.active !== undefined) link.active = updates.active;
|
||||
this.save();
|
||||
return link;
|
||||
}
|
||||
|
||||
setPaused(paused: boolean): void {
|
||||
this.data.paused = paused;
|
||||
this.save();
|
||||
}
|
||||
|
||||
updateSettings(partial: Partial<SavedSettings>): void {
|
||||
Object.assign(this.data, partial);
|
||||
this.save();
|
||||
}
|
||||
}
|
||||
227
src/dashboard/DashboardServer.ts
Normal file
227
src/dashboard/DashboardServer.ts
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
import express from 'express';
|
||||
import http from 'http';
|
||||
import { WebSocketServer, WebSocket } from 'ws';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { logger } from '../util/logger.js';
|
||||
import type { BotController } from './BotController.js';
|
||||
import type { ScreenReader } from '../game/ScreenReader.js';
|
||||
import type { GameController } from '../game/GameController.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export interface DebugDeps {
|
||||
screenReader: ScreenReader;
|
||||
gameController: GameController;
|
||||
}
|
||||
|
||||
export class DashboardServer {
|
||||
private app = express();
|
||||
private server: http.Server;
|
||||
private wss: WebSocketServer;
|
||||
private clients: Set<WebSocket> = new Set();
|
||||
private bot: BotController;
|
||||
private debug: DebugDeps | null = null;
|
||||
|
||||
constructor(bot: BotController, private port: number = 3000) {
|
||||
this.bot = bot;
|
||||
this.app.use(express.json());
|
||||
|
||||
this.app.get('/', (_req, res) => {
|
||||
res.sendFile(path.join(__dirname, '..', '..', 'src', 'dashboard', 'index.html'));
|
||||
});
|
||||
|
||||
// Status
|
||||
this.app.get('/api/status', (_req, res) => {
|
||||
res.json(this.bot.getStatus());
|
||||
});
|
||||
|
||||
// Pause / Resume
|
||||
this.app.post('/api/pause', (_req, res) => {
|
||||
this.bot.pause();
|
||||
this.broadcastStatus();
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
this.app.post('/api/resume', (_req, res) => {
|
||||
this.bot.resume();
|
||||
this.broadcastStatus();
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// Links CRUD
|
||||
this.app.post('/api/links', (req, res) => {
|
||||
const { url, name } = req.body as { url: string; name?: string };
|
||||
if (!url || !url.includes('pathofexile.com/trade')) {
|
||||
res.status(400).json({ error: 'Invalid trade URL' });
|
||||
return;
|
||||
}
|
||||
this.bot.addLink(url, name || '');
|
||||
this.broadcastStatus();
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
this.app.delete('/api/links/:id', (req, res) => {
|
||||
this.bot.removeLink(req.params.id);
|
||||
this.broadcastStatus();
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// Toggle link active/inactive
|
||||
this.app.post('/api/links/:id/toggle', (req, res) => {
|
||||
const { active } = req.body as { active: boolean };
|
||||
this.bot.toggleLink(req.params.id, active);
|
||||
this.broadcastStatus();
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// Rename link
|
||||
this.app.post('/api/links/:id/name', (req, res) => {
|
||||
const { name } = req.body as { name: string };
|
||||
this.bot.updateLinkName(req.params.id, name);
|
||||
this.broadcastStatus();
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// Settings
|
||||
this.app.post('/api/settings', (req, res) => {
|
||||
const updates = req.body as Record<string, unknown>;
|
||||
const store = this.bot.getStore();
|
||||
store.updateSettings(updates);
|
||||
this.broadcastStatus();
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// Debug endpoints
|
||||
this.app.post('/api/debug/screenshot', async (_req, res) => {
|
||||
if (!this.debug) { res.status(503).json({ error: 'Debug not available' }); return; }
|
||||
try {
|
||||
const files = await this.debug.screenReader.saveDebugScreenshots('debug-screenshots');
|
||||
this.broadcastLog('info', `Debug screenshots saved: ${files.map(f => f.split(/[\\/]/).pop()).join(', ')}`);
|
||||
res.json({ ok: true, files });
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Debug screenshot failed');
|
||||
res.status(500).json({ error: 'Screenshot failed' });
|
||||
}
|
||||
});
|
||||
|
||||
this.app.post('/api/debug/ocr', async (_req, res) => {
|
||||
if (!this.debug) { res.status(503).json({ error: 'Debug not available' }); return; }
|
||||
try {
|
||||
const text = await this.debug.screenReader.readFullScreen();
|
||||
this.broadcastLog('info', `OCR result (${text.length} chars): ${text.substring(0, 200)}`);
|
||||
res.json({ ok: true, text });
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Debug OCR failed');
|
||||
res.status(500).json({ error: 'OCR failed' });
|
||||
}
|
||||
});
|
||||
|
||||
this.app.post('/api/debug/find-text', async (req, res) => {
|
||||
if (!this.debug) { res.status(503).json({ error: 'Debug not available' }); return; }
|
||||
const { text } = req.body as { text: string };
|
||||
if (!text) { res.status(400).json({ error: 'Missing text parameter' }); return; }
|
||||
try {
|
||||
const pos = await this.debug.screenReader.findTextOnScreen(text);
|
||||
if (pos) {
|
||||
this.broadcastLog('info', `Found "${text}" at (${pos.x}, ${pos.y})`);
|
||||
} else {
|
||||
this.broadcastLog('warn', `"${text}" not found on screen`);
|
||||
}
|
||||
res.json({ ok: true, found: !!pos, position: pos });
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Debug find-text failed');
|
||||
res.status(500).json({ error: 'Find text failed' });
|
||||
}
|
||||
});
|
||||
|
||||
this.app.post('/api/debug/click', async (req, res) => {
|
||||
if (!this.debug) { res.status(503).json({ error: 'Debug not available' }); return; }
|
||||
const { x, y } = req.body as { x: number; y: number };
|
||||
if (x == null || y == null) { res.status(400).json({ error: 'Missing x/y' }); return; }
|
||||
try {
|
||||
await this.debug.gameController.focusGame();
|
||||
await this.debug.gameController.leftClickAt(x, y);
|
||||
this.broadcastLog('info', `Clicked at (${x}, ${y})`);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Debug click failed');
|
||||
res.status(500).json({ error: 'Click failed' });
|
||||
}
|
||||
});
|
||||
|
||||
this.app.post('/api/debug/find-and-click', async (req, res) => {
|
||||
if (!this.debug) { res.status(503).json({ error: 'Debug not available' }); return; }
|
||||
const { text } = req.body as { text: string };
|
||||
if (!text) { res.status(400).json({ error: 'Missing text parameter' }); return; }
|
||||
try {
|
||||
const pos = await this.debug.screenReader.findTextOnScreen(text);
|
||||
if (pos) {
|
||||
await this.debug.gameController.focusGame();
|
||||
await this.debug.gameController.leftClickAt(pos.x, pos.y);
|
||||
this.broadcastLog('info', `Found "${text}" at (${pos.x}, ${pos.y}) and clicked`);
|
||||
res.json({ ok: true, found: true, position: pos });
|
||||
} else {
|
||||
this.broadcastLog('warn', `"${text}" not found on screen`);
|
||||
res.json({ ok: true, found: false, position: null });
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Debug find-and-click failed');
|
||||
res.status(500).json({ error: 'Find and click failed' });
|
||||
}
|
||||
});
|
||||
|
||||
this.server = http.createServer(this.app);
|
||||
this.wss = new WebSocketServer({ server: this.server });
|
||||
|
||||
this.wss.on('connection', (ws) => {
|
||||
this.clients.add(ws);
|
||||
ws.send(JSON.stringify({ type: 'status', data: this.bot.getStatus() }));
|
||||
ws.on('close', () => this.clients.delete(ws));
|
||||
});
|
||||
}
|
||||
|
||||
setDebugDeps(deps: DebugDeps): void {
|
||||
this.debug = deps;
|
||||
logger.info('Debug tools available on dashboard');
|
||||
}
|
||||
|
||||
broadcastStatus(): void {
|
||||
const msg = JSON.stringify({ type: 'status', data: this.bot.getStatus() });
|
||||
for (const client of this.clients) {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
broadcastLog(level: string, message: string): void {
|
||||
const msg = JSON.stringify({
|
||||
type: 'log',
|
||||
data: { level, message, time: new Date().toISOString() },
|
||||
});
|
||||
for (const client of this.clients) {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
this.server.listen(this.port, () => {
|
||||
logger.info({ port: this.port }, `Dashboard running at http://localhost:${this.port}`);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
for (const client of this.clients) {
|
||||
client.close();
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
this.server.close(() => resolve());
|
||||
});
|
||||
}
|
||||
}
|
||||
670
src/dashboard/index.html
Normal file
670
src/dashboard/index.html
Normal file
|
|
@ -0,0 +1,670 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>POE2 Trade Bot</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: 'Segoe UI', system-ui, sans-serif;
|
||||
background: #0d1117;
|
||||
color: #e6edf3;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.container { max-width: 900px; margin: 0 auto; padding: 20px; }
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid #30363d;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
header h1 { font-size: 20px; font-weight: 600; }
|
||||
.header-right { display: flex; align-items: center; gap: 14px; }
|
||||
.settings-btn {
|
||||
background: none; border: none; cursor: pointer; padding: 4px;
|
||||
color: #8b949e; transition: color 0.15s; line-height: 1;
|
||||
}
|
||||
.settings-btn:hover { background: none; color: #e6edf3; }
|
||||
.settings-btn svg { display: block; }
|
||||
.status-dot {
|
||||
width: 10px; height: 10px; border-radius: 50%;
|
||||
display: inline-block; margin-right: 8px;
|
||||
}
|
||||
.status-dot.running { background: #3fb950; box-shadow: 0 0 6px #3fb950; }
|
||||
.status-dot.paused { background: #d29922; box-shadow: 0 0 6px #d29922; }
|
||||
.status-dot.idle { background: #8b949e; }
|
||||
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.stat-card {
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 8px;
|
||||
padding: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
.stat-card .value { font-size: 24px; font-weight: 700; color: #58a6ff; }
|
||||
.stat-card .label { font-size: 11px; color: #8b949e; text-transform: uppercase; margin-top: 4px; }
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
button {
|
||||
padding: 8px 20px;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 6px;
|
||||
background: #21262d;
|
||||
color: #e6edf3;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
button:hover { background: #30363d; }
|
||||
button.primary { background: #238636; border-color: #2ea043; }
|
||||
button.primary:hover { background: #2ea043; }
|
||||
button.danger { background: #da3633; border-color: #f85149; }
|
||||
button.danger:hover { background: #f85149; }
|
||||
button.warning { background: #9e6a03; border-color: #d29922; }
|
||||
button.warning:hover { background: #d29922; }
|
||||
|
||||
.section { margin-bottom: 20px; }
|
||||
.section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 10px;
|
||||
color: #8b949e;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.add-link {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.add-link input {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
background: #0d1117;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 6px;
|
||||
color: #e6edf3;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
}
|
||||
.add-link input:focus { border-color: #58a6ff; }
|
||||
.add-link input::placeholder { color: #484f58; }
|
||||
|
||||
.links-list { display: flex; flex-direction: column; gap: 6px; }
|
||||
.link-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 6px;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
.link-left { display: flex; align-items: center; gap: 10px; flex: 1; min-width: 0; }
|
||||
.link-info { flex: 1; min-width: 0; }
|
||||
.link-name { font-size: 13px; font-weight: 600; color: #e6edf3; }
|
||||
.link-label { font-size: 12px; color: #8b949e; }
|
||||
.link-url {
|
||||
font-size: 11px; color: #484f58;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
max-width: 600px;
|
||||
}
|
||||
.link-actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
|
||||
.link-item button { padding: 4px 12px; font-size: 12px; }
|
||||
.link-item.inactive { opacity: 0.5; }
|
||||
|
||||
/* Toggle switch */
|
||||
.toggle { position: relative; width: 36px; height: 20px; cursor: pointer; flex-shrink: 0; }
|
||||
.toggle input { opacity: 0; width: 0; height: 0; }
|
||||
.toggle .slider {
|
||||
position: absolute; inset: 0; background: #30363d;
|
||||
border-radius: 20px; transition: background 0.2s;
|
||||
}
|
||||
.toggle .slider::before {
|
||||
content: ''; position: absolute; left: 2px; top: 2px;
|
||||
width: 16px; height: 16px; background: #8b949e;
|
||||
border-radius: 50%; transition: transform 0.2s, background 0.2s;
|
||||
}
|
||||
.toggle input:checked + .slider { background: #238636; }
|
||||
.toggle input:checked + .slider::before { transform: translateX(16px); background: #fff; }
|
||||
|
||||
.log-panel {
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 8px;
|
||||
height: 280px;
|
||||
overflow-y: auto;
|
||||
font-family: 'Cascadia Code', 'Fira Code', monospace;
|
||||
font-size: 12px;
|
||||
padding: 10px;
|
||||
}
|
||||
.log-line { padding: 2px 0; line-height: 1.5; }
|
||||
.log-line .time { color: #484f58; }
|
||||
.log-line.info .msg { color: #58a6ff; }
|
||||
.log-line.warn .msg { color: #d29922; }
|
||||
.log-line.error .msg { color: #f85149; }
|
||||
.log-line.debug .msg { color: #8b949e; }
|
||||
|
||||
.empty-state {
|
||||
color: #484f58;
|
||||
text-align: center;
|
||||
padding: 30px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.settings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
.settings-grid.full { grid-template-columns: 1fr; }
|
||||
.setting-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.setting-row label {
|
||||
font-size: 11px;
|
||||
color: #8b949e;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.setting-row input {
|
||||
padding: 6px 10px;
|
||||
background: #0d1117;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 6px;
|
||||
color: #e6edf3;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
}
|
||||
.setting-row input:focus { border-color: #58a6ff; }
|
||||
.settings-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.saved-badge {
|
||||
font-size: 12px;
|
||||
color: #3fb950;
|
||||
margin-right: 12px;
|
||||
line-height: 32px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
.saved-badge.show { opacity: 1; }
|
||||
|
||||
/* Modal overlay */
|
||||
.modal-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
z-index: 100;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
.modal-overlay.open { display: flex; }
|
||||
.modal {
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 10px;
|
||||
width: 480px;
|
||||
max-width: 90vw;
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
padding: 24px;
|
||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.modal-header h2 { font-size: 16px; font-weight: 600; }
|
||||
.modal-close {
|
||||
background: none; border: none; color: #8b949e;
|
||||
cursor: pointer; padding: 4px; font-size: 18px; line-height: 1;
|
||||
}
|
||||
.modal-close:hover { background: none; color: #e6edf3; }
|
||||
|
||||
.debug-panel {
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 8px;
|
||||
padding: 14px;
|
||||
}
|
||||
.debug-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.debug-row:last-of-type { margin-bottom: 0; }
|
||||
.debug-row input {
|
||||
padding: 6px 10px;
|
||||
background: #0d1117;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 6px;
|
||||
color: #e6edf3;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
}
|
||||
.debug-row input:focus { border-color: #58a6ff; }
|
||||
.debug-row input[type="text"] { flex: 1; }
|
||||
.debug-result {
|
||||
margin-top: 8px;
|
||||
font-family: 'Cascadia Code', 'Fira Code', monospace;
|
||||
font-size: 12px;
|
||||
color: #8b949e;
|
||||
white-space: pre-wrap;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.debug-result:empty { display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>POE2 Trade Bot</h1>
|
||||
<div class="header-right">
|
||||
<div id="statusBadge">
|
||||
<span class="status-dot idle"></span>
|
||||
<span id="statusText">Connecting...</span>
|
||||
</div>
|
||||
<button class="settings-btn" onclick="openSettings()" title="Settings">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="10" cy="10" r="3"/>
|
||||
<path d="M10 1.5v2M10 16.5v2M3.4 3.4l1.4 1.4M15.2 15.2l1.4 1.4M1.5 10h2M16.5 10h2M3.4 16.6l1.4-1.4M15.2 4.8l1.4-1.4"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat-card">
|
||||
<div class="value" id="stateValue">IDLE</div>
|
||||
<div class="label">State</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="value" id="linksValue">0</div>
|
||||
<div class="label">Active Links</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="value" id="completedValue">0</div>
|
||||
<div class="label">Trades Done</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="value" id="failedValue">0</div>
|
||||
<div class="label">Failed</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls" id="controls">
|
||||
<button class="warning" id="pauseBtn" onclick="togglePause()">Pause</button>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">Trade Links</div>
|
||||
<div class="add-link">
|
||||
<input type="text" id="nameInput" placeholder="Name (optional)" style="max-width:180px" />
|
||||
<input type="text" id="urlInput" placeholder="Paste trade URL..." />
|
||||
<button class="primary" onclick="addLink()">Add</button>
|
||||
</div>
|
||||
<div class="links-list" id="linksList">
|
||||
<div class="empty-state">No trade links added yet</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">Debug Tools</div>
|
||||
<div class="debug-panel">
|
||||
<div class="debug-row">
|
||||
<button onclick="debugScreenshot()">Screenshot</button>
|
||||
<button onclick="debugOcr()">OCR Screen</button>
|
||||
</div>
|
||||
<div class="debug-row">
|
||||
<input type="text" id="debugTextInput" placeholder="Text to find (e.g. Stash, Ange)" />
|
||||
<button onclick="debugFindText()">Find</button>
|
||||
<button class="primary" onclick="debugFindAndClick()">Find & Click</button>
|
||||
</div>
|
||||
<div class="debug-row">
|
||||
<input type="number" id="debugClickX" placeholder="X" style="width:80px" />
|
||||
<input type="number" id="debugClickY" placeholder="Y" style="width:80px" />
|
||||
<button onclick="debugClick()">Click At</button>
|
||||
</div>
|
||||
<div class="debug-result" id="debugResult"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">Activity Log</div>
|
||||
<div class="log-panel" id="logPanel"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="settingsModal">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h2>Settings</h2>
|
||||
<button class="modal-close" onclick="closeSettings()">×</button>
|
||||
</div>
|
||||
<div class="settings-grid full">
|
||||
<div class="setting-row">
|
||||
<label>POE2 Client.txt Path</label>
|
||||
<input type="text" id="settLogPath" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-grid" style="margin-top:10px">
|
||||
<div class="setting-row">
|
||||
<label>Window Title</label>
|
||||
<input type="text" id="settWindowTitle" />
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label>Travel Timeout (ms)</label>
|
||||
<input type="number" id="settTravelTimeout" />
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label>Wait for More Items (ms)</label>
|
||||
<input type="number" id="settWaitMore" />
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label>Delay Between Trades (ms)</label>
|
||||
<input type="number" id="settTradeDelay" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-actions">
|
||||
<span class="saved-badge" id="savedBadge">Saved</span>
|
||||
<button class="primary" onclick="saveSettings()">Save Settings</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let ws;
|
||||
let status = { paused: false, state: 'IDLE', links: [], tradesCompleted: 0, tradesFailed: 0, uptime: 0, settings: {} };
|
||||
let settingsLoaded = false;
|
||||
|
||||
function connect() {
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
ws = new WebSocket(`${proto}//${location.host}`);
|
||||
ws.onmessage = (e) => {
|
||||
const msg = JSON.parse(e.data);
|
||||
if (msg.type === 'status') {
|
||||
status = msg.data;
|
||||
render();
|
||||
} else if (msg.type === 'log') {
|
||||
addLog(msg.data);
|
||||
}
|
||||
};
|
||||
ws.onclose = () => {
|
||||
addLog({ level: 'warn', message: 'Dashboard disconnected. Reconnecting...', time: new Date().toISOString() });
|
||||
setTimeout(connect, 2000);
|
||||
};
|
||||
ws.onerror = () => {};
|
||||
}
|
||||
|
||||
function render() {
|
||||
// Status badge
|
||||
const dot = document.querySelector('.status-dot');
|
||||
const text = document.getElementById('statusText');
|
||||
dot.className = 'status-dot ' + (status.paused ? 'paused' : status.state === 'IDLE' ? 'idle' : 'running');
|
||||
text.textContent = status.paused ? 'Paused' : status.state;
|
||||
|
||||
// Stats
|
||||
document.getElementById('stateValue').textContent = status.state;
|
||||
document.getElementById('linksValue').textContent = status.links.length;
|
||||
document.getElementById('completedValue').textContent = status.tradesCompleted;
|
||||
document.getElementById('failedValue').textContent = status.tradesFailed;
|
||||
|
||||
// Pause button
|
||||
const btn = document.getElementById('pauseBtn');
|
||||
btn.textContent = status.paused ? 'Resume' : 'Pause';
|
||||
btn.className = status.paused ? 'primary' : 'warning';
|
||||
|
||||
// Settings (populate once on first status)
|
||||
if (status.settings) populateSettings(status.settings);
|
||||
|
||||
// Active links count
|
||||
document.getElementById('linksValue').textContent = status.links.filter(l => l.active).length;
|
||||
|
||||
// Links list
|
||||
const list = document.getElementById('linksList');
|
||||
if (status.links.length === 0) {
|
||||
list.innerHTML = '<div class="empty-state">No trade links added yet</div>';
|
||||
} else {
|
||||
list.innerHTML = status.links.map(link => `
|
||||
<div class="link-item${link.active ? '' : ' inactive'}">
|
||||
<div class="link-left">
|
||||
<label class="toggle" title="${link.active ? 'Active' : 'Inactive'}">
|
||||
<input type="checkbox" ${link.active ? 'checked' : ''} onchange="toggleLink('${esc(link.id)}', this.checked)" />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<div class="link-info">
|
||||
<div class="link-name" contenteditable="true" spellcheck="false"
|
||||
onblur="renameLink('${esc(link.id)}', this.textContent)"
|
||||
title="Click to edit name">${esc(link.name || link.label)}</div>
|
||||
<div class="link-url">${esc(link.url)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="link-actions">
|
||||
<button class="danger" onclick="removeLink('${esc(link.id)}')">Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
}
|
||||
|
||||
function addLog(data) {
|
||||
const panel = document.getElementById('logPanel');
|
||||
const line = document.createElement('div');
|
||||
line.className = 'log-line ' + (data.level || 'info');
|
||||
const t = new Date(data.time).toLocaleTimeString();
|
||||
line.innerHTML = `<span class="time">${t}</span> <span class="msg">${esc(data.message)}</span>`;
|
||||
panel.appendChild(line);
|
||||
if (panel.children.length > 500) panel.removeChild(panel.firstChild);
|
||||
panel.scrollTop = panel.scrollHeight;
|
||||
}
|
||||
|
||||
async function togglePause() {
|
||||
const endpoint = status.paused ? '/api/resume' : '/api/pause';
|
||||
await fetch(endpoint, { method: 'POST' });
|
||||
}
|
||||
|
||||
async function addLink() {
|
||||
const urlEl = document.getElementById('urlInput');
|
||||
const nameEl = document.getElementById('nameInput');
|
||||
const url = urlEl.value.trim();
|
||||
if (!url) return;
|
||||
await fetch('/api/links', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url, name: nameEl.value.trim() }),
|
||||
});
|
||||
urlEl.value = '';
|
||||
nameEl.value = '';
|
||||
}
|
||||
|
||||
async function removeLink(id) {
|
||||
await fetch('/api/links/' + encodeURIComponent(id), { method: 'DELETE' });
|
||||
}
|
||||
|
||||
async function toggleLink(id, active) {
|
||||
await fetch('/api/links/' + encodeURIComponent(id) + '/toggle', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ active }),
|
||||
});
|
||||
}
|
||||
|
||||
let renameTimer = null;
|
||||
async function renameLink(id, name) {
|
||||
clearTimeout(renameTimer);
|
||||
renameTimer = setTimeout(async () => {
|
||||
await fetch('/api/links/' + encodeURIComponent(id) + '/name', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: name.trim() }),
|
||||
});
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function populateSettings(s) {
|
||||
if (!s || settingsLoaded) return;
|
||||
settingsLoaded = true;
|
||||
document.getElementById('settLogPath').value = s.poe2LogPath || '';
|
||||
document.getElementById('settWindowTitle').value = s.poe2WindowTitle || '';
|
||||
document.getElementById('settTravelTimeout').value = s.travelTimeoutMs || 15000;
|
||||
document.getElementById('settWaitMore').value = s.waitForMoreItemsMs || 20000;
|
||||
document.getElementById('settTradeDelay').value = s.betweenTradesDelayMs || 5000;
|
||||
}
|
||||
|
||||
function openSettings() {
|
||||
// Re-populate from latest status in case it changed
|
||||
if (status.settings) {
|
||||
const s = status.settings;
|
||||
document.getElementById('settLogPath').value = s.poe2LogPath || '';
|
||||
document.getElementById('settWindowTitle').value = s.poe2WindowTitle || '';
|
||||
document.getElementById('settTravelTimeout').value = s.travelTimeoutMs || 15000;
|
||||
document.getElementById('settWaitMore').value = s.waitForMoreItemsMs || 20000;
|
||||
document.getElementById('settTradeDelay').value = s.betweenTradesDelayMs || 5000;
|
||||
}
|
||||
document.getElementById('settingsModal').classList.add('open');
|
||||
}
|
||||
|
||||
function closeSettings() {
|
||||
document.getElementById('settingsModal').classList.remove('open');
|
||||
}
|
||||
|
||||
// Close modal on overlay click
|
||||
document.getElementById('settingsModal').addEventListener('click', (e) => {
|
||||
if (e.target === e.currentTarget) closeSettings();
|
||||
});
|
||||
|
||||
// Close modal on Escape
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') closeSettings();
|
||||
});
|
||||
|
||||
async function saveSettings() {
|
||||
const body = {
|
||||
poe2LogPath: document.getElementById('settLogPath').value,
|
||||
poe2WindowTitle: document.getElementById('settWindowTitle').value,
|
||||
travelTimeoutMs: parseInt(document.getElementById('settTravelTimeout').value) || 15000,
|
||||
waitForMoreItemsMs: parseInt(document.getElementById('settWaitMore').value) || 20000,
|
||||
betweenTradesDelayMs: parseInt(document.getElementById('settTradeDelay').value) || 5000,
|
||||
};
|
||||
await fetch('/api/settings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const badge = document.getElementById('savedBadge');
|
||||
badge.classList.add('show');
|
||||
setTimeout(() => badge.classList.remove('show'), 2000);
|
||||
}
|
||||
|
||||
// Debug functions
|
||||
async function debugScreenshot() {
|
||||
const res = await fetch('/api/debug/screenshot', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
showDebugResult(data.ok ? `Screenshot saved: ${data.filename}` : `Error: ${data.error}`);
|
||||
}
|
||||
|
||||
async function debugOcr() {
|
||||
showDebugResult('Running OCR...');
|
||||
const res = await fetch('/api/debug/ocr', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
showDebugResult(data.ok ? data.text : `Error: ${data.error}`);
|
||||
}
|
||||
|
||||
async function debugFindText() {
|
||||
const text = document.getElementById('debugTextInput').value.trim();
|
||||
if (!text) return;
|
||||
showDebugResult(`Searching for "${text}"...`);
|
||||
const res = await fetch('/api/debug/find-text', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.found) {
|
||||
showDebugResult(`Found "${text}" at (${data.position.x}, ${data.position.y})`);
|
||||
document.getElementById('debugClickX').value = data.position.x;
|
||||
document.getElementById('debugClickY').value = data.position.y;
|
||||
} else {
|
||||
showDebugResult(`"${text}" not found on screen`);
|
||||
}
|
||||
}
|
||||
|
||||
async function debugFindAndClick() {
|
||||
const text = document.getElementById('debugTextInput').value.trim();
|
||||
if (!text) return;
|
||||
showDebugResult(`Finding and clicking "${text}"...`);
|
||||
const res = await fetch('/api/debug/find-and-click', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.found) {
|
||||
showDebugResult(`Clicked "${text}" at (${data.position.x}, ${data.position.y})`);
|
||||
} else {
|
||||
showDebugResult(`"${text}" not found on screen`);
|
||||
}
|
||||
}
|
||||
|
||||
async function debugClick() {
|
||||
const x = parseInt(document.getElementById('debugClickX').value);
|
||||
const y = parseInt(document.getElementById('debugClickY').value);
|
||||
if (isNaN(x) || isNaN(y)) return;
|
||||
const res = await fetch('/api/debug/click', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ x, y }),
|
||||
});
|
||||
const data = await res.json();
|
||||
showDebugResult(data.ok ? `Clicked at (${x}, ${y})` : `Error: ${data.error}`);
|
||||
}
|
||||
|
||||
function showDebugResult(text) {
|
||||
document.getElementById('debugResult').textContent = text;
|
||||
}
|
||||
|
||||
// Enter key in debug text input
|
||||
document.getElementById('debugTextInput').addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') debugFindText();
|
||||
});
|
||||
|
||||
// Enter key in URL input
|
||||
document.getElementById('urlInput').addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') addLink();
|
||||
});
|
||||
|
||||
connect();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
251
src/executor/TradeExecutor.ts
Normal file
251
src/executor/TradeExecutor.ts
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
import { GameController } from '../game/GameController.js';
|
||||
import { ScreenReader } from '../game/ScreenReader.js';
|
||||
import { ClientLogWatcher } from '../log/ClientLogWatcher.js';
|
||||
import { TradeMonitor } from '../trade/TradeMonitor.js';
|
||||
import { sleep, randomDelay } from '../util/sleep.js';
|
||||
import { logger } from '../util/logger.js';
|
||||
import type { Config, TradeInfo, TradeState, Region } from '../types.js';
|
||||
import type { Page } from 'playwright';
|
||||
|
||||
// Default screen regions for 1920x1080 - these need calibration
|
||||
const DEFAULT_REGIONS = {
|
||||
stashArea: { x: 20, y: 140, width: 630, height: 750 },
|
||||
priceWarningDialog: { x: 600, y: 350, width: 700, height: 300 },
|
||||
priceWarningNoButton: { x: 820, y: 560, width: 120, height: 40 },
|
||||
inventoryArea: { x: 1260, y: 580, width: 630, height: 280 },
|
||||
stashTabArea: { x: 20, y: 100, width: 630, height: 40 },
|
||||
};
|
||||
|
||||
export class TradeExecutor {
|
||||
private state: TradeState = 'IDLE';
|
||||
private gameController: GameController;
|
||||
private screenReader: ScreenReader;
|
||||
private logWatcher: ClientLogWatcher;
|
||||
private tradeMonitor: TradeMonitor;
|
||||
private config: Config;
|
||||
|
||||
constructor(
|
||||
gameController: GameController,
|
||||
screenReader: ScreenReader,
|
||||
logWatcher: ClientLogWatcher,
|
||||
tradeMonitor: TradeMonitor,
|
||||
config: Config,
|
||||
) {
|
||||
this.gameController = gameController;
|
||||
this.screenReader = screenReader;
|
||||
this.logWatcher = logWatcher;
|
||||
this.tradeMonitor = tradeMonitor;
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
getState(): TradeState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
async executeTrade(trade: TradeInfo): Promise<boolean> {
|
||||
const page = trade.page as Page;
|
||||
|
||||
try {
|
||||
// Step 1: Click "Travel to Hideout" on the trade website
|
||||
this.state = 'TRAVELING';
|
||||
logger.info({ searchId: trade.searchId }, 'Clicking Travel to Hideout...');
|
||||
|
||||
const travelClicked = await this.tradeMonitor.clickTravelToHideout(
|
||||
page,
|
||||
trade.itemIds[0],
|
||||
);
|
||||
if (!travelClicked) {
|
||||
logger.error('Failed to click Travel to Hideout');
|
||||
this.state = 'FAILED';
|
||||
return false;
|
||||
}
|
||||
|
||||
// Step 2: Wait for area transition (arrival at seller's hideout)
|
||||
logger.info('Waiting for area transition...');
|
||||
const arrived = await this.waitForAreaTransition(this.config.travelTimeoutMs);
|
||||
if (!arrived) {
|
||||
logger.error('Timed out waiting for hideout arrival');
|
||||
this.state = 'FAILED';
|
||||
return false;
|
||||
}
|
||||
|
||||
this.state = 'IN_SELLERS_HIDEOUT';
|
||||
logger.info('Arrived at seller hideout');
|
||||
|
||||
// Step 3: Focus game window and click on Ange then Stash
|
||||
await this.gameController.focusGame();
|
||||
await sleep(1500); // Wait for hideout to render
|
||||
|
||||
// Click on Ange NPC to interact
|
||||
const angePos = await this.findAndClickNameplate('Ange');
|
||||
if (!angePos) {
|
||||
logger.warn('Could not find Ange nameplate, trying Stash directly');
|
||||
} else {
|
||||
await sleep(1000); // Wait for NPC interaction
|
||||
}
|
||||
|
||||
// Click on Stash to open it
|
||||
const stashPos = await this.findAndClickNameplate('Stash');
|
||||
if (!stashPos) {
|
||||
logger.error('Could not find Stash nameplate in seller hideout');
|
||||
this.state = 'FAILED';
|
||||
return false;
|
||||
}
|
||||
await sleep(1000); // Wait for stash to open
|
||||
|
||||
// Step 4: Scan stash and buy items
|
||||
this.state = 'SCANNING_STASH';
|
||||
logger.info('Scanning stash for items...');
|
||||
|
||||
await this.scanAndBuyItems();
|
||||
|
||||
// Step 5: Wait for more items
|
||||
this.state = 'WAITING_FOR_MORE';
|
||||
logger.info(
|
||||
{ waitMs: this.config.waitForMoreItemsMs },
|
||||
'Waiting for seller to add more items...',
|
||||
);
|
||||
await sleep(this.config.waitForMoreItemsMs);
|
||||
|
||||
// Do one more scan after waiting
|
||||
await this.scanAndBuyItems();
|
||||
|
||||
// Step 6: Go back to own hideout
|
||||
this.state = 'GOING_HOME';
|
||||
logger.info('Traveling to own hideout...');
|
||||
await this.gameController.focusGame();
|
||||
await sleep(300);
|
||||
await this.gameController.goToHideout();
|
||||
|
||||
const home = await this.waitForAreaTransition(this.config.travelTimeoutMs);
|
||||
if (!home) {
|
||||
logger.warn('Timed out going home, continuing anyway...');
|
||||
}
|
||||
|
||||
// Step 7: Store items in stash
|
||||
this.state = 'IN_HIDEOUT';
|
||||
await sleep(1000);
|
||||
await this.storeItems();
|
||||
|
||||
this.state = 'IDLE';
|
||||
return true;
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Trade execution failed');
|
||||
this.state = 'FAILED';
|
||||
|
||||
// Try to recover by going home
|
||||
try {
|
||||
await this.gameController.focusGame();
|
||||
await this.gameController.pressEscape(); // Close any open dialogs
|
||||
await sleep(500);
|
||||
await this.gameController.goToHideout();
|
||||
} catch {
|
||||
// Best-effort recovery
|
||||
}
|
||||
|
||||
this.state = 'IDLE';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async scanAndBuyItems(): Promise<void> {
|
||||
// Take a screenshot of the stash area
|
||||
const stashText = await this.screenReader.readRegionText(DEFAULT_REGIONS.stashArea);
|
||||
logger.info({ stashText: stashText.substring(0, 200) }, 'Stash OCR result');
|
||||
|
||||
// For now, we'll use a simple grid-based approach to click items
|
||||
// The exact positions depend on the stash layout and resolution
|
||||
// This needs calibration with real game screenshots
|
||||
//
|
||||
// TODO: Implement item matching logic based on OCR text
|
||||
// For now, we'll Ctrl+right-click at known grid positions
|
||||
|
||||
this.state = 'BUYING';
|
||||
|
||||
// Check for price warning dialog after each buy
|
||||
await this.checkPriceWarning();
|
||||
}
|
||||
|
||||
private async checkPriceWarning(): Promise<void> {
|
||||
// Check if a price warning dialog appeared
|
||||
const hasWarning = await this.screenReader.checkForText(
|
||||
DEFAULT_REGIONS.priceWarningDialog,
|
||||
'price',
|
||||
);
|
||||
|
||||
if (hasWarning) {
|
||||
logger.warn('Price mismatch warning detected! Clicking No.');
|
||||
// Click the "No" button
|
||||
await this.gameController.leftClickAt(
|
||||
DEFAULT_REGIONS.priceWarningNoButton.x + DEFAULT_REGIONS.priceWarningNoButton.width / 2,
|
||||
DEFAULT_REGIONS.priceWarningNoButton.y + DEFAULT_REGIONS.priceWarningNoButton.height / 2,
|
||||
);
|
||||
await sleep(500);
|
||||
}
|
||||
}
|
||||
|
||||
private async storeItems(): Promise<void> {
|
||||
logger.info('Storing purchased items in stash...');
|
||||
|
||||
// Focus game and find Stash in own hideout
|
||||
await this.gameController.focusGame();
|
||||
await sleep(500);
|
||||
|
||||
const stashPos = await this.findAndClickNameplate('Stash');
|
||||
if (!stashPos) {
|
||||
logger.error('Could not find Stash nameplate in own hideout');
|
||||
return;
|
||||
}
|
||||
await sleep(1000); // Wait for stash to open
|
||||
|
||||
// Open inventory
|
||||
await this.gameController.openInventory();
|
||||
await sleep(500);
|
||||
|
||||
// TODO: Implement inventory scanning to find purchased items
|
||||
// and Ctrl+right-click each to transfer to stash
|
||||
|
||||
logger.info('Item storage complete (needs calibration)');
|
||||
}
|
||||
|
||||
private async findAndClickNameplate(
|
||||
name: string,
|
||||
maxRetries: number = 3,
|
||||
retryDelayMs: number = 1000,
|
||||
): Promise<{ x: number; y: number } | null> {
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
logger.info({ name, attempt, maxRetries }, 'Searching for nameplate...');
|
||||
const pos = await this.screenReader.findTextOnScreen(name);
|
||||
|
||||
if (pos) {
|
||||
logger.info({ name, x: pos.x, y: pos.y }, 'Clicking nameplate');
|
||||
await this.gameController.leftClickAt(pos.x, pos.y);
|
||||
return pos;
|
||||
}
|
||||
|
||||
if (attempt < maxRetries) {
|
||||
logger.debug({ name, attempt }, 'Nameplate not found, retrying...');
|
||||
await sleep(retryDelayMs);
|
||||
}
|
||||
}
|
||||
|
||||
logger.warn({ name, maxRetries }, 'Nameplate not found after all retries');
|
||||
return null;
|
||||
}
|
||||
|
||||
private waitForAreaTransition(timeoutMs: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.logWatcher.removeListener('area-entered', handler);
|
||||
resolve(false);
|
||||
}, timeoutMs);
|
||||
|
||||
const handler = () => {
|
||||
clearTimeout(timer);
|
||||
resolve(true);
|
||||
};
|
||||
|
||||
this.logWatcher.once('area-entered', handler);
|
||||
});
|
||||
}
|
||||
}
|
||||
69
src/executor/TradeQueue.ts
Normal file
69
src/executor/TradeQueue.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { logger } from '../util/logger.js';
|
||||
import { sleep, randomDelay } from '../util/sleep.js';
|
||||
import type { TradeExecutor } from './TradeExecutor.js';
|
||||
import type { TradeInfo, Config } from '../types.js';
|
||||
|
||||
export class TradeQueue {
|
||||
private queue: TradeInfo[] = [];
|
||||
private processing = false;
|
||||
|
||||
constructor(
|
||||
private executor: TradeExecutor,
|
||||
private config: Config,
|
||||
) {}
|
||||
|
||||
enqueue(trade: TradeInfo): void {
|
||||
// De-duplicate: skip if same item ID already queued
|
||||
const existingIds = new Set(this.queue.flatMap((t) => t.itemIds));
|
||||
const newIds = trade.itemIds.filter((id) => !existingIds.has(id));
|
||||
|
||||
if (newIds.length === 0) {
|
||||
logger.info({ itemIds: trade.itemIds }, 'Skipping duplicate trade');
|
||||
return;
|
||||
}
|
||||
|
||||
const dedupedTrade = { ...trade, itemIds: newIds };
|
||||
this.queue.push(dedupedTrade);
|
||||
logger.info(
|
||||
{ itemIds: newIds, queueLength: this.queue.length },
|
||||
'Trade enqueued',
|
||||
);
|
||||
|
||||
this.processNext();
|
||||
}
|
||||
|
||||
get length(): number {
|
||||
return this.queue.length;
|
||||
}
|
||||
|
||||
get isProcessing(): boolean {
|
||||
return this.processing;
|
||||
}
|
||||
|
||||
private async processNext(): Promise<void> {
|
||||
if (this.processing || this.queue.length === 0) return;
|
||||
this.processing = true;
|
||||
|
||||
const trade = this.queue.shift()!;
|
||||
try {
|
||||
logger.info(
|
||||
{ searchId: trade.searchId, itemIds: trade.itemIds },
|
||||
'Processing trade',
|
||||
);
|
||||
const success = await this.executor.executeTrade(trade);
|
||||
if (success) {
|
||||
logger.info({ itemIds: trade.itemIds }, 'Trade completed successfully');
|
||||
} else {
|
||||
logger.warn({ itemIds: trade.itemIds }, 'Trade failed');
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error({ err, itemIds: trade.itemIds }, 'Trade execution error');
|
||||
}
|
||||
|
||||
this.processing = false;
|
||||
|
||||
// Delay between trades
|
||||
await randomDelay(this.config.betweenTradesDelayMs, this.config.betweenTradesDelayMs + 3000);
|
||||
this.processNext();
|
||||
}
|
||||
}
|
||||
107
src/game/GameController.ts
Normal file
107
src/game/GameController.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import { WindowManager } from './WindowManager.js';
|
||||
import { InputSender, VK } from './InputSender.js';
|
||||
import { sleep, randomDelay } from '../util/sleep.js';
|
||||
import { writeClipboard } from '../util/clipboard.js';
|
||||
import { logger } from '../util/logger.js';
|
||||
import type { Config } from '../types.js';
|
||||
|
||||
export class GameController {
|
||||
private windowManager: WindowManager;
|
||||
private inputSender: InputSender;
|
||||
|
||||
constructor(config: Config) {
|
||||
this.windowManager = new WindowManager(config.poe2WindowTitle);
|
||||
this.inputSender = new InputSender();
|
||||
}
|
||||
|
||||
async focusGame(): Promise<boolean> {
|
||||
const result = this.windowManager.focusWindow();
|
||||
if (result) {
|
||||
await sleep(300); // Wait for window to actually focus
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
isGameFocused(): boolean {
|
||||
return this.windowManager.isGameFocused();
|
||||
}
|
||||
|
||||
getWindowRect() {
|
||||
return this.windowManager.getWindowRect();
|
||||
}
|
||||
|
||||
async sendChat(message: string): Promise<void> {
|
||||
logger.info({ message }, 'Sending chat message');
|
||||
|
||||
// Open chat
|
||||
await this.inputSender.pressKey(VK.RETURN);
|
||||
await randomDelay(100, 200);
|
||||
|
||||
// Clear any existing text
|
||||
await this.inputSender.selectAll();
|
||||
await sleep(50);
|
||||
await this.inputSender.pressKey(VK.DELETE);
|
||||
await sleep(50);
|
||||
|
||||
// Type the message
|
||||
await this.inputSender.typeText(message);
|
||||
await randomDelay(50, 100);
|
||||
|
||||
// Send
|
||||
await this.inputSender.pressKey(VK.RETURN);
|
||||
await sleep(100);
|
||||
}
|
||||
|
||||
async sendChatViaPaste(message: string): Promise<void> {
|
||||
logger.info({ message }, 'Sending chat message via paste');
|
||||
|
||||
// Copy message to clipboard
|
||||
writeClipboard(message);
|
||||
await sleep(50);
|
||||
|
||||
// Open chat
|
||||
await this.inputSender.pressKey(VK.RETURN);
|
||||
await randomDelay(100, 200);
|
||||
|
||||
// Clear any existing text
|
||||
await this.inputSender.selectAll();
|
||||
await sleep(50);
|
||||
await this.inputSender.pressKey(VK.DELETE);
|
||||
await sleep(50);
|
||||
|
||||
// Paste
|
||||
await this.inputSender.paste();
|
||||
await randomDelay(100, 200);
|
||||
|
||||
// Send
|
||||
await this.inputSender.pressKey(VK.RETURN);
|
||||
await sleep(100);
|
||||
}
|
||||
|
||||
async goToHideout(): Promise<void> {
|
||||
logger.info('Sending /hideout command');
|
||||
await this.sendChat('/hideout');
|
||||
}
|
||||
|
||||
async ctrlRightClickAt(x: number, y: number): Promise<void> {
|
||||
await this.inputSender.ctrlRightClick(x, y);
|
||||
}
|
||||
|
||||
async leftClickAt(x: number, y: number): Promise<void> {
|
||||
await this.inputSender.leftClick(x, y);
|
||||
}
|
||||
|
||||
async rightClickAt(x: number, y: number): Promise<void> {
|
||||
await this.inputSender.rightClick(x, y);
|
||||
}
|
||||
|
||||
async pressEscape(): Promise<void> {
|
||||
await this.inputSender.pressKey(VK.ESCAPE);
|
||||
}
|
||||
|
||||
async openInventory(): Promise<void> {
|
||||
logger.info('Opening inventory');
|
||||
await this.inputSender.pressKey(VK.I);
|
||||
await sleep(300);
|
||||
}
|
||||
}
|
||||
294
src/game/InputSender.ts
Normal file
294
src/game/InputSender.ts
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
import koffi from 'koffi';
|
||||
import { sleep, randomDelay } from '../util/sleep.js';
|
||||
|
||||
// Win32 POINT struct for GetCursorPos
|
||||
const POINT = koffi.struct('POINT', { x: 'int32', y: 'int32' });
|
||||
|
||||
// Win32 INPUT struct on x64 is 40 bytes:
|
||||
// type (4) + pad (4) + union (32)
|
||||
// MOUSEINPUT is 32 bytes (the largest union member)
|
||||
// KEYBDINPUT is 24 bytes, so needs 8 bytes trailing pad in the union
|
||||
//
|
||||
// We define flat structs that match the exact memory layout,
|
||||
// then cast with koffi.as() when calling SendInput.
|
||||
|
||||
const INPUT_KEYBOARD = koffi.struct('INPUT_KEYBOARD', {
|
||||
type: 'uint32', // offset 0
|
||||
_pad0: 'uint32', // offset 4 (alignment for union at offset 8)
|
||||
wVk: 'uint16', // offset 8
|
||||
wScan: 'uint16', // offset 10
|
||||
dwFlags: 'uint32', // offset 12
|
||||
time: 'uint32', // offset 16
|
||||
_pad1: 'uint32', // offset 20 (alignment for dwExtraInfo)
|
||||
dwExtraInfo: 'uint64', // offset 24
|
||||
_pad2: koffi.array('uint8', 8), // offset 32, pad to 40 bytes total
|
||||
});
|
||||
|
||||
const INPUT_MOUSE = koffi.struct('INPUT_MOUSE', {
|
||||
type: 'uint32', // offset 0
|
||||
_pad0: 'uint32', // offset 4 (alignment for union at offset 8)
|
||||
dx: 'int32', // offset 8
|
||||
dy: 'int32', // offset 12
|
||||
mouseData: 'uint32', // offset 16
|
||||
dwFlags: 'uint32', // offset 20
|
||||
time: 'uint32', // offset 24
|
||||
_pad1: 'uint32', // offset 28 (alignment for dwExtraInfo)
|
||||
dwExtraInfo: 'uint64', // offset 32
|
||||
});
|
||||
// INPUT_MOUSE is already 40 bytes, no trailing pad needed
|
||||
|
||||
const user32 = koffi.load('user32.dll');
|
||||
|
||||
const SendInput = user32.func('SendInput', 'uint32', ['uint32', 'void *', 'int32']);
|
||||
const MapVirtualKeyW = user32.func('MapVirtualKeyW', 'uint32', ['uint32', 'uint32']);
|
||||
const GetSystemMetrics = user32.func('GetSystemMetrics', 'int32', ['int32']);
|
||||
const GetCursorPos = user32.func('GetCursorPos', 'int32', ['_Out_ POINT *']);
|
||||
|
||||
// Constants
|
||||
const INPUT_MOUSE_TYPE = 0;
|
||||
const INPUT_KEYBOARD_TYPE = 1;
|
||||
const KEYEVENTF_SCANCODE = 0x0008;
|
||||
const KEYEVENTF_KEYUP = 0x0002;
|
||||
const KEYEVENTF_UNICODE = 0x0004;
|
||||
|
||||
// Mouse flags
|
||||
const MOUSEEVENTF_MOVE = 0x0001;
|
||||
const MOUSEEVENTF_LEFTDOWN = 0x0002;
|
||||
const MOUSEEVENTF_LEFTUP = 0x0004;
|
||||
const MOUSEEVENTF_RIGHTDOWN = 0x0008;
|
||||
const MOUSEEVENTF_RIGHTUP = 0x0010;
|
||||
const MOUSEEVENTF_ABSOLUTE = 0x8000;
|
||||
|
||||
// System metrics
|
||||
const SM_CXSCREEN = 0;
|
||||
const SM_CYSCREEN = 1;
|
||||
|
||||
// Virtual key codes
|
||||
export const VK = {
|
||||
RETURN: 0x0d,
|
||||
CONTROL: 0x11,
|
||||
MENU: 0x12, // Alt
|
||||
SHIFT: 0x10,
|
||||
ESCAPE: 0x1b,
|
||||
TAB: 0x09,
|
||||
SPACE: 0x20,
|
||||
DELETE: 0x2e,
|
||||
BACK: 0x08,
|
||||
V: 0x56,
|
||||
A: 0x41,
|
||||
C: 0x43,
|
||||
I: 0x49,
|
||||
} as const;
|
||||
|
||||
// Size to pass to SendInput (must be sizeof(INPUT) = 40 on x64)
|
||||
const INPUT_SIZE = koffi.sizeof(INPUT_MOUSE); // 40
|
||||
|
||||
// Bézier curve helpers for natural mouse movement
|
||||
|
||||
function easeInOutQuad(t: number): number {
|
||||
return t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2;
|
||||
}
|
||||
|
||||
interface Point {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
function cubicBezier(t: number, p0: Point, p1: Point, p2: Point, p3: Point): Point {
|
||||
const u = 1 - t;
|
||||
const u2 = u * u;
|
||||
const u3 = u2 * u;
|
||||
const t2 = t * t;
|
||||
const t3 = t2 * t;
|
||||
return {
|
||||
x: u3 * p0.x + 3 * u2 * t * p1.x + 3 * u * t2 * p2.x + t3 * p3.x,
|
||||
y: u3 * p0.y + 3 * u2 * t * p1.y + 3 * u * t2 * p2.y + t3 * p3.y,
|
||||
};
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
export class InputSender {
|
||||
private screenWidth: number;
|
||||
private screenHeight: number;
|
||||
|
||||
constructor() {
|
||||
this.screenWidth = GetSystemMetrics(SM_CXSCREEN);
|
||||
this.screenHeight = GetSystemMetrics(SM_CYSCREEN);
|
||||
}
|
||||
|
||||
async pressKey(vkCode: number): Promise<void> {
|
||||
const scanCode = MapVirtualKeyW(vkCode, 0); // MAPVK_VK_TO_VSC
|
||||
this.sendScanKeyDown(scanCode);
|
||||
await randomDelay(30, 50);
|
||||
this.sendScanKeyUp(scanCode);
|
||||
await randomDelay(20, 40);
|
||||
}
|
||||
|
||||
async keyDown(vkCode: number): Promise<void> {
|
||||
const scanCode = MapVirtualKeyW(vkCode, 0);
|
||||
this.sendScanKeyDown(scanCode);
|
||||
await randomDelay(15, 30);
|
||||
}
|
||||
|
||||
async keyUp(vkCode: number): Promise<void> {
|
||||
const scanCode = MapVirtualKeyW(vkCode, 0);
|
||||
this.sendScanKeyUp(scanCode);
|
||||
await randomDelay(15, 30);
|
||||
}
|
||||
|
||||
async typeText(text: string): Promise<void> {
|
||||
for (const char of text) {
|
||||
this.sendUnicodeChar(char);
|
||||
await randomDelay(20, 50);
|
||||
}
|
||||
}
|
||||
|
||||
async paste(): Promise<void> {
|
||||
await this.keyDown(VK.CONTROL);
|
||||
await sleep(30);
|
||||
await this.pressKey(VK.V);
|
||||
await this.keyUp(VK.CONTROL);
|
||||
await sleep(50);
|
||||
}
|
||||
|
||||
async selectAll(): Promise<void> {
|
||||
await this.keyDown(VK.CONTROL);
|
||||
await sleep(30);
|
||||
await this.pressKey(VK.A);
|
||||
await this.keyUp(VK.CONTROL);
|
||||
await sleep(50);
|
||||
}
|
||||
|
||||
private getCursorPos(): Point {
|
||||
const pt = { x: 0, y: 0 };
|
||||
GetCursorPos(pt);
|
||||
return pt;
|
||||
}
|
||||
|
||||
private moveMouseRaw(x: number, y: number): void {
|
||||
const normalizedX = Math.round((x * 65535) / this.screenWidth);
|
||||
const normalizedY = Math.round((y * 65535) / this.screenHeight);
|
||||
this.sendMouseInput(normalizedX, normalizedY, 0, MOUSEEVENTF_MOVE | MOUSEEVENTF_ABSOLUTE);
|
||||
}
|
||||
|
||||
async moveMouse(x: number, y: number): Promise<void> {
|
||||
const start = this.getCursorPos();
|
||||
const end: Point = { x, y };
|
||||
const dx = end.x - start.x;
|
||||
const dy = end.y - start.y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
// Short distance: just teleport
|
||||
if (distance < 10) {
|
||||
this.moveMouseRaw(x, y);
|
||||
await randomDelay(10, 20);
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate 2 random control points offset from the straight line
|
||||
const perpX = -dy / distance;
|
||||
const perpY = dx / distance;
|
||||
const spread = distance * 0.3;
|
||||
|
||||
const cp1: Point = {
|
||||
x: start.x + dx * 0.25 + perpX * (Math.random() - 0.5) * spread,
|
||||
y: start.y + dy * 0.25 + perpY * (Math.random() - 0.5) * spread,
|
||||
};
|
||||
const cp2: Point = {
|
||||
x: start.x + dx * 0.75 + perpX * (Math.random() - 0.5) * spread,
|
||||
y: start.y + dy * 0.75 + perpY * (Math.random() - 0.5) * spread,
|
||||
};
|
||||
|
||||
const steps = clamp(Math.round(distance / 15), 15, 40);
|
||||
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
const rawT = i / steps;
|
||||
const t = easeInOutQuad(rawT);
|
||||
const pt = cubicBezier(t, start, cp1, cp2, end);
|
||||
|
||||
// Add ±1px jitter except on the last step
|
||||
const jitterX = i < steps ? Math.round((Math.random() - 0.5) * 2) : 0;
|
||||
const jitterY = i < steps ? Math.round((Math.random() - 0.5) * 2) : 0;
|
||||
|
||||
this.moveMouseRaw(Math.round(pt.x) + jitterX, Math.round(pt.y) + jitterY);
|
||||
await sleep(2 + Math.random() * 3); // 2-5ms between steps
|
||||
}
|
||||
|
||||
// Final exact landing
|
||||
this.moveMouseRaw(x, y);
|
||||
await randomDelay(10, 25);
|
||||
}
|
||||
|
||||
async leftClick(x: number, y: number): Promise<void> {
|
||||
await this.moveMouse(x, y);
|
||||
await randomDelay(50, 100);
|
||||
this.sendMouseInput(0, 0, 0, MOUSEEVENTF_LEFTDOWN);
|
||||
await randomDelay(30, 80);
|
||||
this.sendMouseInput(0, 0, 0, MOUSEEVENTF_LEFTUP);
|
||||
await randomDelay(30, 60);
|
||||
}
|
||||
|
||||
async rightClick(x: number, y: number): Promise<void> {
|
||||
await this.moveMouse(x, y);
|
||||
await randomDelay(50, 100);
|
||||
this.sendMouseInput(0, 0, 0, MOUSEEVENTF_RIGHTDOWN);
|
||||
await randomDelay(30, 80);
|
||||
this.sendMouseInput(0, 0, 0, MOUSEEVENTF_RIGHTUP);
|
||||
await randomDelay(30, 60);
|
||||
}
|
||||
|
||||
async ctrlRightClick(x: number, y: number): Promise<void> {
|
||||
await this.keyDown(VK.CONTROL);
|
||||
await randomDelay(30, 60);
|
||||
await this.rightClick(x, y);
|
||||
await this.keyUp(VK.CONTROL);
|
||||
await randomDelay(30, 60);
|
||||
}
|
||||
|
||||
private sendMouseInput(dx: number, dy: number, mouseData: number, flags: number): void {
|
||||
const input = {
|
||||
type: INPUT_MOUSE_TYPE,
|
||||
_pad0: 0,
|
||||
dx,
|
||||
dy,
|
||||
mouseData,
|
||||
dwFlags: flags,
|
||||
time: 0,
|
||||
_pad1: 0,
|
||||
dwExtraInfo: 0,
|
||||
};
|
||||
SendInput(1, koffi.as(input, 'INPUT_MOUSE *'), INPUT_SIZE);
|
||||
}
|
||||
|
||||
private sendKeyInput(wVk: number, wScan: number, flags: number): void {
|
||||
const input = {
|
||||
type: INPUT_KEYBOARD_TYPE,
|
||||
_pad0: 0,
|
||||
wVk,
|
||||
wScan,
|
||||
dwFlags: flags,
|
||||
time: 0,
|
||||
_pad1: 0,
|
||||
dwExtraInfo: 0,
|
||||
_pad2: [0, 0, 0, 0, 0, 0, 0, 0],
|
||||
};
|
||||
SendInput(1, koffi.as(input, 'INPUT_KEYBOARD *'), INPUT_SIZE);
|
||||
}
|
||||
|
||||
private sendScanKeyDown(scanCode: number): void {
|
||||
this.sendKeyInput(0, scanCode, KEYEVENTF_SCANCODE);
|
||||
}
|
||||
|
||||
private sendScanKeyUp(scanCode: number): void {
|
||||
this.sendKeyInput(0, scanCode, KEYEVENTF_SCANCODE | KEYEVENTF_KEYUP);
|
||||
}
|
||||
|
||||
private sendUnicodeChar(char: string): void {
|
||||
const code = char.charCodeAt(0);
|
||||
this.sendKeyInput(0, code, KEYEVENTF_UNICODE);
|
||||
this.sendKeyInput(0, code, KEYEVENTF_UNICODE | KEYEVENTF_KEYUP);
|
||||
}
|
||||
}
|
||||
256
src/game/OcrDaemon.ts
Normal file
256
src/game/OcrDaemon.ts
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
import { spawn, type ChildProcess } from 'child_process';
|
||||
import { join } from 'path';
|
||||
import { logger } from '../util/logger.js';
|
||||
import type { Region } from '../types.js';
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface OcrWord {
|
||||
text: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface OcrLine {
|
||||
text: string;
|
||||
words: OcrWord[];
|
||||
}
|
||||
|
||||
export interface OcrResponse {
|
||||
ok: true;
|
||||
text: string;
|
||||
lines: OcrLine[];
|
||||
}
|
||||
|
||||
interface DaemonRequest {
|
||||
cmd: string;
|
||||
region?: Region;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
interface DaemonResponse {
|
||||
ok: boolean;
|
||||
ready?: boolean;
|
||||
text?: string;
|
||||
lines?: OcrLine[];
|
||||
image?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// ── OcrDaemon ───────────────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_EXE = join(
|
||||
'tools', 'OcrDaemon', 'bin', 'Release',
|
||||
'net8.0-windows10.0.19041.0', 'OcrDaemon.exe',
|
||||
);
|
||||
|
||||
const REQUEST_TIMEOUT = 5_000;
|
||||
const CAPTURE_TIMEOUT = 10_000;
|
||||
|
||||
export class OcrDaemon {
|
||||
private proc: ChildProcess | null = null;
|
||||
private exePath: string;
|
||||
private readyResolve: ((value: void) => void) | null = null;
|
||||
private readyReject: ((err: Error) => void) | null = null;
|
||||
private pendingResolve: ((resp: DaemonResponse) => void) | null = null;
|
||||
private pendingReject: ((err: Error) => void) | null = null;
|
||||
private queue: Array<{ request: DaemonRequest; resolve: (resp: DaemonResponse) => void; reject: (err: Error) => void }> = [];
|
||||
private processing = false;
|
||||
private buffer = '';
|
||||
private stopped = false;
|
||||
|
||||
constructor(exePath?: string) {
|
||||
this.exePath = exePath ?? DEFAULT_EXE;
|
||||
}
|
||||
|
||||
// ── Public API ──────────────────────────────────────────────────────────
|
||||
|
||||
async ocr(region?: Region): Promise<OcrResponse> {
|
||||
const req: DaemonRequest = { cmd: 'ocr' };
|
||||
if (region) req.region = region;
|
||||
const resp = await this.sendWithRetry(req, REQUEST_TIMEOUT);
|
||||
return {
|
||||
ok: true,
|
||||
text: resp.text ?? '',
|
||||
lines: resp.lines ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
async captureBuffer(region?: Region): Promise<Buffer> {
|
||||
const req: DaemonRequest = { cmd: 'capture' };
|
||||
if (region) req.region = region;
|
||||
const resp = await this.sendWithRetry(req, CAPTURE_TIMEOUT);
|
||||
return Buffer.from(resp.image!, 'base64');
|
||||
}
|
||||
|
||||
async saveScreenshot(path: string, region?: Region): Promise<void> {
|
||||
const req: DaemonRequest = { cmd: 'screenshot', path };
|
||||
if (region) req.region = region;
|
||||
await this.sendWithRetry(req, REQUEST_TIMEOUT);
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.stopped = true;
|
||||
if (this.proc) {
|
||||
const p = this.proc;
|
||||
this.proc = null;
|
||||
p.stdin?.end();
|
||||
p.kill();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Internal ────────────────────────────────────────────────────────────
|
||||
|
||||
private async ensureRunning(): Promise<void> {
|
||||
if (this.proc && this.proc.exitCode === null) return;
|
||||
|
||||
this.proc = null;
|
||||
this.buffer = '';
|
||||
|
||||
logger.info({ exe: this.exePath }, 'Spawning OCR daemon');
|
||||
|
||||
const proc = spawn(this.exePath, [], {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
this.proc = proc;
|
||||
|
||||
proc.stderr?.on('data', (data: Buffer) => {
|
||||
logger.warn({ daemon: data.toString().trim() }, 'OcrDaemon stderr');
|
||||
});
|
||||
|
||||
proc.on('exit', (code) => {
|
||||
logger.warn({ code }, 'OcrDaemon exited');
|
||||
if (this.pendingReject) {
|
||||
this.pendingReject(new Error(`Daemon exited with code ${code}`));
|
||||
this.pendingResolve = null;
|
||||
this.pendingReject = null;
|
||||
}
|
||||
});
|
||||
|
||||
proc.stdout!.on('data', (data: Buffer) => {
|
||||
this.buffer += data.toString();
|
||||
this.processBuffer();
|
||||
});
|
||||
|
||||
// Wait for ready signal
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
this.readyResolve = resolve;
|
||||
this.readyReject = reject;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
this.readyReject = null;
|
||||
this.readyResolve = null;
|
||||
reject(new Error('Daemon did not become ready within 10s'));
|
||||
}, 10_000);
|
||||
|
||||
// Store so we can clear on resolve
|
||||
(this as any)._readyTimeout = timeout;
|
||||
});
|
||||
|
||||
logger.info('OCR daemon ready');
|
||||
}
|
||||
|
||||
private processBuffer(): void {
|
||||
let newlineIdx: number;
|
||||
while ((newlineIdx = this.buffer.indexOf('\n')) !== -1) {
|
||||
const line = this.buffer.slice(0, newlineIdx).trim();
|
||||
this.buffer = this.buffer.slice(newlineIdx + 1);
|
||||
|
||||
if (!line) continue;
|
||||
|
||||
let parsed: DaemonResponse;
|
||||
try {
|
||||
parsed = JSON.parse(line);
|
||||
} catch {
|
||||
logger.warn({ line }, 'Failed to parse daemon response');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle ready signal
|
||||
if (parsed.ready && this.readyResolve) {
|
||||
clearTimeout((this as any)._readyTimeout);
|
||||
const resolve = this.readyResolve;
|
||||
this.readyResolve = null;
|
||||
this.readyReject = null;
|
||||
resolve();
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle normal response
|
||||
if (this.pendingResolve) {
|
||||
const resolve = this.pendingResolve;
|
||||
this.pendingResolve = null;
|
||||
this.pendingReject = null;
|
||||
resolve(parsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async send(request: DaemonRequest, timeout: number): Promise<DaemonResponse> {
|
||||
await this.ensureRunning();
|
||||
|
||||
return new Promise<DaemonResponse>((resolve, reject) => {
|
||||
this.queue.push({ request, resolve, reject });
|
||||
this.drainQueue(timeout);
|
||||
});
|
||||
}
|
||||
|
||||
private drainQueue(timeout: number): void {
|
||||
if (this.processing || this.queue.length === 0) return;
|
||||
this.processing = true;
|
||||
|
||||
const { request, resolve, reject } = this.queue.shift()!;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
this.pendingResolve = null;
|
||||
this.pendingReject = null;
|
||||
this.processing = false;
|
||||
reject(new Error(`Daemon request timed out after ${timeout}ms`));
|
||||
this.drainQueue(timeout);
|
||||
}, timeout);
|
||||
|
||||
this.pendingResolve = (resp) => {
|
||||
clearTimeout(timer);
|
||||
this.processing = false;
|
||||
resolve(resp);
|
||||
this.drainQueue(timeout);
|
||||
};
|
||||
|
||||
this.pendingReject = (err) => {
|
||||
clearTimeout(timer);
|
||||
this.processing = false;
|
||||
reject(err);
|
||||
this.drainQueue(timeout);
|
||||
};
|
||||
|
||||
const json = JSON.stringify(request) + '\n';
|
||||
this.proc!.stdin!.write(json);
|
||||
}
|
||||
|
||||
private async sendWithRetry(request: DaemonRequest, timeout: number): Promise<DaemonResponse> {
|
||||
try {
|
||||
const resp = await this.send(request, timeout);
|
||||
if (!resp.ok) throw new Error(resp.error ?? 'Daemon returned error');
|
||||
return resp;
|
||||
} catch (err) {
|
||||
if (this.stopped) throw err;
|
||||
|
||||
// Kill and retry once
|
||||
logger.warn({ err, cmd: request.cmd }, 'Daemon request failed, restarting');
|
||||
if (this.proc) {
|
||||
const p = this.proc;
|
||||
this.proc = null;
|
||||
p.stdin?.end();
|
||||
p.kill();
|
||||
}
|
||||
|
||||
const resp = await this.send(request, timeout);
|
||||
if (!resp.ok) throw new Error(resp.error ?? 'Daemon returned error on retry');
|
||||
return resp;
|
||||
}
|
||||
}
|
||||
}
|
||||
129
src/game/ScreenReader.ts
Normal file
129
src/game/ScreenReader.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import { mkdir } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { logger } from '../util/logger.js';
|
||||
import { OcrDaemon, type OcrResponse } from './OcrDaemon.js';
|
||||
import type { Region } from '../types.js';
|
||||
|
||||
function elapsed(start: number): string {
|
||||
return `${(performance.now() - start).toFixed(0)}ms`;
|
||||
}
|
||||
|
||||
export class ScreenReader {
|
||||
private daemon = new OcrDaemon();
|
||||
|
||||
// ── Screenshot capture ──────────────────────────────────────────────
|
||||
|
||||
async captureScreen(): Promise<Buffer> {
|
||||
const t = performance.now();
|
||||
const buf = await this.daemon.captureBuffer();
|
||||
logger.info({ ms: elapsed(t) }, 'captureScreen');
|
||||
return buf;
|
||||
}
|
||||
|
||||
async captureRegion(region: Region): Promise<Buffer> {
|
||||
const t = performance.now();
|
||||
const buf = await this.daemon.captureBuffer(region);
|
||||
logger.info({ ms: elapsed(t) }, 'captureRegion');
|
||||
return buf;
|
||||
}
|
||||
|
||||
// ── OCR helpers ─────────────────────────────────────────────────────
|
||||
|
||||
private findWordInOcrResult(
|
||||
result: OcrResponse,
|
||||
needle: string,
|
||||
): { x: number; y: number } | null {
|
||||
const lower = needle.toLowerCase();
|
||||
for (const line of result.lines) {
|
||||
for (const word of line.words) {
|
||||
if (word.text.toLowerCase().includes(lower)) {
|
||||
return {
|
||||
x: Math.round(word.x + word.width / 2),
|
||||
y: Math.round(word.y + word.height / 2),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Full-screen methods ─────────────────────────────────────────────
|
||||
|
||||
async findTextOnScreen(
|
||||
searchText: string,
|
||||
): Promise<{ x: number; y: number } | null> {
|
||||
const t = performance.now();
|
||||
const result = await this.daemon.ocr();
|
||||
const pos = this.findWordInOcrResult(result, searchText);
|
||||
|
||||
if (pos) {
|
||||
logger.info({ searchText, x: pos.x, y: pos.y, totalMs: elapsed(t) }, 'Found text on screen');
|
||||
} else {
|
||||
logger.info({ searchText, totalMs: elapsed(t) }, 'Text not found on screen');
|
||||
}
|
||||
return pos;
|
||||
}
|
||||
|
||||
async readFullScreen(): Promise<string> {
|
||||
const result = await this.daemon.ocr();
|
||||
return result.text;
|
||||
}
|
||||
|
||||
// ── Region methods ──────────────────────────────────────────────────
|
||||
|
||||
async findTextInRegion(
|
||||
region: Region,
|
||||
searchText: string,
|
||||
): Promise<{ x: number; y: number } | null> {
|
||||
const t = performance.now();
|
||||
const result = await this.daemon.ocr(region);
|
||||
const pos = this.findWordInOcrResult(result, searchText);
|
||||
|
||||
if (pos) {
|
||||
// Offset back to screen space
|
||||
const screenPos = { x: region.x + pos.x, y: region.y + pos.y };
|
||||
logger.info({ searchText, x: screenPos.x, y: screenPos.y, region, totalMs: elapsed(t) }, 'Found text in region');
|
||||
return screenPos;
|
||||
}
|
||||
|
||||
logger.info({ searchText, region, totalMs: elapsed(t) }, 'Text not found in region');
|
||||
return null;
|
||||
}
|
||||
|
||||
async readRegionText(region: Region): Promise<string> {
|
||||
const result = await this.daemon.ocr(region);
|
||||
return result.text;
|
||||
}
|
||||
|
||||
async checkForText(region: Region, searchText: string): Promise<boolean> {
|
||||
const pos = await this.findTextInRegion(region, searchText);
|
||||
return pos !== null;
|
||||
}
|
||||
|
||||
// ── Save utilities ──────────────────────────────────────────────────
|
||||
|
||||
async saveScreenshot(path: string): Promise<void> {
|
||||
await this.daemon.saveScreenshot(path);
|
||||
logger.info({ path }, 'Screenshot saved');
|
||||
}
|
||||
|
||||
async saveDebugScreenshots(dir: string): Promise<string[]> {
|
||||
await mkdir(dir, { recursive: true });
|
||||
const ts = Date.now();
|
||||
const originalPath = join(dir, `${ts}-screenshot.png`);
|
||||
await this.daemon.saveScreenshot(originalPath);
|
||||
logger.info({ dir, files: [originalPath.split(/[\\/]/).pop()] }, 'Debug screenshot saved');
|
||||
return [originalPath];
|
||||
}
|
||||
|
||||
async saveRegion(region: Region, path: string): Promise<void> {
|
||||
await this.daemon.saveScreenshot(path, region);
|
||||
logger.info({ path, region }, 'Region screenshot saved');
|
||||
}
|
||||
|
||||
// ── Lifecycle ───────────────────────────────────────────────────────
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
await this.daemon.stop();
|
||||
}
|
||||
}
|
||||
90
src/game/WindowManager.ts
Normal file
90
src/game/WindowManager.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import koffi from 'koffi';
|
||||
import { logger } from '../util/logger.js';
|
||||
|
||||
// Win32 types
|
||||
const HWND = 'int';
|
||||
const BOOL = 'bool';
|
||||
const RECT = koffi.struct('RECT', {
|
||||
left: 'long',
|
||||
top: 'long',
|
||||
right: 'long',
|
||||
bottom: 'long',
|
||||
});
|
||||
|
||||
// Load user32.dll
|
||||
const user32 = koffi.load('user32.dll');
|
||||
|
||||
const FindWindowW = user32.func('FindWindowW', HWND, ['str16', 'str16']);
|
||||
const SetForegroundWindow = user32.func('SetForegroundWindow', BOOL, [HWND]);
|
||||
const ShowWindow = user32.func('ShowWindow', BOOL, [HWND, 'int']);
|
||||
const BringWindowToTop = user32.func('BringWindowToTop', BOOL, [HWND]);
|
||||
const GetForegroundWindow = user32.func('GetForegroundWindow', HWND, []);
|
||||
const GetWindowRect = user32.func('GetWindowRect', BOOL, [HWND, koffi.out(koffi.pointer(RECT))]);
|
||||
const IsWindow = user32.func('IsWindow', BOOL, [HWND]);
|
||||
const keybd_event = user32.func('keybd_event', 'void', ['uint8', 'uint8', 'uint32', 'uint']);
|
||||
const MapVirtualKeyW = user32.func('MapVirtualKeyW', 'uint32', ['uint32', 'uint32']);
|
||||
|
||||
// Constants
|
||||
const SW_RESTORE = 9;
|
||||
const VK_MENU = 0x12; // Alt key
|
||||
const KEYEVENTF_KEYUP = 0x0002;
|
||||
|
||||
export class WindowManager {
|
||||
private hwnd: number = 0;
|
||||
|
||||
constructor(private windowTitle: string) {}
|
||||
|
||||
findWindow(): number {
|
||||
this.hwnd = FindWindowW(null as unknown as string, this.windowTitle);
|
||||
if (this.hwnd === 0) {
|
||||
logger.warn({ title: this.windowTitle }, 'Window not found');
|
||||
} else {
|
||||
logger.info({ title: this.windowTitle, hwnd: this.hwnd }, 'Window found');
|
||||
}
|
||||
return this.hwnd;
|
||||
}
|
||||
|
||||
focusWindow(): boolean {
|
||||
if (!this.hwnd || !IsWindow(this.hwnd)) {
|
||||
this.findWindow();
|
||||
}
|
||||
if (!this.hwnd) return false;
|
||||
|
||||
// Restore if minimized
|
||||
ShowWindow(this.hwnd, SW_RESTORE);
|
||||
|
||||
// Alt-key trick to bypass SetForegroundWindow restriction
|
||||
const altScan = MapVirtualKeyW(VK_MENU, 0);
|
||||
keybd_event(VK_MENU, altScan, 0, 0);
|
||||
keybd_event(VK_MENU, altScan, KEYEVENTF_KEYUP, 0);
|
||||
|
||||
BringWindowToTop(this.hwnd);
|
||||
const result = SetForegroundWindow(this.hwnd);
|
||||
|
||||
if (!result) {
|
||||
logger.warn('SetForegroundWindow failed');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
getWindowRect(): { left: number; top: number; right: number; bottom: number } | null {
|
||||
if (!this.hwnd || !IsWindow(this.hwnd)) {
|
||||
this.findWindow();
|
||||
}
|
||||
if (!this.hwnd) return null;
|
||||
|
||||
const rect = { left: 0, top: 0, right: 0, bottom: 0 };
|
||||
const success = GetWindowRect(this.hwnd, rect);
|
||||
if (!success) return null;
|
||||
return rect;
|
||||
}
|
||||
|
||||
isGameFocused(): boolean {
|
||||
const fg = GetForegroundWindow();
|
||||
return fg === this.hwnd && this.hwnd !== 0;
|
||||
}
|
||||
|
||||
getHwnd(): number {
|
||||
return this.hwnd;
|
||||
}
|
||||
}
|
||||
190
src/index.ts
Normal file
190
src/index.ts
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
import { Command } from 'commander';
|
||||
import { loadConfig } from './config.js';
|
||||
import { TradeMonitor } from './trade/TradeMonitor.js';
|
||||
import { GameController } from './game/GameController.js';
|
||||
import { ScreenReader } from './game/ScreenReader.js';
|
||||
import { ClientLogWatcher } from './log/ClientLogWatcher.js';
|
||||
import { TradeExecutor } from './executor/TradeExecutor.js';
|
||||
import { TradeQueue } from './executor/TradeQueue.js';
|
||||
import { BotController } from './dashboard/BotController.js';
|
||||
import { DashboardServer } from './dashboard/DashboardServer.js';
|
||||
import { ConfigStore } from './dashboard/ConfigStore.js';
|
||||
import { logger } from './util/logger.js';
|
||||
import type { Page } from 'playwright';
|
||||
|
||||
const program = new Command();
|
||||
|
||||
program
|
||||
.name('poe2trade')
|
||||
.description('POE2 automated trade bot')
|
||||
.option('-u, --url <urls...>', 'Trade search URLs to monitor')
|
||||
.option('--log-path <path>', 'Path to POE2 Client.txt')
|
||||
.option('-p, --port <number>', 'Dashboard port')
|
||||
.option('-c, --config <path>', 'Path to config.json', 'config.json')
|
||||
.action(async (options) => {
|
||||
// Load persisted config
|
||||
const store = new ConfigStore(options.config);
|
||||
const saved = store.settings;
|
||||
|
||||
// CLI/env overrides persisted values
|
||||
const envConfig = loadConfig(options.url);
|
||||
if (options.logPath) envConfig.poe2LogPath = options.logPath;
|
||||
|
||||
// Merge: CLI args > .env > config.json defaults
|
||||
const config = {
|
||||
...envConfig,
|
||||
poe2LogPath: options.logPath || saved.poe2LogPath,
|
||||
poe2WindowTitle: saved.poe2WindowTitle,
|
||||
browserUserDataDir: saved.browserUserDataDir,
|
||||
travelTimeoutMs: saved.travelTimeoutMs,
|
||||
stashScanTimeoutMs: saved.stashScanTimeoutMs,
|
||||
waitForMoreItemsMs: saved.waitForMoreItemsMs,
|
||||
betweenTradesDelayMs: saved.betweenTradesDelayMs,
|
||||
};
|
||||
|
||||
const port = parseInt(options.port, 10) || saved.dashboardPort;
|
||||
|
||||
// Collect all URLs: CLI args + saved links (deduped)
|
||||
const allUrls = new Set<string>([
|
||||
...config.tradeUrls,
|
||||
...saved.links.map((l) => l.url),
|
||||
]);
|
||||
|
||||
// Initialize bot controller with config store
|
||||
const bot = new BotController(store);
|
||||
|
||||
// Start dashboard
|
||||
const dashboard = new DashboardServer(bot, port);
|
||||
await dashboard.start();
|
||||
|
||||
// Initialize game components
|
||||
const screenReader = new ScreenReader();
|
||||
|
||||
const gameController = new GameController(config);
|
||||
dashboard.setDebugDeps({ screenReader, gameController });
|
||||
|
||||
const logWatcher = new ClientLogWatcher(config.poe2LogPath);
|
||||
await logWatcher.start();
|
||||
dashboard.broadcastLog('info', 'Watching Client.txt for game events');
|
||||
|
||||
const tradeMonitor = new TradeMonitor(config);
|
||||
await tradeMonitor.start(`http://localhost:${port}`);
|
||||
dashboard.broadcastLog('info', 'Browser launched');
|
||||
|
||||
const executor = new TradeExecutor(
|
||||
gameController,
|
||||
screenReader,
|
||||
logWatcher,
|
||||
tradeMonitor,
|
||||
config,
|
||||
);
|
||||
|
||||
const tradeQueue = new TradeQueue(executor, config);
|
||||
|
||||
// Helper to add a trade search
|
||||
const activateLink = async (url: string) => {
|
||||
try {
|
||||
await tradeMonitor.addSearch(url);
|
||||
dashboard.broadcastLog('info', `Monitoring: ${url}`);
|
||||
dashboard.broadcastStatus();
|
||||
} catch (err) {
|
||||
logger.error({ err, url }, 'Failed to add trade search');
|
||||
dashboard.broadcastLog('error', `Failed to add: ${url}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Load all saved + CLI links (only activate ones marked active)
|
||||
for (const url of allUrls) {
|
||||
const link = bot.addLink(url);
|
||||
if (link.active) {
|
||||
await activateLink(url);
|
||||
} else {
|
||||
dashboard.broadcastLog('info', `Loaded (inactive): ${link.name || link.label}`);
|
||||
}
|
||||
}
|
||||
|
||||
dashboard.broadcastLog('info', `Loaded ${allUrls.size} trade link(s) from config`);
|
||||
|
||||
// When dashboard adds a link, activate it in the trade monitor
|
||||
bot.on('link-added', async (link) => {
|
||||
if (link.active) {
|
||||
await activateLink(link.url);
|
||||
}
|
||||
});
|
||||
|
||||
// When dashboard removes a link, deactivate it
|
||||
bot.on('link-removed', async (id: string) => {
|
||||
await tradeMonitor.removeSearch(id);
|
||||
dashboard.broadcastLog('info', `Removed search: ${id}`);
|
||||
dashboard.broadcastStatus();
|
||||
});
|
||||
|
||||
// When dashboard toggles a link active/inactive
|
||||
bot.on('link-toggled', async (data: { id: string; active: boolean; link: { url: string; name: string } }) => {
|
||||
if (data.active) {
|
||||
await activateLink(data.link.url);
|
||||
dashboard.broadcastLog('info', `Activated: ${data.link.name || data.id}`);
|
||||
} else {
|
||||
await tradeMonitor.pauseSearch(data.id);
|
||||
dashboard.broadcastLog('info', `Deactivated: ${data.link.name || data.id}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Wire up events: when new listings appear, queue them for trading
|
||||
tradeMonitor.on('new-listings', (data: { searchId: string; itemIds: string[]; page: Page }) => {
|
||||
if (bot.isPaused) {
|
||||
dashboard.broadcastLog('warn', `New listings (${data.itemIds.length}) skipped - bot paused`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this specific link is active
|
||||
if (!bot.isLinkActive(data.searchId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
{ searchId: data.searchId, itemCount: data.itemIds.length },
|
||||
'New listings received, queuing trade...',
|
||||
);
|
||||
dashboard.broadcastLog('info', `New listings: ${data.itemIds.length} items from ${data.searchId}`);
|
||||
|
||||
tradeQueue.enqueue({
|
||||
searchId: data.searchId,
|
||||
itemIds: data.itemIds,
|
||||
whisperText: '',
|
||||
timestamp: Date.now(),
|
||||
tradeUrl: '',
|
||||
page: data.page,
|
||||
});
|
||||
});
|
||||
|
||||
// Forward executor state changes to dashboard
|
||||
const stateInterval = setInterval(() => {
|
||||
const execState = executor.getState();
|
||||
if (bot.state !== execState) {
|
||||
bot.state = execState;
|
||||
dashboard.broadcastStatus();
|
||||
}
|
||||
}, 500);
|
||||
|
||||
// Graceful shutdown
|
||||
const shutdown = async () => {
|
||||
logger.info('Shutting down...');
|
||||
clearInterval(stateInterval);
|
||||
await screenReader.dispose();
|
||||
await dashboard.stop();
|
||||
await tradeMonitor.stop();
|
||||
await logWatcher.stop();
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
|
||||
logger.info(`Dashboard: http://localhost:${port}`);
|
||||
logger.info(
|
||||
`Monitoring ${allUrls.size} trade search(es). Press Ctrl+C to stop.`,
|
||||
);
|
||||
});
|
||||
|
||||
program.parse();
|
||||
130
src/log/ClientLogWatcher.ts
Normal file
130
src/log/ClientLogWatcher.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import { EventEmitter } from 'events';
|
||||
import { watch } from 'chokidar';
|
||||
import { createReadStream, statSync } from 'fs';
|
||||
import { createInterface } from 'readline';
|
||||
import { logger } from '../util/logger.js';
|
||||
|
||||
export interface LogEvents {
|
||||
'area-entered': (area: string) => void;
|
||||
'whisper-received': (data: { player: string; message: string }) => void;
|
||||
'whisper-sent': (data: { player: string; message: string }) => void;
|
||||
'trade-accepted': () => void;
|
||||
'party-joined': (player: string) => void;
|
||||
'party-left': (player: string) => void;
|
||||
line: (line: string) => void;
|
||||
}
|
||||
|
||||
export class ClientLogWatcher extends EventEmitter {
|
||||
private watcher: ReturnType<typeof watch> | null = null;
|
||||
private fileOffset: number = 0;
|
||||
private logPath: string;
|
||||
|
||||
constructor(logPath: string) {
|
||||
super();
|
||||
this.logPath = logPath;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
// Start reading from end of file (only new lines)
|
||||
try {
|
||||
const stats = statSync(this.logPath);
|
||||
this.fileOffset = stats.size;
|
||||
} catch {
|
||||
logger.warn({ path: this.logPath }, 'Log file not found yet, will watch for creation');
|
||||
this.fileOffset = 0;
|
||||
}
|
||||
|
||||
this.watcher = watch(this.logPath, {
|
||||
persistent: true,
|
||||
usePolling: true,
|
||||
interval: 200,
|
||||
});
|
||||
|
||||
this.watcher.on('change', () => {
|
||||
this.readNewLines();
|
||||
});
|
||||
|
||||
logger.info({ path: this.logPath }, 'Watching Client.txt for game events');
|
||||
}
|
||||
|
||||
private readNewLines(): void {
|
||||
const stream = createReadStream(this.logPath, {
|
||||
start: this.fileOffset,
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
|
||||
const rl = createInterface({ input: stream });
|
||||
let bytesRead = 0;
|
||||
|
||||
rl.on('line', (line) => {
|
||||
bytesRead += Buffer.byteLength(line, 'utf-8') + 2; // +2 for \r\n on Windows
|
||||
if (line.trim()) {
|
||||
this.parseLine(line.trim());
|
||||
}
|
||||
});
|
||||
|
||||
rl.on('close', () => {
|
||||
this.fileOffset += bytesRead;
|
||||
});
|
||||
}
|
||||
|
||||
private parseLine(line: string): void {
|
||||
this.emit('line', line);
|
||||
|
||||
// Area transition: "You have entered Hideout"
|
||||
const areaMatch = line.match(/You have entered (.+?)\.?$/);
|
||||
if (areaMatch) {
|
||||
const area = areaMatch[1];
|
||||
logger.info({ area }, 'Area entered');
|
||||
this.emit('area-entered', area);
|
||||
return;
|
||||
}
|
||||
|
||||
// Incoming whisper: "@From PlayerName: message"
|
||||
const whisperFromMatch = line.match(/@From\s+(.+?):\s+(.+)$/);
|
||||
if (whisperFromMatch) {
|
||||
const data = { player: whisperFromMatch[1], message: whisperFromMatch[2] };
|
||||
logger.info(data, 'Whisper received');
|
||||
this.emit('whisper-received', data);
|
||||
return;
|
||||
}
|
||||
|
||||
// Outgoing whisper: "@To PlayerName: message"
|
||||
const whisperToMatch = line.match(/@To\s+(.+?):\s+(.+)$/);
|
||||
if (whisperToMatch) {
|
||||
const data = { player: whisperToMatch[1], message: whisperToMatch[2] };
|
||||
this.emit('whisper-sent', data);
|
||||
return;
|
||||
}
|
||||
|
||||
// Party join: "PlayerName has joined the party"
|
||||
const partyJoinMatch = line.match(/(.+?) has joined the party/);
|
||||
if (partyJoinMatch) {
|
||||
logger.info({ player: partyJoinMatch[1] }, 'Player joined party');
|
||||
this.emit('party-joined', partyJoinMatch[1]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Party leave: "PlayerName has left the party"
|
||||
const partyLeaveMatch = line.match(/(.+?) has left the party/);
|
||||
if (partyLeaveMatch) {
|
||||
this.emit('party-left', partyLeaveMatch[1]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Trade accepted
|
||||
if (line.includes('Trade accepted') || line.includes('Trade completed')) {
|
||||
logger.info('Trade accepted/completed');
|
||||
this.emit('trade-accepted');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (this.watcher) {
|
||||
await this.watcher.close();
|
||||
this.watcher = null;
|
||||
}
|
||||
logger.info('Client log watcher stopped');
|
||||
}
|
||||
}
|
||||
256
src/trade/TradeMonitor.ts
Normal file
256
src/trade/TradeMonitor.ts
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
import { EventEmitter } from 'events';
|
||||
import { chromium, type Browser, type BrowserContext, type Page, type WebSocket } from 'playwright';
|
||||
import { SELECTORS } from './selectors.js';
|
||||
import { logger } from '../util/logger.js';
|
||||
import { sleep } from '../util/sleep.js';
|
||||
import type { Config } from '../types.js';
|
||||
|
||||
// Stealth JS injected into every page to avoid Playwright detection
|
||||
const STEALTH_SCRIPT = `
|
||||
// Remove navigator.webdriver flag
|
||||
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
|
||||
|
||||
// Fake plugins array (empty = headless giveaway)
|
||||
Object.defineProperty(navigator, 'plugins', {
|
||||
get: () => [
|
||||
{ name: 'Chrome PDF Plugin', filename: 'internal-pdf-viewer' },
|
||||
{ name: 'Chrome PDF Viewer', filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai' },
|
||||
{ name: 'Native Client', filename: 'internal-nacl-plugin' },
|
||||
],
|
||||
});
|
||||
|
||||
// Fake languages
|
||||
Object.defineProperty(navigator, 'languages', {
|
||||
get: () => ['en-US', 'en'],
|
||||
});
|
||||
|
||||
// Remove Playwright/automation artifacts from window
|
||||
delete window.__playwright;
|
||||
delete window.__pw_manual;
|
||||
|
||||
// Fix chrome.runtime to look like a real browser
|
||||
if (!window.chrome) window.chrome = {};
|
||||
if (!window.chrome.runtime) window.chrome.runtime = { id: undefined };
|
||||
|
||||
// Prevent detection via permissions API
|
||||
const originalQuery = window.navigator.permissions?.query;
|
||||
if (originalQuery) {
|
||||
window.navigator.permissions.query = (params) => {
|
||||
if (params.name === 'notifications') {
|
||||
return Promise.resolve({ state: Notification.permission });
|
||||
}
|
||||
return originalQuery(params);
|
||||
};
|
||||
}
|
||||
`;
|
||||
|
||||
export class TradeMonitor extends EventEmitter {
|
||||
private browser: Browser | null = null;
|
||||
private context: BrowserContext | null = null;
|
||||
private pages: Map<string, Page> = new Map();
|
||||
private pausedSearches: Set<string> = new Set();
|
||||
private config: Config;
|
||||
|
||||
constructor(config: Config) {
|
||||
super();
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
async start(dashboardUrl?: string): Promise<void> {
|
||||
logger.info('Launching Playwright browser (stealth mode)...');
|
||||
|
||||
this.context = await chromium.launchPersistentContext(this.config.browserUserDataDir, {
|
||||
headless: false,
|
||||
viewport: null,
|
||||
args: [
|
||||
'--disable-blink-features=AutomationControlled',
|
||||
'--disable-features=AutomationControlled',
|
||||
'--no-first-run',
|
||||
'--no-default-browser-check',
|
||||
'--disable-infobars',
|
||||
],
|
||||
ignoreDefaultArgs: ['--enable-automation'],
|
||||
});
|
||||
|
||||
// Inject stealth script into all pages (current and future)
|
||||
await this.context.addInitScript(STEALTH_SCRIPT);
|
||||
|
||||
// Open dashboard as the first tab
|
||||
if (dashboardUrl) {
|
||||
const pages = this.context.pages();
|
||||
if (pages.length > 0) {
|
||||
await pages[0].goto(dashboardUrl);
|
||||
} else {
|
||||
const page = await this.context.newPage();
|
||||
await page.goto(dashboardUrl);
|
||||
}
|
||||
logger.info({ dashboardUrl }, 'Dashboard opened in browser');
|
||||
}
|
||||
|
||||
logger.info('Browser launched (stealth active).');
|
||||
}
|
||||
|
||||
async addSearch(tradeUrl: string): Promise<void> {
|
||||
if (!this.context) throw new Error('Browser not started');
|
||||
|
||||
const searchId = this.extractSearchId(tradeUrl);
|
||||
|
||||
// Don't add duplicate
|
||||
if (this.pages.has(searchId)) {
|
||||
logger.info({ searchId }, 'Search already open, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info({ tradeUrl, searchId }, 'Adding trade search');
|
||||
|
||||
const page = await this.context.newPage();
|
||||
this.pages.set(searchId, page);
|
||||
|
||||
await page.goto(tradeUrl, { waitUntil: 'networkidle' });
|
||||
await sleep(2000);
|
||||
|
||||
// Listen for WebSocket connections (must be registered before clicking live search)
|
||||
page.on('websocket', (ws: WebSocket) => {
|
||||
this.handleWebSocket(ws, searchId, page);
|
||||
});
|
||||
|
||||
// Click the "Activate Live Search" button
|
||||
try {
|
||||
const liveBtn = page.locator(SELECTORS.liveSearchButton).first();
|
||||
await liveBtn.click({ timeout: 5000 });
|
||||
logger.info({ searchId }, 'Live search activated');
|
||||
} catch {
|
||||
logger.warn({ searchId }, 'Could not click Activate Live Search button');
|
||||
}
|
||||
|
||||
logger.info({ searchId }, 'Trade search monitoring active');
|
||||
}
|
||||
|
||||
async pauseSearch(searchId: string): Promise<void> {
|
||||
this.pausedSearches.add(searchId);
|
||||
// Close the page to stop the WebSocket / live search
|
||||
const page = this.pages.get(searchId);
|
||||
if (page) {
|
||||
await page.close();
|
||||
this.pages.delete(searchId);
|
||||
}
|
||||
logger.info({ searchId }, 'Search paused (page closed)');
|
||||
}
|
||||
|
||||
async resumeSearch(tradeUrl: string): Promise<void> {
|
||||
const searchId = this.extractSearchId(tradeUrl);
|
||||
this.pausedSearches.delete(searchId);
|
||||
await this.addSearch(tradeUrl);
|
||||
logger.info({ searchId }, 'Search resumed');
|
||||
}
|
||||
|
||||
isSearchActive(searchId: string): boolean {
|
||||
return this.pages.has(searchId) && !this.pausedSearches.has(searchId);
|
||||
}
|
||||
|
||||
private handleWebSocket(ws: WebSocket, searchId: string, page: Page): void {
|
||||
const url = ws.url();
|
||||
|
||||
if (!url.includes('/api/trade') || !url.includes('/live/')) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info({ url, searchId }, 'WebSocket connected for live search');
|
||||
|
||||
ws.on('framereceived', (frame) => {
|
||||
// Don't emit if this search is paused
|
||||
if (this.pausedSearches.has(searchId)) return;
|
||||
|
||||
try {
|
||||
const payload = typeof frame.payload === 'string' ? frame.payload : frame.payload.toString();
|
||||
const data = JSON.parse(payload);
|
||||
|
||||
if (data.new && Array.isArray(data.new) && data.new.length > 0) {
|
||||
logger.info({ searchId, itemCount: data.new.length, itemIds: data.new }, 'New listings detected!');
|
||||
this.emit('new-listings', {
|
||||
searchId,
|
||||
itemIds: data.new as string[],
|
||||
page,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Not all frames are JSON
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
logger.warn({ searchId }, 'WebSocket closed');
|
||||
});
|
||||
|
||||
ws.on('socketerror', (err) => {
|
||||
logger.error({ searchId, err }, 'WebSocket error');
|
||||
});
|
||||
}
|
||||
|
||||
async clickTravelToHideout(page: Page, itemId?: string): Promise<boolean> {
|
||||
try {
|
||||
if (itemId) {
|
||||
const row = page.locator(SELECTORS.listingById(itemId));
|
||||
if (await row.isVisible({ timeout: 5000 })) {
|
||||
const travelBtn = row.locator(SELECTORS.travelToHideoutButton).first();
|
||||
if (await travelBtn.isVisible({ timeout: 3000 })) {
|
||||
await travelBtn.click();
|
||||
logger.info({ itemId }, 'Clicked Travel to Hideout for specific item');
|
||||
await this.handleConfirmDialog(page);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const travelBtn = page.locator(SELECTORS.travelToHideoutButton).first();
|
||||
await travelBtn.click({ timeout: 5000 });
|
||||
logger.info('Clicked Travel to Hideout');
|
||||
await this.handleConfirmDialog(page);
|
||||
return true;
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Failed to click Travel to Hideout');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async handleConfirmDialog(page: Page): Promise<void> {
|
||||
await sleep(500);
|
||||
try {
|
||||
const confirmBtn = page.locator(SELECTORS.confirmYesButton).first();
|
||||
if (await confirmBtn.isVisible({ timeout: 2000 })) {
|
||||
await confirmBtn.click();
|
||||
logger.info('Confirmed "Are you sure?" dialog');
|
||||
}
|
||||
} catch {
|
||||
// No dialog
|
||||
}
|
||||
}
|
||||
|
||||
extractSearchId(url: string): string {
|
||||
const cleaned = url.replace(/\/live\/?$/, '');
|
||||
const parts = cleaned.split('/');
|
||||
return parts[parts.length - 1] || url;
|
||||
}
|
||||
|
||||
async removeSearch(searchId: string): Promise<void> {
|
||||
this.pausedSearches.delete(searchId);
|
||||
const page = this.pages.get(searchId);
|
||||
if (page) {
|
||||
await page.close();
|
||||
this.pages.delete(searchId);
|
||||
logger.info({ searchId }, 'Trade search removed');
|
||||
}
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
for (const [id, page] of this.pages) {
|
||||
await page.close();
|
||||
this.pages.delete(id);
|
||||
}
|
||||
if (this.context) {
|
||||
await this.context.close();
|
||||
this.context = null;
|
||||
}
|
||||
logger.info('Trade monitor stopped');
|
||||
}
|
||||
}
|
||||
30
src/trade/selectors.ts
Normal file
30
src/trade/selectors.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
// CSS selectors for the POE2 trade website (pathofexile.com/trade2)
|
||||
// These need to be verified against the live site and updated if the site changes.
|
||||
|
||||
export const SELECTORS = {
|
||||
// Live search activation button
|
||||
liveSearchButton: 'button.livesearch-btn, button:has-text("Activate Live Search")',
|
||||
|
||||
// Individual listing rows
|
||||
listingRow: '.resultset .row, [class*="result"]',
|
||||
|
||||
// Listing by item ID
|
||||
listingById: (id: string) => `[data-id="${id}"]`,
|
||||
|
||||
// "Travel to Hideout" / "Visit Hideout" button on a listing
|
||||
travelToHideoutButton:
|
||||
'button:has-text("Travel to Hideout"), button:has-text("Visit Hideout"), a:has-text("Travel to Hideout"), [class*="hideout"]',
|
||||
|
||||
// Whisper / copy button on a listing
|
||||
whisperButton:
|
||||
'.whisper-btn, button[class*="whisper"], [data-tooltip="Whisper"], button:has-text("Whisper")',
|
||||
|
||||
// "Are you sure?" confirmation dialog
|
||||
confirmDialog: '[class*="modal"], [class*="dialog"], [class*="confirm"]',
|
||||
confirmYesButton:
|
||||
'button:has-text("Yes"), button:has-text("Confirm"), button:has-text("OK"), button:has-text("Accept")',
|
||||
confirmNoButton: 'button:has-text("No"), button:has-text("Cancel"), button:has-text("Decline")',
|
||||
|
||||
// Search results container
|
||||
resultsContainer: '.resultset, [class*="results"]',
|
||||
} as const;
|
||||
58
src/types.ts
Normal file
58
src/types.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
export interface Config {
|
||||
tradeUrls: string[];
|
||||
poe2LogPath: string;
|
||||
poe2WindowTitle: string;
|
||||
browserUserDataDir: string;
|
||||
travelTimeoutMs: number;
|
||||
stashScanTimeoutMs: number;
|
||||
waitForMoreItemsMs: number;
|
||||
betweenTradesDelayMs: number;
|
||||
}
|
||||
|
||||
export interface Region {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface ScreenRegions {
|
||||
stashArea: Region;
|
||||
priceWarningDialog: Region;
|
||||
priceWarningNoButton: Region;
|
||||
inventoryArea: Region;
|
||||
stashTabArea: Region;
|
||||
}
|
||||
|
||||
export interface TradeInfo {
|
||||
searchId: string;
|
||||
itemIds: string[];
|
||||
whisperText: string;
|
||||
timestamp: number;
|
||||
tradeUrl: string;
|
||||
page: unknown; // Playwright Page reference
|
||||
}
|
||||
|
||||
export interface StashItem {
|
||||
name: string;
|
||||
stats: string;
|
||||
price: string;
|
||||
position: { x: number; y: number };
|
||||
}
|
||||
|
||||
export type TradeState =
|
||||
| 'IDLE'
|
||||
| 'TRAVELING'
|
||||
| 'IN_SELLERS_HIDEOUT'
|
||||
| 'SCANNING_STASH'
|
||||
| 'BUYING'
|
||||
| 'WAITING_FOR_MORE'
|
||||
| 'GOING_HOME'
|
||||
| 'IN_HIDEOUT'
|
||||
| 'FAILED';
|
||||
|
||||
export interface LogEvent {
|
||||
timestamp: Date;
|
||||
type: 'area-entered' | 'whisper-received' | 'trade-accepted' | 'unknown';
|
||||
data: Record<string, string>;
|
||||
}
|
||||
13
src/util/clipboard.ts
Normal file
13
src/util/clipboard.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { execSync } from 'child_process';
|
||||
|
||||
export function readClipboard(): string {
|
||||
try {
|
||||
return execSync('powershell -command "Get-Clipboard"', { encoding: 'utf-8' }).trim();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function writeClipboard(text: string): void {
|
||||
execSync(`powershell -command "Set-Clipboard -Value '${text.replace(/'/g, "''")}'"`);
|
||||
}
|
||||
12
src/util/logger.ts
Normal file
12
src/util/logger.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import pino from 'pino';
|
||||
|
||||
export const logger = pino({
|
||||
transport: {
|
||||
target: 'pino-pretty',
|
||||
options: {
|
||||
colorize: true,
|
||||
translateTime: 'HH:MM:ss',
|
||||
ignore: 'pid,hostname',
|
||||
},
|
||||
},
|
||||
});
|
||||
24
src/util/retry.ts
Normal file
24
src/util/retry.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { sleep } from './sleep.js';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
export async function retry<T>(
|
||||
fn: () => Promise<T>,
|
||||
options: { maxAttempts?: number; delayMs?: number; label?: string } = {},
|
||||
): Promise<T> {
|
||||
const { maxAttempts = 3, delayMs = 1000, label = 'operation' } = options;
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
if (attempt === maxAttempts) {
|
||||
logger.error({ err, attempt, label }, `${label} failed after ${maxAttempts} attempts`);
|
||||
throw err;
|
||||
}
|
||||
logger.warn({ err, attempt, label }, `${label} failed, retrying in ${delayMs}ms...`);
|
||||
await sleep(delayMs * attempt);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Unreachable');
|
||||
}
|
||||
8
src/util/sleep.ts
Normal file
8
src/util/sleep.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
export function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export function randomDelay(minMs: number, maxMs: number): Promise<void> {
|
||||
const delay = minMs + Math.random() * (maxMs - minMs);
|
||||
return sleep(delay);
|
||||
}
|
||||
14
tools/OcrDaemon/OcrDaemon.csproj
Normal file
14
tools/OcrDaemon/OcrDaemon.csproj
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.Drawing.Common" Version="8.0.12" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
293
tools/OcrDaemon/Program.cs
Normal file
293
tools/OcrDaemon/Program.cs
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
using System.Drawing;
|
||||
using System.Drawing.Imaging;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Windows.Graphics.Imaging;
|
||||
using Windows.Media.Ocr;
|
||||
using Windows.Storage.Streams;
|
||||
|
||||
// Make GDI capture DPI-aware so coordinates match physical pixels
|
||||
SetProcessDPIAware();
|
||||
|
||||
// Pre-create the OCR engine (reused across all requests)
|
||||
var ocrEngine = OcrEngine.TryCreateFromUserProfileLanguages();
|
||||
if (ocrEngine == null)
|
||||
{
|
||||
WriteResponse(new ErrorResponse("Failed to create OCR engine. Ensure a language pack is installed."));
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Signal ready
|
||||
WriteResponse(new ReadyResponse());
|
||||
|
||||
// JSON options
|
||||
var jsonOptions = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
|
||||
// Main loop: read one JSON line, handle, write one JSON line
|
||||
var stdin = Console.In;
|
||||
string? line;
|
||||
while ((line = stdin.ReadLine()) != null)
|
||||
{
|
||||
line = line.Trim();
|
||||
if (line.Length == 0) continue;
|
||||
|
||||
try
|
||||
{
|
||||
var request = JsonSerializer.Deserialize<Request>(line, jsonOptions);
|
||||
if (request == null)
|
||||
{
|
||||
WriteResponse(new ErrorResponse("Failed to parse request"));
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (request.Cmd?.ToLowerInvariant())
|
||||
{
|
||||
case "ocr":
|
||||
HandleOcr(request, ocrEngine);
|
||||
break;
|
||||
case "screenshot":
|
||||
HandleScreenshot(request);
|
||||
break;
|
||||
case "capture":
|
||||
HandleCapture(request);
|
||||
break;
|
||||
default:
|
||||
WriteResponse(new ErrorResponse($"Unknown command: {request.Cmd}"));
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
WriteResponse(new ErrorResponse(ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
|
||||
// ── Handlers ────────────────────────────────────────────────────────────────
|
||||
|
||||
void HandleOcr(Request req, OcrEngine engine)
|
||||
{
|
||||
using var bitmap = CaptureScreen(req.Region);
|
||||
var softwareBitmap = BitmapToSoftwareBitmap(bitmap);
|
||||
var result = engine.RecognizeAsync(softwareBitmap).AsTask().GetAwaiter().GetResult();
|
||||
|
||||
var lines = new List<OcrLineResult>();
|
||||
foreach (var ocrLine in result.Lines)
|
||||
{
|
||||
var words = new List<OcrWordResult>();
|
||||
foreach (var word in ocrLine.Words)
|
||||
{
|
||||
words.Add(new OcrWordResult
|
||||
{
|
||||
Text = word.Text,
|
||||
X = (int)Math.Round(word.BoundingRect.X),
|
||||
Y = (int)Math.Round(word.BoundingRect.Y),
|
||||
Width = (int)Math.Round(word.BoundingRect.Width),
|
||||
Height = (int)Math.Round(word.BoundingRect.Height),
|
||||
});
|
||||
}
|
||||
lines.Add(new OcrLineResult { Text = ocrLine.Text, Words = words });
|
||||
}
|
||||
|
||||
WriteResponse(new OcrResponse { Text = result.Text, Lines = lines });
|
||||
}
|
||||
|
||||
void HandleScreenshot(Request req)
|
||||
{
|
||||
if (string.IsNullOrEmpty(req.Path))
|
||||
{
|
||||
WriteResponse(new ErrorResponse("screenshot command requires 'path'"));
|
||||
return;
|
||||
}
|
||||
|
||||
using var bitmap = CaptureScreen(req.Region);
|
||||
var format = GetImageFormat(req.Path);
|
||||
bitmap.Save(req.Path, format);
|
||||
WriteResponse(new OkResponse());
|
||||
}
|
||||
|
||||
void HandleCapture(Request req)
|
||||
{
|
||||
using var bitmap = CaptureScreen(req.Region);
|
||||
using var ms = new MemoryStream();
|
||||
bitmap.Save(ms, ImageFormat.Png);
|
||||
var base64 = Convert.ToBase64String(ms.ToArray());
|
||||
WriteResponse(new CaptureResponse { Image = base64 });
|
||||
}
|
||||
|
||||
// ── Screen Capture ──────────────────────────────────────────────────────────
|
||||
|
||||
Bitmap CaptureScreen(RegionRect? region)
|
||||
{
|
||||
int x, y, w, h;
|
||||
if (region != null)
|
||||
{
|
||||
x = region.X;
|
||||
y = region.Y;
|
||||
w = region.Width;
|
||||
h = region.Height;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Primary monitor only (0,0 origin, SM_CXSCREEN / SM_CYSCREEN)
|
||||
x = 0;
|
||||
y = 0;
|
||||
w = GetSystemMetrics(0); // SM_CXSCREEN
|
||||
h = GetSystemMetrics(1); // SM_CYSCREEN
|
||||
}
|
||||
|
||||
var bitmap = new Bitmap(w, h, PixelFormat.Format32bppArgb);
|
||||
using var g = Graphics.FromImage(bitmap);
|
||||
g.CopyFromScreen(x, y, 0, 0, new Size(w, h), CopyPixelOperation.SourceCopy);
|
||||
return bitmap;
|
||||
}
|
||||
|
||||
// ── Bitmap → SoftwareBitmap conversion (in-memory) ─────────────────────────
|
||||
|
||||
SoftwareBitmap BitmapToSoftwareBitmap(Bitmap bitmap)
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
bitmap.Save(ms, ImageFormat.Bmp);
|
||||
ms.Position = 0;
|
||||
|
||||
var stream = ms.AsRandomAccessStream();
|
||||
var decoder = BitmapDecoder.CreateAsync(stream).AsTask().GetAwaiter().GetResult();
|
||||
var softwareBitmap = decoder.GetSoftwareBitmapAsync().AsTask().GetAwaiter().GetResult();
|
||||
return softwareBitmap;
|
||||
}
|
||||
|
||||
// ── Response writing ────────────────────────────────────────────────────────
|
||||
|
||||
void WriteResponse(object response)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(response, jsonOptions);
|
||||
Console.Out.WriteLine(json);
|
||||
Console.Out.Flush();
|
||||
}
|
||||
|
||||
ImageFormat GetImageFormat(string path)
|
||||
{
|
||||
var ext = Path.GetExtension(path).ToLowerInvariant();
|
||||
return ext switch
|
||||
{
|
||||
".jpg" or ".jpeg" => ImageFormat.Jpeg,
|
||||
".bmp" => ImageFormat.Bmp,
|
||||
_ => ImageFormat.Png,
|
||||
};
|
||||
}
|
||||
|
||||
// ── P/Invoke ────────────────────────────────────────────────────────────────
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
static extern bool SetProcessDPIAware();
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
static extern int GetSystemMetrics(int nIndex);
|
||||
|
||||
// ── Request / Response Models ───────────────────────────────────────────────
|
||||
|
||||
class Request
|
||||
{
|
||||
[JsonPropertyName("cmd")]
|
||||
public string? Cmd { get; set; }
|
||||
|
||||
[JsonPropertyName("region")]
|
||||
public RegionRect? Region { get; set; }
|
||||
|
||||
[JsonPropertyName("path")]
|
||||
public string? Path { get; set; }
|
||||
}
|
||||
|
||||
class RegionRect
|
||||
{
|
||||
[JsonPropertyName("x")]
|
||||
public int X { get; set; }
|
||||
|
||||
[JsonPropertyName("y")]
|
||||
public int Y { get; set; }
|
||||
|
||||
[JsonPropertyName("width")]
|
||||
public int Width { get; set; }
|
||||
|
||||
[JsonPropertyName("height")]
|
||||
public int Height { get; set; }
|
||||
}
|
||||
|
||||
class ReadyResponse
|
||||
{
|
||||
[JsonPropertyName("ok")]
|
||||
public bool Ok => true;
|
||||
|
||||
[JsonPropertyName("ready")]
|
||||
public bool Ready => true;
|
||||
}
|
||||
|
||||
class OkResponse
|
||||
{
|
||||
[JsonPropertyName("ok")]
|
||||
public bool Ok => true;
|
||||
}
|
||||
|
||||
class ErrorResponse(string message)
|
||||
{
|
||||
[JsonPropertyName("ok")]
|
||||
public bool Ok => false;
|
||||
|
||||
[JsonPropertyName("error")]
|
||||
public string Error => message;
|
||||
}
|
||||
|
||||
class OcrResponse
|
||||
{
|
||||
[JsonPropertyName("ok")]
|
||||
public bool Ok => true;
|
||||
|
||||
[JsonPropertyName("text")]
|
||||
public string Text { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("lines")]
|
||||
public List<OcrLineResult> Lines { get; set; } = [];
|
||||
}
|
||||
|
||||
class OcrLineResult
|
||||
{
|
||||
[JsonPropertyName("text")]
|
||||
public string Text { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("words")]
|
||||
public List<OcrWordResult> Words { get; set; } = [];
|
||||
}
|
||||
|
||||
class OcrWordResult
|
||||
{
|
||||
[JsonPropertyName("text")]
|
||||
public string Text { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("x")]
|
||||
public int X { get; set; }
|
||||
|
||||
[JsonPropertyName("y")]
|
||||
public int Y { get; set; }
|
||||
|
||||
[JsonPropertyName("width")]
|
||||
public int Width { get; set; }
|
||||
|
||||
[JsonPropertyName("height")]
|
||||
public int Height { get; set; }
|
||||
}
|
||||
|
||||
class CaptureResponse
|
||||
{
|
||||
[JsonPropertyName("ok")]
|
||||
public bool Ok => true;
|
||||
|
||||
[JsonPropertyName("image")]
|
||||
public string Image { get; set; } = "";
|
||||
}
|
||||
19
tsconfig.json
Normal file
19
tsconfig.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue