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.
Files changed (37) hide show
  1. package/CLAUDE.md +5 -5
  2. package/README.md +7 -6
  3. package/package.json +2 -1
  4. package/server.js +83 -15
  5. package/src/cli/commands/browser.js +77 -0
  6. package/src/cli/index.js +3 -1
  7. package/src/core/ActionExecutor.js +185 -7
  8. package/src/core/AuthManager.js +26 -0
  9. package/src/core/ChangeTracker.js +25 -8
  10. package/src/core/browser/SessionStore.js +331 -0
  11. package/src/core/browser/snapshot.js +346 -0
  12. package/src/core/llm/LLMManager.js +86 -6
  13. package/src/core/processing/PDFProcessor.js +3 -1
  14. package/src/server/fallbackHints.js +4 -0
  15. package/src/server/inlineThreshold.js +31 -1
  16. package/src/server/requestContext.js +26 -5
  17. package/src/server/toolFilter.js +2 -2
  18. package/src/server/transports/streamableHttp.js +38 -8
  19. package/src/skills/agent-skills/crawlforge-batch-automation/SKILL.md +9 -2
  20. package/src/skills/agent-skills/crawlforge-batch-automation/references/actions.md +50 -4
  21. package/src/skills/agent-skills/crawlforge-browser-sessions/SKILL.md +178 -0
  22. package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +7 -4
  23. package/src/skills/agent-skills/crawlforge-getting-started/references/cli.md +6 -1
  24. package/src/skills/agent-skills/crawlforge-getting-started/references/credits.md +4 -0
  25. package/src/skills/installer.js +1 -1
  26. package/src/tools/advanced/BrowserSessionTool.js +476 -0
  27. package/src/tools/advanced/ScrapeWithActionsTool.js +10 -1
  28. package/src/tools/crawl/mapSite.js +81 -7
  29. package/src/tools/extract/extractEmbeddedState.js +18 -2
  30. package/src/tools/extract/extractStructured.js +4 -0
  31. package/src/tools/extract/processDocument.js +94 -1
  32. package/src/tools/scrape/_brandingExtractor.js +23 -5
  33. package/src/tools/scrape/unifiedScrape.js +8 -1
  34. package/src/tools/search/redditSearch.js +24 -17
  35. package/src/utils/hiddenContent.js +67 -2
  36. package/src/utils/redditHosts.js +123 -0
  37. 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
+ }