zan-browser 1.3.38 → 2.0.1
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/dist/ai.d.ts +3 -16
- package/dist/ai.d.ts.map +1 -1
- package/dist/ai.js +101 -208
- package/dist/ai.js.map +1 -1
- package/dist/blacklist.d.ts +2 -0
- package/dist/blacklist.d.ts.map +1 -0
- package/dist/blacklist.js +115 -0
- package/dist/blacklist.js.map +1 -0
- package/dist/browser-provider.d.ts +81 -0
- package/dist/browser-provider.d.ts.map +1 -0
- package/dist/browser-provider.js +6 -0
- package/dist/browser-provider.js.map +1 -0
- package/dist/browser.d.ts +1 -1
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js +57 -59
- package/dist/browser.js.map +1 -1
- package/dist/index.d.ts +7 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/interceptor.d.ts +6 -3
- package/dist/interceptor.d.ts.map +1 -1
- package/dist/interceptor.js +61 -21
- package/dist/interceptor.js.map +1 -1
- package/dist/navigator.d.ts +40 -0
- package/dist/navigator.d.ts.map +1 -0
- package/dist/navigator.js +506 -0
- package/dist/navigator.js.map +1 -0
- package/dist/observer.d.ts +23 -3
- package/dist/observer.d.ts.map +1 -1
- package/dist/observer.js +310 -270
- package/dist/observer.js.map +1 -1
- package/dist/perception.d.ts +42 -0
- package/dist/perception.d.ts.map +1 -0
- package/dist/perception.js +140 -0
- package/dist/perception.js.map +1 -0
- package/dist/providers/browserbase.d.ts +12 -0
- package/dist/providers/browserbase.d.ts.map +1 -0
- package/dist/providers/browserbase.js +226 -0
- package/dist/providers/browserbase.js.map +1 -0
- package/dist/providers/puppeteer-local.d.ts +5 -0
- package/dist/providers/puppeteer-local.d.ts.map +1 -0
- package/dist/providers/puppeteer-local.js +218 -0
- package/dist/providers/puppeteer-local.js.map +1 -0
- package/dist/session.d.ts +17 -23
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +123 -407
- package/dist/session.js.map +1 -1
- package/dist/types.d.ts +1 -32
- package/dist/types.d.ts.map +1 -1
- 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
|
-
//
|
|
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((
|
|
55
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
return
|
|
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
|
-
|
|
81
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
//
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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
|
|
86
|
+
return path.join(" > ");
|
|
122
87
|
};
|
|
123
|
-
//
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
|
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()
|
|
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:
|
|
187
|
+
url: window.location.href,
|
|
214
188
|
title: document.title,
|
|
215
|
-
|
|
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
|
|
195
|
+
// Assign IDs and build structured list + cache selectors
|
|
228
196
|
const counters = {};
|
|
229
197
|
const typedElements = [];
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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 =
|
|
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:
|
|
253
|
-
placeholder:
|
|
254
|
-
href:
|
|
255
|
-
value:
|
|
256
|
-
disabled:
|
|
257
|
-
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 =
|
|
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
|
-
//
|
|
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']
|
|
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"
|
|
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
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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
|
-
|
|
372
|
-
return `${id}[:]<${htmlTag}${attrStr}>${text}</${htmlTag}>`;
|
|
412
|
+
return lines.join("\n");
|
|
373
413
|
}
|
|
374
414
|
}
|
|
375
415
|
exports.Observer = Observer;
|