zan-browser 1.3.38 → 2.0.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 (51) hide show
  1. package/dist/ai.d.ts +3 -16
  2. package/dist/ai.d.ts.map +1 -1
  3. package/dist/ai.js +101 -208
  4. package/dist/ai.js.map +1 -1
  5. package/dist/blacklist.d.ts +2 -0
  6. package/dist/blacklist.d.ts.map +1 -0
  7. package/dist/blacklist.js +115 -0
  8. package/dist/blacklist.js.map +1 -0
  9. package/dist/browser-provider.d.ts +81 -0
  10. package/dist/browser-provider.d.ts.map +1 -0
  11. package/dist/browser-provider.js +6 -0
  12. package/dist/browser-provider.js.map +1 -0
  13. package/dist/browser.d.ts +1 -1
  14. package/dist/browser.d.ts.map +1 -1
  15. package/dist/browser.js +51 -60
  16. package/dist/browser.js.map +1 -1
  17. package/dist/index.d.ts +7 -1
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +5 -1
  20. package/dist/index.js.map +1 -1
  21. package/dist/interceptor.d.ts +3 -3
  22. package/dist/interceptor.d.ts.map +1 -1
  23. package/dist/interceptor.js +45 -19
  24. package/dist/interceptor.js.map +1 -1
  25. package/dist/navigator.d.ts +40 -0
  26. package/dist/navigator.d.ts.map +1 -0
  27. package/dist/navigator.js +507 -0
  28. package/dist/navigator.js.map +1 -0
  29. package/dist/observer.d.ts +23 -3
  30. package/dist/observer.d.ts.map +1 -1
  31. package/dist/observer.js +310 -270
  32. package/dist/observer.js.map +1 -1
  33. package/dist/perception.d.ts +42 -0
  34. package/dist/perception.d.ts.map +1 -0
  35. package/dist/perception.js +140 -0
  36. package/dist/perception.js.map +1 -0
  37. package/dist/providers/browserbase.d.ts +12 -0
  38. package/dist/providers/browserbase.d.ts.map +1 -0
  39. package/dist/providers/browserbase.js +226 -0
  40. package/dist/providers/browserbase.js.map +1 -0
  41. package/dist/providers/puppeteer-local.d.ts +5 -0
  42. package/dist/providers/puppeteer-local.d.ts.map +1 -0
  43. package/dist/providers/puppeteer-local.js +218 -0
  44. package/dist/providers/puppeteer-local.js.map +1 -0
  45. package/dist/session.d.ts +17 -23
  46. package/dist/session.d.ts.map +1 -1
  47. package/dist/session.js +112 -407
  48. package/dist/session.js.map +1 -1
  49. package/dist/types.d.ts +1 -32
  50. package/dist/types.d.ts.map +1 -1
  51. package/package.json +3 -2
package/dist/observer.js CHANGED
@@ -12,292 +12,208 @@ const PREFIX = {
12
12
  radio: "R",
13
13
  other: "O",
14
14
  };
