tests
This commit is contained in:
parent
adc2450013
commit
dcbeebac83
14 changed files with 859 additions and 368 deletions
425
tools/trade-daemon/daemon.mjs
Normal file
425
tools/trade-daemon/daemon.mjs
Normal file
|
|
@ -0,0 +1,425 @@
|
|||
import { chromium } from "playwright";
|
||||
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 });
|
||||
}
|
||||
|
||||
// --- Stealth script (same as the working TS version) ---
|
||||
const STEALTH_SCRIPT = `
|
||||
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
|
||||
|
||||
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' },
|
||||
],
|
||||
});
|
||||
|
||||
Object.defineProperty(navigator, 'languages', { get: () => ['en-US', 'en'] });
|
||||
|
||||
delete window.__playwright;
|
||||
delete window.__pw_manual;
|
||||
|
||||
if (!window.chrome) window.chrome = {};
|
||||
if (!window.chrome.runtime) window.chrome.runtime = { id: undefined };
|
||||
|
||||
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);
|
||||
};
|
||||
}
|
||||
`;
|
||||
|
||||
// --- 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, userDataDir=${browserUserDataDir}, headless=${headless}`);
|
||||
|
||||
context = await chromium.launchPersistentContext(browserUserDataDir, {
|
||||
channel: "chrome",
|
||||
headless: !!headless,
|
||||
viewport: null,
|
||||
args: [
|
||||
"--disable-blink-features=AutomationControlled",
|
||||
"--disable-features=AutomationControlled",
|
||||
"--no-first-run",
|
||||
"--no-default-browser-check",
|
||||
"--disable-infobars",
|
||||
],
|
||||
ignoreDefaultArgs: ["--enable-automation"],
|
||||
});
|
||||
|
||||
await context.addInitScript(STEALTH_SCRIPT);
|
||||
|
||||
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 (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;
|
||||
// pageId is a searchId or scrapId
|
||||
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...");
|
||||
59
tools/trade-daemon/package-lock.json
generated
Normal file
59
tools/trade-daemon/package-lock.json
generated
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
{
|
||||
"name": "trade-daemon",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "trade-daemon",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"playwright": "^1.49.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
||||
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
||||
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
9
tools/trade-daemon/package.json
Normal file
9
tools/trade-daemon/package.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"name": "trade-daemon",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"playwright": "^1.49.0"
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue