183 lines
6.6 KiB
TypeScript
183 lines
6.6 KiB
TypeScript
import { mkdir, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
import { chromium, type Browser, type BrowserContext, type Page, type Response } from "playwright";
|
|
import { shortHash } from "./hash.js";
|
|
import { resolveLocator } from "./locator.js";
|
|
import { buildEmptyGraph, defaultViewport } from "./metadata.js";
|
|
import { isLowRiskClickable, classifyClickRisk } from "./risk.js";
|
|
import { snapshotPage } from "./snapshot.js";
|
|
import type { ActionEdge, EffectRecord, TopologyGraph } from "./types.js";
|
|
|
|
export interface EngineOptions {
|
|
url: string;
|
|
out?: string;
|
|
headed?: boolean;
|
|
storageState?: string;
|
|
maxActions?: number;
|
|
}
|
|
|
|
export async function scanUrl(options: EngineOptions): Promise<TopologyGraph> {
|
|
const browser = await chromium.launch({ headless: !options.headed });
|
|
try {
|
|
const context = await newContext(browser, options);
|
|
const page = await context.newPage();
|
|
await page.goto(toNavigableUrl(options.url), { waitUntil: "domcontentloaded" });
|
|
await page.waitForLoadState("networkidle").catch(() => undefined);
|
|
const snapshot = await snapshotPage(page);
|
|
const graph = buildEmptyGraph({
|
|
inputUrl: options.url,
|
|
entryUrl: page.url(),
|
|
mode: "scan",
|
|
storageState: options.storageState,
|
|
});
|
|
graph.states.push(snapshot.state);
|
|
graph.elements.push(...snapshot.elements);
|
|
await maybeWriteGraph(graph, options.out);
|
|
await context.close();
|
|
return graph;
|
|
} finally {
|
|
await browser.close();
|
|
}
|
|
}
|
|
|
|
export async function exploreUrl(options: EngineOptions): Promise<TopologyGraph> {
|
|
const browser = await chromium.launch({ headless: !options.headed });
|
|
try {
|
|
const context = await newContext(browser, options);
|
|
const entryUrl = toNavigableUrl(options.url);
|
|
const page = await context.newPage();
|
|
await page.goto(entryUrl, { waitUntil: "domcontentloaded" });
|
|
await page.waitForLoadState("networkidle").catch(() => undefined);
|
|
|
|
const entrySnapshot = await snapshotPage(page);
|
|
const graph = buildEmptyGraph({
|
|
inputUrl: options.url,
|
|
entryUrl: page.url(),
|
|
mode: "explore",
|
|
storageState: options.storageState,
|
|
});
|
|
graph.states.push(entrySnapshot.state);
|
|
graph.elements.push(...entrySnapshot.elements);
|
|
|
|
const candidates = entrySnapshot.elements.filter(isLowRiskClickable).slice(0, options.maxActions ?? 10);
|
|
await page.close();
|
|
|
|
for (const element of candidates) {
|
|
const actionPage = await context.newPage();
|
|
const networkEvents: EffectRecord[] = [];
|
|
actionPage.on("response", (response) => {
|
|
const record = responseToEffect(response);
|
|
if (record) networkEvents.push(record);
|
|
});
|
|
|
|
await actionPage.goto(entryUrl, { waitUntil: "domcontentloaded" });
|
|
await actionPage.waitForLoadState("networkidle").catch(() => undefined);
|
|
const beforeUrl = actionPage.url();
|
|
const beforeSnapshot = await snapshotPage(actionPage);
|
|
const locator = element.locators.find((candidate) => candidate.verified) ?? element.locators[0];
|
|
|
|
let toStateId: string | null = null;
|
|
const effects: EffectRecord[] = [];
|
|
if (locator) {
|
|
try {
|
|
await resolveLocator(actionPage, locator.descriptor).first().click({ timeout: 2000 });
|
|
await actionPage.waitForLoadState("networkidle", { timeout: 2000 }).catch(() => undefined);
|
|
const afterSnapshot = await snapshotPage(actionPage);
|
|
graph.states = upsertById(graph.states, afterSnapshot.state, "stateId");
|
|
graph.elements.push(...afterSnapshot.elements);
|
|
toStateId = afterSnapshot.state.stateId;
|
|
effects.push(...diffEffects(beforeUrl, beforeSnapshot.visibleText, actionPage.url(), afterSnapshot.visibleText));
|
|
} catch (error) {
|
|
effects.push({
|
|
type: "dom",
|
|
description: `Action failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
});
|
|
}
|
|
}
|
|
|
|
const edge: ActionEdge = {
|
|
edgeId: `edge_${shortHash([entrySnapshot.state.stateId, element.elementId, "click"])}`,
|
|
fromStateId: entrySnapshot.state.stateId,
|
|
toStateId,
|
|
elementId: element.elementId,
|
|
actionType: "click",
|
|
actionParams: {},
|
|
preconditions: [],
|
|
effects: [...effects, ...networkEvents],
|
|
parameterSpecs: [],
|
|
riskLevel: classifyClickRisk(element),
|
|
confidence: locator?.verified ? 0.82 : 0.55,
|
|
createdAt: new Date().toISOString(),
|
|
};
|
|
graph.edges = upsertById(graph.edges, edge, "edgeId");
|
|
await actionPage.close();
|
|
}
|
|
|
|
await maybeWriteGraph(graph, options.out);
|
|
await context.close();
|
|
return graph;
|
|
} finally {
|
|
await browser.close();
|
|
}
|
|
}
|
|
|
|
async function newContext(browser: Browser, options: EngineOptions): Promise<BrowserContext> {
|
|
return browser.newContext({
|
|
storageState: options.storageState,
|
|
viewport: defaultViewport(),
|
|
locale: "en-US",
|
|
});
|
|
}
|
|
|
|
async function maybeWriteGraph(graph: TopologyGraph, out?: string): Promise<void> {
|
|
if (!out) {
|
|
console.log(JSON.stringify(graph, null, 2));
|
|
return;
|
|
}
|
|
await mkdir(path.dirname(out), { recursive: true });
|
|
await writeFile(out, `${JSON.stringify(graph, null, 2)}\n`, "utf8");
|
|
}
|
|
|
|
function toNavigableUrl(value: string): string {
|
|
if (/^https?:\/\//.test(value) || value.startsWith("file://")) return value;
|
|
return pathToFileURL(path.resolve(value)).toString();
|
|
}
|
|
|
|
function diffEffects(beforeUrl: string, beforeText: string, afterUrl: string, afterText: string): EffectRecord[] {
|
|
const effects: EffectRecord[] = [];
|
|
if (beforeUrl !== afterUrl) {
|
|
effects.push({ type: "url", before: beforeUrl, after: afterUrl, description: "URL changed after action" });
|
|
}
|
|
if (shortHash(beforeText) !== shortHash(afterText)) {
|
|
effects.push({
|
|
type: "visibleText",
|
|
before: shortHash(beforeText),
|
|
after: shortHash(afterText),
|
|
description: "Visible text changed after action",
|
|
});
|
|
}
|
|
return effects;
|
|
}
|
|
|
|
function responseToEffect(response: Response): EffectRecord | null {
|
|
const request = response.request();
|
|
const resourceType = request.resourceType();
|
|
if (!["fetch", "xhr", "document"].includes(resourceType)) return null;
|
|
return {
|
|
type: "network",
|
|
method: request.method(),
|
|
url: response.url(),
|
|
status: response.status(),
|
|
description: `${request.method()} ${response.url()} -> ${response.status()}`,
|
|
};
|
|
}
|
|
|
|
function upsertById<T extends Record<K, string>, K extends keyof T>(items: T[], item: T, key: K): T[] {
|
|
const index = items.findIndex((existing) => existing[key] === item[key]);
|
|
if (index === -1) return [...items, item];
|
|
const next = [...items];
|
|
next[index] = item;
|
|
return next;
|
|
}
|