15
- // Notte whitelist: only these attributes are included in the rendered output
16
- const ATTR_WHITELIST = [
17
- "title", "type", "name", "role", "tabindex", "aria_label",
18
- "placeholder", "value", "alt", "src", "href", "aria_expanded",
19
- ];
20
- // Map from HTML attribute names to our whitelist keys
21
- const HTML_ATTR_MAP = {
22
- "title": "title",
23
- "type": "type",
24
- "name": "name",
25
- "role": "role",
26
- "tabindex": "tabindex",
27
- "aria-label": "aria_label",
28
- "placeholder": "placeholder",
29
- "value": "value",
30
- "alt": "alt",
31
- "src": "src",
32
- "href": "href",
33
- "aria-expanded": "aria_expanded",
34
- };
35
- // Interactive element selectors
36
- const INTERACTIVE_SELECTOR = [
37
- "button", "[role='button']", "input[type='submit']", "input[type='button']",
38
- "input:not([type='submit']):not([type='button']):not([type='hidden'])",
39
- "a[href]", "select", "textarea",
40
- "[role='link']", "[role='menuitem']", "[role='tab']", "[role='option']",
41
- "[onclick]", "[tabindex]",
42
- ].join(", ");
43
- // Dialog/modal selectors
44
- const DIALOG_SELECTORS = [
45
- "[role='dialog']", "[role='alertdialog']", "[aria-modal='true']",
46
- "dialog", ".modal",
47
- ];
15
+ // ─── Observer ──────────────────────────────────────────────────────────────────
48
16
  class Observer {
49
17
  page;
18
+ // Cache of element selectors from last observation for reliable re-targeting
19
+ elementSelectors = new Map();
50
20
  constructor(page) {
51
21
  this.page = page;
52
22
  }
23
+ // Returns the full DOM state as structured data + human-readable text
53
24
  async observe() {
54
- const result = await this.page.evaluate((args) => {
55
- const { INTERACTIVE_SEL, DIALOG_SELS, HTML_ATTR_KEYS } = args;
56
- const baseUrl = window.location.href;
25
+ const result = await this.page.evaluate(() => {
26
+ const elements = [];
57
27
  const isVisible = (el) => {
58
- const htmlEl = el;
59
- // offsetParent is null when the element or any ancestor has display:none.
60
- // Exception: <body>, <html>, and position:fixed/sticky elements legitimately have offsetParent === null.
61
- if (htmlEl.offsetParent === null) {
62
- const style = window.getComputedStyle(el);
63
- const pos = style.position;
64
- if (pos !== "fixed" && pos !== "sticky" && el.tagName !== "BODY" && el.tagName !== "HTML") {
65
- return false;
66
- }
67
- }
68
- const style = window.getComputedStyle(el);
69
- if (style.visibility === "hidden" || style.opacity === "0")
70
- return false;
71
28
  const rect = el.getBoundingClientRect();
72
- return rect.width > 0 && rect.height > 0;
29
+ const style = window.getComputedStyle(el);
30
+ return (rect.width > 0 &&
31
+ rect.height > 0 &&
32
+ style.display !== "none" &&
33
+ style.visibility !== "hidden" &&
34
+ style.opacity !== "0");
35
+ };
36
+ const getText = (el) => {
37
+ const aria = el.getAttribute("aria-label");
38
+ if (aria)
39
+ return aria.trim();
40
+ const title = el.getAttribute("title");
41
+ if (title)
42
+ return title.trim();
43
+ return (el.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 100);
73
44
  };
74
- const resolveUrl = (url) => {
75
- if (!url)
76
- return url;
77
- try {
78
- return new URL(url, baseUrl).href;
45
+ // Build a CSS selector that uniquely identifies an element
46
+ const getSelector = (el) => {
47
+ // Try id first
48
+ if (el.id)
49
+ return `#${CSS.escape(el.id)}`;
50
+ // Try name for inputs
51
+ const name = el.getAttribute("name");
52
+ if (name) {
53
+ const tag = el.tagName.toLowerCase();
54
+ return `${tag}[name="${CSS.escape(name)}"]`;
79
55
  }
80
- catch {
81
- return url;
56
+ // Try aria-label
57
+ const aria = el.getAttribute("aria-label");
58
+ if (aria) {
59
+ const tag = el.tagName.toLowerCase();
60
+ return `${tag}[aria-label="${CSS.escape(aria)}"]`;
82
61
  }
83
- };
84
- const truncate = (val, max) => val.length > max ? val.slice(0, max) : val;
85
- const getWhitelistedAttrs = (el) => {
86
- const out = {};
87
- for (const htmlAttr of HTML_ATTR_KEYS) {
88
- const val = el.getAttribute(htmlAttr);
89
- if (val == null || val === "")
90
- continue;
91
- // Map HTML attr name to our key
92
- const key = htmlAttr.replace(/-/g, "_");
93
- let resolved = val;
94
- if (htmlAttr === "href" || htmlAttr === "src") {
95
- resolved = resolveUrl(val);
96
- }
97
- out[key] = truncate(resolved, 60);
62
+ // Try placeholder for inputs
63
+ const placeholder = el.getAttribute("placeholder");
64
+ if (placeholder) {
65
+ const tag = el.tagName.toLowerCase();
66
+ return `${tag}[placeholder="${CSS.escape(placeholder)}"]`;
98
67
  }
99
- // For inputs, also grab the live .value if different from attr
100
- if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || el instanceof HTMLSelectElement) {
101
- if (el.value && el.value !== out["value"]) {
102
- out["value"] = truncate(el.value, 60);
68
+ // Fallback to nth-child path
69
+ const path = [];
70
+ let current = el;
71
+ while (current && current !== document.body) {
72
+ const parentEl = current.parentElement;
73
+ if (!parentEl)
74
+ break;
75
+ const tag = current.tagName;
76
+ const siblings = Array.from(parentEl.children).filter((c) => c.tagName === tag);
77
+ if (siblings.length === 1) {
78
+ path.unshift(tag.toLowerCase());
103
79
  }
104
- }
105
- return out;
106
- };
107
- const getInnerText = (el) => {
108
- return (el.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 100);
109
- };
110
- // Find which dialog (if any) an element is inside
111
- const findParentDialog = (el) => {
112
- for (const sel of DIALOG_SELS) {
113
- const dialog = el.closest(sel);
114
- if (dialog) {
115
- const label = dialog.getAttribute("aria-label")
116
- ?? dialog.getAttribute("title")
117
- ?? (dialog.textContent ?? "").trim().slice(0, 40);
118
- return { inDialog: true, dialogLabel: label };
80
+ else {
81
+ const idx = siblings.indexOf(current) + 1;
82
+ path.unshift(`${tag.toLowerCase()}:nth-of-type(${idx})`);
119
83
  }
84
+ current = parentEl;
120
85
  }
121
- return { inDialog: false };
86
+ return path.join(" > ");
122
87
  };
123
- // Scroll info
124
- const scrollTop = window.scrollY;
125
- const scrollHeight = document.documentElement.scrollHeight;
126
- const viewportHeight = window.innerHeight;
127
- const pixelsAbove = Math.round(scrollTop);
128
- const pixelsBelow = Math.max(0, Math.round(scrollHeight - scrollTop - viewportHeight));
129
- // Gather all interactive elements
130
- const interactiveSet = new Set();
131
- document.querySelectorAll(INTERACTIVE_SEL).forEach((el) => interactiveSet.add(el));
132
- // Build the node list — walk the DOM in order to get interleaved text context
133
- const nodes = [];
134
- let order = 0;
135
- const walk = (node) => {
136
- if (node.nodeType === Node.TEXT_NODE) {
137
- const text = (node.textContent ?? "").replace(/\s+/g, " ").trim();
138
- if (text.length < 2)
139
- return;
140
- // Skip if parent is an interactive element (text goes inside the tag)
141
- const parent = node.parentElement;
142
- if (parent && interactiveSet.has(parent))
143
- return;
144
- // Also skip script/style
145
- if (parent && (parent.tagName === "SCRIPT" || parent.tagName === "STYLE"))
146
- return;
147
- // Check visibility of parent
148
- if (parent && !isVisible(parent))
149
- return;
150
- const dialog = parent ? findParentDialog(parent) : { inDialog: false };
151
- nodes.push({
152
- tag: "_text",
153
- text: text.slice(0, 150),
154
- attrs: {},
155
- isInteractive: false,
156
- visible: true,
157
- inDialog: dialog.inDialog,
158
- dialogLabel: dialog.dialogLabel,
159
- order: order++,
160
- });
161
- return;
162
- }
163
- if (node.nodeType !== Node.ELEMENT_NODE)
164
- return;
165
- const el = node;
166
- // Skip script/style/noscript
167
- const tagName = el.tagName.toLowerCase();
168
- if (tagName === "script" || tagName === "style" || tagName === "noscript")
169
- return;
170
- if (interactiveSet.has(el)) {
171
- const visible = isVisible(el);
172
- const dialog = findParentDialog(el);
173
- const attrs = getWhitelistedAttrs(el);
174
- const text = getInnerText(el);
175
- let resolvedTag = tagName;
176
- // Normalize tag for our type system
177
- if (tagName === "a")
178
- resolvedTag = "link";
179
- if (el.getAttribute("role") === "button" && tagName !== "button")
180
- resolvedTag = "button";
181
- nodes.push({
182
- tag: resolvedTag,
183
- inputType: el.type || undefined,
184
- text,
185
- attrs,
186
- isInteractive: true,
187
- visible,
188
- inDialog: dialog.inDialog,
189
- dialogLabel: dialog.dialogLabel,
190
- order: order++,
191
- });
192
- // Don't recurse into interactive elements — text is already captured
88
+ // Buttons
89
+ document.querySelectorAll("button, [role='button'], input[type='submit'], input[type='button']").forEach((el) => {
90
+ elements.push({
91
+ tag: "button",
92
+ text: getText(el),
93
+ disabled: el.disabled ?? false,
94
+ visible: isVisible(el),
95
+ ariaLabel: el.getAttribute("aria-label") ?? undefined,
96
+ selector: getSelector(el),
97
+ });
98
+ });
99
+ // Inputs
100
+ document.querySelectorAll("input:not([type='submit']):not([type='button']):not([type='hidden']):not([type='checkbox']):not([type='radio'])").forEach((el) => {
101
+ const input = el;
102
+ elements.push({
103
+ tag: "input",
104
+ type: input.type,
105
+ text: getText(el),
106
+ placeholder: input.placeholder || undefined,
107
+ value: input.value || undefined,
108
+ disabled: input.disabled,
109
+ visible: isVisible(el),
110
+ selector: getSelector(el),
111
+ name: input.name || undefined,
112
+ });
113
+ });
114
+ // Links
115
+ document.querySelectorAll("a[href]").forEach((el) => {
116
+ const anchor = el;
117
+ const text = getText(el);
118
+ if (!text)
193
119
  return;
194
- }
195
- // Recurse children for non-interactive elements
196
- for (const child of Array.from(el.childNodes)) {
197
- walk(child);
198
- }
199
- };
200
- walk(document.body);
120
+ elements.push({
121
+ tag: "link",
122
+ text,
123
+ href: anchor.href,
124
+ disabled: false,
125
+ visible: isVisible(el),
126
+ selector: getSelector(el),
127
+ });
128
+ });
129
+ // Selects
130
+ document.querySelectorAll("select").forEach((el) => {
131
+ const select = el;
132
+ elements.push({
133
+ tag: "select",
134
+ text: getText(el),
135
+ value: select.value || undefined,
136
+ disabled: select.disabled,
137
+ visible: isVisible(el),
138
+ selector: getSelector(el),
139
+ name: select.name || undefined,
140
+ });
141
+ });
142
+ // Textareas
143
+ document.querySelectorAll("textarea").forEach((el) => {
144
+ const textarea = el;
145
+ elements.push({
146
+ tag: "textarea",
147
+ text: getText(el),
148
+ placeholder: textarea.placeholder || undefined,
149
+ value: textarea.value || undefined,
150
+ disabled: textarea.disabled,
151
+ visible: isVisible(el),
152
+ selector: getSelector(el),
153
+ name: textarea.name || undefined,
154
+ });
155
+ });
156
+ // Checkboxes & radios
157
+ document.querySelectorAll("input[type='checkbox'], input[type='radio']").forEach((el) => {
158
+ const input = el;
159
+ elements.push({
160
+ tag: input.type,
161
+ text: getText(el),
162
+ value: input.checked ? "checked" : "unchecked",
163
+ disabled: input.disabled,
164
+ visible: isVisible(el),
165
+ selector: getSelector(el),
166
+ name: input.name || undefined,
167
+ });
168
+ });
201
169
  // Modal detection
202
- const hasModal = DIALOG_SELS.some((s) => document.querySelector(s) !== null);
170
+ const modalSelectors = [
171
+ "[role='dialog']", "[role='alertdialog']",
172
+ ".modal", ".overlay", "[aria-modal='true']",
173
+ ];
174
+ const hasModal = modalSelectors.some((s) => document.querySelector(s) !== null);
203
175
  // Cookie banner detection
204
- const cookieKeywords = ["cookie", "gdpr", "consent", "accept all"];
205
- const bodyText = document.body.innerText.toLowerCase().slice(0, 5000);
176
+ const cookieKeywords = ["cookie", "gdpr", "consent", "accept all", "accept cookies"];
177
+ const bodyText = document.body.innerText.toLowerCase();
206
178
  const hasCookieBanner = cookieKeywords.some((k) => bodyText.includes(k));
207
- // Loading detection
179
+ // Loading detection (expanded)
208
180
  const loadingSelectors = [
209
181
  "[aria-busy='true']", ".loading", ".spinner", "[role='progressbar']",
182
+ "[class*='skeleton']", "[class*='shimmer']", "[class*='placeholder']",
183
+ "[class*='loading']",
210
184
  ];
211
185
  const isLoading = loadingSelectors.some((s) => document.querySelector(s) !== null);
212
186
  return {
213
- url: baseUrl,
187
+ url: window.location.href,
214
188
  title: document.title,
215
- nodes,
189
+ elements,
216
190
  hasModal,
217
191
  hasCookieBanner,
218
192
  isLoading,
219
- pixelsAbove,
220
- pixelsBelow,
221
193
  };
222
- }, {
223
- INTERACTIVE_SEL: INTERACTIVE_SELECTOR,
224
- DIALOG_SELS: DIALOG_SELECTORS,
225
- HTML_ATTR_KEYS: Object.keys(HTML_ATTR_MAP),
226
194
  });
227
- // Assign IDs to interactive elements and build structured list
195
+ // Assign IDs and build structured list + cache selectors
228
196
  const counters = {};
229
197
  const typedElements = [];
230
- const textLines = [];
231
- // Separate dialog vs non-dialog nodes
232
- const dialogNodes = result.nodes.filter((n) => n.inDialog);
233
- const mainNodes = result.nodes.filter((n) => !n.inDialog);
234
- // Render main content
235
- if (result.pixelsAbove > 0) {
236
- textLines.push(`... ${result.pixelsAbove} pixels above - scroll to see more ...`);
237
- }
238
- for (const node of mainNodes) {
239
- if (!node.isInteractive) {
240
- // Context text node
241
- textLines.push(`_[:]${node.text}`);
242
- continue;
243
- }
244
- if (!node.visible)
245
- continue;
246
- const type = this.resolveType(node.tag, node.inputType);
198
+ this.elementSelectors.clear();
199
+ for (const raw of result.elements) {
200
+ const type = this.resolveType(raw.tag, raw.type);
247
201
  counters[type] = (counters[type] ?? 0) + 1;
248
- const id = `${PREFIX[type]}${counters[type]}`;
202
+ const id = `@${PREFIX[type]}${counters[type]}`;
203
+ // Cache the CSS selector for reliable re-targeting
204
+ this.elementSelectors.set(id, raw.selector);
249
205
  typedElements.push({
250
206
  id,
251
207
  type,
252
- text: node.text || undefined,
253
- placeholder: node.attrs.placeholder,
254
- href: node.attrs.href,
255
- value: node.attrs.value,
256
- disabled: false,
257
- visible: node.visible,
208
+ text: raw.text || undefined,
209
+ placeholder: raw.placeholder,
210
+ href: raw.href,
211
+ value: raw.value,
212
+ disabled: raw.disabled,
213
+ visible: raw.visible,
258
214
  });
259
- textLines.push(this.renderElementLine(id, node.tag, node.text, node.attrs));
260
- }
261
- if (result.pixelsBelow > 0) {
262
- textLines.push(`... ${result.pixelsBelow} pixels below - scroll to see more ...`);
263
- }
264
- // Render dialog content separately
265
- if (dialogNodes.length > 0) {
266
- const dialogGroups = new Map();
267
- for (const node of dialogNodes) {
268
- const key = node.dialogLabel ?? "dialog";
269
- if (!dialogGroups.has(key))
270
- dialogGroups.set(key, []);
271
- dialogGroups.get(key).push(node);
272
- }
273
- for (const [label, nodes] of dialogGroups) {
274
- textLines.push("");
275
- textLines.push(`### Content of 'dialog' component with text '${label.slice(0, 50)}'`);
276
- for (const node of nodes) {
277
- if (!node.isInteractive) {
278
- textLines.push(`_[:]${node.text}`);
279
- continue;
280
- }
281
- if (!node.visible)
282
- continue;
283
- const type = this.resolveType(node.tag, node.inputType);
284
- counters[type] = (counters[type] ?? 0) + 1;
285
- const id = `${PREFIX[type]}${counters[type]}`;
286
- typedElements.push({
287
- id,
288
- type,
289
- text: node.text || undefined,
290
- placeholder: node.attrs.placeholder,
291
- href: node.attrs.href,
292
- value: node.attrs.value,
293
- disabled: false,
294
- visible: node.visible,
295
- });
296
- textLines.push(this.renderElementLine(id, node.tag, node.text, node.attrs));
297
- }
298
- }
299
215
  }
300
- const text = textLines.join("\n");
216
+ const text = this.buildTextRepresentation(typedElements, result);
301
217
  return {
302
218
  url: result.url,
303
219
  title: result.title,
@@ -306,34 +222,139 @@ class Observer {
306
222
  hasCookieBanner: result.hasCookieBanner,
307
223
  isLoading: result.isLoading,
308
224
  text,
309
- currentUrl: result.url,
310
- pageTitle: result.title,
311
- scrollPixelsAbove: result.pixelsAbove,
312
- scrollPixelsBelow: result.pixelsBelow,
313
225
  };
314
226
  }
315
- // Find an element by ID (B3, I1, etc.) and return its locator
227
+ // ─── Element resolution uses cached CSS selectors for reliability ──────────
316
228
  async resolveElementId(id) {
229
+ // First try cached selector (reliable)
230
+ const cachedSelector = this.elementSelectors.get(id);
231
+ if (cachedSelector) {
232
+ const coords = await this.page.evaluate((sel) => {
233
+ const el = document.querySelector(sel);
234
+ if (!el)
235
+ return null;
236
+ const rect = el.getBoundingClientRect();
237
+ return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
238
+ }, cachedSelector);
239
+ if (coords)
240
+ return coords;
241
+ }
242
+ // Fallback: find by text content (original behavior)
317
243
  const elements = await this.observe();
318
244
  const el = elements.elements.find((e) => e.id === id);
319
245
  if (!el)
320
246
  return null;
321
247
  return await this.page.evaluate((targetText) => {
322
- const all = document.querySelectorAll("button, input, a, select, textarea, [role='button'], [role='link'], [role='menuitem'], [role='tab'], [onclick], [tabindex]");
248
+ const all = document.querySelectorAll("button, input, a, select, textarea, [role='button']");
323
249
  for (const el of Array.from(all)) {
324
250
  const text = (el.textContent ?? "").replace(/\s+/g, " ").trim();
325
251
  const aria = el.getAttribute("aria-label") ?? "";
326
252
  if (text.includes(targetText) || aria.includes(targetText)) {
327
253
  const rect = el.getBoundingClientRect();
328
- return {
329
- x: rect.left + rect.width / 2,
330
- y: rect.top + rect.height / 2,
331
- };
254
+ return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
332
255
  }
333
256
  }
334
257
  return null;
335
258
  }, el.text ?? el.placeholder ?? "");
336
259
  }
260
+ // Get the cached CSS selector for an element ID
261
+ getSelector(id) {
262
+ return this.elementSelectors.get(id);
263
+ }
264
+ // ─── Form detection ─────────────────────────────────────────────────────────
265
+ async detectForms() {
266
+ const observation = await this.observe();
267
+ const visibleInputs = observation.elements.filter((e) => e.visible && (e.type === "input" || e.type === "textarea"));
268
+ const visibleButtons = observation.elements.filter((e) => e.visible && e.type === "button" && !e.disabled);
269
+ if (visibleInputs.length === 0)
270
+ return [];
271
+ const forms = [];
272
+ // Detect search forms: single input + optional submit button
273
+ const searchInput = visibleInputs.find((e) => /search|buscar|query|find|lookup/i.test(e.placeholder ?? "") ||
274
+ /search|buscar|query|find|lookup/i.test(e.text ?? "")) ?? visibleInputs[0]; // default to first visible input
275
+ const submitButton = visibleButtons.find((e) => /search|submit|go|find|buscar|enter/i.test(e.text ?? "")) ?? visibleButtons[0] ?? null;
276
+ const isMultiField = visibleInputs.length > 2;
277
+ const isLogin = visibleInputs.some((e) => /password|contraseña/i.test(e.placeholder ?? ""));
278
+ forms.push({
279
+ mainInput: searchInput?.id ?? null,
280
+ submitButton: submitButton?.id ?? null,
281
+ fields: visibleInputs.map((e) => ({
282
+ elementId: e.id,
283
+ name: e.text ?? e.placeholder ?? "",
284
+ type: e.type,
285
+ isMain: e.id === searchInput?.id,
286
+ })),
287
+ formType: isLogin ? "login" : isMultiField ? "multi-field" : "search",
288
+ });
289
+ return forms;
290
+ }
291
+ // ─── Autocomplete/dropdown detection ────────────────────────────────────────
292
+ async detectAutocomplete() {
293
+ return await this.page.evaluate(() => {
294
+ const selectors = [
295
+ '[class*="autocomplete"] li',
296
+ '[class*="autocomplete"] a',
297
+ '[class*="suggestion"] li',
298
+ '[class*="suggestion"] a',
299
+ '[class*="dropdown"] li a',
300
+ '[class*="search-result"] a',
301
+ '[class*="search-result"] li',
302
+ '[role="listbox"] [role="option"]',
303
+ '[role="listbox"] li',
304
+ 'ul[class*="result"] li',
305
+ 'ul[class*="result"] a',
306
+ ".autocomplete-results a",
307
+ ".tt-suggestion",
308
+ ".ui-autocomplete li",
309
+ ".search-suggestions a",
310
+ ".pac-item",
311
+ ];
312
+ const items = [];
313
+ for (const sel of selectors) {
314
+ const els = document.querySelectorAll(sel);
315
+ for (const el of Array.from(els)) {
316
+ const text = (el.textContent ?? "").trim();
317
+ if (text.length > 0 && text.length < 200) {
318
+ items.push({ text, selector: sel });
319
+ }
320
+ }
321
+ if (items.length > 0)
322
+ break; // found autocomplete items
323
+ }
324
+ return items.slice(0, 10);
325
+ });
326
+ }
327
+ // ─── Error message detection ────────────────────────────────────────────────
328
+ async detectErrors() {
329
+ return await this.page.evaluate(() => {
330
+ const errorSelectors = [
331
+ '[class*="error"]', '[class*="no-results"]', '[class*="empty-state"]',
332
+ '[class*="not-found"]', '[role="alert"]',
333
+ ];
334
+ const errorTexts = [];
335
+ for (const sel of errorSelectors) {
336
+ const els = document.querySelectorAll(sel);
337
+ for (const el of Array.from(els)) {
338
+ const text = (el.textContent ?? "").trim();
339
+ if (text.length > 5 && text.length < 300) {
340
+ const style = window.getComputedStyle(el);
341
+ if (style.display !== "none" && style.visibility !== "hidden") {
342
+ errorTexts.push(text);
343
+ }
344
+ }
345
+ }
346
+ }
347
+ // Also check for common error text patterns
348
+ const body = document.body.innerText.toLowerCase();
349
+ const patterns = ["no results", "no se encontr", "not found", "no data", "no matches"];
350
+ for (const p of patterns) {
351
+ if (body.includes(p) && !errorTexts.some((t) => t.toLowerCase().includes(p))) {
352
+ errorTexts.push(`Page contains: "${p}"`);
353
+ }
354
+ }
355
+ return [...new Set(errorTexts)].slice(0, 5);
356
+ });
357
+ }
337
358
  // ─── Helpers ─────────────────────────────────────────────────────────────────
338
359
  resolveType(tag, inputType) {
339
360
  if (tag === "button")
@@ -345,7 +366,7 @@ class Observer {
345
366
  return "radio";
346
367
  return "input";
347
368
  }
348
- if (tag === "link" || tag === "a")
369
+ if (tag === "link")
349
370
  return "link";
350
371
  if (tag === "select")
351
372
  return "select";
@@ -357,19 +378,38 @@ class Observer {
357
378
  return "radio";
358
379
  return "other";
359
380
  }
360
- // Renders: B1[:]<button type="submit" aria_label="Search">Google Search</button>
361
- renderElementLine(id, tag, text, attrs) {
362
- // Map our tag names to HTML tag names for rendering
363
- const htmlTag = tag === "link" ? "a" : tag;
364
- const attrParts = [];
365
- for (const key of ATTR_WHITELIST) {
366
- const val = attrs[key];
367
- if (val != null && val !== "") {
368
- attrParts.push(`${key}="${val}"`);
369
- }
381
+ buildTextRepresentation(elements, meta) {
382
+ const lines = [
383
+ `PAGE: ${meta.title}`,
384
+ `URL: ${meta.url}`,
385
+ ];
386
+ if (meta.isLoading)
387
+ lines.push("[PAGE IS LOADING]");
388
+ if (meta.hasModal)
389
+ lines.push("[MODAL IS OPEN - may block interactions]");
390
+ if (meta.hasCookieBanner)
391
+ lines.push("[COOKIE BANNER DETECTED - dismiss first]");
392
+ lines.push("", "INTERACTIVE ELEMENTS:");
393
+ const visible = elements.filter((e) => e.visible !== false);
394
+ const hidden = elements.filter((e) => e.visible === false);
395
+ for (const el of visible) {
396
+ let desc = ` ${el.id} [${el.type}]`;
397
+ if (el.text)
398
+ desc += ` "${el.text}"`;
399
+ if (el.placeholder)
400
+ desc += ` placeholder="${el.placeholder}"`;
401
+ if (el.value)
402
+ desc += ` value="${el.value}"`;
403
+ if (el.href)
404
+ desc += ` -> ${el.href.slice(0, 80)}`;
405
+ if (el.disabled)
406
+ desc += " (disabled)";
407
+ lines.push(desc);
408
+ }
409
+ if (hidden.length > 0) {
410
+ lines.push(` ... and ${hidden.length} hidden elements`);
370
411
  }
371
- const attrStr = attrParts.length > 0 ? " " + attrParts.join(" ") : "";
372
- return `${id}[:]<${htmlTag}${attrStr}>${text}</${htmlTag}>`;
412
+ return lines.join("\n");
373
413
  }
374
414
  }
375
415
  exports.Observer = Observer;