poe2-bot/tools/trade-daemon/daemon.mjs
2026-02-23 11:02:10 -05:00

384 lines
10 KiB
JavaScript

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 };
}
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;
try {
const payload = typeof frame === "string" ? frame : frame.payload?.toString() ?? "";
const doc = JSON.parse(payload);
if (doc.new && Array.isArray(doc.new)) {
const ids = doc.new.filter((s) => s != null);
if (ids.length > 0) {
log(`New listings: ${searchId} (${ids.length} items)`);
sendEvent("newListings", { searchId, itemIds: ids });
}
}
} catch {
/* Non-JSON WebSocket frame */
}
});
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);
await page.goto(url, { waitUntil: "networkidle" });
await new Promise((r) => setTimeout(r, 2000)); // PageLoad delay
page.on("websocket", (ws) => handleWebSocket(ws, searchId));
try {
const liveBtn = page.locator(Selectors.LiveSearchButton).first();
await liveBtn.click({ timeout: 5000 });
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 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,
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...");