478 lines
13 KiB
JavaScript
478 lines
13 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 };
|
|
}
|
|
|
|
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...");
|