290 lines
9.1 KiB
TypeScript
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');
|
|
}
|
|
}
|