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 = new Map(); private pausedSearches: Set = new Set(); private config: Config; constructor(config: Config) { super(); this.config = config; } async start(dashboardUrl?: string): Promise { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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'); } }