poe2-bot/src-old/trade/TradeMonitor.ts
2026-02-13 01:12:11 -05:00

290 lines
9.1 KiB
TypeScript

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, TradeItem } 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
}
}
async openScrapPage(tradeUrl: string): Promise<{ page: Page; items: TradeItem[] }> {
if (!this.context) throw new Error('Browser not started');
const page = await this.context.newPage();
const items: TradeItem[] = [];
page.on('response', async (response) => {
if (response.url().includes('/api/trade2/fetch/')) {
try {
const json = await response.json();
if (json.result && Array.isArray(json.result)) {
for (const r of json.result) {
items.push({
id: r.id,
w: r.item?.w ?? 1,
h: r.item?.h ?? 1,
stashX: r.listing?.stash?.x ?? 0,
stashY: r.listing?.stash?.y ?? 0,
account: r.listing?.account?.name ?? '',
});
}
}
} catch {
// Response may not be JSON
}
}
});
await page.goto(tradeUrl, { waitUntil: 'networkidle' });
await sleep(2000); // ensure API response received
logger.info({ url: tradeUrl, itemCount: items.length }, 'Scrap page opened');
return { page, items };
}
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');
}
}