feat: version topology graph metadata

This commit is contained in:
赵义仑
2026-06-16 19:00:39 +08:00
parent 5ea50e7288
commit 1324f1882c
5 changed files with 228 additions and 30 deletions

View File

@@ -4,6 +4,7 @@ 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";
@@ -24,18 +25,14 @@ export async function scanUrl(options: EngineOptions): Promise<TopologyGraph> {
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: [],
};
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;
@@ -54,18 +51,14 @@ export async function exploreUrl(options: EngineOptions): Promise<TopologyGraph>
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 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();
@@ -132,7 +125,8 @@ export async function exploreUrl(options: EngineOptions): Promise<TopologyGraph>
async function newContext(browser: Browser, options: EngineOptions): Promise<BrowserContext> {
return browser.newContext({
storageState: options.storageState,
viewport: { width: 1440, height: 1000 },
viewport: defaultViewport(),
locale: "en-US",
});
}
@@ -186,4 +180,3 @@ function upsertById<T extends Record<K, string>, K extends keyof T>(items: T[],
next[index] = item;
return next;
}

73
src/metadata.ts Normal file
View File

@@ -0,0 +1,73 @@
import { randomUUID } from "node:crypto";
import { ENGINE_VERSION, SCHEMA_VERSION, type GraphMode, type RuntimeContext, type TopologyGraph } from "./types.js";
import { shortHash } from "./hash.js";
const DEFAULT_VIEWPORT = { width: 1440, height: 1000 } as const;
export function defaultViewport(): { width: number; height: number } {
return { ...DEFAULT_VIEWPORT };
}
export function buildRuntimeContext(storageState?: string): RuntimeContext {
return {
browserName: "chromium",
viewport: defaultViewport(),
locale: "en-US",
timezoneId: null,
authContext: storageState ? "storage_state" : "anonymous",
};
}
export function buildEmptyGraph(args: {
inputUrl: string;
entryUrl: string;
mode: GraphMode;
storageState?: string;
}): TopologyGraph {
const generatedAt = new Date().toISOString();
const crawlSessionId = `crawl_${randomUUID()}`;
const inputFingerprint = `input_${shortHash([
args.inputUrl,
args.mode,
"risk-policy-v0.2",
"normalization-v0.2",
"locator-scoring-v0.2",
])}`;
return {
schemaVersion: SCHEMA_VERSION,
metadata: {
artifactVersion: "topology-graph-v0.2",
inputUrl: args.inputUrl,
entryUrl: args.entryUrl,
generatedAt,
engineVersion: ENGINE_VERSION,
mode: args.mode,
crawlSessionId,
inputSeed: args.inputUrl,
inputFingerprint,
runtimeContext: buildRuntimeContext(args.storageState),
site: {
baseUrl: baseUrl(args.entryUrl),
entryUrl: args.entryUrl,
},
riskPolicyVersion: "risk-policy-v0.2",
normalizationRuleVersion: "normalization-v0.2",
locatorScoringVersion: "locator-scoring-v0.2",
},
states: [],
elements: [],
edges: [],
skippedActions: [],
failedObservations: [],
};
}
function baseUrl(value: string): string {
try {
const url = new URL(value);
if (url.protocol === "file:") return url.protocol;
return `${url.protocol}//${url.host}`;
} catch {
return "unknown";
}
}

View File

@@ -1,3 +1,24 @@
export const SCHEMA_VERSION = "0.2" as const;
export const ENGINE_VERSION = "0.2.0" as const;
export type GraphMode = "scan" | "explore";
export type EvidenceLevel = "observed" | "derived" | "verified" | "heuristic" | "external";
export type StateLifecycle = "observed" | "fingerprinted" | "verified" | "stale" | "deprecated" | "conflicted";
export interface EvidenceRecord {
level: EvidenceLevel;
source: string;
description: string;
}
export interface RuntimeContext {
browserName: "chromium";
viewport: { width: number; height: number };
locale: string;
timezoneId: string | null;
authContext: "anonymous" | "storage_state";
}
export type LocatorDescriptor =
| { kind: "role"; role: string; name?: string }
| { kind: "label"; label: string }
@@ -106,16 +127,69 @@ export interface ActionEdge {
}
export interface TopologyGraph {
schemaVersion: "0.1";
schemaVersion: typeof SCHEMA_VERSION;
metadata: {
artifactVersion: string;
inputUrl: string;
entryUrl: string;
generatedAt: string;
engineVersion: string;
mode: "scan" | "explore";
engineVersion: typeof ENGINE_VERSION;
mode: GraphMode;
crawlSessionId: string;
inputSeed: string;
inputFingerprint: string;
runtimeContext: RuntimeContext;
site: {
baseUrl: string;
entryUrl: string;
};
riskPolicyVersion: "risk-policy-v0.2";
normalizationRuleVersion: "normalization-v0.2";
locatorScoringVersion: "locator-scoring-v0.2";
};
states: StateNode[];
elements: ActionableElement[];
edges: ActionEdge[];
skippedActions: SkippedAction[];
failedObservations: FailedObservation[];
}
export interface SkippedAction {
skippedActionId: string;
stateId: string;
elementId: string;
actionType: ActionType;
riskLevel: "low" | "medium" | "high";
reason: string;
evidence: EvidenceRecord[];
createdAt: string;
}
export interface FailedObservation {
failedObservationId: string;
stateId: string;
elementId: string | null;
actionType: ActionType | null;
reason:
| "locator_not_found"
| "locator_not_unique"
| "element_hidden"
| "element_disabled"
| "element_obscured"
| "state_precondition_missing"
| "frame_inaccessible"
| "shadow_root_closed"
| "navigation_timeout"
| "network_error"
| "permission_denied"
| "auth_required"
| "captcha_or_challenge"
| "risk_policy_blocked"
| "effect_not_observed"
| "unexpected_state";
message: string;
evidence: EvidenceRecord[];
createdAt: string;
}
export interface RawActionableElement {
@@ -136,4 +210,3 @@ export interface SnapshotResult {
elements: ActionableElement[];
visibleText: string;
}