@auto-wiz/puppeteer 1.2.2 → 1.2.4
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/runner.d.ts +1 -1
- package/dist/runner.js +292 -25
- package/package.json +3 -3
package/dist/runner.d.ts
CHANGED
package/dist/runner.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.PuppeteerFlowRunner = void 0;
|
|
4
|
+
const MIN_CONFIDENCE_SCORE = 80;
|
|
5
|
+
const LOCATOR_POLL_INTERVAL_MS = 100;
|
|
4
6
|
class PuppeteerFlowRunner {
|
|
5
7
|
async run(flow, page, options = {}) {
|
|
6
8
|
const extractedData = {};
|
|
@@ -62,35 +64,63 @@ class PuppeteerFlowRunner {
|
|
|
62
64
|
break;
|
|
63
65
|
}
|
|
64
66
|
case "select": {
|
|
65
|
-
const
|
|
66
|
-
// Puppeteer select uses selector string, not element handle usually
|
|
67
|
+
const el = await this.getElement(page, step, timeout);
|
|
67
68
|
if (step.value) {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
69
|
+
await el.evaluate((node, value) => {
|
|
70
|
+
if (!(node instanceof HTMLSelectElement)) {
|
|
71
|
+
throw new Error("Element is not a select element");
|
|
72
|
+
}
|
|
73
|
+
node.value = value;
|
|
74
|
+
node.dispatchEvent(new Event("input", { bubbles: true }));
|
|
75
|
+
node.dispatchEvent(new Event("change", { bubbles: true }));
|
|
76
|
+
}, step.value);
|
|
71
77
|
}
|
|
72
78
|
break;
|
|
73
79
|
}
|
|
74
80
|
case "extract": {
|
|
75
|
-
const
|
|
76
|
-
await
|
|
77
|
-
// Extract text
|
|
78
|
-
const text = await page.$eval(selector, (el) => el.textContent);
|
|
81
|
+
const el = await this.getElement(page, step, timeout);
|
|
82
|
+
const text = await el.evaluate((node) => node.textContent);
|
|
79
83
|
return { success: true, extractedData: text?.trim() };
|
|
80
84
|
}
|
|
81
85
|
case "waitFor": {
|
|
82
86
|
if (step.locator) {
|
|
83
|
-
const
|
|
84
|
-
await
|
|
85
|
-
visible: true,
|
|
86
|
-
timeout: step.timeoutMs || timeout,
|
|
87
|
-
});
|
|
87
|
+
const handle = await this.getElement(page, step, step.timeoutMs || timeout);
|
|
88
|
+
await handle.dispose();
|
|
88
89
|
}
|
|
89
90
|
else if (step.timeoutMs) {
|
|
90
91
|
await new Promise((r) => setTimeout(r, step.timeoutMs));
|
|
91
92
|
}
|
|
92
93
|
break;
|
|
93
94
|
}
|
|
95
|
+
case "keyboard": {
|
|
96
|
+
const key = step.key;
|
|
97
|
+
if (!key) {
|
|
98
|
+
return { success: false, error: "Keyboard step requires key" };
|
|
99
|
+
}
|
|
100
|
+
if (step.locator) {
|
|
101
|
+
const el = await this.getElement(page, step, timeout);
|
|
102
|
+
await el.focus();
|
|
103
|
+
}
|
|
104
|
+
await page.keyboard.press(key);
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
case "waitForNavigation": {
|
|
108
|
+
const navTimeout = step.timeoutMs || timeout;
|
|
109
|
+
await page.waitForNavigation({
|
|
110
|
+
waitUntil: "domcontentloaded",
|
|
111
|
+
timeout: navTimeout,
|
|
112
|
+
});
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
case "screenshot": {
|
|
116
|
+
if (step.locator) {
|
|
117
|
+
const el = await this.getElement(page, step, timeout);
|
|
118
|
+
const base64 = await el.screenshot({ encoding: "base64" });
|
|
119
|
+
return { success: true, extractedData: base64 };
|
|
120
|
+
}
|
|
121
|
+
const base64 = await page.screenshot({ encoding: "base64" });
|
|
122
|
+
return { success: true, extractedData: base64 };
|
|
123
|
+
}
|
|
94
124
|
}
|
|
95
125
|
return { success: true };
|
|
96
126
|
}
|
|
@@ -106,22 +136,259 @@ class PuppeteerFlowRunner {
|
|
|
106
136
|
return text;
|
|
107
137
|
return text.replace(/\{\{(\w+)\}\}/g, (_, key) => variables[key] ?? "");
|
|
108
138
|
}
|
|
109
|
-
|
|
139
|
+
async getElement(page, step, timeout) {
|
|
110
140
|
if (!("locator" in step) || !step.locator) {
|
|
111
141
|
throw new Error(`Step ${step.type} requires a locator`);
|
|
112
142
|
}
|
|
113
|
-
const { primary } = step.locator;
|
|
114
|
-
|
|
143
|
+
const { primary, fallbacks = [], metadata } = step.locator;
|
|
144
|
+
const selectors = [primary, ...fallbacks];
|
|
145
|
+
if (selectors.length === 0) {
|
|
146
|
+
throw new Error(`Step ${step.type} has no valid selectors`);
|
|
147
|
+
}
|
|
148
|
+
const startedAt = Date.now();
|
|
149
|
+
let lastError = "No candidates found";
|
|
150
|
+
while (Date.now() - startedAt < timeout) {
|
|
151
|
+
const resolved = await this.pickBestCandidate(page, selectors, metadata);
|
|
152
|
+
if (resolved) {
|
|
153
|
+
if (resolved.totalCandidates === 1 ||
|
|
154
|
+
resolved.candidate.score >= MIN_CONFIDENCE_SCORE) {
|
|
155
|
+
const handles = await page.$$(resolved.candidate.selector);
|
|
156
|
+
const element = handles[resolved.candidate.elementIndex];
|
|
157
|
+
if (element)
|
|
158
|
+
return element;
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
lastError = `AMBIGUOUS_LOCATOR: ${resolved.totalCandidates} candidates found, best score=${resolved.candidate.score}, selector=${resolved.candidate.selector}`;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
await new Promise((resolve) => setTimeout(resolve, LOCATOR_POLL_INTERVAL_MS));
|
|
165
|
+
}
|
|
166
|
+
throw new Error(`Failed to resolve locator. Tried selectors=${JSON.stringify(selectors)}, metadata=${JSON.stringify(metadata || {})}, reason=${lastError}`);
|
|
115
167
|
}
|
|
116
|
-
async
|
|
117
|
-
const
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
168
|
+
async pickBestCandidate(page, selectors, metadata) {
|
|
169
|
+
const rawCandidates = [];
|
|
170
|
+
for (const [selectorOrder, selector] of selectors.entries()) {
|
|
171
|
+
let evaluated = [];
|
|
172
|
+
try {
|
|
173
|
+
evaluated = await page.$$eval(selector, (elements, payload) => {
|
|
174
|
+
const getImplicitRoleForElement = (element) => {
|
|
175
|
+
const tagName = element.tagName.toLowerCase();
|
|
176
|
+
const type = element instanceof HTMLInputElement
|
|
177
|
+
? element.getAttribute("type")
|
|
178
|
+
: null;
|
|
179
|
+
const roleMap = {
|
|
180
|
+
button: "button",
|
|
181
|
+
input: type === "text" || !type
|
|
182
|
+
? "textbox"
|
|
183
|
+
: type === "checkbox"
|
|
184
|
+
? "checkbox"
|
|
185
|
+
: type === "radio"
|
|
186
|
+
? "radio"
|
|
187
|
+
: type === "button" || type === "submit"
|
|
188
|
+
? "button"
|
|
189
|
+
: "",
|
|
190
|
+
textarea: "textbox",
|
|
191
|
+
select: "combobox",
|
|
192
|
+
};
|
|
193
|
+
return roleMap[tagName] || null;
|
|
194
|
+
};
|
|
195
|
+
const getAssociatedLabelText = (element) => {
|
|
196
|
+
if (!(element instanceof HTMLInputElement) &&
|
|
197
|
+
!(element instanceof HTMLTextAreaElement) &&
|
|
198
|
+
!(element instanceof HTMLSelectElement)) {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
if (element.id) {
|
|
202
|
+
const label = document.querySelector(`label[for="${CSS.escape(element.id)}"]`);
|
|
203
|
+
if (label)
|
|
204
|
+
return label.textContent?.trim() || null;
|
|
205
|
+
}
|
|
206
|
+
const parentLabel = element.closest("label");
|
|
207
|
+
if (parentLabel) {
|
|
208
|
+
const clone = parentLabel.cloneNode(true);
|
|
209
|
+
const input = clone.querySelector("input, textarea, select");
|
|
210
|
+
if (input)
|
|
211
|
+
input.remove();
|
|
212
|
+
const text = clone.textContent?.trim();
|
|
213
|
+
if (text)
|
|
214
|
+
return text;
|
|
215
|
+
}
|
|
216
|
+
const labelledBy = element.getAttribute("aria-labelledby");
|
|
217
|
+
if (labelledBy) {
|
|
218
|
+
const labelEl = document.getElementById(labelledBy);
|
|
219
|
+
if (labelEl)
|
|
220
|
+
return labelEl.textContent?.trim() || null;
|
|
221
|
+
}
|
|
222
|
+
const prevSibling = element.previousElementSibling;
|
|
223
|
+
if (prevSibling && prevSibling.tagName.toLowerCase() === "label") {
|
|
224
|
+
return prevSibling.textContent?.trim() || null;
|
|
225
|
+
}
|
|
226
|
+
const parent = element.parentElement;
|
|
227
|
+
if (parent) {
|
|
228
|
+
const labelInParent = parent.querySelector("label");
|
|
229
|
+
if (labelInParent) {
|
|
230
|
+
const forAttr = labelInParent.getAttribute("for");
|
|
231
|
+
if (!forAttr || forAttr === element.id) {
|
|
232
|
+
return labelInParent.textContent?.trim() || null;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return null;
|
|
237
|
+
};
|
|
238
|
+
const getFormFieldIndex = (element) => {
|
|
239
|
+
if (!(element instanceof HTMLElement))
|
|
240
|
+
return null;
|
|
241
|
+
const form = element.closest("form");
|
|
242
|
+
if (!form)
|
|
243
|
+
return null;
|
|
244
|
+
const fields = Array.from(form.querySelectorAll("input, textarea, select"));
|
|
245
|
+
const index = fields.indexOf(element);
|
|
246
|
+
return index >= 0 ? index + 1 : null;
|
|
247
|
+
};
|
|
248
|
+
const isVisible = (element) => {
|
|
249
|
+
if (!(element instanceof HTMLElement || element instanceof SVGElement)) {
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
const style = window.getComputedStyle(element);
|
|
253
|
+
if (style.display === "none" ||
|
|
254
|
+
style.visibility === "hidden" ||
|
|
255
|
+
style.opacity === "0") {
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
if (element.getClientRects().length === 0) {
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
return true;
|
|
262
|
+
};
|
|
263
|
+
const isInteractable = (element) => {
|
|
264
|
+
if (!(element instanceof HTMLElement || element instanceof SVGElement)) {
|
|
265
|
+
return false;
|
|
266
|
+
}
|
|
267
|
+
if (!isVisible(element))
|
|
268
|
+
return false;
|
|
269
|
+
if (element instanceof HTMLInputElement ||
|
|
270
|
+
element instanceof HTMLTextAreaElement ||
|
|
271
|
+
element instanceof HTMLSelectElement ||
|
|
272
|
+
element instanceof HTMLButtonElement) {
|
|
273
|
+
if (element.disabled)
|
|
274
|
+
return false;
|
|
275
|
+
}
|
|
276
|
+
const style = window.getComputedStyle(element);
|
|
277
|
+
return style.pointerEvents !== "none";
|
|
278
|
+
};
|
|
279
|
+
const getDomPath = (element) => {
|
|
280
|
+
const parts = [];
|
|
281
|
+
let current = element;
|
|
282
|
+
while (current && current !== document.body) {
|
|
283
|
+
const tag = current.tagName.toLowerCase();
|
|
284
|
+
let siblingIndex = 1;
|
|
285
|
+
let sibling = current.previousElementSibling;
|
|
286
|
+
while (sibling) {
|
|
287
|
+
if (sibling.tagName === current.tagName)
|
|
288
|
+
siblingIndex += 1;
|
|
289
|
+
sibling = sibling.previousElementSibling;
|
|
290
|
+
}
|
|
291
|
+
parts.unshift(`${tag}:nth-of-type(${siblingIndex})`);
|
|
292
|
+
current = current.parentElement;
|
|
293
|
+
}
|
|
294
|
+
return parts.join(">");
|
|
295
|
+
};
|
|
296
|
+
const calculateScore = (element, candidateMetadata) => {
|
|
297
|
+
if (!candidateMetadata)
|
|
298
|
+
return 0;
|
|
299
|
+
let score = 0;
|
|
300
|
+
const elementTag = element.tagName.toLowerCase();
|
|
301
|
+
if (candidateMetadata.testId &&
|
|
302
|
+
element instanceof HTMLElement &&
|
|
303
|
+
(element.getAttribute("data-testid") === candidateMetadata.testId ||
|
|
304
|
+
element.getAttribute("data-test") === candidateMetadata.testId ||
|
|
305
|
+
element.getAttribute("data-cy") === candidateMetadata.testId ||
|
|
306
|
+
element.getAttribute("data-test-id") === candidateMetadata.testId)) {
|
|
307
|
+
score += 120;
|
|
308
|
+
}
|
|
309
|
+
if (candidateMetadata.labelText) {
|
|
310
|
+
const labelText = getAssociatedLabelText(element);
|
|
311
|
+
if (labelText === candidateMetadata.labelText) {
|
|
312
|
+
score += 100;
|
|
313
|
+
}
|
|
314
|
+
else if (labelText &&
|
|
315
|
+
labelText.includes(candidateMetadata.labelText)) {
|
|
316
|
+
score += 50;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
if (candidateMetadata.formContext?.fieldIndex) {
|
|
320
|
+
const index = getFormFieldIndex(element);
|
|
321
|
+
if (index === candidateMetadata.formContext.fieldIndex) {
|
|
322
|
+
score += 90;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
if (candidateMetadata.placeholder &&
|
|
326
|
+
element instanceof HTMLElement &&
|
|
327
|
+
element.getAttribute("placeholder") === candidateMetadata.placeholder) {
|
|
328
|
+
score += 70;
|
|
329
|
+
}
|
|
330
|
+
if (candidateMetadata.ariaLabel &&
|
|
331
|
+
element instanceof HTMLElement &&
|
|
332
|
+
element.getAttribute("aria-label") === candidateMetadata.ariaLabel) {
|
|
333
|
+
score += 60;
|
|
334
|
+
}
|
|
335
|
+
if (candidateMetadata.tagName &&
|
|
336
|
+
elementTag === candidateMetadata.tagName) {
|
|
337
|
+
score += 20;
|
|
338
|
+
}
|
|
339
|
+
if (candidateMetadata.role) {
|
|
340
|
+
const elementRole = element instanceof HTMLElement
|
|
341
|
+
? element.getAttribute("role") || getImplicitRoleForElement(element)
|
|
342
|
+
: null;
|
|
343
|
+
if (elementRole === candidateMetadata.role) {
|
|
344
|
+
score += 10;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return score;
|
|
348
|
+
};
|
|
349
|
+
return elements.map((element, elementIndex) => ({
|
|
350
|
+
selector: payload.selector,
|
|
351
|
+
selectorOrder: payload.selectorOrder,
|
|
352
|
+
elementIndex,
|
|
353
|
+
score: calculateScore(element, payload.metadata),
|
|
354
|
+
visible: isVisible(element),
|
|
355
|
+
interactable: isInteractable(element),
|
|
356
|
+
domPath: getDomPath(element),
|
|
357
|
+
}));
|
|
358
|
+
}, { metadata, selector, selectorOrder });
|
|
359
|
+
}
|
|
360
|
+
catch {
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
rawCandidates.push(...evaluated);
|
|
364
|
+
}
|
|
365
|
+
const filtered = rawCandidates.filter((item) => item.visible && item.interactable);
|
|
366
|
+
if (filtered.length === 0)
|
|
367
|
+
return null;
|
|
368
|
+
const deduped = new Map();
|
|
369
|
+
for (const item of filtered) {
|
|
370
|
+
const prev = deduped.get(item.domPath);
|
|
371
|
+
if (!prev) {
|
|
372
|
+
deduped.set(item.domPath, item);
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
if (item.score > prev.score ||
|
|
376
|
+
(item.score === prev.score && item.selectorOrder < prev.selectorOrder)) {
|
|
377
|
+
deduped.set(item.domPath, item);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
const resolved = Array.from(deduped.values());
|
|
381
|
+
resolved.sort((a, b) => {
|
|
382
|
+
if (b.score !== a.score)
|
|
383
|
+
return b.score - a.score;
|
|
384
|
+
if (a.selectorOrder !== b.selectorOrder)
|
|
385
|
+
return a.selectorOrder - b.selectorOrder;
|
|
386
|
+
return a.elementIndex - b.elementIndex;
|
|
121
387
|
});
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
388
|
+
return {
|
|
389
|
+
candidate: resolved[0],
|
|
390
|
+
totalCandidates: resolved.length,
|
|
391
|
+
};
|
|
125
392
|
}
|
|
126
393
|
}
|
|
127
394
|
exports.PuppeteerFlowRunner = PuppeteerFlowRunner;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@auto-wiz/puppeteer",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.4",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"author": "JaeSang",
|
|
6
6
|
"repository": {
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
}
|
|
24
24
|
},
|
|
25
25
|
"peerDependencies": {
|
|
26
|
-
"@auto-wiz/core": "^1.2.
|
|
26
|
+
"@auto-wiz/core": "^1.2.3"
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
29
|
"puppeteer": "^23.0.0"
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"devDependencies": {
|
|
32
32
|
"typescript": "^5.0.0",
|
|
33
33
|
"@types/node": "^20.0.0",
|
|
34
|
-
"@auto-wiz/core": "1.2.
|
|
34
|
+
"@auto-wiz/core": "1.2.3"
|
|
35
35
|
},
|
|
36
36
|
"scripts": {
|
|
37
37
|
"build": "tsc"
|