crawlforge-mcp-server 6.4.0 → 6.6.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/CLAUDE.md +5 -5
- package/README.md +7 -6
- package/package.json +2 -1
- package/server.js +83 -15
- package/src/cli/commands/browser.js +77 -0
- package/src/cli/index.js +3 -1
- package/src/core/ActionExecutor.js +185 -7
- package/src/core/AuthManager.js +26 -0
- package/src/core/ChangeTracker.js +25 -8
- package/src/core/browser/SessionStore.js +331 -0
- package/src/core/browser/snapshot.js +346 -0
- package/src/core/llm/LLMManager.js +86 -6
- package/src/core/processing/PDFProcessor.js +3 -1
- package/src/server/fallbackHints.js +4 -0
- package/src/server/inlineThreshold.js +31 -1
- package/src/server/requestContext.js +26 -5
- package/src/server/toolFilter.js +2 -2
- package/src/server/transports/streamableHttp.js +38 -8
- package/src/skills/agent-skills/crawlforge-batch-automation/SKILL.md +9 -2
- package/src/skills/agent-skills/crawlforge-batch-automation/references/actions.md +50 -4
- package/src/skills/agent-skills/crawlforge-browser-sessions/SKILL.md +178 -0
- package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +7 -4
- package/src/skills/agent-skills/crawlforge-getting-started/references/cli.md +6 -1
- package/src/skills/agent-skills/crawlforge-getting-started/references/credits.md +4 -0
- package/src/skills/installer.js +1 -1
- package/src/tools/advanced/BrowserSessionTool.js +476 -0
- package/src/tools/advanced/ScrapeWithActionsTool.js +10 -1
- package/src/tools/crawl/mapSite.js +81 -7
- package/src/tools/extract/extractEmbeddedState.js +18 -2
- package/src/tools/extract/extractStructured.js +4 -0
- package/src/tools/extract/processDocument.js +94 -1
- package/src/tools/scrape/_brandingExtractor.js +23 -5
- package/src/tools/scrape/unifiedScrape.js +8 -1
- package/src/tools/search/redditSearch.js +24 -17
- package/src/utils/hiddenContent.js +67 -2
- package/src/utils/redditHosts.js +123 -0
- package/src/utils/robotsGate.js +27 -3
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BrowserSessionStore — the registry behind a browser session that outlives a
|
|
3
|
+
* single tool call.
|
|
4
|
+
*
|
|
5
|
+
* A session is a page the caller gets to keep: log in once, then click, type
|
|
6
|
+
* and snapshot across several calls without paying for the login every time.
|
|
7
|
+
* This file holds those pages, decides when one has gone stale, and hands it
|
|
8
|
+
* back. How a page was made — stealth or not, which engine, which context — is
|
|
9
|
+
* the caller's business, never ours.
|
|
10
|
+
*
|
|
11
|
+
* NO SECOND POOL. Sessions hold pages whose browser contexts are already
|
|
12
|
+
* registered in StealthBrowserManager's BrowserContextPool by the code that
|
|
13
|
+
* opened them. Constructing a pool here would double-count every context
|
|
14
|
+
* against a cap that exists to protect a 2 GB box, so do not "fix" the missing
|
|
15
|
+
* pool. The plan's "context id" field is carried implicitly by `releasePage`,
|
|
16
|
+
* the closure the creator supplies to give the page back: the store knows how
|
|
17
|
+
* to return a page, not how it was built, and so imports neither
|
|
18
|
+
* BrowserProcessor nor StealthBrowserManager.
|
|
19
|
+
*
|
|
20
|
+
* REFS NEED NO FIELD either. Element refs (`@e1`) live in a page-scoped WeakMap
|
|
21
|
+
* inside src/core/browser/snapshot.js, so a session that keeps its page keeps
|
|
22
|
+
* its refs for free — and loses them exactly when it should, on navigation or
|
|
23
|
+
* when the page closes. A second copy here could only disagree with that one.
|
|
24
|
+
*
|
|
25
|
+
* OWNERSHIP IS A TENANT BOUNDARY, not a convenience. Ids are random and every
|
|
26
|
+
* lookup is scoped to the caller; a wrong owner is answered with the same
|
|
27
|
+
* "session not found" an unknown id gets, so ids cannot be probed from the
|
|
28
|
+
* outside. See SessionNotFoundError.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { randomUUID } from 'node:crypto';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Thrown for an unknown id, a wrong owner and an expired session alike.
|
|
35
|
+
*
|
|
36
|
+
* It takes no message on purpose: a call site cannot vary what it says, so the
|
|
37
|
+
* three cases cannot drift apart over time and start telling a caller which one
|
|
38
|
+
* they hit. That sameness is what makes session ids non-enumerable, and it is
|
|
39
|
+
* also why the message never echoes the id back.
|
|
40
|
+
*/
|
|
41
|
+
export class SessionNotFoundError extends Error {
|
|
42
|
+
constructor() {
|
|
43
|
+
super(
|
|
44
|
+
'Session not found. It may have expired, been closed, or never existed — ' +
|
|
45
|
+
'open a new session and try again.'
|
|
46
|
+
);
|
|
47
|
+
this.name = 'SessionNotFoundError';
|
|
48
|
+
this.code = 'SESSION_NOT_FOUND';
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Thrown when a new session would breach the per-owner or process-wide cap. */
|
|
53
|
+
export class SessionLimitError extends Error {
|
|
54
|
+
constructor(message) {
|
|
55
|
+
super(message);
|
|
56
|
+
this.name = 'SessionLimitError';
|
|
57
|
+
this.code = 'SESSION_LIMIT';
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Two clocks, and their bounds. The numbers match Firecrawl's session TTLs so
|
|
62
|
+
// that our documentation reads familiar to anyone arriving from theirs.
|
|
63
|
+
export const TTL_DEFAULT_MS = 600_000; // 10 minutes from creation
|
|
64
|
+
export const TTL_MIN_MS = 30_000;
|
|
65
|
+
export const TTL_MAX_MS = 3_600_000; // 1 hour
|
|
66
|
+
export const ACTIVITY_TTL_DEFAULT_MS = 300_000; // 5 minutes since last use
|
|
67
|
+
export const ACTIVITY_TTL_MIN_MS = 10_000;
|
|
68
|
+
export const ACTIVITY_TTL_MAX_MS = 3_600_000;
|
|
69
|
+
|
|
70
|
+
export const DEFAULT_MAX_SESSIONS_PER_OWNER = 3;
|
|
71
|
+
|
|
72
|
+
// Every live session pins one browser context for as long as it lives, and
|
|
73
|
+
// one-shot scrapes draw contexts from the same pool — which caps at
|
|
74
|
+
// MAX_BROWSER_CONTEXTS. Sessions therefore get at most half of it, so a burst
|
|
75
|
+
// of them can never leave an ordinary scrape waiting on a slot that will not
|
|
76
|
+
// free for another ten minutes.
|
|
77
|
+
//
|
|
78
|
+
// This is the SECOND reader of that variable — BrowserContextPool.js reads it
|
|
79
|
+
// with the same '10' fallback — and "half the pool" holds only while the two
|
|
80
|
+
// defaults agree. Change one and change this one with it. Deliberately not an
|
|
81
|
+
// import of the pool's constant: nothing in this file may reach for the pool
|
|
82
|
+
// (see the header), and a grep for MAX_BROWSER_CONTEXTS finds both sites.
|
|
83
|
+
const CONTEXT_CAP = parseInt(process.env.MAX_BROWSER_CONTEXTS || '10', 10) || 10;
|
|
84
|
+
export const DEFAULT_MAX_SESSIONS_TOTAL = Math.max(1, Math.floor(CONTEXT_CAP / 2));
|
|
85
|
+
|
|
86
|
+
const DEFAULT_SWEEP_INTERVAL_MS = 30_000;
|
|
87
|
+
|
|
88
|
+
/** Caller-supplied ttls are clamped, not rejected — see create(). */
|
|
89
|
+
function clamp(value, min, max, fallback) {
|
|
90
|
+
const n = Number(value);
|
|
91
|
+
return Number.isFinite(n) ? Math.min(Math.max(n, min), max) : fallback;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export class BrowserSessionStore {
|
|
95
|
+
/**
|
|
96
|
+
* @param {Object} [opts]
|
|
97
|
+
* @param {number} [opts.maxPerOwner] — concurrent sessions one API key may hold
|
|
98
|
+
* @param {number} [opts.maxTotal] — concurrent sessions the process may hold
|
|
99
|
+
* @param {number} [opts.sweepIntervalMs]
|
|
100
|
+
*/
|
|
101
|
+
constructor(opts = {}) {
|
|
102
|
+
this._maxPerOwner = opts.maxPerOwner ?? DEFAULT_MAX_SESSIONS_PER_OWNER;
|
|
103
|
+
this._maxTotal = opts.maxTotal ?? DEFAULT_MAX_SESSIONS_TOTAL;
|
|
104
|
+
|
|
105
|
+
// The only index. A store holds single digits of sessions, so the
|
|
106
|
+
// per-owner questions (cap, list, stats) are answered by scanning this map
|
|
107
|
+
// rather than by a second one that could fall out of step with it.
|
|
108
|
+
/** @type {Map<string, { id: string, ownerId: string, page: any, releasePage: Function, url: string|null, stealth: boolean, createdAt: number, lastUsedAt: number, ttlMs: number, activityTtlMs: number }>} */
|
|
109
|
+
this._sessions = new Map();
|
|
110
|
+
|
|
111
|
+
this._sweepTimer = setInterval(() => {
|
|
112
|
+
// An unhandled rejection inside a timer callback takes the server down
|
|
113
|
+
// with it. _release never rejects, and this is what keeps that true even
|
|
114
|
+
// if someone later changes it.
|
|
115
|
+
this.sweep().catch(() => {});
|
|
116
|
+
}, opts.sweepIntervalMs ?? DEFAULT_SWEEP_INTERVAL_MS);
|
|
117
|
+
this._sweepTimer.unref?.(); // never hold the process open
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Register a live page as a session.
|
|
122
|
+
*
|
|
123
|
+
* @param {Object} args
|
|
124
|
+
* @param {string} args.ownerId — the caller's API-key identity; the tenant boundary
|
|
125
|
+
* @param {any} args.page — the Playwright page the session keeps
|
|
126
|
+
* @param {() => Promise<void>} args.releasePage — hands the page, and its context, back
|
|
127
|
+
* @param {string} [args.url]
|
|
128
|
+
* @param {boolean} [args.stealth=false]
|
|
129
|
+
* @param {number} [args.ttlMs] — clamped into [TTL_MIN_MS, TTL_MAX_MS]
|
|
130
|
+
* @param {number} [args.activityTtlMs] — clamped into [ACTIVITY_TTL_MIN_MS, ACTIVITY_TTL_MAX_MS]
|
|
131
|
+
* @param {number} [args.maxPerOwner] — a tighter cap for THIS owner, in place
|
|
132
|
+
* of the store's. Some callers are not equal: a hosted REST customer shares
|
|
133
|
+
* one box with every other one, where a stdio install has the box to itself.
|
|
134
|
+
* Only the creator may say so, because only the creator knows who is asking.
|
|
135
|
+
* @throws {SessionLimitError} when the owner is at maxPerOwner, or the store at maxTotal
|
|
136
|
+
*/
|
|
137
|
+
create({ ownerId, page, releasePage, url = null, stealth = false, ttlMs, activityTtlMs, maxPerOwner }) {
|
|
138
|
+
// Both of these are load-bearing rather than defensive: without an ownerId
|
|
139
|
+
// two tenants' sessions would share one anonymous bucket, and without a
|
|
140
|
+
// releasePage the page's context is pinned with no way to give it back.
|
|
141
|
+
if (!ownerId) {
|
|
142
|
+
throw new TypeError('BrowserSessionStore.create requires an ownerId');
|
|
143
|
+
}
|
|
144
|
+
if (typeof releasePage !== 'function') {
|
|
145
|
+
throw new TypeError('BrowserSessionStore.create requires a releasePage callback');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Expired-but-unswept sessions must not count against the caps: a caller
|
|
149
|
+
// whose three sessions all timed out a second ago is not over quota.
|
|
150
|
+
this._purgeExpired();
|
|
151
|
+
|
|
152
|
+
// A refusal, never a queue. BrowserContextPool can make a caller wait for a
|
|
153
|
+
// slot because a context frees in milliseconds; a session slot frees on its
|
|
154
|
+
// TTL, minutes away, so waiting would simply hang the call.
|
|
155
|
+
const ownerCap = maxPerOwner ?? this._maxPerOwner;
|
|
156
|
+
if (this._countFor(ownerId) >= ownerCap) {
|
|
157
|
+
throw new SessionLimitError(
|
|
158
|
+
`You already have ${ownerCap} open browser session${ownerCap === 1 ? '' : 's'}, the maximum per API key. ` +
|
|
159
|
+
`Close one before opening another — sessions also close themselves when their TTL expires.`
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
if (this._sessions.size >= this._maxTotal) {
|
|
163
|
+
throw new SessionLimitError(
|
|
164
|
+
`The server is holding its maximum of ${this._maxTotal} browser sessions. ` +
|
|
165
|
+
`Try again shortly, or use a one-shot scrape instead of a session.`
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const now = Date.now();
|
|
170
|
+
const session = {
|
|
171
|
+
id: randomUUID(),
|
|
172
|
+
ownerId,
|
|
173
|
+
page,
|
|
174
|
+
releasePage,
|
|
175
|
+
url,
|
|
176
|
+
stealth,
|
|
177
|
+
createdAt: now,
|
|
178
|
+
lastUsedAt: now,
|
|
179
|
+
// Clamped rather than rejected: the tool validates the caller-facing
|
|
180
|
+
// seconds with zod, so anything arriving out of range here is a bug on
|
|
181
|
+
// our side of the boundary, not a caller error to report.
|
|
182
|
+
ttlMs: clamp(ttlMs, TTL_MIN_MS, TTL_MAX_MS, TTL_DEFAULT_MS),
|
|
183
|
+
activityTtlMs: clamp(activityTtlMs, ACTIVITY_TTL_MIN_MS, ACTIVITY_TTL_MAX_MS, ACTIVITY_TTL_DEFAULT_MS)
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
this._sessions.set(session.id, session);
|
|
187
|
+
return session;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Look a session up on behalf of its owner. It records nothing — touch() does
|
|
192
|
+
* that — so a read never extends a session's life by accident.
|
|
193
|
+
*
|
|
194
|
+
* Unknown id, wrong owner and expired session all raise the identical
|
|
195
|
+
* SessionNotFoundError.
|
|
196
|
+
*
|
|
197
|
+
* @throws {SessionNotFoundError}
|
|
198
|
+
*/
|
|
199
|
+
get(sessionId, ownerId) {
|
|
200
|
+
const session = this._sessions.get(sessionId);
|
|
201
|
+
if (!session || session.ownerId !== ownerId) throw new SessionNotFoundError();
|
|
202
|
+
|
|
203
|
+
if (this._isExpired(session, Date.now())) {
|
|
204
|
+
// Expiry is not the sweep's alone to notice. A session that timed out
|
|
205
|
+
// between sweeps is gone the moment it is asked for, and its page goes
|
|
206
|
+
// back now rather than up to half a minute later. get() is synchronous,
|
|
207
|
+
// so the release runs unawaited; _release never rejects.
|
|
208
|
+
this._sessions.delete(sessionId);
|
|
209
|
+
this._release(session);
|
|
210
|
+
throw new SessionNotFoundError();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return session;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Record activity, and the page's new url when it moved. Restarts the idle clock. */
|
|
217
|
+
touch(session, url) {
|
|
218
|
+
session.lastUsedAt = Date.now();
|
|
219
|
+
if (url) session.url = url;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Close a session on behalf of its owner and give its page back.
|
|
224
|
+
* @throws {SessionNotFoundError}
|
|
225
|
+
*/
|
|
226
|
+
async close(sessionId, ownerId) {
|
|
227
|
+
const session = this.get(sessionId, ownerId);
|
|
228
|
+
// Out of the map BEFORE the first await. That is what makes releasePage run
|
|
229
|
+
// exactly once when two closes race, and what stops a throwing release from
|
|
230
|
+
// leaving a dead session behind for someone to find.
|
|
231
|
+
this._sessions.delete(sessionId);
|
|
232
|
+
await this._release(session);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** One owner's live sessions, in the shape the tool reports. Pages stay in here. */
|
|
236
|
+
list(ownerId) {
|
|
237
|
+
this._purgeExpired();
|
|
238
|
+
const sessions = [];
|
|
239
|
+
for (const session of this._sessions.values()) {
|
|
240
|
+
if (session.ownerId !== ownerId) continue;
|
|
241
|
+
sessions.push({
|
|
242
|
+
id: session.id,
|
|
243
|
+
url: session.url,
|
|
244
|
+
stealth: session.stealth,
|
|
245
|
+
createdAt: session.createdAt,
|
|
246
|
+
lastUsedAt: session.lastUsedAt,
|
|
247
|
+
expiresAt: session.createdAt + session.ttlMs,
|
|
248
|
+
idleExpiresAt: session.lastUsedAt + session.activityTtlMs
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
return sessions;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Close everything past either clock. This is the backstop that keeps an
|
|
256
|
+
* abandoned session from pinning a browser context for the life of the
|
|
257
|
+
* process — on the 2 GB box that is an outage, not a leak.
|
|
258
|
+
*
|
|
259
|
+
* @returns {Promise<number>} sessions closed
|
|
260
|
+
*/
|
|
261
|
+
async sweep() {
|
|
262
|
+
const expired = this._takeExpired(Date.now());
|
|
263
|
+
await Promise.all(expired.map((session) => this._release(session)));
|
|
264
|
+
return expired.length;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Close every session and stop the sweep. Safe to call twice. */
|
|
268
|
+
async destroy() {
|
|
269
|
+
clearInterval(this._sweepTimer);
|
|
270
|
+
const sessions = Array.from(this._sessions.values());
|
|
271
|
+
this._sessions.clear();
|
|
272
|
+
await Promise.all(sessions.map((session) => this._release(session)));
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
getStats() {
|
|
276
|
+
const byOwner = {};
|
|
277
|
+
for (const session of this._sessions.values()) {
|
|
278
|
+
byOwner[session.ownerId] = (byOwner[session.ownerId] || 0) + 1;
|
|
279
|
+
}
|
|
280
|
+
return { total: this._sessions.size, byOwner };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ── internals ───────────────────────────────────────────────────────────────
|
|
284
|
+
|
|
285
|
+
_countFor(ownerId) {
|
|
286
|
+
let count = 0;
|
|
287
|
+
for (const session of this._sessions.values()) {
|
|
288
|
+
if (session.ownerId === ownerId) count++;
|
|
289
|
+
}
|
|
290
|
+
return count;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Past its absolute TTL, or idle past its activity TTL — whichever fires first. */
|
|
294
|
+
_isExpired(session, now) {
|
|
295
|
+
return now >= session.createdAt + session.ttlMs
|
|
296
|
+
|| now >= session.lastUsedAt + session.activityTtlMs;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Remove every expired session from the map and return them, still unreleased. */
|
|
300
|
+
_takeExpired(now) {
|
|
301
|
+
const expired = [];
|
|
302
|
+
for (const [id, session] of this._sessions.entries()) {
|
|
303
|
+
if (this._isExpired(session, now)) {
|
|
304
|
+
this._sessions.delete(id);
|
|
305
|
+
expired.push(session);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return expired;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** _takeExpired for the synchronous paths, which have no way to await the releases. */
|
|
312
|
+
_purgeExpired() {
|
|
313
|
+
for (const session of this._takeExpired(Date.now())) this._release(session);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Give one page back. Never rejects: by the time this runs the session is
|
|
318
|
+
* already out of the map, so a failed close is the pool's problem rather than
|
|
319
|
+
* anything the caller can act on — the same call BrowserContextPool.dispose
|
|
320
|
+
* makes about a context that will not close.
|
|
321
|
+
*/
|
|
322
|
+
async _release(session) {
|
|
323
|
+
try {
|
|
324
|
+
await session.releasePage();
|
|
325
|
+
} catch {
|
|
326
|
+
// ignore release errors
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export default BrowserSessionStore;
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Snapshot — the accessibility-style page tree that hands an agent stable
|
|
3
|
+
* element refs (`@e1`, `@e2`, …) to act on instead of guessed CSS selectors.
|
|
4
|
+
*
|
|
5
|
+
* Why ours and not Playwright's: 1.62's `locator.ariaSnapshot()` emits YAML
|
|
6
|
+
* with no element refs at all, and `page._snapshotForAI()` is private API we
|
|
7
|
+
* will not depend on. The walk below is injected by us and returns the tree
|
|
8
|
+
* and the refs from one pass.
|
|
9
|
+
*
|
|
10
|
+
* A REF LIVES IN TWO PLACES, and the halves do different jobs:
|
|
11
|
+
*
|
|
12
|
+
* 1. In the page — the walk stamps `data-cf-ref="e1"` onto every element it
|
|
13
|
+
* refs, so a ref resolves to an ordinary CSS selector,
|
|
14
|
+
* `[data-cf-ref="e1"]`. That is what makes refs work with every existing
|
|
15
|
+
* action path for free, including the stealth human-behaviour code that
|
|
16
|
+
* takes a raw selector string.
|
|
17
|
+
* 2. In Node — a WeakMap<Page, state> cleared on every main-frame
|
|
18
|
+
* navigation. This is the half that DETECTS staleness. Without it a ref
|
|
19
|
+
* from a previous page would merely fail to match, and the caller would
|
|
20
|
+
* be told "selector not found" instead of "take a new snapshot".
|
|
21
|
+
*
|
|
22
|
+
* So a stale ref fails loudly, with the reason and the fix (StaleRefError),
|
|
23
|
+
* and never silently hits the wrong element.
|
|
24
|
+
*
|
|
25
|
+
* Not to be confused with src/core/SnapshotManager.js, which is change-
|
|
26
|
+
* tracking history.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { randomUUID } from 'node:crypto';
|
|
30
|
+
|
|
31
|
+
export const REF_ATTRIBUTE = 'data-cf-ref';
|
|
32
|
+
export const DEFAULT_MAX_NODES = 200;
|
|
33
|
+
export const MAX_NODES_LIMIT = 1000;
|
|
34
|
+
|
|
35
|
+
// Walks of one snapshot call, including the retry when the page navigates
|
|
36
|
+
// mid-walk. Two: one retry is enough for a page that settles, and a page
|
|
37
|
+
// navigating repeatedly is not one a snapshot can describe.
|
|
38
|
+
const MAX_WALK_ATTEMPTS = 2;
|
|
39
|
+
|
|
40
|
+
// Indentation follows the nesting of EMITTED nodes, and stops deepening past
|
|
41
|
+
// this many levels so a deep DOM cannot produce runaway leading whitespace.
|
|
42
|
+
const MAX_INDENT = 10;
|
|
43
|
+
const MAX_NAME_LENGTH = 120;
|
|
44
|
+
|
|
45
|
+
const REF_PATTERN = /^@e[1-9]\d*$/;
|
|
46
|
+
|
|
47
|
+
/** Thrown when a ref cannot be resolved against the page's current snapshot. */
|
|
48
|
+
export class StaleRefError extends Error {
|
|
49
|
+
constructor(message) {
|
|
50
|
+
super(message);
|
|
51
|
+
this.name = 'StaleRefError';
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Page -> { snapshotId, refs, invalidated, tracking }. WeakMap so a closed
|
|
56
|
+
// page's refs go with it.
|
|
57
|
+
const pageState = new WeakMap();
|
|
58
|
+
|
|
59
|
+
/** True for an element ref (`@e1`), false for anything else, including non-strings. */
|
|
60
|
+
export function isRef(selector) {
|
|
61
|
+
return typeof selector === 'string' && REF_PATTERN.test(selector);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The injected walk. Fixed source, written by us — it is not caller-supplied
|
|
66
|
+
* JavaScript, so it has nothing to do with the ALLOW_JAVASCRIPT_EXECUTION flag
|
|
67
|
+
* that gates the `executeJavaScript` action. Do not put it behind that flag.
|
|
68
|
+
*/
|
|
69
|
+
function snapshotScript({ refAttribute, interactiveOnly, maxNodes, maxIndent, maxNameLength }) {
|
|
70
|
+
const SKIP_TAGS = new Set(['script', 'style', 'noscript', 'template', 'head']);
|
|
71
|
+
const INTERACTIVE_ROLES = new Set([
|
|
72
|
+
'button', 'link', 'checkbox', 'radio', 'textbox', 'combobox', 'menuitem',
|
|
73
|
+
'menuitemcheckbox', 'menuitemradio', 'tab', 'switch', 'option', 'searchbox',
|
|
74
|
+
'slider', 'spinbutton'
|
|
75
|
+
]);
|
|
76
|
+
const STRUCTURAL_ROLES = new Set([
|
|
77
|
+
'heading', 'banner', 'navigation', 'contentinfo', 'complementary', 'main',
|
|
78
|
+
'form', 'region', 'search'
|
|
79
|
+
]);
|
|
80
|
+
const LANDMARK_TAGS = {
|
|
81
|
+
main: 'main', nav: 'navigation', header: 'banner', footer: 'contentinfo',
|
|
82
|
+
aside: 'complementary', form: 'form'
|
|
83
|
+
};
|
|
84
|
+
const INPUT_ROLES = {
|
|
85
|
+
text: 'textbox', search: 'textbox', email: 'textbox', tel: 'textbox',
|
|
86
|
+
url: 'textbox', password: 'textbox', checkbox: 'checkbox', radio: 'radio',
|
|
87
|
+
submit: 'button', button: 'button', reset: 'button'
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// Double quotes delimit the name in the tree, so a name may not contain one.
|
|
91
|
+
const clean = (value) => (value || '').replace(/\s+/g, ' ').trim().replace(/"/g, "'");
|
|
92
|
+
const truncate = (value) =>
|
|
93
|
+
(value.length > maxNameLength ? `${value.slice(0, maxNameLength - 1)}…` : value);
|
|
94
|
+
|
|
95
|
+
function roleOf(el, tag) {
|
|
96
|
+
const explicit = (el.getAttribute('role') || '').trim().toLowerCase();
|
|
97
|
+
if (explicit) return explicit.split(/\s+/)[0];
|
|
98
|
+
if (tag === 'input') return INPUT_ROLES[(el.getAttribute('type') || 'text').toLowerCase()] || tag;
|
|
99
|
+
if (tag === 'a') return el.hasAttribute('href') ? 'link' : tag;
|
|
100
|
+
if (tag === 'button' || tag === 'summary') return 'button';
|
|
101
|
+
if (tag === 'select') return 'combobox';
|
|
102
|
+
if (tag === 'textarea') return 'textbox';
|
|
103
|
+
if (/^h[1-6]$/.test(tag)) return 'heading';
|
|
104
|
+
return LANDMARK_TAGS[tag] || tag;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function nameOf(el, tag, allowTextContent) {
|
|
108
|
+
const ariaLabel = clean(el.getAttribute('aria-label'));
|
|
109
|
+
if (ariaLabel) return ariaLabel;
|
|
110
|
+
|
|
111
|
+
const labelledBy = (el.getAttribute('aria-labelledby') || '').trim();
|
|
112
|
+
if (labelledBy) {
|
|
113
|
+
const referenced = clean(labelledBy.split(/\s+/)
|
|
114
|
+
.map((id) => (document.getElementById(id) || {}).textContent || '')
|
|
115
|
+
.join(' '));
|
|
116
|
+
if (referenced) return referenced;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// el.labels covers both `label[for=id]` and an ancestor <label>; closest()
|
|
120
|
+
// is the fallback for elements that have no .labels (a contenteditable).
|
|
121
|
+
const labels = el.labels;
|
|
122
|
+
const labelText = clean(labels && labels.length
|
|
123
|
+
? labels[0].textContent
|
|
124
|
+
: (el.closest('label') || {}).textContent);
|
|
125
|
+
if (labelText) return labelText;
|
|
126
|
+
|
|
127
|
+
for (const attribute of ['placeholder', 'title', 'alt']) {
|
|
128
|
+
const value = clean(el.getAttribute(attribute));
|
|
129
|
+
if (value) return value;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// A push button's label is its value; a text field's value is user data,
|
|
133
|
+
// not a name, which is why this is narrowed to the button types.
|
|
134
|
+
if (tag === 'input' && /^(button|submit|reset)$/.test((el.getAttribute('type') || '').toLowerCase())) {
|
|
135
|
+
const value = clean(el.value);
|
|
136
|
+
if (value) return value;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// A landmark is named by its label, never by everything inside it —
|
|
140
|
+
// otherwise <main> would be captioned with the whole page.
|
|
141
|
+
return allowTextContent ? clean(el.textContent) : '';
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function isInteractive(el, tag, role) {
|
|
145
|
+
if (INTERACTIVE_ROLES.has(role)) return true;
|
|
146
|
+
if (tag === 'a') return el.hasAttribute('href');
|
|
147
|
+
if (tag === 'input') return (el.getAttribute('type') || '').toLowerCase() !== 'hidden';
|
|
148
|
+
if (tag === 'select' || tag === 'textarea' || tag === 'button' || tag === 'summary') return true;
|
|
149
|
+
const editable = el.getAttribute('contenteditable');
|
|
150
|
+
if (editable !== null && editable !== 'false') return true;
|
|
151
|
+
const tabindex = el.getAttribute('tabindex');
|
|
152
|
+
if (tabindex !== null && tabindex.trim() !== '-1') return true;
|
|
153
|
+
return el.hasAttribute('onclick');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function isHidden(el, tag) {
|
|
157
|
+
if (SKIP_TAGS.has(tag)) return true;
|
|
158
|
+
if (el.hasAttribute('hidden')) return true;
|
|
159
|
+
if (el.getAttribute('aria-hidden') === 'true') return true;
|
|
160
|
+
const style = getComputedStyle(el);
|
|
161
|
+
return style.display === 'none' || style.visibility === 'hidden';
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const hasSize = (el) => {
|
|
165
|
+
const rect = el.getBoundingClientRect();
|
|
166
|
+
return rect.width > 0 && rect.height > 0;
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const lines = [];
|
|
170
|
+
const refs = [];
|
|
171
|
+
let truncated = false;
|
|
172
|
+
|
|
173
|
+
function walk(el, depth) {
|
|
174
|
+
if (truncated) return;
|
|
175
|
+
const tag = el.tagName.toLowerCase();
|
|
176
|
+
if (isHidden(el, tag)) return; // the subtree is hidden with it
|
|
177
|
+
|
|
178
|
+
const role = roleOf(el, tag);
|
|
179
|
+
const interactive = isInteractive(el, tag, role);
|
|
180
|
+
const structural = !interactive && !interactiveOnly &&
|
|
181
|
+
(/^h[1-6]$/.test(tag) || Boolean(LANDMARK_TAGS[tag]) || STRUCTURAL_ROLES.has(role));
|
|
182
|
+
let childDepth = depth;
|
|
183
|
+
|
|
184
|
+
// A zero-size element is not emitted, but its children still are: a
|
|
185
|
+
// collapsed wrapper is common, an unreachable subtree is not.
|
|
186
|
+
if ((interactive || structural) && hasSize(el)) {
|
|
187
|
+
if (lines.length >= maxNodes) {
|
|
188
|
+
truncated = true;
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const name = truncate(nameOf(el, tag, interactive || role === 'heading'));
|
|
192
|
+
let ref = '';
|
|
193
|
+
// Only interactive nodes get a ref — a structural line is context, not a target.
|
|
194
|
+
if (interactive) {
|
|
195
|
+
const id = `e${refs.length + 1}`;
|
|
196
|
+
el.setAttribute(refAttribute, id);
|
|
197
|
+
refs.push({ id, role, name, tag });
|
|
198
|
+
ref = `@${id} `;
|
|
199
|
+
}
|
|
200
|
+
lines.push(`${' '.repeat(Math.min(depth, maxIndent))}${ref}[${role}]${name ? ` "${name}"` : ''}`);
|
|
201
|
+
childDepth = depth + 1;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
for (const child of el.children) walk(child, childDepth);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Drop refs from an earlier walk so a re-snapshot of the same page numbers
|
|
208
|
+
// cleanly instead of leaving elements answering to a retired id.
|
|
209
|
+
for (const stale of document.querySelectorAll(`[${refAttribute}]`)) {
|
|
210
|
+
stale.removeAttribute(refAttribute);
|
|
211
|
+
}
|
|
212
|
+
walk(document.body || document.documentElement, 1);
|
|
213
|
+
|
|
214
|
+
return { title: clean(document.title), lines, refs, truncated };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function stateFor(page) {
|
|
218
|
+
let state = pageState.get(page);
|
|
219
|
+
if (!state) {
|
|
220
|
+
state = { snapshotId: null, refs: null, invalidated: false, tracking: false, generation: 0 };
|
|
221
|
+
pageState.set(page, state);
|
|
222
|
+
}
|
|
223
|
+
return state;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Walk the page and return its tree plus the refs it assigned.
|
|
228
|
+
*
|
|
229
|
+
* @param {import('playwright').Page} page
|
|
230
|
+
* @param {object} [options]
|
|
231
|
+
* @param {boolean} [options.interactiveOnly=true] — false also emits headings and landmarks, unreffed
|
|
232
|
+
* @param {number} [options.maxNodes=200] — cap on emitted nodes, clamped to [1, MAX_NODES_LIMIT]
|
|
233
|
+
*/
|
|
234
|
+
export async function captureSnapshot(page, options = {}) {
|
|
235
|
+
const interactiveOnly = options.interactiveOnly !== false;
|
|
236
|
+
const requested = Number(options.maxNodes);
|
|
237
|
+
const maxNodes = Number.isFinite(requested)
|
|
238
|
+
? Math.min(Math.max(Math.floor(requested), 1), MAX_NODES_LIMIT)
|
|
239
|
+
: DEFAULT_MAX_NODES;
|
|
240
|
+
|
|
241
|
+
// Idempotent, and the guarantee that a later navigation invalidates these
|
|
242
|
+
// refs rather than leaving them to fail as a missing selector.
|
|
243
|
+
attachRefTracking(page);
|
|
244
|
+
|
|
245
|
+
const state = stateFor(page);
|
|
246
|
+
let title, lines, refs, truncated;
|
|
247
|
+
|
|
248
|
+
// A navigation that commits WHILE the walk is running would otherwise leave us
|
|
249
|
+
// holding refs for a document that has gone — the attributes were stamped on
|
|
250
|
+
// the old page, so `@e1` would match nothing and surface as a locator timeout
|
|
251
|
+
// instead of the named error D2 requires. `generation` moves on every
|
|
252
|
+
// main-frame navigation, so a change across the evaluate means exactly that.
|
|
253
|
+
// Walk the new document instead; if it navigates again, give up and leave the
|
|
254
|
+
// refs invalidated rather than publishing a tree for a page nobody is on.
|
|
255
|
+
for (let attempt = 0; ; attempt++) {
|
|
256
|
+
const generation = state.generation;
|
|
257
|
+
({ title, lines, refs, truncated } = await page.evaluate(snapshotScript, {
|
|
258
|
+
refAttribute: REF_ATTRIBUTE,
|
|
259
|
+
interactiveOnly,
|
|
260
|
+
maxNodes,
|
|
261
|
+
maxIndent: MAX_INDENT,
|
|
262
|
+
maxNameLength: MAX_NAME_LENGTH
|
|
263
|
+
}));
|
|
264
|
+
if (state.generation === generation) break;
|
|
265
|
+
if (attempt >= MAX_WALK_ATTEMPTS - 1) {
|
|
266
|
+
clearRefs(page);
|
|
267
|
+
throw new StaleRefError(
|
|
268
|
+
'The page navigated while the snapshot was being taken — take a new snapshot.'
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const snapshotId = randomUUID().slice(0, 8);
|
|
274
|
+
state.snapshotId = snapshotId;
|
|
275
|
+
state.refs = new Map(refs.map(({ id, role, name, tag }) => [id, { role, name, tag }]));
|
|
276
|
+
state.invalidated = false;
|
|
277
|
+
|
|
278
|
+
return {
|
|
279
|
+
snapshotId,
|
|
280
|
+
url: page.url(),
|
|
281
|
+
title,
|
|
282
|
+
tree: [`[document]${title ? ` "${title}"` : ''}`, ...lines].join('\n'),
|
|
283
|
+
refCount: refs.length,
|
|
284
|
+
nodeCount: lines.length,
|
|
285
|
+
truncated,
|
|
286
|
+
interactiveOnly
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Turn `@e1` into the CSS selector every action path already understands.
|
|
292
|
+
* Throws StaleRefError when the ref does not belong to the page's current
|
|
293
|
+
* snapshot — it never guesses.
|
|
294
|
+
*/
|
|
295
|
+
export function resolveRef(page, selector) {
|
|
296
|
+
if (!isRef(selector)) {
|
|
297
|
+
// A programming error, not a stale ref: callers gate on isRef().
|
|
298
|
+
throw new Error(`resolveRef expects an element ref like "@e1", got ${JSON.stringify(selector)}`);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const state = pageState.get(page);
|
|
302
|
+
if (!state || (!state.refs && !state.invalidated)) {
|
|
303
|
+
throw new StaleRefError(
|
|
304
|
+
`Unknown element ref ${selector}: no snapshot has been taken on this page — add a { "type": "snapshot" } action before acting on refs.`
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
if (!state.refs) {
|
|
308
|
+
throw new StaleRefError(
|
|
309
|
+
`Stale element ref ${selector}: the page navigated since the last snapshot — take a new snapshot before acting on refs.`
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const id = selector.slice(1);
|
|
314
|
+
if (!state.refs.has(id)) {
|
|
315
|
+
const count = state.refs.size;
|
|
316
|
+
throw new StaleRefError(count === 0
|
|
317
|
+
? `Unknown element ref ${selector}: the current snapshot has no refs — take a new snapshot.`
|
|
318
|
+
: `Unknown element ref ${selector}: the current snapshot has ${count} refs (@e1-@e${count}) — take a new snapshot.`);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return `[${REF_ATTRIBUTE}="${id}"]`;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Register the navigation listener that invalidates this page's refs. Idempotent. */
|
|
325
|
+
export function attachRefTracking(page) {
|
|
326
|
+
const state = stateFor(page);
|
|
327
|
+
if (state.tracking) return;
|
|
328
|
+
state.tracking = true;
|
|
329
|
+
page.on('framenavigated', (frame) => {
|
|
330
|
+
if (frame === page.mainFrame()) clearRefs(page);
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Drop the page's refs. A ref resolved afterwards reports the navigation, not a miss. */
|
|
335
|
+
export function clearRefs(page) {
|
|
336
|
+
const state = pageState.get(page);
|
|
337
|
+
if (!state) return;
|
|
338
|
+
// `invalidated` is only meaningful once a snapshot existed — it is what
|
|
339
|
+
// separates "the page navigated" from "no snapshot has been taken".
|
|
340
|
+
if (state.refs) state.invalidated = true;
|
|
341
|
+
state.refs = null;
|
|
342
|
+
state.snapshotId = null;
|
|
343
|
+
// Bumped on every clear so a walk in flight can tell the document changed
|
|
344
|
+
// under it — see captureSnapshot.
|
|
345
|
+
state.generation++;
|
|
346
|
+
}
|