switched to new way :)
This commit is contained in:
parent
b03a2a25f1
commit
f22d182c8f
30 changed files with 0 additions and 0 deletions
290
src-old/trade/TradeMonitor.ts
Normal file
290
src-old/trade/TradeMonitor.ts
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
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');
|
||||
}
|
||||
}
|
||||
30
src-old/trade/selectors.ts
Normal file
30
src-old/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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue