pi-lean-portal 0.1.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 +661 -0
- package/README.md +608 -0
- package/backends/chromium/index.ts +50 -0
- package/backends/chromium-py/bridge.py +67 -0
- package/backends/firefox/index.ts +60 -0
- package/backends/firefox-py/bridge.py +64 -0
- package/backends/playwright-base/playwright-plugin.ts +1294 -0
- package/backends/python-adapter.ts +1141 -0
- package/backends/python-base/pi_browser_bridge/__init__.py +71 -0
- package/backends/python-base/pi_browser_bridge/accessibility.py +408 -0
- package/backends/python-base/pi_browser_bridge/bot_detection.py +115 -0
- package/backends/python-base/pi_browser_bridge/bridge.py +598 -0
- package/backends/python-base/pi_browser_bridge/playwright_base.py +1222 -0
- package/backends/python-base/pi_browser_bridge/transport.py +167 -0
- package/backends/python-base/pyproject.toml +15 -0
- package/browser-cookies.ts +88 -0
- package/browser-profile.ts +260 -0
- package/browser-status.ts +84 -0
- package/browser-toggle.ts +527 -0
- package/core/fetch-backend.ts +466 -0
- package/core/guides.ts +467 -0
- package/core/plugin-api.ts +302 -0
- package/core/plugin-config.ts +388 -0
- package/core/plugin-registry.ts +263 -0
- package/core/router.ts +1186 -0
- package/core/shared/accessibility-tree.ts +408 -0
- package/core/shared/bot-detection.ts +187 -0
- package/core/shared/browser-events.ts +111 -0
- package/core/shared/dom-extractor.ts +550 -0
- package/core/shared/nav-settle.ts +187 -0
- package/core/shared/paths.ts +56 -0
- package/core/shared/session-manager.ts +258 -0
- package/core/shared/settings-reader.ts +63 -0
- package/core/shared/snapshot-cache.ts +231 -0
- package/core/shared/storage-state.ts +560 -0
- package/core/shared/task-id.ts +77 -0
- package/core/shared/url-safety.ts +164 -0
- package/index.ts +253 -0
- package/package.json +63 -0
- package/ship-manifest.test.ts +12 -0
- package/tools/browser-back.ts +50 -0
- package/tools/browser-click.ts +74 -0
- package/tools/browser-console.ts +160 -0
- package/tools/browser-inspect.ts +136 -0
- package/tools/browser-navigate.ts +254 -0
- package/tools/browser-press.ts +80 -0
- package/tools/browser-scroll.ts +56 -0
- package/tools/browser-snapshot.ts +90 -0
- package/tools/browser-type.ts +60 -0
- package/tools/index.ts +19 -0
- package/tools/utils.ts +157 -0
- package/tools/web-fetch.ts +147 -0
- package/tools/web-guide.ts +55 -0
- package/tools/web-learn.ts +128 -0
- package/verify-ship-manifest.ts +126 -0
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Accessibility tree utilities.
|
|
3
|
+
*
|
|
4
|
+
* Parses Playwright's page.ariaSnapshot() YAML-like output into an
|
|
5
|
+
* LLM-friendly text format with @e1, @e2 element references. Caches
|
|
6
|
+
* parsed nodes so interactions (click, type) can map back via getByRole().
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** A single parsed node from the aria snapshot, cached for interaction */
|
|
10
|
+
export interface AriaCachedNode {
|
|
11
|
+
ref: string;
|
|
12
|
+
role: string;
|
|
13
|
+
name: string;
|
|
14
|
+
props: string[];
|
|
15
|
+
depth: number;
|
|
16
|
+
raw: string;
|
|
17
|
+
/** 0-based position among siblings with the same role+name in the snapshot */
|
|
18
|
+
occurrenceIndex: number;
|
|
19
|
+
/** Ref of the nearest interactive ancestor (e.g., for subtree queries) */
|
|
20
|
+
parentRef?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface AriaParseResult {
|
|
24
|
+
/** Text with @e1, @e2 refs added */
|
|
25
|
+
text: string;
|
|
26
|
+
/** Map of ref → parsed node for interaction lookup */
|
|
27
|
+
elements: Map<string, AriaCachedNode>;
|
|
28
|
+
/** Total interactive elements found */
|
|
29
|
+
count: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Roles that get @e refs and can be used for interaction.
|
|
34
|
+
*/
|
|
35
|
+
export const INTERACTIVE_ROLES = new Set([
|
|
36
|
+
"button",
|
|
37
|
+
"link",
|
|
38
|
+
"textbox",
|
|
39
|
+
"searchbox",
|
|
40
|
+
"combobox",
|
|
41
|
+
"checkbox",
|
|
42
|
+
"radio",
|
|
43
|
+
"heading",
|
|
44
|
+
"listbox",
|
|
45
|
+
"option",
|
|
46
|
+
"menuitem",
|
|
47
|
+
"menuitemcheckbox",
|
|
48
|
+
"menuitemradio",
|
|
49
|
+
"tab",
|
|
50
|
+
"treeitem",
|
|
51
|
+
"switch",
|
|
52
|
+
"slider",
|
|
53
|
+
"spinbutton",
|
|
54
|
+
"progressbar",
|
|
55
|
+
"meter",
|
|
56
|
+
"scrollbar",
|
|
57
|
+
"gridcell",
|
|
58
|
+
"cell",
|
|
59
|
+
"columnheader",
|
|
60
|
+
"rowheader",
|
|
61
|
+
"tabpanel",
|
|
62
|
+
"img",
|
|
63
|
+
"figure",
|
|
64
|
+
"listitem",
|
|
65
|
+
"dialog",
|
|
66
|
+
"alertdialog",
|
|
67
|
+
"tooltip",
|
|
68
|
+
"navigation",
|
|
69
|
+
"banner",
|
|
70
|
+
"form",
|
|
71
|
+
"search",
|
|
72
|
+
"toolbar",
|
|
73
|
+
"menu",
|
|
74
|
+
"menubar",
|
|
75
|
+
"note",
|
|
76
|
+
"alert",
|
|
77
|
+
"status",
|
|
78
|
+
"list",
|
|
79
|
+
"table",
|
|
80
|
+
"grid",
|
|
81
|
+
"treegrid",
|
|
82
|
+
"article",
|
|
83
|
+
"section",
|
|
84
|
+
"blockquote",
|
|
85
|
+
"code",
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Roles that are shown in the tree but DON'T get @e refs
|
|
90
|
+
* (informational only, not useful click targets).
|
|
91
|
+
*/
|
|
92
|
+
export const INFORMATIONAL_ROLES = new Set([
|
|
93
|
+
"paragraph",
|
|
94
|
+
"text",
|
|
95
|
+
"group",
|
|
96
|
+
"region",
|
|
97
|
+
"main",
|
|
98
|
+
"complementary",
|
|
99
|
+
"contentinfo",
|
|
100
|
+
"definition",
|
|
101
|
+
"term",
|
|
102
|
+
"math",
|
|
103
|
+
"marquee",
|
|
104
|
+
"timer",
|
|
105
|
+
"log",
|
|
106
|
+
"deletion",
|
|
107
|
+
"insertion",
|
|
108
|
+
"mark",
|
|
109
|
+
"suggestion",
|
|
110
|
+
"comment",
|
|
111
|
+
]);
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Parse the YAML-like output of page.ariaSnapshot().
|
|
115
|
+
*
|
|
116
|
+
* Assigns @e refs sequentially to all interactive elements in DOM order.
|
|
117
|
+
* Every interactive element gets a ref — no cap, no dialog prioritization.
|
|
118
|
+
*/
|
|
119
|
+
export function parseSnapshot(snap: string): AriaParseResult {
|
|
120
|
+
const elements = new Map<string, AriaCachedNode>();
|
|
121
|
+
const outLines: string[] = [];
|
|
122
|
+
let refCounter = 0;
|
|
123
|
+
const occurrenceTracker = new Map<string, number>();
|
|
124
|
+
const parentStack: string[] = [];
|
|
125
|
+
|
|
126
|
+
const lines = snap.split("\n");
|
|
127
|
+
|
|
128
|
+
for (const rawLine of lines) {
|
|
129
|
+
if (!rawLine.trim()) continue;
|
|
130
|
+
|
|
131
|
+
const depth = countLeadingSpaces(rawLine);
|
|
132
|
+
const trimmed = rawLine.trim();
|
|
133
|
+
|
|
134
|
+
// Property lines (start with /) — pass through as-is
|
|
135
|
+
if (trimmed.startsWith("/")) {
|
|
136
|
+
outLines.push(rawLine);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Parse "- role ..."
|
|
141
|
+
const parsed = parseLine(trimmed);
|
|
142
|
+
if (!parsed) {
|
|
143
|
+
outLines.push(rawLine);
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const { role, name, props } = parsed;
|
|
148
|
+
|
|
149
|
+
// Informational roles: show in tree but no @e ref
|
|
150
|
+
if (INFORMATIONAL_ROLES.has(role)) {
|
|
151
|
+
const indent = " ".repeat(depth);
|
|
152
|
+
const icon = roleIcon(role);
|
|
153
|
+
const namePart = name ? ` "${truncate(name, 80)}"` : "";
|
|
154
|
+
outLines.push(`${indent}${icon}${role}${namePart}`);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Non-interactive skip
|
|
159
|
+
if (!INTERACTIVE_ROLES.has(role)) {
|
|
160
|
+
outLines.push(rawLine);
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
refCounter++;
|
|
165
|
+
const ref = `e${refCounter}`;
|
|
166
|
+
const occKey = `${role}||${name}`;
|
|
167
|
+
const occurrenceIndex = occurrenceTracker.get(occKey) ?? 0;
|
|
168
|
+
occurrenceTracker.set(occKey, occurrenceIndex + 1);
|
|
169
|
+
|
|
170
|
+
// Parent stack: trim entries past current depth
|
|
171
|
+
while (parentStack.length > depth) {
|
|
172
|
+
parentStack.pop();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Determine parentRef from the element at depth-1 (if any)
|
|
176
|
+
const parentRef =
|
|
177
|
+
parentStack.length >= depth && depth > 0
|
|
178
|
+
? parentStack[depth - 1]
|
|
179
|
+
: undefined;
|
|
180
|
+
|
|
181
|
+
// Push this ref onto the parent stack at its depth
|
|
182
|
+
parentStack[depth] = ref;
|
|
183
|
+
|
|
184
|
+
const node: AriaCachedNode = {
|
|
185
|
+
ref,
|
|
186
|
+
role,
|
|
187
|
+
name,
|
|
188
|
+
props,
|
|
189
|
+
depth,
|
|
190
|
+
raw: trimmed,
|
|
191
|
+
occurrenceIndex,
|
|
192
|
+
...(parentRef ? { parentRef } : {}),
|
|
193
|
+
};
|
|
194
|
+
elements.set(ref, node);
|
|
195
|
+
|
|
196
|
+
const indent = " ".repeat(depth);
|
|
197
|
+
const icon = roleIcon(role);
|
|
198
|
+
const refTag = `@${ref}`;
|
|
199
|
+
const namePart = name ? ` "${truncate(name, 80)}"` : "";
|
|
200
|
+
const propStr = props.length > 0 ? ` [${props.join(", ")}]` : "";
|
|
201
|
+
|
|
202
|
+
outLines.push(`${indent}${refTag} ${icon}${role}${namePart}${propStr}`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
text: outLines.join("\n"),
|
|
207
|
+
elements,
|
|
208
|
+
count: elements.size,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Build a Playwright locator for a cached node using getByRole().
|
|
214
|
+
*/
|
|
215
|
+
export function buildLocator(
|
|
216
|
+
page: import("playwright").Page,
|
|
217
|
+
node: AriaCachedNode,
|
|
218
|
+
): import("playwright").Locator | null {
|
|
219
|
+
try {
|
|
220
|
+
const opts: Record<string, unknown> = {};
|
|
221
|
+
|
|
222
|
+
if (node.name) {
|
|
223
|
+
opts.name = node.name;
|
|
224
|
+
opts.exact = node.name.length < 60;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
for (const prop of node.props) {
|
|
228
|
+
const eqIdx = prop.indexOf("=");
|
|
229
|
+
if (eqIdx > 0) {
|
|
230
|
+
const key = prop.slice(0, eqIdx);
|
|
231
|
+
const val = prop.slice(eqIdx + 1);
|
|
232
|
+
if (key === "level") opts.level = parseInt(val, 10);
|
|
233
|
+
if (key === "checked") opts.checked = val === "mixed" ? "mixed" : true;
|
|
234
|
+
if (key === "expanded") opts.expanded = val === "true";
|
|
235
|
+
if (key === "pressed") opts.pressed = val === "mixed" ? "mixed" : true;
|
|
236
|
+
if (key === "selected") opts.selected = val === "true";
|
|
237
|
+
} else {
|
|
238
|
+
if (prop === "checked") opts.checked = true;
|
|
239
|
+
if (prop === "expanded") opts.expanded = true;
|
|
240
|
+
if (prop === "pressed") opts.pressed = true;
|
|
241
|
+
if (prop === "selected") opts.selected = true;
|
|
242
|
+
if (prop === "disabled") opts.disabled = true;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const locator = page.getByRole(node.role as any, opts);
|
|
247
|
+
// Always use .nth(occurrenceIndex) to avoid strict-mode violations
|
|
248
|
+
// when multiple elements share the same role+name. For unique elements
|
|
249
|
+
// (occurrenceIndex = 0) this is equivalent to the bare locator.
|
|
250
|
+
return locator.nth(node.occurrenceIndex);
|
|
251
|
+
} catch {
|
|
252
|
+
if (node.name) {
|
|
253
|
+
return page.getByText(node.name, { exact: node.name.length < 60 });
|
|
254
|
+
}
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ─── Parsing ──────────────────────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
interface ParsedLine {
|
|
262
|
+
role: string;
|
|
263
|
+
name: string;
|
|
264
|
+
props: string[];
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function parseLine(line: string): ParsedLine | null {
|
|
268
|
+
if (!line.startsWith("- ")) return null;
|
|
269
|
+
const content = line.slice(2).trim();
|
|
270
|
+
|
|
271
|
+
const props: string[] = [];
|
|
272
|
+
const cleaned = content
|
|
273
|
+
.replace(/\[([^\]]+)\]/g, (_m, capture) => {
|
|
274
|
+
props.push(capture.trim());
|
|
275
|
+
return "";
|
|
276
|
+
})
|
|
277
|
+
.trim();
|
|
278
|
+
|
|
279
|
+
const match = cleaned.match(/^([a-zA-Z_-]+)\s*/);
|
|
280
|
+
if (!match) return null;
|
|
281
|
+
|
|
282
|
+
const role = (match[1] ?? "").toLowerCase();
|
|
283
|
+
const remainder = cleaned.slice(match[0].length).trim();
|
|
284
|
+
let name = "";
|
|
285
|
+
|
|
286
|
+
// Quoted name: "name" or "name":
|
|
287
|
+
const nameMatch = remainder.match(/^"((?:[^"\\]|\\.)*)"\s*:?\s*/);
|
|
288
|
+
if (nameMatch) {
|
|
289
|
+
name = nameMatch[1] ?? "";
|
|
290
|
+
} else {
|
|
291
|
+
// Colon-text format: ": text content"
|
|
292
|
+
const textMatch = remainder.match(/^:\s*(.*)/);
|
|
293
|
+
if (textMatch) {
|
|
294
|
+
name = (textMatch[1] ?? "").trim().slice(0, 100);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return { role, name, props };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function countLeadingSpaces(s: string): number {
|
|
302
|
+
const match = s.match(/^(\s*)/);
|
|
303
|
+
return match ? (match[1] ?? "").length : 0;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ─── Helpers ──────────────────────────────────────────────────────────
|
|
307
|
+
|
|
308
|
+
export function roleIcon(role: string): string {
|
|
309
|
+
const icons: Record<string, string> = {
|
|
310
|
+
alert: "🔔 ",
|
|
311
|
+
alertdialog: "⚠ ",
|
|
312
|
+
article: "📰 ",
|
|
313
|
+
banner: "📰 ",
|
|
314
|
+
blockquote: "💬 ",
|
|
315
|
+
button: "🔘 ",
|
|
316
|
+
cell: "▫ ",
|
|
317
|
+
checkbox: "☑ ",
|
|
318
|
+
code: "💻 ",
|
|
319
|
+
columnheader: "📊 ",
|
|
320
|
+
combobox: "📋 ",
|
|
321
|
+
comment: "💬 ",
|
|
322
|
+
complementary: "📎 ",
|
|
323
|
+
contentinfo: "ℹ ",
|
|
324
|
+
definition: "📖 ",
|
|
325
|
+
deletion: "❌ ",
|
|
326
|
+
dialog: "💬 ",
|
|
327
|
+
figure: "🖼 ",
|
|
328
|
+
form: "📝 ",
|
|
329
|
+
grid: "📊 ",
|
|
330
|
+
gridcell: "▫ ",
|
|
331
|
+
group: "📦 ",
|
|
332
|
+
heading: "📌 ",
|
|
333
|
+
img: "🖼 ",
|
|
334
|
+
insertion: "➕ ",
|
|
335
|
+
link: "🔗 ",
|
|
336
|
+
list: "📋 ",
|
|
337
|
+
listbox: "📋 ",
|
|
338
|
+
listitem: "• ",
|
|
339
|
+
log: "📋 ",
|
|
340
|
+
main: "📄 ",
|
|
341
|
+
mark: "🖍️ ",
|
|
342
|
+
marquee: "📜 ",
|
|
343
|
+
math: "🧮 ",
|
|
344
|
+
menu: "📋 ",
|
|
345
|
+
menubar: "📋 ",
|
|
346
|
+
menuitem: "📋 ",
|
|
347
|
+
menuitemcheckbox: "☑ ",
|
|
348
|
+
menuitemradio: "○ ",
|
|
349
|
+
meter: "📊 ",
|
|
350
|
+
navigation: "🧭 ",
|
|
351
|
+
note: "📝 ",
|
|
352
|
+
option: "• ",
|
|
353
|
+
paragraph: "📃 ",
|
|
354
|
+
progressbar: "⏳ ",
|
|
355
|
+
radio: "○ ",
|
|
356
|
+
region: "📦 ",
|
|
357
|
+
rowheader: "📊 ",
|
|
358
|
+
search: "🔍 ",
|
|
359
|
+
searchbox: "🔍 ",
|
|
360
|
+
scrollbar: "📜 ",
|
|
361
|
+
section: "📄 ",
|
|
362
|
+
slider: "🔧 ",
|
|
363
|
+
spinbutton: "🔢 ",
|
|
364
|
+
status: "📊 ",
|
|
365
|
+
suggestion: "💡 ",
|
|
366
|
+
switch: "🔀 ",
|
|
367
|
+
tab: "📑 ",
|
|
368
|
+
table: "📊 ",
|
|
369
|
+
tabpanel: "📑 ",
|
|
370
|
+
term: "📖 ",
|
|
371
|
+
text: "📝 ",
|
|
372
|
+
textbox: "📝 ",
|
|
373
|
+
timer: "⏱️ ",
|
|
374
|
+
toolbar: "🔧 ",
|
|
375
|
+
tooltip: "💡 ",
|
|
376
|
+
treegrid: "📊 ",
|
|
377
|
+
treeitem: "• ",
|
|
378
|
+
};
|
|
379
|
+
return icons[role] || "";
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function truncate(s: string, max: number): string {
|
|
383
|
+
if (s.length <= max) return s;
|
|
384
|
+
return s.slice(0, max - 1) + "…";
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Compute a stable, lightweight fingerprint of an accessibility snapshot.
|
|
389
|
+
*
|
|
390
|
+
* Uses the first 200 characters of the snapshot to produce a short hash.
|
|
391
|
+
* Same snapshot → same fingerprint. Different content → different fingerprint.
|
|
392
|
+
* The fingerprint captures enough structural information to detect significant
|
|
393
|
+
* DOM changes (SPA navigation, dynamic content loading) while being cheap to
|
|
394
|
+
* compute (O(200) with no allocations).
|
|
395
|
+
*
|
|
396
|
+
* The hash is a DJB2 digest of the first 200 chars, returned as a base-36
|
|
397
|
+
* string for compactness.
|
|
398
|
+
*/
|
|
399
|
+
export function snapshotFingerprint(snapshot: string): string {
|
|
400
|
+
const sample = snapshot.slice(0, 200);
|
|
401
|
+
let hash = 5381;
|
|
402
|
+
for (let i = 0; i < sample.length; i++) {
|
|
403
|
+
hash = (hash << 5) + hash + sample.charCodeAt(i);
|
|
404
|
+
hash = hash & hash;
|
|
405
|
+
}
|
|
406
|
+
// Use unsigned 32-bit to avoid negative toString(36) output
|
|
407
|
+
return (hash >>> 0).toString(36);
|
|
408
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bot detection heuristics.
|
|
3
|
+
*
|
|
4
|
+
* Analyzes page content to determine if a site is blocking
|
|
5
|
+
* automation tools (Cloudflare, CAPTCHA, etc.). When detected,
|
|
6
|
+
* the router flags the navigation as bot-blocked so the agent
|
|
7
|
+
* can decide how to proceed — try web-fetch, try a different URL,
|
|
8
|
+
* or switch to a stealth browser backend if one is configured.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface BotDetectionResult {
|
|
12
|
+
/** True if the page appears to be a bot block/challenge page */
|
|
13
|
+
isBlocked: boolean;
|
|
14
|
+
/** Confidence score 0-1 */
|
|
15
|
+
confidence: number;
|
|
16
|
+
/** Signal that triggered the detection */
|
|
17
|
+
signal?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Content patterns that indicate a bot block.
|
|
22
|
+
*
|
|
23
|
+
* Only specific challenge phrases are included — generic single words
|
|
24
|
+
* like "cloudflare", "captcha", "recaptcha", "hcaptcha", "enable javascript"
|
|
25
|
+
* etc. are excluded because they cause false positives on legitimate
|
|
26
|
+
* pages (Cloudflare's own site, web scraping articles, CAPTCHA service pages).
|
|
27
|
+
* Real challenge pages always use these exact phrases.
|
|
28
|
+
*/
|
|
29
|
+
const BLOCK_SIGNALS = [
|
|
30
|
+
"please verify you are human",
|
|
31
|
+
"attention required!",
|
|
32
|
+
"just a moment...",
|
|
33
|
+
"checking your browser",
|
|
34
|
+
"you have been blocked",
|
|
35
|
+
"sorry, you have been blocked",
|
|
36
|
+
"verify you are human",
|
|
37
|
+
"your request has been blocked",
|
|
38
|
+
"we are checking your browser",
|
|
39
|
+
"cf-challenge",
|
|
40
|
+
"_cf_chl_opt",
|
|
41
|
+
"cdn-cgi/challenge",
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Body-only string patterns — checked in body text (not the title) to
|
|
46
|
+
* avoid false matches on legitimate content. Catches CDN-specific
|
|
47
|
+
* block pages (Akamai "Access Denied", generic 403s).
|
|
48
|
+
*/
|
|
49
|
+
const BODY_ONLY_SIGNALS = [
|
|
50
|
+
"errors.edgesuite.net",
|
|
51
|
+
"you don't have permission to access",
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Body-only regex patterns — more specific than string inclusion.
|
|
56
|
+
* These match the exact format of CDN error reference codes to
|
|
57
|
+
* avoid false positives from generic "reference #123" in normal content.
|
|
58
|
+
*/
|
|
59
|
+
const BODY_ONLY_PATTERNS: RegExp[] = [
|
|
60
|
+
/reference\s*#[a-f0-9]+(?:\.[a-f0-9]+)+/i,
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* HTML-level signals — checked against ``document.documentElement.innerHTML``
|
|
65
|
+
* rather than visible body text. These look for CAPTCHA widget embed codes
|
|
66
|
+
* in the raw HTML source, which is a stronger signal than visible text
|
|
67
|
+
* (generic mentions of "captcha" in body copy are excluded to avoid false
|
|
68
|
+
* positives, but embed codes in HTML attributes are unambiguous).
|
|
69
|
+
*/
|
|
70
|
+
const HTML_SIGNALS = [
|
|
71
|
+
"recaptcha",
|
|
72
|
+
"hcaptcha",
|
|
73
|
+
"turnstile",
|
|
74
|
+
"g-recaptcha",
|
|
75
|
+
"data-sitekey",
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Check if page text content suggests a bot block.
|
|
80
|
+
*/
|
|
81
|
+
export function checkBodyText(bodyText: string): BotDetectionResult {
|
|
82
|
+
if (!bodyText) return { isBlocked: false, confidence: 0 };
|
|
83
|
+
|
|
84
|
+
const lower = bodyText.toLowerCase();
|
|
85
|
+
for (const signal of BLOCK_SIGNALS) {
|
|
86
|
+
if (lower.includes(signal)) {
|
|
87
|
+
return {
|
|
88
|
+
isBlocked: true,
|
|
89
|
+
confidence: signal.length > 20 ? 0.9 : 0.7,
|
|
90
|
+
signal,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return { isBlocked: false, confidence: 0 };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Check body text against body-only signal patterns that are specific
|
|
100
|
+
* enough to not false-positive on normal content. Checks both string
|
|
101
|
+
* inclusion (for CDN domains, generic 403 messages) and regex patterns
|
|
102
|
+
* (for Akamai reference codes, etc.).
|
|
103
|
+
*/
|
|
104
|
+
function checkBodyOnlyText(bodyText: string): BotDetectionResult {
|
|
105
|
+
if (!bodyText) return { isBlocked: false, confidence: 0 };
|
|
106
|
+
|
|
107
|
+
const lower = bodyText.toLowerCase();
|
|
108
|
+
|
|
109
|
+
// Check string signals first
|
|
110
|
+
for (const signal of BODY_ONLY_SIGNALS) {
|
|
111
|
+
if (lower.includes(signal)) {
|
|
112
|
+
return { isBlocked: true, confidence: 0.85, signal };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Check regex patterns against raw text (case-insensitive via /i flag)
|
|
117
|
+
for (const pattern of BODY_ONLY_PATTERNS) {
|
|
118
|
+
const match = bodyText.match(pattern);
|
|
119
|
+
if (match) {
|
|
120
|
+
return {
|
|
121
|
+
isBlocked: true,
|
|
122
|
+
confidence: 0.9,
|
|
123
|
+
signal: `regex: ${match[0].slice(0, 50)}`,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return { isBlocked: false, confidence: 0 };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Check raw HTML content for CAPTCHA widget embed codes.
|
|
133
|
+
*
|
|
134
|
+
* Looks for specific widget identifiers in the HTML source (not visible text),
|
|
135
|
+
* so false positive risk from generic mentions is low. Mirrors the Python
|
|
136
|
+
* bridge's ``_HTML_SIGNALS`` in ``backends/chromium-py/bridge.py``.
|
|
137
|
+
*/
|
|
138
|
+
export function checkHtmlContent(html: string): BotDetectionResult {
|
|
139
|
+
if (!html) return { isBlocked: false, confidence: 0 };
|
|
140
|
+
|
|
141
|
+
const lower = html.toLowerCase();
|
|
142
|
+
for (const signal of HTML_SIGNALS) {
|
|
143
|
+
if (lower.includes(signal)) {
|
|
144
|
+
return {
|
|
145
|
+
isBlocked: true,
|
|
146
|
+
confidence: 0.85,
|
|
147
|
+
signal: `html:${signal}`,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return { isBlocked: false, confidence: 0 };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Combined check: analyze page title, body text, and (optionally) raw HTML.
|
|
157
|
+
*
|
|
158
|
+
* - Title is checked against BLOCK_SIGNALS only (avoids false positives).
|
|
159
|
+
* - Body is checked against BLOCK_SIGNALS + BODY_ONLY_SIGNALS + BODY_ONLY_PATTERNS
|
|
160
|
+
* (catches CDN-specific block pages like Akamai "Access Denied").
|
|
161
|
+
* - HTML (if provided) is checked for CAPTCHA widget embed codes.
|
|
162
|
+
*/
|
|
163
|
+
export function checkPage(
|
|
164
|
+
title: string,
|
|
165
|
+
bodyText: string,
|
|
166
|
+
html?: string,
|
|
167
|
+
): BotDetectionResult {
|
|
168
|
+
// Check title first (often contains "Attention Required!" etc.)
|
|
169
|
+
const titleResult = checkBodyText(title);
|
|
170
|
+
if (titleResult.isBlocked) return titleResult;
|
|
171
|
+
|
|
172
|
+
// Check body against challenge phrases
|
|
173
|
+
const bodyResult = checkBodyText(bodyText);
|
|
174
|
+
if (bodyResult.isBlocked) return bodyResult;
|
|
175
|
+
|
|
176
|
+
// Check body against CDN-specific patterns (reference #, etc.)
|
|
177
|
+
const bodyOnlyResult = checkBodyOnlyText(bodyText);
|
|
178
|
+
if (bodyOnlyResult.isBlocked) return bodyOnlyResult;
|
|
179
|
+
|
|
180
|
+
// Check HTML source for CAPTCHA widget embed codes
|
|
181
|
+
if (html !== undefined) {
|
|
182
|
+
const htmlResult = checkHtmlContent(html);
|
|
183
|
+
if (htmlResult.isBlocked) return htmlResult;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return { isBlocked: false, confidence: 0 };
|
|
187
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser Event Supervisor — handles JavaScript dialogs and browser events.
|
|
3
|
+
*
|
|
4
|
+
* Automatically dismisses alert/confirm/prompt dialogs and logs them
|
|
5
|
+
* for the user to see. Also handles page crash and unresponsive events.
|
|
6
|
+
*
|
|
7
|
+
* Playwright-generic — lives in core/shared so all Playwright-based
|
|
8
|
+
* backends (chromium, firefox, etc.) share the same handler logic.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { Page } from "playwright";
|
|
12
|
+
|
|
13
|
+
export interface DialogEvent {
|
|
14
|
+
type: "alert" | "confirm" | "prompt" | "beforeunload";
|
|
15
|
+
message: string;
|
|
16
|
+
/** Default value for prompt dialogs */
|
|
17
|
+
defaultValue?: string;
|
|
18
|
+
/** How the dialog was handled */
|
|
19
|
+
handledAs: "accepted" | "dismissed";
|
|
20
|
+
timestamp: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ConsoleEvent {
|
|
24
|
+
type:
|
|
25
|
+
| "log"
|
|
26
|
+
| "warn"
|
|
27
|
+
| "error"
|
|
28
|
+
| "info"
|
|
29
|
+
| "debug"
|
|
30
|
+
| "dir"
|
|
31
|
+
| "trace"
|
|
32
|
+
| "assert";
|
|
33
|
+
text: string;
|
|
34
|
+
timestamp: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Dialogs logged per task, for reporting to user */
|
|
38
|
+
const _dialogLog = new Map<string, DialogEvent[]>();
|
|
39
|
+
|
|
40
|
+
/** Console messages logged per task */
|
|
41
|
+
const _consoleLog = new Map<string, ConsoleEvent[]>();
|
|
42
|
+
|
|
43
|
+
/** Get logged dialogs for a task */
|
|
44
|
+
export function getDialogLog(taskId: string): DialogEvent[] {
|
|
45
|
+
return _dialogLog.get(taskId) ?? [];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Get console messages for a task */
|
|
49
|
+
export function getConsoleLog(taskId: string): ConsoleEvent[] {
|
|
50
|
+
return _consoleLog.get(taskId) ?? [];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Clear console log for a task */
|
|
54
|
+
export function clearConsoleLog(taskId: string): void {
|
|
55
|
+
_consoleLog.delete(taskId);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Install dialog and console handlers on a Playwright page.
|
|
60
|
+
* Automatically accepts all dialogs (alert, confirm, prompt) and logs them.
|
|
61
|
+
* Captures console messages (log, warn, error, info, debug) for retrieval.
|
|
62
|
+
*/
|
|
63
|
+
export function installDialogHandlers(taskId: string, page: Page): void {
|
|
64
|
+
const dialogLog: DialogEvent[] = [];
|
|
65
|
+
_dialogLog.set(taskId, dialogLog);
|
|
66
|
+
|
|
67
|
+
const consoleLog: ConsoleEvent[] = [];
|
|
68
|
+
_consoleLog.set(taskId, consoleLog);
|
|
69
|
+
|
|
70
|
+
// Auto-accept JavaScript dialogs
|
|
71
|
+
page.on("dialog", async (dialog) => {
|
|
72
|
+
const entry: DialogEvent = {
|
|
73
|
+
type: dialog.type() as DialogEvent["type"],
|
|
74
|
+
message: dialog.message(),
|
|
75
|
+
defaultValue: dialog.defaultValue(),
|
|
76
|
+
handledAs: "accepted",
|
|
77
|
+
timestamp: Date.now(),
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
await dialog.accept();
|
|
82
|
+
} catch {
|
|
83
|
+
entry.handledAs = "dismissed";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
dialogLog.push(entry);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// Capture console messages
|
|
90
|
+
page.on("console", (msg) => {
|
|
91
|
+
const type = msg.type() as ConsoleEvent["type"];
|
|
92
|
+
if (consoleLog.length >= 500) {
|
|
93
|
+
consoleLog.shift(); // Ring buffer: keep latest 500
|
|
94
|
+
}
|
|
95
|
+
consoleLog.push({
|
|
96
|
+
type,
|
|
97
|
+
text: msg.text(),
|
|
98
|
+
timestamp: Date.now(),
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// Handle page crashes
|
|
103
|
+
page.on("crash", () => {
|
|
104
|
+
dialogLog.push({
|
|
105
|
+
type: "alert",
|
|
106
|
+
message: "⚠ Page crashed",
|
|
107
|
+
handledAs: "dismissed",
|
|
108
|
+
timestamp: Date.now(),
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
}
|