import { chromium } from "patchright"; import { createInterface } from "readline"; // All logging goes to stderr — never corrupt the JSON protocol on stdout const log = (...args) => process.stderr.write(`[trade-daemon] ${args.join(" ")}\n`); // --- Protocol helpers --- function sendJson(obj) { process.stdout.write(JSON.stringify(obj) + "\n"); } function sendResponse(reqId, extras = {}) { sendJson({ type: "response", reqId, ok: true, ...extras }); } function sendError(reqId, error) { sendJson({ type: "response", reqId, ok: false, error: String(error) }); } function sendEvent(event, data = {}) { sendJson({ type: "event", event, ...data }); } // --- Selectors (mirrored from Selectors.cs) --- const Selectors = { LiveSearchButton: 'button.livesearch-btn, button:has-text("Activate Live Search")', ListingRow: '.resultset .row, [class*="result"]', ListingById: (id) => `[data-id="${id}"]`, TravelToHideoutButton: 'button:has-text("Travel to Hideout"), button:has-text("Visit Hideout"), a:has-text("Travel to Hideout"), [class*="hideout"]', ConfirmYesButton: 'button:has-text("Yes"), button:has-text("Confirm"), button:has-text("OK"), button:has-text("Accept")', }; // --- State --- let context = null; const searchPages = new Map(); // searchId → page const pausedSearches = new Set(); const scrapPages = new Map(); // scrapId → { page, items } let scrapIdCounter = 0; // --- Helpers --- function extractSearchId(url) { const cleaned = url.replace(/\/live\/?$/, ""); const parts = cleaned.split("/"); return parts[parts.length - 1] || url; } function parseTradeItem(r) { const id = r.id || ""; let w = 1, h = 1, stashX = 0, stashY = 0, account = ""; if (r.item) { if (r.item.w != null) w = r.item.w; if (r.item.h != null) h = r.item.h; } if (r.listing) { if (r.listing.stash) { if (r.listing.stash.x != null) stashX = r.listing.stash.x; if (r.listing.stash.y != null) stashY = r.listing.stash.y; } if (r.listing.account?.name) account = r.listing.account.name; } return { id, w, h, stashX, stashY, account }; } function parseTradeItemWithPrice(r) { const base = parseTradeItem(r); let name = ""; let priceAmount = 0; let priceCurrency = ""; if (r.item?.icon) { try { const pathname = new URL(r.item.icon).pathname; const filename = pathname.split("/").pop() || ""; name = filename.replace(".png", ""); } catch { // fallback: regex on last path segment const match = r.item.icon.match(/\/([^/]+)\.png/); if (match) name = match[1]; } } if (r.listing?.price) { if (r.listing.price.amount != null) priceAmount = r.listing.price.amount; if (r.listing.price.currency) priceCurrency = r.listing.price.currency; } return { ...base, name, priceAmount, priceCurrency }; } async function waitForVisible(locator, timeoutMs) { try { await locator.waitFor({ state: "visible", timeout: timeoutMs }); return true; } catch { return false; } } async function handleConfirmDialog(page) { await new Promise((r) => setTimeout(r, 500)); try { const confirmBtn = page.locator(Selectors.ConfirmYesButton).first(); if (await waitForVisible(confirmBtn, 2000)) { await confirmBtn.click(); log("Confirmed dialog"); } } catch { /* No dialog */ } } function handleWebSocket(ws, searchId) { if (!ws.url().includes("/api/trade") || !ws.url().includes("/live/")) return; log(`WebSocket connected for live search: ${searchId}`); ws.on("framereceived", (frame) => { if (pausedSearches.has(searchId)) return; // Note: trade site now sends JWTs, not raw item IDs. // We rely on the fetch response interceptor instead. }); ws.on("close", () => { log(`WebSocket closed: ${searchId}`); sendEvent("wsClose", { searchId }); }); } // --- Command handlers --- async function cmdStart(reqId, params) { const { browserUserDataDir, headless, dashboardUrl } = params; log(`Starting browser (stealth), userDataDir=${browserUserDataDir}`); context = await chromium.launchPersistentContext(browserUserDataDir, { channel: "chrome", headless: false, viewport: null, }); if (dashboardUrl) { const pages = context.pages(); if (pages.length > 0) { await pages[0].goto(dashboardUrl); } else { const p = await context.newPage(); await p.goto(dashboardUrl); } log(`Dashboard opened: ${dashboardUrl}`); } log("Browser launched (playwright-extra stealth active)"); sendResponse(reqId); } async function cmdAddSearch(reqId, params) { if (!context) throw new Error("Browser not started"); const { url } = params; const searchId = extractSearchId(url); if (searchPages.has(searchId)) { log(`Search already open: ${searchId}`); sendResponse(reqId, { searchId }); return; } log(`Adding trade search: ${url} (${searchId})`); const page = await context.newPage(); searchPages.set(searchId, page); // Register WebSocket handler BEFORE navigation so we catch connections during page load page.on("websocket", (ws) => handleWebSocket(ws, searchId)); // Track whether live search is active — don't emit items from initial page load let liveActive = false; const seenIds = new Set(); // Intercept fetch responses to get full item data (WebSocket now sends JWTs, not raw IDs) page.on("response", async (response) => { if (!liveActive) return; if (!response.url().includes("/api/trade2/fetch/")) return; try { const body = await response.text(); const doc = JSON.parse(body); if (doc.result && Array.isArray(doc.result)) { const items = doc.result .map((r) => parseTradeItem(r)) .filter((i) => i.id && !seenIds.has(i.id)); items.forEach((i) => seenIds.add(i.id)); if (items.length > 0) { log(`New listings (fetch): ${searchId} (${items.length} new items)`); sendEvent("newListings", { searchId, items }); } } } catch { /* Non-JSON trade response */ } }); await page.goto(url, { waitUntil: "networkidle" }); await new Promise((r) => setTimeout(r, 2000)); // PageLoad delay try { const liveBtn = page.locator(Selectors.LiveSearchButton).first(); await liveBtn.click({ timeout: 5000 }); liveActive = true; log(`Live search activated: ${searchId}`); } catch { log(`Could not click Activate Live Search: ${searchId}`); } sendResponse(reqId, { searchId }); } async function cmdPauseSearch(reqId, params) { const { searchId } = params; pausedSearches.add(searchId); const page = searchPages.get(searchId); if (page) { await page.close(); searchPages.delete(searchId); } log(`Search paused: ${searchId}`); sendResponse(reqId); } async function cmdClickTravel(reqId, params) { const { pageId, itemId } = params; let page = searchPages.get(pageId) || scrapPages.get(pageId)?.page; if (!page) { sendResponse(reqId, { clicked: false }); return; } try { if (itemId) { const row = page.locator(Selectors.ListingById(itemId)); if (await waitForVisible(row, 5000)) { const travelBtn = row.locator(Selectors.TravelToHideoutButton).first(); if (await waitForVisible(travelBtn, 3000)) { await travelBtn.click(); log(`Clicked Travel to Hideout for item ${itemId}`); await handleConfirmDialog(page); sendResponse(reqId, { clicked: true }); return; } } } const btn = page.locator(Selectors.TravelToHideoutButton).first(); await btn.click({ timeout: 5000 }); log("Clicked Travel to Hideout"); await handleConfirmDialog(page); sendResponse(reqId, { clicked: true }); } catch (ex) { log(`Failed to click Travel to Hideout: ${ex.message}`); sendResponse(reqId, { clicked: false }); } } async function cmdAddDiamondSearch(reqId, params) { if (!context) throw new Error("Browser not started"); const { url } = params; const searchId = extractSearchId(url); if (searchPages.has(searchId)) { log(`Diamond search already open: ${searchId}`); sendResponse(reqId, { searchId }); return; } log(`Adding diamond search: ${url} (${searchId})`); const page = await context.newPage(); searchPages.set(searchId, page); // Intercept fetch responses for item data + prices page.on("response", async (response) => { if (!response.url().includes("/api/trade2/fetch/")) return; try { const body = await response.text(); const doc = JSON.parse(body); if (doc.result && Array.isArray(doc.result)) { const items = doc.result.map((r) => parseTradeItemWithPrice(r)); if (items.length > 0) { log(`Diamond listings: ${searchId} (${items.length} items)`); sendEvent("diamondListings", { searchId, items }); } } } catch { /* Non-JSON trade response */ } }); // Hook WebSocket BEFORE navigation so we catch connections during page load page.on("websocket", (ws) => handleWebSocket(ws, searchId)); await page.goto(url, { waitUntil: "networkidle" }); await new Promise((r) => setTimeout(r, 2000)); try { const liveBtn = page.locator(Selectors.LiveSearchButton).first(); await liveBtn.click({ timeout: 5000 }); log(`Diamond live search activated: ${searchId}`); } catch { log(`Could not click Activate Live Search: ${searchId}`); } sendResponse(reqId, { searchId }); } async function cmdOpenScrapPage(reqId, params) { if (!context) throw new Error("Browser not started"); const { url } = params; const scrapId = `scrap-${++scrapIdCounter}`; const page = await context.newPage(); const items = []; page.on("response", async (response) => { if (!response.url().includes("/api/trade2/fetch/")) return; try { const body = await response.text(); const doc = JSON.parse(body); if (doc.result && Array.isArray(doc.result)) { for (const r of doc.result) { items.push(parseTradeItem(r)); } } } catch { /* Non-JSON trade response */ } }); await page.goto(url, { waitUntil: "networkidle" }); await new Promise((r) => setTimeout(r, 2000)); // PageLoad delay scrapPages.set(scrapId, { page, items: [...items] }); log(`Scrap page opened: ${url} (${items.length} items) → ${scrapId}`); sendResponse(reqId, { scrapId, items }); } async function cmdReloadScrapPage(reqId, params) { const { scrapId } = params; const entry = scrapPages.get(scrapId); if (!entry) throw new Error(`Unknown scrapId: ${scrapId}`); const { page } = entry; const items = []; const onResponse = async (response) => { if (!response.url().includes("/api/trade2/fetch/")) return; try { const body = await response.text(); const doc = JSON.parse(body); if (doc.result && Array.isArray(doc.result)) { for (const r of doc.result) { items.push(parseTradeItem(r)); } } } catch { /* Non-JSON */ } }; page.on("response", onResponse); await page.reload({ waitUntil: "networkidle" }); await new Promise((r) => setTimeout(r, 2000)); // PageLoad delay page.removeListener("response", onResponse); entry.items = [...items]; log(`Scrap page reloaded: ${scrapId} (${items.length} items)`); sendResponse(reqId, { items }); } async function cmdCloseScrapPage(reqId, params) { const { scrapId } = params; const entry = scrapPages.get(scrapId); if (entry) { try { await entry.page.close(); } catch { /* already closed */ } scrapPages.delete(scrapId); } log(`Scrap page closed: ${scrapId}`); sendResponse(reqId); } async function cmdStop(reqId) { log("Stopping daemon..."); for (const [id, page] of searchPages) { try { await page.close(); } catch { /* ignore */ } } searchPages.clear(); for (const [id, entry] of scrapPages) { try { await entry.page.close(); } catch { /* ignore */ } } scrapPages.clear(); if (context) { try { await context.close(); } catch { /* ignore */ } context = null; } sendResponse(reqId); log("Daemon stopped"); process.exit(0); } // --- Command dispatch --- const handlers = { start: cmdStart, addSearch: cmdAddSearch, addDiamondSearch: cmdAddDiamondSearch, pauseSearch: cmdPauseSearch, clickTravel: cmdClickTravel, openScrapPage: cmdOpenScrapPage, reloadScrapPage: cmdReloadScrapPage, closeScrapPage: cmdCloseScrapPage, stop: cmdStop, }; async function handleCommand(line) { let msg; try { msg = JSON.parse(line); } catch { log(`Invalid JSON: ${line}`); return; } const { reqId, cmd, ...params } = msg; const handler = handlers[cmd]; if (!handler) { sendError(reqId, `Unknown command: ${cmd}`); return; } try { await handler(reqId, params); } catch (ex) { log(`Command ${cmd} failed: ${ex.message}`); sendError(reqId, ex.message); } } // --- Main --- const rl = createInterface({ input: process.stdin }); rl.on("line", (line) => handleCommand(line.trim())); rl.on("close", () => { log("stdin closed, shutting down"); process.exit(0); }); // Signal ready sendJson({ type: "ready" }); log("Daemon ready, waiting for commands...");