@dikshanty94/webnav 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +283 -0
- package/bin/webnav +18 -0
- package/dist/agent/loop.d.ts +59 -0
- package/dist/agent/loop.js +267 -0
- package/dist/agent/server.d.ts +60 -0
- package/dist/agent/server.js +254 -0
- package/dist/cli-help.d.ts +3 -0
- package/dist/cli-help.js +111 -0
- package/dist/cli-spec.d.ts +24 -0
- package/dist/cli-spec.js +460 -0
- package/dist/cli.d.ts +208 -0
- package/dist/cli.js +2558 -0
- package/dist/contract.d.ts +20 -0
- package/dist/contract.js +15 -0
- package/dist/creds.d.ts +46 -0
- package/dist/creds.js +126 -0
- package/dist/dashboard/server.d.ts +137 -0
- package/dist/dashboard/server.js +395 -0
- package/dist/dashboard/shell.d.ts +1 -0
- package/dist/dashboard/shell.js +1343 -0
- package/dist/explorer/analyse.d.ts +25 -0
- package/dist/explorer/analyse.js +39 -0
- package/dist/explorer/diff.d.ts +11 -0
- package/dist/explorer/diff.js +22 -0
- package/dist/explorer/draft.d.ts +81 -0
- package/dist/explorer/draft.js +1658 -0
- package/dist/explorer/fingerprint-page.d.ts +11 -0
- package/dist/explorer/fingerprint-page.js +19 -0
- package/dist/explorer/fingerprint.d.ts +14 -0
- package/dist/explorer/fingerprint.js +18 -0
- package/dist/explorer/infer.d.ts +85 -0
- package/dist/explorer/infer.js +370 -0
- package/dist/explorer/pattern-propose.d.ts +27 -0
- package/dist/explorer/pattern-propose.js +81 -0
- package/dist/explorer/patterns.d.ts +49 -0
- package/dist/explorer/patterns.js +290 -0
- package/dist/explorer/shadow.d.ts +3 -0
- package/dist/explorer/shadow.js +95 -0
- package/dist/graph/coverage.d.ts +33 -0
- package/dist/graph/coverage.js +155 -0
- package/dist/graph/edit.d.ts +47 -0
- package/dist/graph/edit.js +211 -0
- package/dist/graph/frontier.d.ts +24 -0
- package/dist/graph/frontier.js +95 -0
- package/dist/graph/seed.d.ts +23 -0
- package/dist/graph/seed.js +29 -0
- package/dist/graph/show.d.ts +8 -0
- package/dist/graph/show.js +6 -0
- package/dist/graph/teach.d.ts +8 -0
- package/dist/graph/teach.js +10 -0
- package/dist/hosted.d.ts +36 -0
- package/dist/hosted.js +69 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +4 -0
- package/dist/mapstore/record.d.ts +128 -0
- package/dist/mapstore/record.js +224 -0
- package/dist/mapstore/schema.sql +47 -0
- package/dist/mapstore/store.d.ts +108 -0
- package/dist/mapstore/store.js +399 -0
- package/dist/mapstore/types.d.ts +87 -0
- package/dist/mapstore/types.js +29 -0
- package/dist/mcp/server.d.ts +46 -0
- package/dist/mcp/server.js +163 -0
- package/dist/paths.d.ts +10 -0
- package/dist/paths.js +33 -0
- package/dist/playwright/adapter.d.ts +62 -0
- package/dist/playwright/adapter.js +145 -0
- package/dist/playwright/ax-adapter.d.ts +33 -0
- package/dist/playwright/ax-adapter.js +76 -0
- package/dist/playwright/capture.d.ts +2 -0
- package/dist/playwright/capture.js +10 -0
- package/dist/playwright/fingerprint.d.ts +40 -0
- package/dist/playwright/fingerprint.js +139 -0
- package/dist/playwright/profile-lock.d.ts +15 -0
- package/dist/playwright/profile-lock.js +51 -0
- package/dist/playwright/sessions.d.ts +72 -0
- package/dist/playwright/sessions.js +229 -0
- package/dist/playwright/snapshot.d.ts +10 -0
- package/dist/playwright/snapshot.js +52 -0
- package/dist/playwright/throttle.d.ts +14 -0
- package/dist/playwright/throttle.js +76 -0
- package/dist/playwright/video.d.ts +13 -0
- package/dist/playwright/video.js +32 -0
- package/dist/protocol.d.ts +49 -0
- package/dist/protocol.js +3 -0
- package/dist/recorder/agent-session.d.ts +59 -0
- package/dist/recorder/agent-session.js +230 -0
- package/dist/recorder/capture-loop.d.ts +19 -0
- package/dist/recorder/capture-loop.js +40 -0
- package/dist/recorder/coverage.d.ts +22 -0
- package/dist/recorder/coverage.js +49 -0
- package/dist/recorder/hover-probe.d.ts +35 -0
- package/dist/recorder/hover-probe.js +100 -0
- package/dist/recorder/ingest.d.ts +39 -0
- package/dist/recorder/ingest.js +138 -0
- package/dist/recorder/live-record.d.ts +41 -0
- package/dist/recorder/live-record.js +255 -0
- package/dist/recorder/live.d.ts +53 -0
- package/dist/recorder/live.js +400 -0
- package/dist/recorder/probe.d.ts +14 -0
- package/dist/recorder/probe.js +33 -0
- package/dist/recorder/replay.d.ts +60 -0
- package/dist/recorder/replay.js +401 -0
- package/dist/recorder/review.d.ts +73 -0
- package/dist/recorder/review.js +171 -0
- package/dist/recorder/snapshot-dom.d.ts +7 -0
- package/dist/recorder/snapshot-dom.js +25 -0
- package/dist/router/auth-status.d.ts +20 -0
- package/dist/router/auth-status.js +55 -0
- package/dist/router/browse.d.ts +108 -0
- package/dist/router/browse.js +214 -0
- package/dist/router/catalog.d.ts +18 -0
- package/dist/router/catalog.js +6 -0
- package/dist/router/extract-content.d.ts +13 -0
- package/dist/router/extract-content.js +87 -0
- package/dist/router/live-extension-browser.d.ts +35 -0
- package/dist/router/live-extension-browser.js +175 -0
- package/dist/router/path.d.ts +7 -0
- package/dist/router/path.js +55 -0
- package/dist/router/read.d.ts +26 -0
- package/dist/router/read.js +30 -0
- package/dist/router/readiness.d.ts +9 -0
- package/dist/router/readiness.js +55 -0
- package/dist/router/replay.d.ts +14 -0
- package/dist/router/replay.js +23 -0
- package/dist/router/resolve.d.ts +21 -0
- package/dist/router/resolve.js +33 -0
- package/dist/router/search-live.d.ts +21 -0
- package/dist/router/search-live.js +98 -0
- package/dist/router/search-providers.d.ts +5 -0
- package/dist/router/search-providers.js +12 -0
- package/dist/router/search.d.ts +6 -0
- package/dist/router/search.js +62 -0
- package/dist/router/suite.d.ts +69 -0
- package/dist/router/suite.js +226 -0
- package/dist/router/tokens.d.ts +13 -0
- package/dist/router/tokens.js +26 -0
- package/dist/router/walk-live.d.ts +57 -0
- package/dist/router/walk-live.js +308 -0
- package/dist/router/walk-session.d.ts +49 -0
- package/dist/router/walk-session.js +90 -0
- package/dist/router/walk.d.ts +48 -0
- package/dist/router/walk.js +384 -0
- package/package.json +59 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { adaptAXTreeWithRefs } from '../playwright/ax-adapter.js';
|
|
2
|
+
import { parseSnapshot, findByRoleAndName } from '../playwright/snapshot.js';
|
|
3
|
+
/**
|
|
4
|
+
* WalkBrowser over an AgentChannel — the extension-driven counterpart to
|
|
5
|
+
* makeLiveWalkBrowser (walk-live.ts), so walkRoute runs UNMODIFIED against a live
|
|
6
|
+
* tab instead of a playwright-cli session.
|
|
7
|
+
*
|
|
8
|
+
* REF FORMAT NOTE: adaptAXTreeWithRefs assigns each node a synthetic `bN` ref
|
|
9
|
+
* (settled format, tests/playwright/ax-adapter.test.ts asserts /^b\d+$/). But
|
|
10
|
+
* parseSnapshot's REF_RE only matches `e\d+` (playwright-cli's own format) — a
|
|
11
|
+
* literal `bN` token would round-trip as SnapNode.ref === null, and every walk-side
|
|
12
|
+
* consumer (resolveStep, resolveByFingerprint) filters on `n.ref` truthy, so the
|
|
13
|
+
* walk could never resolve anything. Fix: re-key each node's ref to a sequential
|
|
14
|
+
* `eN` "walk ref" when serializing, and cache walkRef -> real {nodeId,
|
|
15
|
+
* backendDOMNodeId} for act() to dispatch against. This is entirely internal to
|
|
16
|
+
* this file — ax-adapter.ts / snapshot.ts are untouched.
|
|
17
|
+
*/
|
|
18
|
+
export function makeLiveExtensionBrowser(channel, inputs) {
|
|
19
|
+
let calls = 0;
|
|
20
|
+
let lastSnapshot = '';
|
|
21
|
+
let refMap = new Map();
|
|
22
|
+
// --- recording state (all internal; snapshot()/act()/goto() behavior is unchanged) ---
|
|
23
|
+
// The raw AX + eN->bN map of the CURRENT snapshot. We must keep the RAW ax (not the
|
|
24
|
+
// eN-rekeyed SnapNode[]) because ingestAX re-adapts fromAX itself and looks up the
|
|
25
|
+
// clicked node by its bN ref — the same ref adaptAXTreeWithRefs assigned here.
|
|
26
|
+
let lastAX = [];
|
|
27
|
+
let lastUrl = '';
|
|
28
|
+
let eToB = new Map();
|
|
29
|
+
const steps = [];
|
|
30
|
+
// A step whose action has fired but whose post-action AX isn't observed yet. The next
|
|
31
|
+
// refresh() completes it (toAX = that snapshot); getRecordedSteps() finalizes any leftover.
|
|
32
|
+
let pending = null;
|
|
33
|
+
// URL is fetched only when a step actually needs it (begin/complete), never on every
|
|
34
|
+
// bare snapshot — avoids an extra channel round-trip per agent read. Falls back to the
|
|
35
|
+
// last known url when the channel can't report one (unit fake / test channel).
|
|
36
|
+
async function url() {
|
|
37
|
+
return channel.currentUrl ? await channel.currentUrl() : lastUrl;
|
|
38
|
+
}
|
|
39
|
+
async function completePending(toAX) {
|
|
40
|
+
if (!pending)
|
|
41
|
+
return;
|
|
42
|
+
const p = pending;
|
|
43
|
+
pending = null; // clear before await so a re-entrant begin can't double-close
|
|
44
|
+
// tMs = when this step's post-action snapshot landed → the ledger shows real per-step
|
|
45
|
+
// times instead of the single flush-time. (Browser runtime: Date.now() is fine here.)
|
|
46
|
+
steps.push({ fromUrl: p.fromUrl, fromAX: p.fromAX, toUrl: await url(), toAX, clickedRef: p.clickedRef, tMs: Date.now() });
|
|
47
|
+
}
|
|
48
|
+
// Open a step for a just-fired action. fromAX/fromUrl = the last observed snapshot;
|
|
49
|
+
// clickedRef = the bN in that fromAX for the eN acted on (null for a pure goto). If an
|
|
50
|
+
// action was already pending (no snapshot since), close it first using the current AX
|
|
51
|
+
// so we never drop a step or mis-pair two actions.
|
|
52
|
+
async function beginStep(clickedRef) {
|
|
53
|
+
if (pending)
|
|
54
|
+
await completePending(lastAX);
|
|
55
|
+
pending = { fromUrl: await url(), fromAX: lastAX, clickedRef };
|
|
56
|
+
}
|
|
57
|
+
async function refresh() {
|
|
58
|
+
const ax = await channel.getAX();
|
|
59
|
+
calls++;
|
|
60
|
+
// A prior action was awaiting its landing — THIS snapshot is it.
|
|
61
|
+
await completePending(ax);
|
|
62
|
+
const { nodes, refMap: bRefMap } = adaptAXTreeWithRefs(ax);
|
|
63
|
+
const walkRefMap = new Map();
|
|
64
|
+
const walkEToB = new Map();
|
|
65
|
+
const lines = [];
|
|
66
|
+
nodes.forEach((n, i) => {
|
|
67
|
+
const walkRef = 'e' + (i + 1);
|
|
68
|
+
const real = bRefMap.get(n.ref); // every emitted node has a ref (ax-adapter invariant)
|
|
69
|
+
walkRefMap.set(walkRef, real);
|
|
70
|
+
walkEToB.set(walkRef, n.ref); // retain eN -> bN so a recorded click maps to fromAX's bN
|
|
71
|
+
const raw = n.ref ? n.raw.replace(`[ref=${n.ref}]`, `[ref=${walkRef}]`) : n.raw;
|
|
72
|
+
lines.push(' '.repeat(n.depth) + raw);
|
|
73
|
+
if (n.url)
|
|
74
|
+
lines.push(' '.repeat(n.depth + 1) + '/url: ' + n.url);
|
|
75
|
+
});
|
|
76
|
+
refMap = walkRefMap;
|
|
77
|
+
eToB = walkEToB;
|
|
78
|
+
lastAX = ax;
|
|
79
|
+
lastSnapshot = lines.join('\n');
|
|
80
|
+
return lastSnapshot;
|
|
81
|
+
}
|
|
82
|
+
// eN (what the walk/agent acts on) -> bN (the ref indexing the RAW fromAX ingestAX
|
|
83
|
+
// re-adapts). THIS is the pairing trap: recording the eN would make ingestAX's
|
|
84
|
+
// fromNodes.find(n => n.ref === clickedRef) miss (adapted nodes carry bN, not eN).
|
|
85
|
+
function clickedBRef(ref) {
|
|
86
|
+
return eToB.get(ref) ?? null;
|
|
87
|
+
}
|
|
88
|
+
// Resolve a textbox ref by accessible name against the CACHED snapshot (mirrors
|
|
89
|
+
// makeLiveWalkBrowser's fieldRef), refreshing once on miss.
|
|
90
|
+
async function fieldRef(name) {
|
|
91
|
+
let nodes = parseSnapshot(lastSnapshot);
|
|
92
|
+
let node = findByRoleAndName(nodes, 'textbox', name);
|
|
93
|
+
if (!node || !node.ref) {
|
|
94
|
+
await refresh();
|
|
95
|
+
nodes = parseSnapshot(lastSnapshot);
|
|
96
|
+
node = findByRoleAndName(nodes, 'textbox', name);
|
|
97
|
+
}
|
|
98
|
+
if (!node || !node.ref)
|
|
99
|
+
throw new Error('live-extension-browser: could not resolve textbox "' + name + '"');
|
|
100
|
+
return node.ref;
|
|
101
|
+
}
|
|
102
|
+
function nodeIdFor(ref) {
|
|
103
|
+
const entry = refMap.get(ref);
|
|
104
|
+
if (!entry)
|
|
105
|
+
throw new Error('live-extension-browser: ref "' + ref + '" not found in the current snapshot');
|
|
106
|
+
return entry.nodeId;
|
|
107
|
+
}
|
|
108
|
+
async function dispatchClick(ref) {
|
|
109
|
+
await channel.dispatch({ kind: 'click', nodeId: nodeIdFor(ref) });
|
|
110
|
+
calls++;
|
|
111
|
+
}
|
|
112
|
+
async function dispatchType(ref, text) {
|
|
113
|
+
await channel.dispatch({ kind: 'type', nodeId: nodeIdFor(ref), text });
|
|
114
|
+
calls++;
|
|
115
|
+
}
|
|
116
|
+
const browser = {
|
|
117
|
+
snapshot: () => refresh(),
|
|
118
|
+
callCount: () => calls,
|
|
119
|
+
waitMs: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
120
|
+
act: async (ref, inputSlot) => {
|
|
121
|
+
// ponytail: commit-gating (hard rule #2) is walkRoute's job, not the
|
|
122
|
+
// browser's — act() doesn't see the edge, so it just resolves+dispatches.
|
|
123
|
+
// RECORD the SEMANTIC clicked node (ref = the button), NOT the credential/shipping
|
|
124
|
+
// pre-fills below. Resolve its bN NOW — a fieldRef miss can refresh() and rebuild
|
|
125
|
+
// eToB, which would invalidate this ref's mapping.
|
|
126
|
+
await beginStep(clickedBRef(ref));
|
|
127
|
+
if (inputSlot === 'credentials') {
|
|
128
|
+
await dispatchType(await fieldRef('Username'), inputs.username);
|
|
129
|
+
await dispatchType(await fieldRef('Password'), inputs.password);
|
|
130
|
+
await dispatchClick(ref);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (inputSlot === 'shipping') {
|
|
134
|
+
await dispatchType(await fieldRef('First Name'), inputs.firstName ?? 'A');
|
|
135
|
+
await dispatchType(await fieldRef('Last Name'), inputs.lastName ?? 'B');
|
|
136
|
+
await dispatchType(await fieldRef('Zip/Postal Code'), inputs.zip);
|
|
137
|
+
await dispatchClick(ref);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
await dispatchClick(ref);
|
|
141
|
+
},
|
|
142
|
+
// A standalone type (the agent's `type` tool). Records the typed FIELD's identity
|
|
143
|
+
// (role+name+fp), NEVER the text — the step carries no value (secret-safe).
|
|
144
|
+
typeText: async (ref, text) => {
|
|
145
|
+
await beginStep(clickedBRef(ref));
|
|
146
|
+
await dispatchType(ref, text);
|
|
147
|
+
},
|
|
148
|
+
getRecordedSteps: () => {
|
|
149
|
+
// Finalize a step whose landing was never snapshotted (run ended right after an
|
|
150
|
+
// action): use its own fromAX as toAX. didNavigate('','') → false; a degenerate but
|
|
151
|
+
// honest same-page effect, better than dropping the click entirely.
|
|
152
|
+
if (pending) {
|
|
153
|
+
steps.push({ fromUrl: pending.fromUrl, fromAX: pending.fromAX, toUrl: pending.fromUrl, toAX: pending.fromAX, clickedRef: pending.clickedRef, tMs: Date.now() });
|
|
154
|
+
pending = null;
|
|
155
|
+
}
|
|
156
|
+
return steps;
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
if (channel.goto) {
|
|
160
|
+
// A pure navigation: clickedRef = null. beginStep captures the current page as fromAX;
|
|
161
|
+
// the next snapshot (or getRecordedSteps) supplies toAX.
|
|
162
|
+
browser.goto = async (url) => { await beginStep(null); await channel.goto(url); };
|
|
163
|
+
}
|
|
164
|
+
if (channel.scroll) {
|
|
165
|
+
// A pure in-page reveal (no navigation, no click) — records NO step. The next
|
|
166
|
+
// snapshot() naturally picks up whatever scrolled into view.
|
|
167
|
+
browser.scroll = (dy) => channel.scroll(dy);
|
|
168
|
+
}
|
|
169
|
+
if (channel.currentUrl) {
|
|
170
|
+
browser.currentUrl = () => channel.currentUrl();
|
|
171
|
+
}
|
|
172
|
+
// ponytail: no reopenFresh — the extension can't reopen a fresh browser tab;
|
|
173
|
+
// walkRoute already treats an absent reopenFresh as "skip the wall retry".
|
|
174
|
+
return browser;
|
|
175
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { MapStore } from '../mapstore/store.js';
|
|
2
|
+
/**
|
|
3
|
+
* Weighted shortest path (Dijkstra) over graph edges from startId to goalId.
|
|
4
|
+
* Returns the ordered state-id list (inclusive of both ends), or null if the
|
|
5
|
+
* goal is unreachable. Pure: reads the store only. Cycles terminate (visited set).
|
|
6
|
+
*/
|
|
7
|
+
export declare function findPath(store: MapStore, startId: string, goalId: string): string[] | null;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/** Weight for an edge: lower = preferred. Cheap edges win; cost is static
|
|
2
|
+
* declared data (playwright-cli call count). Usage-learned weighting
|
|
3
|
+
* (reliability/confidence) is a hosted-service concern, not stored here. */
|
|
4
|
+
function edgeWeight(e) {
|
|
5
|
+
return 1 + e.cost;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Weighted shortest path (Dijkstra) over graph edges from startId to goalId.
|
|
9
|
+
* Returns the ordered state-id list (inclusive of both ends), or null if the
|
|
10
|
+
* goal is unreachable. Pure: reads the store only. Cycles terminate (visited set).
|
|
11
|
+
*/
|
|
12
|
+
export function findPath(store, startId, goalId) {
|
|
13
|
+
if (startId === goalId)
|
|
14
|
+
return [startId];
|
|
15
|
+
const dist = new Map([[startId, 0]]);
|
|
16
|
+
const prev = new Map();
|
|
17
|
+
const visited = new Set();
|
|
18
|
+
const frontier = new Set([startId]);
|
|
19
|
+
while (frontier.size > 0) {
|
|
20
|
+
let cur = '';
|
|
21
|
+
let best = Infinity;
|
|
22
|
+
for (const id of frontier) {
|
|
23
|
+
const d = dist.get(id) ?? Infinity;
|
|
24
|
+
if (d < best) {
|
|
25
|
+
best = d;
|
|
26
|
+
cur = id;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
frontier.delete(cur);
|
|
30
|
+
if (cur === goalId)
|
|
31
|
+
break;
|
|
32
|
+
if (visited.has(cur))
|
|
33
|
+
continue;
|
|
34
|
+
visited.add(cur);
|
|
35
|
+
for (const e of store.edgesFrom(cur)) {
|
|
36
|
+
if (visited.has(e.toState))
|
|
37
|
+
continue;
|
|
38
|
+
const nd = (dist.get(cur) ?? Infinity) + edgeWeight(e);
|
|
39
|
+
if (nd < (dist.get(e.toState) ?? Infinity)) {
|
|
40
|
+
dist.set(e.toState, nd);
|
|
41
|
+
prev.set(e.toState, cur);
|
|
42
|
+
}
|
|
43
|
+
frontier.add(e.toState);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (!dist.has(goalId))
|
|
47
|
+
return null;
|
|
48
|
+
const path = [];
|
|
49
|
+
let node = goalId;
|
|
50
|
+
while (node !== undefined) {
|
|
51
|
+
path.unshift(node);
|
|
52
|
+
node = prev.get(node);
|
|
53
|
+
}
|
|
54
|
+
return path[0] === startId ? path : null;
|
|
55
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type ContentEvidence } from './extract-content.js';
|
|
2
|
+
export type ReadResponse = {
|
|
3
|
+
status: 'done';
|
|
4
|
+
url: string;
|
|
5
|
+
content: ContentEvidence;
|
|
6
|
+
raw?: string;
|
|
7
|
+
} | {
|
|
8
|
+
status: 'blocked';
|
|
9
|
+
url: string;
|
|
10
|
+
reason: string;
|
|
11
|
+
} | {
|
|
12
|
+
status: 'failed';
|
|
13
|
+
url: string;
|
|
14
|
+
reason: string;
|
|
15
|
+
};
|
|
16
|
+
export interface ReadOpts {
|
|
17
|
+
raw?: boolean;
|
|
18
|
+
queryTerms?: string[];
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Open a URL and return its DISTILLED content (or the raw snapshot with --raw).
|
|
22
|
+
* Zero-LLM: readiness + extraction are deterministic. On a loading/interstitial
|
|
23
|
+
* page we report `blocked` and do NOT evade (principle: detect, never bypass).
|
|
24
|
+
* `fetchSnapshot` is injected so this is unit-testable without a browser.
|
|
25
|
+
*/
|
|
26
|
+
export declare function readUrl(url: string, fetchSnapshot: (url: string) => Promise<string>, opts?: ReadOpts): Promise<ReadResponse>;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { classifyReadiness } from './readiness.js';
|
|
2
|
+
import { extractContent } from './extract-content.js';
|
|
3
|
+
/**
|
|
4
|
+
* Open a URL and return its DISTILLED content (or the raw snapshot with --raw).
|
|
5
|
+
* Zero-LLM: readiness + extraction are deterministic. On a loading/interstitial
|
|
6
|
+
* page we report `blocked` and do NOT evade (principle: detect, never bypass).
|
|
7
|
+
* `fetchSnapshot` is injected so this is unit-testable without a browser.
|
|
8
|
+
*/
|
|
9
|
+
export async function readUrl(url, fetchSnapshot, opts = {}) {
|
|
10
|
+
let snapshot;
|
|
11
|
+
try {
|
|
12
|
+
snapshot = await fetchSnapshot(url);
|
|
13
|
+
}
|
|
14
|
+
catch (e) {
|
|
15
|
+
return { status: 'failed', url, reason: String(e) };
|
|
16
|
+
}
|
|
17
|
+
const readiness = classifyReadiness(snapshot);
|
|
18
|
+
if (readiness !== 'ready') {
|
|
19
|
+
return {
|
|
20
|
+
status: 'blocked',
|
|
21
|
+
url,
|
|
22
|
+
reason: `page not ready (${readiness}); webnav does not evade walls`,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
const content = extractContent(snapshot, url, opts.queryTerms);
|
|
26
|
+
if (opts.raw) {
|
|
27
|
+
return { status: 'done', url, content, raw: snapshot };
|
|
28
|
+
}
|
|
29
|
+
return { status: 'done', url, content };
|
|
30
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export type Readiness = 'ready' | 'loading' | 'interstitial';
|
|
2
|
+
export interface ReadinessOpts {
|
|
3
|
+
minNodes?: number;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Classify whether a snapshot represents a ready page, an unfinished render to
|
|
7
|
+
* retry, or a hard interstitial/bot-wall to escalate. Pure + deterministic.
|
|
8
|
+
*/
|
|
9
|
+
export declare function classifyReadiness(snapshotYaml: string, opts?: ReadinessOpts): Readiness;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { parseSnapshot } from '../playwright/snapshot.js';
|
|
2
|
+
// Known bot-wall / verification phrases. These are matched against a lowercased
|
|
3
|
+
// blob of all node names to DETECT a hard interstitial so the caller can
|
|
4
|
+
// ESCALATE — never to evade it. Each entry is specific enough that ordinary
|
|
5
|
+
// prose ("human resources") does not trip it: we match phrases, not bare words.
|
|
6
|
+
const INTERSTITIAL_PATTERNS = [
|
|
7
|
+
/just a moment/,
|
|
8
|
+
/checking your browser/,
|
|
9
|
+
/verify you are (?:a )?human/,
|
|
10
|
+
/cloudflare/,
|
|
11
|
+
/please enable javascript and cookies/,
|
|
12
|
+
/attention required/,
|
|
13
|
+
/ddos protection/,
|
|
14
|
+
/are you a robot/,
|
|
15
|
+
/complete the (?:captcha|security check)/,
|
|
16
|
+
];
|
|
17
|
+
// Roles that carry real page content. A finished render has at least one such
|
|
18
|
+
// node with a non-empty name; a nav-only/empty shell has none.
|
|
19
|
+
const CONTENT_ROLES = new Set([
|
|
20
|
+
'heading',
|
|
21
|
+
'paragraph',
|
|
22
|
+
'link',
|
|
23
|
+
'button',
|
|
24
|
+
'listitem',
|
|
25
|
+
'article',
|
|
26
|
+
'cell',
|
|
27
|
+
'textbox',
|
|
28
|
+
'img',
|
|
29
|
+
]);
|
|
30
|
+
const DEFAULT_MIN_NODES = 8;
|
|
31
|
+
/**
|
|
32
|
+
* Classify whether a snapshot represents a ready page, an unfinished render to
|
|
33
|
+
* retry, or a hard interstitial/bot-wall to escalate. Pure + deterministic.
|
|
34
|
+
*/
|
|
35
|
+
export function classifyReadiness(snapshotYaml, opts) {
|
|
36
|
+
const minNodes = opts?.minNodes ?? DEFAULT_MIN_NODES;
|
|
37
|
+
const nodes = parseSnapshot(snapshotYaml);
|
|
38
|
+
// 1. Lowercased blob of all node names.
|
|
39
|
+
const blob = nodes
|
|
40
|
+
.map((n) => n.name ?? '')
|
|
41
|
+
.join(' ')
|
|
42
|
+
.toLowerCase();
|
|
43
|
+
// 2. Interstitial FIRST: a verification screen can be small AND match these.
|
|
44
|
+
if (INTERSTITIAL_PATTERNS.some((re) => re.test(blob))) {
|
|
45
|
+
return 'interstitial';
|
|
46
|
+
}
|
|
47
|
+
// 3. Loading: too sparse to be a finished render — fewer than minNodes total,
|
|
48
|
+
// OR no content-ish node with a non-empty name (nav-only/empty shell).
|
|
49
|
+
const hasContent = nodes.some((n) => CONTENT_ROLES.has(n.role.toLowerCase()) && (n.name ?? '').trim() !== '');
|
|
50
|
+
if (nodes.length < minNodes || !hasContent) {
|
|
51
|
+
return 'loading';
|
|
52
|
+
}
|
|
53
|
+
// 4. Ready otherwise.
|
|
54
|
+
return 'ready';
|
|
55
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Edge } from '../mapstore/types.js';
|
|
2
|
+
import type { SnapNode } from '../playwright/snapshot.js';
|
|
3
|
+
export type ReplayResult = {
|
|
4
|
+
status: 'ok';
|
|
5
|
+
ref: string;
|
|
6
|
+
repaired: boolean;
|
|
7
|
+
} | {
|
|
8
|
+
status: 'escalate';
|
|
9
|
+
} | {
|
|
10
|
+
status: 'needs-classify';
|
|
11
|
+
} | {
|
|
12
|
+
status: 'blocked-commit';
|
|
13
|
+
};
|
|
14
|
+
export declare function replayStep(edge: Edge, nodes: SnapNode[]): ReplayResult;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { resolveStep } from './resolve.js';
|
|
2
|
+
import { resolveByFingerprint } from '../playwright/fingerprint.js';
|
|
3
|
+
export function replayStep(edge, nodes) {
|
|
4
|
+
if (edge.kind === 'commit-point')
|
|
5
|
+
return { status: 'blocked-commit' };
|
|
6
|
+
if (edge.kind === 'unclassified')
|
|
7
|
+
return { status: 'needs-classify' };
|
|
8
|
+
// Deterministic resolve, zero-LLM. When the edge carries a durable elementFp
|
|
9
|
+
// (role+name+content anchor), use the layered fingerprint resolver — this
|
|
10
|
+
// disambiguates heading-vs-button and identical siblings (e.g. 50 icon buttons in
|
|
11
|
+
// a table row) that name-only matching can't. Legacy edges (no elementFp) fall back
|
|
12
|
+
// to resolveStep's name match (+ selectorCache self-heal) — unchanged behavior.
|
|
13
|
+
if (edge.elementFp) {
|
|
14
|
+
const ref = resolveByFingerprint(edge.elementFp, nodes);
|
|
15
|
+
if (ref)
|
|
16
|
+
return { status: 'ok', ref, repaired: false };
|
|
17
|
+
return { status: 'escalate' };
|
|
18
|
+
}
|
|
19
|
+
const ref = resolveStep(edge.semanticStep, nodes, edge.selectorCache);
|
|
20
|
+
if (ref)
|
|
21
|
+
return { status: 'ok', ref, repaired: !!edge.selectorCache };
|
|
22
|
+
return { status: 'escalate' };
|
|
23
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { SnapNode } from '../playwright/snapshot.js';
|
|
2
|
+
/**
|
|
3
|
+
* Deterministically map a semantic step to a live element ref by matching a
|
|
4
|
+
* NAME against snapshot node names. Returns null on no/ambiguous match — the
|
|
5
|
+
* caller then escalates to the agent (needs-navigation). No LLM.
|
|
6
|
+
*
|
|
7
|
+
* Two name sources, tried in order:
|
|
8
|
+
* 1. the step's own quoted name (`click "Insights"` → "Insights").
|
|
9
|
+
* 2. `cachedSelector` — the SELF-HEAL repair: a name an agent's ref resolved to
|
|
10
|
+
* on a PRIOR walk, written back to the edge when the step's own name failed
|
|
11
|
+
* (drift / icon-only / renamed element). Lets a once-broken step resolve
|
|
12
|
+
* deterministically again without re-asking the agent (principle #3).
|
|
13
|
+
* The step's own name wins when it matches; the cache is the fallback.
|
|
14
|
+
*
|
|
15
|
+
* Strict by design: returns a ref ONLY when exactly one node carries the name.
|
|
16
|
+
* If several do (e.g. 6 equivalent "Add to cart" buttons, or a product label
|
|
17
|
+
* shared by a link + its image), it returns null and the caller escalates —
|
|
18
|
+
* webnav never guesses among genuinely-equivalent targets; that judgment is the
|
|
19
|
+
* agent's (principle #5a). This is correct behavior, not a limitation.
|
|
20
|
+
*/
|
|
21
|
+
export declare function resolveStep(semanticStep: string, nodes: SnapNode[], cachedSelector?: string | null): string | null;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Pull the quoted name out of a semantic step like `click "Insights"`.
|
|
2
|
+
function quotedName(semanticStep) {
|
|
3
|
+
const m = semanticStep.match(/"([^"]+)"/);
|
|
4
|
+
return m ? m[1] : null;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Deterministically map a semantic step to a live element ref by matching a
|
|
8
|
+
* NAME against snapshot node names. Returns null on no/ambiguous match — the
|
|
9
|
+
* caller then escalates to the agent (needs-navigation). No LLM.
|
|
10
|
+
*
|
|
11
|
+
* Two name sources, tried in order:
|
|
12
|
+
* 1. the step's own quoted name (`click "Insights"` → "Insights").
|
|
13
|
+
* 2. `cachedSelector` — the SELF-HEAL repair: a name an agent's ref resolved to
|
|
14
|
+
* on a PRIOR walk, written back to the edge when the step's own name failed
|
|
15
|
+
* (drift / icon-only / renamed element). Lets a once-broken step resolve
|
|
16
|
+
* deterministically again without re-asking the agent (principle #3).
|
|
17
|
+
* The step's own name wins when it matches; the cache is the fallback.
|
|
18
|
+
*
|
|
19
|
+
* Strict by design: returns a ref ONLY when exactly one node carries the name.
|
|
20
|
+
* If several do (e.g. 6 equivalent "Add to cart" buttons, or a product label
|
|
21
|
+
* shared by a link + its image), it returns null and the caller escalates —
|
|
22
|
+
* webnav never guesses among genuinely-equivalent targets; that judgment is the
|
|
23
|
+
* agent's (principle #5a). This is correct behavior, not a limitation.
|
|
24
|
+
*/
|
|
25
|
+
export function resolveStep(semanticStep, nodes, cachedSelector) {
|
|
26
|
+
const tryName = (name) => {
|
|
27
|
+
if (!name)
|
|
28
|
+
return null;
|
|
29
|
+
const hits = nodes.filter((n) => n.ref && n.name === name);
|
|
30
|
+
return hits.length === 1 ? hits[0].ref : null;
|
|
31
|
+
};
|
|
32
|
+
return tryName(quotedName(semanticStep)) ?? tryName(cachedSelector ?? null);
|
|
33
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type ContentEvidence } from './extract-content.js';
|
|
2
|
+
import { type TokenSavings } from './tokens.js';
|
|
3
|
+
export interface SearchGatherResult {
|
|
4
|
+
query: string;
|
|
5
|
+
results: {
|
|
6
|
+
title: string;
|
|
7
|
+
url: string;
|
|
8
|
+
}[];
|
|
9
|
+
evidence: ContentEvidence[];
|
|
10
|
+
blocked: string[];
|
|
11
|
+
providers: {
|
|
12
|
+
id: string;
|
|
13
|
+
results: number;
|
|
14
|
+
blocked: boolean;
|
|
15
|
+
}[];
|
|
16
|
+
cost: {
|
|
17
|
+
playwright_calls: number;
|
|
18
|
+
savings: TokenSavings;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export declare function runSearchLive(query: string, topN?: number): Promise<SearchGatherResult>;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { PlaywrightAdapter } from '../playwright/adapter.js';
|
|
2
|
+
import { parseSearchResults } from './search.js';
|
|
3
|
+
import { classifyReadiness } from './readiness.js';
|
|
4
|
+
import { extractContent } from './extract-content.js';
|
|
5
|
+
import { tokenSavings } from './tokens.js';
|
|
6
|
+
import { SEARCH_PROVIDERS } from './search-providers.js';
|
|
7
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
8
|
+
// Snapshot with a readiness retry: re-snapshot up to 3 times while 'loading'.
|
|
9
|
+
async function readySnapshot(adapter) {
|
|
10
|
+
let yaml = await adapter.snapshot();
|
|
11
|
+
let readiness = classifyReadiness(yaml);
|
|
12
|
+
for (let attempt = 1; attempt < 3 && readiness === 'loading'; attempt++) {
|
|
13
|
+
await sleep(1500);
|
|
14
|
+
yaml = await adapter.snapshot();
|
|
15
|
+
readiness = classifyReadiness(yaml);
|
|
16
|
+
}
|
|
17
|
+
return { yaml, readiness };
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Gather results from ONE provider: open its search url, then re-snapshot until
|
|
21
|
+
* results actually parse out (the search shell renders BEFORE the result list —
|
|
22
|
+
* a render race) or a bounded number of attempts. An interstitial search page is
|
|
23
|
+
* recorded as blocked (DETECT + escalate, never evade). Caps to topN.
|
|
24
|
+
*/
|
|
25
|
+
async function gatherFromProvider(adapter, provider, query, topN) {
|
|
26
|
+
await adapter.open(provider.searchUrl(query));
|
|
27
|
+
let resultsYaml = '';
|
|
28
|
+
let searchReadiness = 'loading';
|
|
29
|
+
let results = [];
|
|
30
|
+
for (let attempt = 0; attempt < 4; attempt++) {
|
|
31
|
+
const snap = await readySnapshot(adapter);
|
|
32
|
+
resultsYaml = snap.yaml;
|
|
33
|
+
searchReadiness = snap.readiness;
|
|
34
|
+
if (searchReadiness === 'interstitial')
|
|
35
|
+
break;
|
|
36
|
+
results = parseSearchResults(resultsYaml, topN);
|
|
37
|
+
if (results.length > 0)
|
|
38
|
+
break; // results rendered — proceed
|
|
39
|
+
await sleep(1500); // shell-only render — wait for results
|
|
40
|
+
}
|
|
41
|
+
if (searchReadiness === 'interstitial') {
|
|
42
|
+
// The provider's search page is bot-walled — record + skip. Never evade.
|
|
43
|
+
return { results: [], blocked: true, rawChars: resultsYaml.length };
|
|
44
|
+
}
|
|
45
|
+
// results already parsed in the loop above (possibly empty for a thin index).
|
|
46
|
+
return { results, blocked: false, rawChars: resultsYaml.length };
|
|
47
|
+
}
|
|
48
|
+
export async function runSearchLive(query, topN = 3) {
|
|
49
|
+
const adapter = new PlaywrightAdapter('search-' + Date.now());
|
|
50
|
+
const evidence = [];
|
|
51
|
+
const blocked = [];
|
|
52
|
+
const providers = [];
|
|
53
|
+
let rawChars = 0;
|
|
54
|
+
let results = [];
|
|
55
|
+
try {
|
|
56
|
+
// 1. Fan out across providers sequentially (one shared adapter). A blocked
|
|
57
|
+
// provider is recorded and skipped; the others still contribute (the whole
|
|
58
|
+
// point — resilience + broader coverage).
|
|
59
|
+
const perProvider = [];
|
|
60
|
+
for (const provider of SEARCH_PROVIDERS) {
|
|
61
|
+
const gather = await gatherFromProvider(adapter, provider, query, topN);
|
|
62
|
+
rawChars += gather.rawChars;
|
|
63
|
+
providers.push({ id: provider.id, results: gather.results.length, blocked: gather.blocked });
|
|
64
|
+
perProvider.push({ provider, gather });
|
|
65
|
+
}
|
|
66
|
+
// 2. MERGE + DEDUPE by url, preserving first-seen order (Marginalia first).
|
|
67
|
+
// Cap the merged list to topN*2 so both providers can contribute.
|
|
68
|
+
const seen = new Set();
|
|
69
|
+
for (const { gather } of perProvider) {
|
|
70
|
+
for (const r of gather.results) {
|
|
71
|
+
if (seen.has(r.url))
|
|
72
|
+
continue;
|
|
73
|
+
seen.add(r.url);
|
|
74
|
+
results.push(r);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
results = results.slice(0, topN * 2);
|
|
78
|
+
// 3. Visit each merged result url, readiness-retry, interstitial -> blocked,
|
|
79
|
+
// else extract content.
|
|
80
|
+
const queryTerms = query.toLowerCase().split(/\s+/);
|
|
81
|
+
for (const result of results) {
|
|
82
|
+
await adapter.goto(result.url);
|
|
83
|
+
const { yaml, readiness } = await readySnapshot(adapter);
|
|
84
|
+
if (readiness === 'interstitial') {
|
|
85
|
+
// DETECT + escalate, never evade.
|
|
86
|
+
blocked.push(result.url);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
rawChars += yaml.length;
|
|
90
|
+
evidence.push(extractContent(yaml, result.url, queryTerms));
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
finally {
|
|
94
|
+
await adapter.close();
|
|
95
|
+
}
|
|
96
|
+
const savings = tokenSavings(rawChars, JSON.stringify({ results, evidence }));
|
|
97
|
+
return { query, results, evidence, blocked, providers, cost: { playwright_calls: adapter.callCount, savings } };
|
|
98
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Browser-navigable search engines webnav fans out across. Live-probed: only
|
|
2
|
+
// these TWO work cleanly without bot-walling a real browser AND have DIFFERENT
|
|
3
|
+
// indexes (Marginalia: broad ~40 results; Wiby: curated/older-web ~8). The
|
|
4
|
+
// good mainstream engines (Google/Bing/Brave/DuckDuckGo) bot-wall real browsers;
|
|
5
|
+
// others probed (mojeek/ecosia/searx/stract/4get) return empty shells — NOT
|
|
6
|
+
// registered. Pure data + a URL builder; no logic.
|
|
7
|
+
// Ordered list — Marginalia first (broader index), then Wiby. Merge preserves
|
|
8
|
+
// this first-seen order.
|
|
9
|
+
export const SEARCH_PROVIDERS = [
|
|
10
|
+
{ id: 'marginalia', searchUrl: (q) => 'https://search.marginalia.nu/search?query=' + encodeURIComponent(q) },
|
|
11
|
+
{ id: 'wiby', searchUrl: (q) => 'https://wiby.me/?q=' + encodeURIComponent(q) },
|
|
12
|
+
];
|