Initialize action topology engine

This commit is contained in:
赵义仑
2026-06-16 14:26:08 +08:00
commit 853404c67a
17 changed files with 3664 additions and 0 deletions

189
src/engine.ts Normal file
View File

@@ -0,0 +1,189 @@
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 { 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: TopologyGraph = {
schemaVersion: "0.1",
metadata: {
entryUrl: page.url(),
generatedAt: new Date().toISOString(),
engineVersion: "0.1.0",
mode: "scan",
},
states: [snapshot.state],
elements: snapshot.elements,
edges: [],
};
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: TopologyGraph = {
schemaVersion: "0.1",
metadata: {
entryUrl: page.url(),
generatedAt: new Date().toISOString(),
engineVersion: "0.1.0",
mode: "explore",
},
states: [entrySnapshot.state],
elements: [...entrySnapshot.elements],
edges: [],
};
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: { width: 1440, height: 1000 },
});
}
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;
}