@evan_zhong/browser-attach 0.17.2 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/README.zh.md +1 -1
- package/docs/ARCHITECTURE.md +12 -8
- package/docs/TEST-REPORT.md +23 -3
- package/docs/USAGE.md +45 -14
- package/extension/actionability.js +8 -3
- package/extension/batch.js +2 -2
- package/extension/click.js +50 -17
- package/extension/connection.js +2 -1
- package/extension/controller.js +15 -7
- package/extension/errors.js +13 -0
- package/extension/evaluate.js +4 -2
- package/extension/input.js +48 -28
- package/extension/locator.js +82 -33
- package/extension/manifest.json +1 -1
- package/extension/scroll.js +9 -3
- package/extension/snapshot.js +55 -3
- package/extension/text.js +7 -5
- package/extension/wait.js +19 -7
- package/package.json +1 -1
- package/scripts/check-package.mjs +1 -1
- package/src/client.mjs +2 -1
- package/src/config.mjs +1 -1
- package/src/hub.mjs +3 -2
- package/src/mcp.mjs +20 -16
package/extension/controller.js
CHANGED
|
@@ -18,6 +18,7 @@ import { waitFor } from './wait.js';
|
|
|
18
18
|
import { boundedRead, installOutcomeObserver } from './outcomes.js';
|
|
19
19
|
import { createDrag, dragOptions } from './drag.js';
|
|
20
20
|
import { createFetches, capturePage } from './fetch.js';
|
|
21
|
+
import { errorData } from './errors.js';
|
|
21
22
|
|
|
22
23
|
export function createController(api, notify = () => {}) {
|
|
23
24
|
const claims = new Map();
|
|
@@ -390,7 +391,7 @@ export function createController(api, notify = () => {}) {
|
|
|
390
391
|
const stepBudget = budget.child(step.timeout_ms ?? 30000, command === 'wait' ? 'wait' : 'input');
|
|
391
392
|
results.push(await perform(tabId, command, { ...inputArgs, dialog_policy: step.dialog_policy ?? args.dialog_policy ?? 'ask', budget: stepBudget }));
|
|
392
393
|
} catch (error) {
|
|
393
|
-
return { completed: false, results, started_at: args.start_at ?? 0, failed_step: index, next_unattempted_step: index + 1 < args.steps.length ? index + 1 : null, retry_failed_step_safe: error.input_dispatched === false,
|
|
394
|
+
return { completed: false, results, started_at: args.start_at ?? 0, failed_step: index, next_unattempted_step: index + 1 < args.steps.length ? index + 1 : null, retry_failed_step_safe: error.input_dispatched === false, ...errorData(error),
|
|
394
395
|
application_result: 'not_checked', observe_again: true };
|
|
395
396
|
}
|
|
396
397
|
}
|
|
@@ -401,7 +402,7 @@ export function createController(api, notify = () => {}) {
|
|
|
401
402
|
if (args.result_type != null && !['json', 'element'].includes(args.result_type)) throw new Error('result_type must be json or element');
|
|
402
403
|
if (args.cursor == null) expireCursor(tabId);
|
|
403
404
|
if (args.cursor != null) return await evaluations.read(tabId, args);
|
|
404
|
-
const budget = deadline(args.timeout_ms ?? 10000, 'evaluation'), version = documents.get(tabId), claim = claims.get(tabId);
|
|
405
|
+
const budget = args.budget || deadline(args.timeout_ms ?? 10000, 'evaluation'), version = documents.get(tabId), claim = claims.get(tabId);
|
|
405
406
|
const frameId = await resolveFrame(cdp, value, sessions, tabId, args, budget), target = sessions.target(tabId, frameId);
|
|
406
407
|
const contextId = args.frame || args.frame_id ? pageContexts.get(JSON.stringify([tabId, target.sessionId, frameId])) : undefined;
|
|
407
408
|
if ((args.frame || args.frame_id) && contextId == null) throw new Error('Page frame context is unavailable; observe again');
|
|
@@ -431,6 +432,11 @@ export function createController(api, notify = () => {}) {
|
|
|
431
432
|
});
|
|
432
433
|
}
|
|
433
434
|
if (command === 'read_text') {
|
|
435
|
+
if (args.selector_type === 'role') {
|
|
436
|
+
const budget = deadline(5000), node = await resolveSelector(cdp, value, sessions, tabId, args, budget);
|
|
437
|
+
try { return { ...await budget.run(() => readText(cdp, value, node.target, { ...args, node })), scope: args.frame || args.frame_id ? 'frame' : 'main_frame', frame_id: node.frameId }; }
|
|
438
|
+
finally { await boundedRead(() => cdp(node.target, 'Runtime.releaseObjectGroup', { objectGroup: 'browser-attach' }), 100).catch(() => {}); }
|
|
439
|
+
}
|
|
434
440
|
if (!args.frame && !args.frame_id) return await readText(cdp, value, tabId, args);
|
|
435
441
|
const budget = deadline(5000), frameId = await resolveFrame(cdp, value, sessions, tabId, args, budget), target = sessions.target(tabId, frameId);
|
|
436
442
|
const contextId = pageContexts.get(JSON.stringify([tabId, target.sessionId, frameId]));
|
|
@@ -440,7 +446,7 @@ export function createController(api, notify = () => {}) {
|
|
|
440
446
|
if (command === 'screenshot') {
|
|
441
447
|
const view = await viewport(tabId);
|
|
442
448
|
const hasTarget = args.ref != null || args.selector != null;
|
|
443
|
-
if (!hasTarget && (args.frame || args.frame_id || args.nth != null || args.selector_type)) throw new Error('Element screenshot frame/nth/type requires an element selector');
|
|
449
|
+
if (!hasTarget && (args.frame || args.frame_id || args.nth != null || args.selector_type || args.name != null)) throw new Error('Element screenshot frame/nth/type/name requires an element selector');
|
|
444
450
|
if (hasTarget && args.full_page) throw new Error('Element target and full_page are mutually exclusive');
|
|
445
451
|
const clip = hasTarget ? await elementClip(tabId, args, view) : args.full_page ? await fullPageClip(tabId) : null;
|
|
446
452
|
const includeIndicators = args.include_indicators === true;
|
|
@@ -498,8 +504,8 @@ export function createController(api, notify = () => {}) {
|
|
|
498
504
|
if (command === 'select_option') selectOptions(args);
|
|
499
505
|
if (command === 'type') typeOptions(args);
|
|
500
506
|
if (command === 'scroll') scrollOptions(args);
|
|
501
|
-
if (['scroll', 'upload_file'].includes(command) && ['ref', 'selector', 'frame', 'frame_id', 'selector_type', 'nth'].some(key => args[key] != null)) targetOptions(args);
|
|
502
|
-
if (['click', 'fill', 'type', 'hover', 'select_option'].includes(command)) targetOptions(args, { coordinates: command === 'hover' || command === 'click' && inputMode(args) !== 'dom' });
|
|
507
|
+
if (['scroll', 'upload_file'].includes(command) && ['ref', 'selector', 'frame', 'frame_id', 'selector_type', 'name', 'nth'].some(key => args[key] != null)) targetOptions(args);
|
|
508
|
+
if (['click', 'fill', 'type', 'hover', 'select_option'].includes(command)) targetOptions(args, { coordinates: command === 'hover' || command === 'click' && inputMode(args) !== 'dom', focused: command === 'type' });
|
|
503
509
|
if (command === 'click') clickOptions(args);
|
|
504
510
|
if (['click', 'fill', 'type', 'press_key', 'scroll', 'hover', 'select_option', 'upload_file'].includes(command)) args = { ...args, budget: args.budget || deadline(args.timeout_ms) };
|
|
505
511
|
// Check before any renderer preparation or ref invalidation. A rejected
|
|
@@ -636,6 +642,9 @@ export function createController(api, notify = () => {}) {
|
|
|
636
642
|
|
|
637
643
|
async function perform(tabId, command, args) {
|
|
638
644
|
if (args.dialog_policy != null && !['ask', 'accept', 'dismiss'].includes(args.dialog_policy)) throw new Error('dialog_policy must be ask, accept or dismiss');
|
|
645
|
+
if (!args.budget && (['click', 'fill', 'type', 'press_key', 'hover', 'scroll', 'select_option', 'upload_file'].includes(command) || command === 'evaluate' && args.cursor == null)) {
|
|
646
|
+
args = { ...args, budget: deadline(args.timeout_ms ?? (command === 'evaluate' ? 10000 : 5000), command === 'evaluate' ? 'evaluation' : 'input') };
|
|
647
|
+
}
|
|
639
648
|
const parent = dialogPolicies.get(tabId), state = { policy: args.dialog_policy ?? parent?.policy ?? 'ask', messages: [], parent };
|
|
640
649
|
dialogPolicies.set(tabId, state);
|
|
641
650
|
const version = documents.get(tabId), startTab = await api.tabs.get(tabId);
|
|
@@ -644,7 +653,7 @@ export function createController(api, notify = () => {}) {
|
|
|
644
653
|
const startObservation = async () => { if (!watch || observer || dialogs.has(tabId)) return; try {
|
|
645
654
|
observer = await boundedRead(async () => {
|
|
646
655
|
if (args.ref) observationFrame = snapshots.get(tabId)?.refs.get(args.ref)?.frameId || observationFrame;
|
|
647
|
-
else if (args.frame || args.frame_id) observationFrame = await resolveFrame(cdp, value, sessions, tabId, args, deadline(Math.max(100, Math.min(200, args.budget?.remaining() ?? 200))));
|
|
656
|
+
else if (args.frame || args.frame_id) observationFrame = await resolveFrame(cdp, value, sessions, tabId, args, deadline(Math.max(100, Math.min(200, args.budget?.remaining() ?? 200))), { wait: false });
|
|
648
657
|
observationTarget = sessions.target(tabId, observationFrame);
|
|
649
658
|
observedVersion = frameStates.get(tabId)?.versions.get(observationFrame);
|
|
650
659
|
const { executionContextId } = await cdp(observationTarget, 'Page.createIsolatedWorld', { frameId: observationFrame, worldName: 'browser-attach-outcome' });
|
|
@@ -672,7 +681,6 @@ export function createController(api, notify = () => {}) {
|
|
|
672
681
|
current_url: current?.url ?? null, dom_changed: dom?.dom_changed ?? null,
|
|
673
682
|
assessment: dialogs.has(tabId) || state.messages.length || documents.get(tabId) !== version || frameChanged || dom?.dom_changed ? 'observable_effects' : 'no_observable_effects',
|
|
674
683
|
note: 'These observations do not prove business success or that an unchanged page received no input. Do not replay automatically.' };
|
|
675
|
-
error.message += ` Post-action observation: ${JSON.stringify(error.post_action)}`;
|
|
676
684
|
} else if (state.messages.length) error.message += ` Dialogs: ${JSON.stringify(state.messages)}`;
|
|
677
685
|
throw error;
|
|
678
686
|
} finally {
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
const fields = ['input_dispatched', 'post_action', 'diagnostics'];
|
|
2
|
+
|
|
3
|
+
export function errorData(error) {
|
|
4
|
+
const data = { error: error.message || String(error) };
|
|
5
|
+
for (const key of fields) if (error[key] !== undefined) data[key] = error[key];
|
|
6
|
+
return data;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function errorFromData(data) {
|
|
10
|
+
const error = new Error(String(data.error));
|
|
11
|
+
for (const key of fields) if (data[key] !== undefined) error[key] = data[key];
|
|
12
|
+
return error;
|
|
13
|
+
}
|
package/extension/evaluate.js
CHANGED
|
@@ -109,8 +109,8 @@ export function createEvaluations(cdp) {
|
|
|
109
109
|
}
|
|
110
110
|
const { kind, items, offset, result } = state;
|
|
111
111
|
const length = kind === 'scalar' ? 1 : items.length;
|
|
112
|
-
const make = end => kind === 'string'
|
|
113
|
-
kind === 'object' ? Object.fromEntries(items.slice(
|
|
112
|
+
const make = (end, start = offset) => kind === 'string' || kind === 'array' ? items.slice(start, end) :
|
|
113
|
+
kind === 'object' ? Object.fromEntries(items.slice(start, end)) : items;
|
|
114
114
|
let low = offset, high = length;
|
|
115
115
|
while (low < high) {
|
|
116
116
|
const mid = Math.ceil((low + high) / 2);
|
|
@@ -124,7 +124,9 @@ export function createEvaluations(cdp) {
|
|
|
124
124
|
}
|
|
125
125
|
state.offset = low;
|
|
126
126
|
state.cursor = low < length ? `${state.token}@${low}` : null;
|
|
127
|
+
const nextRequired = low < length ? JSON.stringify(make(low + 1, low)).length : null;
|
|
127
128
|
return { ...result, value: make(low), result_kind: kind, has_more: low < length,
|
|
129
|
+
...(nextRequired > max ? { next_item: { ...(kind === 'object' ? { key: items[low][0] } : { index: low }), required_chars: nextRequired, exceeds_max_chars: nextRequired > 100000 } } : {}),
|
|
128
130
|
next_cursor: state.cursor, capture_truncated: result.truncated, truncated: result.truncated || low < length };
|
|
129
131
|
}
|
|
130
132
|
return { read, forgetFrame(tabId, frameId) { if (results.get(tabId)?.frameId === frameId) results.delete(tabId); }, forget: tabId => results.delete(tabId) };
|
package/extension/input.js
CHANGED
|
@@ -32,6 +32,30 @@ export function createInput(cdp, value, sessions) {
|
|
|
32
32
|
}
|
|
33
33
|
function cancel(tabId) { pending.get(tabId)?.reject(new Error('Tab detached during keyboard input; outcome is unknown. Observe before retrying.')); }
|
|
34
34
|
|
|
35
|
+
async function focusedNode(tabId, budget, worlds, frames) {
|
|
36
|
+
frames ||= await budget.run(() => sessions.frames(tabId));
|
|
37
|
+
let frameId = frames[0].id;
|
|
38
|
+
const visited = new Set();
|
|
39
|
+
while (true) {
|
|
40
|
+
if (visited.has(frameId)) throw new Error('Focus frame cycle; no input was dispatched.');
|
|
41
|
+
visited.add(frameId);
|
|
42
|
+
let world = worlds?.get(frameId);
|
|
43
|
+
if (!world) {
|
|
44
|
+
if (worlds || !frames.some(frame => frame.id === frameId)) throw new Error('Focused frame is unavailable; no input was dispatched.');
|
|
45
|
+
const target = sessions.target(tabId, frameId);
|
|
46
|
+
const { executionContextId } = await budget.run(() => cdp(target, 'Page.createIsolatedWorld', { frameId, worldName: 'browser-attach-input' }));
|
|
47
|
+
world = { target, contextId: executionContextId };
|
|
48
|
+
}
|
|
49
|
+
const active = await budget.run(() => cdp(world.target, 'Runtime.evaluate', { contextId: world.contextId,
|
|
50
|
+
expression: `(${activeElement.toString()})()`, objectGroup: 'browser-attach' }));
|
|
51
|
+
value(active);
|
|
52
|
+
if (!active.result?.objectId) throw new Error('No focused page element is available; no input was dispatched.');
|
|
53
|
+
const { node } = await budget.run(() => cdp(world.target, 'DOM.describeNode', { objectId: active.result.objectId }));
|
|
54
|
+
if (node.frameId) frameId = node.frameId;
|
|
55
|
+
else return { target: world.target, frameId, objectId: active.result.objectId, backendNodeId: node.backendNodeId };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
35
59
|
async function pressKey(tabId, chord, args = {}) {
|
|
36
60
|
const budget = args.budget || deadline(args.timeout_ms);
|
|
37
61
|
const send = (...params) => budget.run(() => cdp(...params));
|
|
@@ -66,34 +90,20 @@ export function createInput(cdp, value, sessions) {
|
|
|
66
90
|
omittedFrames.push({ frame_id: frame.id, reason: error.message });
|
|
67
91
|
}
|
|
68
92
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
while (focusTarget) {
|
|
72
|
-
const { target, contextId } = focusTarget;
|
|
73
|
-
if (visited.has(contextId + ':' + target.sessionId)) throw new Error('Focus frame cycle; no input was dispatched.');
|
|
74
|
-
visited.add(contextId + ':' + target.sessionId);
|
|
75
|
-
const active = await send(target, 'Runtime.evaluate', { contextId,
|
|
76
|
-
expression: `(${activeElement.toString()})()`, objectGroup: 'browser-attach' });
|
|
77
|
-
if (!active.result?.objectId) throw new Error('No focused page element is available; no input was dispatched.');
|
|
78
|
-
const { node } = await send(target, 'DOM.describeNode', { objectId: active.result.objectId });
|
|
79
|
-
if (node.frameId) {
|
|
80
|
-
focusTarget = worlds.get(node.frameId);
|
|
81
|
-
if (!focusTarget) throw new Error('Focused frame is unavailable; no input was dispatched.');
|
|
82
|
-
} else { focusedNode = { objectId: active.result.objectId }; break; }
|
|
83
|
-
}
|
|
84
|
-
await waitForTarget(cdp, value, focusTarget.target, focusedNode, budget);
|
|
93
|
+
const focused = await focusedNode(tabId, budget, worlds, frames);
|
|
94
|
+
await waitForTarget(cdp, value, focused.target, focused, budget);
|
|
85
95
|
if (args.expectedNode) {
|
|
86
96
|
const expected = args.expectedNode;
|
|
87
|
-
const
|
|
97
|
+
const matches = focused.frameId === expected.frameId && value(await send(expected.target, 'Runtime.callFunctionOn', { objectId: expected.objectId,
|
|
88
98
|
functionDeclaration: 'function() { const a=this.getRootNode().activeElement; return this.isConnected && (a===this || this.contains(a)); }', returnByValue: true }));
|
|
89
|
-
if (!
|
|
99
|
+
if (!matches) throw new Error('Typing target lost focus during key preparation; no further key was dispatched');
|
|
90
100
|
}
|
|
91
101
|
budget.dispatch();
|
|
92
102
|
// Queue the complete chord before waiting for acknowledgements. Always
|
|
93
103
|
// enqueue keyUp, even if keyDown fails or opens a modal.
|
|
94
|
-
const commands = [send(
|
|
95
|
-
if (text != null && args.character == null) commands.push(send(
|
|
96
|
-
commands.push(send(
|
|
104
|
+
const commands = [send(focused.target, 'Input.dispatchKeyEvent', { type: args.character != null ? 'keyDown' : 'rawKeyDown', ...(args.character != null ? { text, unmodifiedText: text } : {}), ...params })];
|
|
105
|
+
if (text != null && args.character == null) commands.push(send(focused.target, 'Input.dispatchKeyEvent', { type: 'char', text, unmodifiedText: text, ...params }));
|
|
106
|
+
commands.push(send(focused.target, 'Input.dispatchKeyEvent', { type: 'keyUp', ...params }));
|
|
97
107
|
await budget.run(() => delivery(Promise.all([Promise.all(commands), receipt])));
|
|
98
108
|
return { sent: chord, method: 'native', delivery_verified: true, application_result: 'not_checked',
|
|
99
109
|
...(omittedFrames.length ? { verification_omitted_frames: omittedFrames } : {}), observe_again: true };
|
|
@@ -135,21 +145,28 @@ export function createInput(cdp, value, sessions) {
|
|
|
135
145
|
async function selectOption(tabId, node, args) {
|
|
136
146
|
selectOptions(args);
|
|
137
147
|
const budget = args.budget || deadline(args.timeout_ms);
|
|
138
|
-
|
|
148
|
+
const choice = { value: args.value, label: args.label, index: args.index };
|
|
149
|
+
value(await budget.run(() => cdp(node.target, 'Runtime.callFunctionOn', {
|
|
150
|
+
objectId: node.objectId, functionDeclaration: selectElement.toString(),
|
|
151
|
+
arguments: [{ value: choice }, { value: false }], returnByValue: true,
|
|
152
|
+
})));
|
|
139
153
|
budget.dispatch();
|
|
140
154
|
const result = value(await budget.run(() => cdp(node.target, 'Runtime.callFunctionOn', {
|
|
141
155
|
objectId: node.objectId, functionDeclaration: selectElement.toString(),
|
|
142
|
-
arguments: [{ value:
|
|
156
|
+
arguments: [{ value: choice }], returnByValue: true,
|
|
143
157
|
})));
|
|
144
158
|
return { ...result, method: 'dom', events_trusted: false, application_result: 'not_checked', observe_again: true };
|
|
145
159
|
}
|
|
146
160
|
async function typeText(tabId, node, args) {
|
|
147
161
|
typeOptions(args);
|
|
148
162
|
const budget = args.budget || deadline(args.timeout_ms);
|
|
163
|
+
if (args.focused) node = await focusedNode(tabId, budget);
|
|
149
164
|
await waitForTarget(cdp, value, node.target, node, budget, { editable: true });
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
165
|
+
if (!args.focused) {
|
|
166
|
+
budget.dispatch();
|
|
167
|
+
value(await budget.run(() => cdp(node.target, 'Runtime.callFunctionOn', { objectId: node.objectId,
|
|
168
|
+
functionDeclaration: 'function() { this.focus(); if (this.getRootNode().activeElement !== this && !this.contains(this.getRootNode().activeElement)) throw new Error("Field did not receive focus"); }', returnByValue: true })));
|
|
169
|
+
}
|
|
153
170
|
let typed = 0; const length = Array.from(args.text).length;
|
|
154
171
|
for (const character of args.text) {
|
|
155
172
|
const focused = value(await budget.run(() => cdp(node.target, 'Runtime.callFunctionOn', { objectId: node.objectId,
|
|
@@ -286,14 +303,17 @@ export function selectOptions(args) {
|
|
|
286
303
|
for (const key of ['value', 'label']) if (args[key] != null && typeof args[key] !== 'string') throw new Error(`${key} must be a string`);
|
|
287
304
|
}
|
|
288
305
|
|
|
289
|
-
export function selectElement(choice) {
|
|
306
|
+
export function selectElement(choice, apply = true) {
|
|
290
307
|
if (this.tagName !== 'SELECT' || !this.isConnected) throw new Error('Target must be a connected HTML select');
|
|
291
|
-
|
|
308
|
+
for (let parent = this; parent; parent = parent.assignedSlot || parent.parentElement || parent.getRootNode().host) {
|
|
309
|
+
if (parent.matches(':disabled') || parent.hasAttribute('inert')) throw new Error('Select is disabled or inert');
|
|
310
|
+
}
|
|
292
311
|
const options = Array.from(this.options);
|
|
293
312
|
const matches = options.filter((option, index) => choice.index != null ? index === choice.index : choice.value != null ? option.value === choice.value : option.label === choice.label);
|
|
294
313
|
if (matches.length !== 1) throw new Error(`Option must match exactly once; matched ${matches.length}. Use an explicit index for duplicate labels/values. No value was set.`);
|
|
295
314
|
const option = matches[0];
|
|
296
315
|
if (option.disabled || option.parentElement?.tagName === 'OPTGROUP' && option.parentElement.disabled) throw new Error('Option is disabled; no value was set');
|
|
316
|
+
if (!apply) return true;
|
|
297
317
|
const win = this.ownerDocument.defaultView;
|
|
298
318
|
const index = options.indexOf(option);
|
|
299
319
|
Object.getOwnPropertyDescriptor(win.HTMLSelectElement.prototype, 'selectedIndex').set.call(this, index);
|
package/extension/locator.js
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
// Selectors are reusable queries, never identities inferred from old AX text.
|
|
2
|
-
export function targetOptions(args, { coordinates = false } = {}) {
|
|
2
|
+
export function targetOptions(args, { coordinates = false, focused = false } = {}) {
|
|
3
3
|
const hasRef = args.ref != null, hasSelector = args.selector != null;
|
|
4
4
|
const hasPoint = args.x != null || args.y != null;
|
|
5
|
-
if (
|
|
5
|
+
if (args.focused != null && (args.focused !== true || !focused)) throw new Error('focused=true is only supported by type');
|
|
6
|
+
if (Number(hasRef) + Number(hasSelector) + Number(hasPoint) + Number(args.focused === true) !== 1 ||
|
|
6
7
|
(hasPoint && (!coordinates || !Number.isFinite(args.x) || !Number.isFinite(args.y)))) {
|
|
7
|
-
throw new Error(coordinates ? 'Provide exactly one of ref, selector, or both finite x and y' : 'Provide exactly one of ref or selector');
|
|
8
|
+
throw new Error(focused ? 'Provide exactly one of ref, selector or focused=true' : coordinates ? 'Provide exactly one of ref, selector, or both finite x and y' : 'Provide exactly one of ref or selector');
|
|
8
9
|
}
|
|
9
10
|
if (hasRef && (typeof args.ref !== 'string' || !args.ref)) throw new Error('ref must be nonempty');
|
|
10
11
|
if (hasSelector && (typeof args.selector !== 'string' || !args.selector.trim() || args.selector.length > 10000)) throw new Error('selector must contain 1..10000 characters');
|
|
11
|
-
if (args.selector_type != null && !['css', 'xpath'].includes(args.selector_type)) throw new Error('selector_type must be css or
|
|
12
|
+
if (args.selector_type != null && !['css', 'xpath', 'label', 'placeholder', 'role'].includes(args.selector_type)) throw new Error('selector_type must be css, xpath, label, placeholder or role');
|
|
13
|
+
if (args.name != null && (args.selector_type !== 'role' || typeof args.name !== 'string' || args.name.length > 10000)) throw new Error('name requires selector_type=role and must be a string of at most 10000 characters');
|
|
12
14
|
frameOptions(args);
|
|
13
|
-
if (!hasSelector && (args.frame_id != null || args.frame != null || args.selector_type != null)) throw new Error('frame_id and selector_type require selector');
|
|
15
|
+
if (!hasSelector && (args.frame_id != null || args.frame != null || args.selector_type != null)) throw new Error('frame/frame_id and selector_type require selector');
|
|
14
16
|
if (args.nth != null && (!hasSelector || !Number.isSafeInteger(args.nth) || args.nth < 0)) throw new Error('nth requires selector and must be a nonnegative safe integer (zero-based)');
|
|
15
17
|
if (args.coordinate_space != null && (!hasPoint || !['viewport', 'document'].includes(args.coordinate_space))) throw new Error('coordinate_space must be viewport or document and applies to coordinate clicks only');
|
|
16
18
|
}
|
|
@@ -22,7 +24,13 @@ export function queryElement(selector, type = 'css', nth, waitForMatch = false)
|
|
|
22
24
|
const result = document.evaluate(selector, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
|
|
23
25
|
count = result.snapshotLength; at = index => result.snapshotItem(index);
|
|
24
26
|
} else {
|
|
25
|
-
const
|
|
27
|
+
const normalize = text => text.replace(/\s+/g, ' ').trim();
|
|
28
|
+
const elements = type === 'label'
|
|
29
|
+
? Array.from(document.querySelectorAll('input,textarea,select,button,meter,output,progress')).filter(element =>
|
|
30
|
+
Array.from(element.labels || []).some(label => normalize(label.textContent || '') === normalize(selector)))
|
|
31
|
+
: type === 'placeholder'
|
|
32
|
+
? Array.from(document.querySelectorAll('[placeholder]')).filter(element => element.getAttribute('placeholder') === selector)
|
|
33
|
+
: document.querySelectorAll(selector);
|
|
26
34
|
count = elements.length; at = index => elements[index];
|
|
27
35
|
}
|
|
28
36
|
if (waitForMatch && (count === 0 || (nth != null && nth >= count))) return null;
|
|
@@ -43,27 +51,55 @@ export function queryElement(selector, type = 'css', nth, waitForMatch = false)
|
|
|
43
51
|
return element;
|
|
44
52
|
}
|
|
45
53
|
|
|
46
|
-
export async function resolveSelector(cdp, value, sessions, tabId, args, budget) {
|
|
54
|
+
export async function resolveSelector(cdp, value, sessions, tabId, args, budget, { wait = true } = {}) {
|
|
55
|
+
targetOptions(args);
|
|
47
56
|
const frameId = await resolveFrame(cdp, value, sessions, tabId, args, budget);
|
|
48
57
|
const target = sessions.target(tabId, frameId);
|
|
49
58
|
const send = (method, params) => budget.run(() => cdp(target, method, params));
|
|
50
|
-
const
|
|
51
|
-
|
|
59
|
+
const role = args.selector_type === 'role';
|
|
60
|
+
const context = role ? null : await send('Page.createIsolatedWorld', { frameId, worldName: 'browser-attach-locator' });
|
|
61
|
+
let backendNodeId;
|
|
52
62
|
while (true) {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
63
|
+
if (role) {
|
|
64
|
+
const { nodes } = await send('Accessibility.getFullAXTree', { frameId });
|
|
65
|
+
backendNodeId = matchRole(nodes, args)?.backendDOMNodeId;
|
|
66
|
+
} else {
|
|
67
|
+
const result = await send('Runtime.evaluate', { contextId: context.executionContextId, objectGroup: 'browser-attach',
|
|
68
|
+
expression: `(${queryElement.toString()})(${JSON.stringify(args.selector)},${JSON.stringify(args.selector_type ?? 'css')},${args.nth ?? 'undefined'},true)` });
|
|
69
|
+
value(result);
|
|
70
|
+
if (result.result?.subtype !== 'null') {
|
|
71
|
+
if (!result.result?.objectId) throw new Error('Selector did not resolve to an element');
|
|
72
|
+
const { node } = await send('DOM.describeNode', { objectId: result.result.objectId });
|
|
73
|
+
backendNodeId = node.backendNodeId;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (backendNodeId != null) break;
|
|
77
|
+
if (!wait) return null;
|
|
78
|
+
budget.describe?.(`selector ${JSON.stringify(args.selector)}${args.name == null ? '' : ` name=${JSON.stringify(args.name)}`}${args.nth == null ? '' : ` nth=${args.nth}`} to appear; current=no matching element`);
|
|
58
79
|
await budget.run(() => new Promise(resolve => setTimeout(resolve, 20)));
|
|
59
80
|
}
|
|
60
|
-
if (!result.result?.objectId) throw new Error('Selector did not resolve to an element');
|
|
61
|
-
const { node } = await send('DOM.describeNode', { objectId: result.result.objectId });
|
|
62
81
|
// Hit-test refs resolve in the default world too. CDP forbids passing object
|
|
63
82
|
// arguments between worlds, even when both wrappers refer to the same node.
|
|
64
|
-
const resolved = await send('DOM.resolveNode', { backendNodeId
|
|
83
|
+
const resolved = await send('DOM.resolveNode', { backendNodeId, objectGroup: 'browser-attach' });
|
|
65
84
|
if (!resolved.object?.objectId) throw new Error('Selector target detached before resolution; no input was dispatched');
|
|
66
|
-
return { target, frameId, objectId: resolved.object.objectId, backendNodeId
|
|
85
|
+
return { target, frameId, objectId: resolved.object.objectId, backendNodeId };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function matchRole(nodes, args) {
|
|
89
|
+
const byId = new Map(nodes.map(node => [node.nodeId, node]));
|
|
90
|
+
const children = new Set(nodes.flatMap(node => node.childIds || []));
|
|
91
|
+
const pending = nodes.filter(node => !children.has(node.nodeId)).reverse(), seen = new Set(), matches = new Map();
|
|
92
|
+
while (pending.length) {
|
|
93
|
+
const node = pending.pop();
|
|
94
|
+
if (!node || seen.has(node.nodeId)) continue;
|
|
95
|
+
seen.add(node.nodeId);
|
|
96
|
+
if (!node.ignored && node.backendDOMNodeId && node.role?.value === args.selector &&
|
|
97
|
+
(args.name == null || String(node.name?.value || '') === args.name)) matches.set(node.backendDOMNodeId, node);
|
|
98
|
+
for (let i = (node.childIds?.length || 0) - 1; i >= 0; i--) pending.push(byId.get(node.childIds[i]));
|
|
99
|
+
}
|
|
100
|
+
const candidates = [...matches.values()];
|
|
101
|
+
if (args.nth == null && candidates.length > 1) throw new Error(`Role selector must match exactly one element; matched ${candidates.length}. Provide name or an explicit zero-based nth. Candidates: ${JSON.stringify(candidates.slice(0, 10).map((node, nth) => ({ nth, role: node.role?.value, name: String(node.name?.value || '').slice(0, 120) })))}`);
|
|
102
|
+
return candidates[args.nth ?? 0] || null;
|
|
67
103
|
}
|
|
68
104
|
|
|
69
105
|
export function frameOptions(args) {
|
|
@@ -78,25 +114,37 @@ export function frameOptions(args) {
|
|
|
78
114
|
async function resolveFrameOnce(cdp, value, sessions, tabId, args, budget) {
|
|
79
115
|
frameOptions(args);
|
|
80
116
|
const frames = await budget.run(() => sessions.frames(tabId));
|
|
117
|
+
const failure = (error, scope = {}) => {
|
|
118
|
+
const candidates = frames.slice(0, 10).map(frame => ({ frame_id: frame.id, parent_frame_id: frame.parentId ?? null, url: frame.url }));
|
|
119
|
+
error.diagnostics = { reason: 'frame_locator', ...scope, candidates, omitted_candidates: Math.max(0, frames.length - candidates.length) };
|
|
120
|
+
error.message += `; frame scope=${JSON.stringify(scope)}; current frames=${JSON.stringify(candidates)}${frames.length > candidates.length ? ` (${frames.length - candidates.length} more omitted)` : ''}`;
|
|
121
|
+
return error;
|
|
122
|
+
};
|
|
81
123
|
if (args.frame_id != null) {
|
|
82
|
-
if (!frames.some(f => f.id === args.frame_id)) throw new Error('frame_id is unknown; take a new snapshot');
|
|
124
|
+
if (!frames.some(f => f.id === args.frame_id)) throw failure(new Error('frame_id is unknown; take a new snapshot'), { frame_id: args.frame_id });
|
|
83
125
|
return args.frame_id;
|
|
84
126
|
}
|
|
85
127
|
if (!args.frame) return frames[0].id;
|
|
86
128
|
const scope = args.frame;
|
|
87
129
|
let frameId = frames[0].id;
|
|
88
|
-
for (const selector of scope.path || []) {
|
|
89
|
-
const
|
|
90
|
-
const send = (method, params) => budget.run(() => cdp(target, method, params));
|
|
91
|
-
const { executionContextId } = await send('Page.createIsolatedWorld', { frameId, worldName: 'browser-attach-locator' });
|
|
92
|
-
const result = await send('Runtime.evaluate', { contextId: executionContextId, expression: `(${queryElement.toString()})(${JSON.stringify(selector)},"css")`, objectGroup: 'browser-attach-frame' });
|
|
93
|
-
value(result);
|
|
130
|
+
for (const [pathIndex, selector] of (scope.path || []).entries()) {
|
|
131
|
+
const parentFrameId = frameId;
|
|
94
132
|
try {
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
133
|
+
const target = sessions.target(tabId, frameId);
|
|
134
|
+
const send = (method, params) => budget.run(() => cdp(target, method, params));
|
|
135
|
+
const { executionContextId } = await send('Page.createIsolatedWorld', { frameId, worldName: 'browser-attach-locator' });
|
|
136
|
+
const result = await send('Runtime.evaluate', { contextId: executionContextId, expression: `(${queryElement.toString()})(${JSON.stringify(selector)},"css")`, objectGroup: 'browser-attach-frame' });
|
|
137
|
+
value(result);
|
|
138
|
+
try {
|
|
139
|
+
const { node } = await send('DOM.describeNode', { objectId: result.result.objectId });
|
|
140
|
+
if (!['IFRAME', 'FRAME'].includes(node.nodeName)) throw new Error('frame.path must resolve to iframe/frame elements');
|
|
141
|
+
frameId = node.frameId || node.contentDocument?.frameId;
|
|
142
|
+
if (!frameId) throw new Error('Frame document is not available yet');
|
|
143
|
+
} finally { await send('Runtime.releaseObjectGroup', { objectGroup: 'browser-attach-frame' }); }
|
|
144
|
+
} catch (error) {
|
|
145
|
+
error.message += '; frame.path entries are CSS selectors, e.g. "#content_iframe"';
|
|
146
|
+
throw failure(error, { parent_frame_id: parentFrameId, path_index: pathIndex, selector });
|
|
147
|
+
}
|
|
100
148
|
}
|
|
101
149
|
const candidates = scope.path ? frames.filter(f => f.id === frameId) : frames.filter(f => f.id !== frames[0].id);
|
|
102
150
|
const matches = [];
|
|
@@ -110,16 +158,17 @@ async function resolveFrameOnce(cdp, value, sessions, tabId, args, budget) {
|
|
|
110
158
|
}
|
|
111
159
|
matches.push(frame.id);
|
|
112
160
|
}
|
|
113
|
-
if (matches.length !== 1) throw new Error(`Frame locator must match exactly once; matched ${matches.length}`);
|
|
161
|
+
if (matches.length !== 1) throw failure(new Error(`Frame locator must match exactly once; matched ${matches.length}`), { locator: scope, matched_frame_ids: matches.slice(0, 10) });
|
|
114
162
|
return matches[0];
|
|
115
163
|
}
|
|
116
164
|
|
|
117
|
-
export async function resolveFrame(cdp, value, sessions, tabId, args, budget) {
|
|
165
|
+
export async function resolveFrame(cdp, value, sessions, tabId, args, budget, { wait = true } = {}) {
|
|
118
166
|
while (true) {
|
|
119
167
|
try { return await resolveFrameOnce(cdp, value, sessions, tabId, args, budget); }
|
|
120
168
|
catch (error) {
|
|
169
|
+
if (!wait) throw error;
|
|
121
170
|
if (!/Frame locator.*matched 0|Frame document.*not available|Selector.*matched 0|context.*destroyed|cannot find context/i.test(error.message)) throw error;
|
|
122
|
-
budget.describe?.(`frame ${JSON.stringify(args.frame)}; current=${error.message}
|
|
171
|
+
budget.describe?.(`frame ${JSON.stringify(args.frame)}; current=${error.message}`, error.diagnostics);
|
|
123
172
|
await budget.run(() => new Promise(resolve => setTimeout(resolve, 20)));
|
|
124
173
|
}
|
|
125
174
|
}
|
package/extension/manifest.json
CHANGED
package/extension/scroll.js
CHANGED
|
@@ -27,10 +27,16 @@ export function scrollState() {
|
|
|
27
27
|
|
|
28
28
|
export function findScrollText(text, selector) {
|
|
29
29
|
if (!this.isConnected) throw new Error('Scroll container detached');
|
|
30
|
-
const elements =
|
|
30
|
+
const elements = this.querySelectorAll(selector || '*');
|
|
31
31
|
if (elements.length > 10000) throw new Error('Scroll search exceeds 10000 candidates; scope until.selector');
|
|
32
|
-
const matches =
|
|
33
|
-
|
|
32
|
+
const matches = [];
|
|
33
|
+
for (const element of elements) {
|
|
34
|
+
if (!(element.textContent || '').includes(text)) continue;
|
|
35
|
+
const rect = element.getBoundingClientRect();
|
|
36
|
+
if (rect.width > 0 && rect.height > 0 && element.ownerDocument.defaultView.getComputedStyle(element).visibility === 'visible') matches.push(element);
|
|
37
|
+
}
|
|
38
|
+
// Document order keeps each subtree contiguous, even after filtering.
|
|
39
|
+
const leaves = matches.filter((element, index) => index === matches.length - 1 || !element.contains(matches[index + 1]));
|
|
34
40
|
if (leaves.length > 1) throw new Error(`Scroll text must match one element; matched ${leaves.length}. Scope until.selector`);
|
|
35
41
|
return leaves[0] || null;
|
|
36
42
|
}
|
package/extension/snapshot.js
CHANGED
|
@@ -15,11 +15,61 @@ export function renderAX(nodes, token, refs, frameId, sessionId, options = snaps
|
|
|
15
15
|
const textRoles = new Set(['StaticText', 'InlineTextBox', 'ListMarker']);
|
|
16
16
|
const controls = new Set(['button', 'checkbox', 'combobox', 'DisclosureTriangle', 'link', 'listbox', 'menuitem',
|
|
17
17
|
'menuitemcheckbox', 'menuitemradio', 'option', 'radio', 'scrollbar', 'searchbox', 'slider', 'spinbutton', 'switch', 'tab', 'textbox', 'treeitem']);
|
|
18
|
+
const fields = new Set(['checkbox', 'combobox', 'radio', 'searchbox', 'slider', 'spinbutton', 'switch', 'textbox']);
|
|
19
|
+
const parents = new Map();
|
|
20
|
+
for (const node of nodes) for (const child of node.childIds || []) parents.set(child, node.nodeId);
|
|
21
|
+
function contextText(node) {
|
|
22
|
+
const pending = [node], parts = [];
|
|
23
|
+
let visited = 0, length = 0;
|
|
24
|
+
while (pending.length && ++visited <= 32) {
|
|
25
|
+
const current = pending.pop();
|
|
26
|
+
if (!current) return null;
|
|
27
|
+
const role = current.role?.value;
|
|
28
|
+
if (current.ignored && !['none', 'generic'].includes(role)) continue;
|
|
29
|
+
if (role === 'StaticText') {
|
|
30
|
+
const text = String(current.name?.value || '');
|
|
31
|
+
const separator = parts.length ? 1 : 0;
|
|
32
|
+
if (length + text.length + separator > 160) return null;
|
|
33
|
+
parts.push(text); length += text.length + separator;
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
if (!['none', 'generic', 'LabelText', 'paragraph'].includes(role)) return null;
|
|
37
|
+
if (visited + pending.length + (current.childIds?.length || 0) > 32) return null;
|
|
38
|
+
for (let i = (current.childIds?.length || 0) - 1; i >= 0; i--) pending.push(byId.get(current.childIds[i]));
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const text = parts.join(' ').replace(/\s+/g, ' ').trim();
|
|
42
|
+
return !pending.length && text && text.length <= 160 ? text : null;
|
|
43
|
+
}
|
|
44
|
+
// Nearby text is observation context, never a replacement for AX identity or
|
|
45
|
+
// an inferred label locator. Bound both the ancestor search and subtree work.
|
|
46
|
+
function fieldContext(node) {
|
|
47
|
+
let child = node;
|
|
48
|
+
for (let depth = 0; depth < 4; depth++) {
|
|
49
|
+
const parent = byId.get(parents.get(child.nodeId));
|
|
50
|
+
if (!parent || !['none', 'generic', 'group'].includes(parent.role?.value)) return null;
|
|
51
|
+
const pending = [parent];
|
|
52
|
+
let count = 0, visited = 0;
|
|
53
|
+
while (pending.length && ++visited <= 64 && count < 2) {
|
|
54
|
+
const current = pending.pop();
|
|
55
|
+
if (!current) return null;
|
|
56
|
+
if (!current.ignored && controls.has(current.role?.value)) count++;
|
|
57
|
+
if (visited + pending.length + (current.childIds?.length || 0) > 64) return null;
|
|
58
|
+
for (const id of current.childIds || []) pending.push(byId.get(id));
|
|
59
|
+
}
|
|
60
|
+
if (pending.length || count !== 1) return null;
|
|
61
|
+
const index = parent.childIds.indexOf(child.nodeId);
|
|
62
|
+
if (index > 0) {
|
|
63
|
+
const text = contextText(byId.get(parent.childIds[index - 1]));
|
|
64
|
+
if (text) return text;
|
|
65
|
+
}
|
|
66
|
+
child = parent;
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
18
70
|
let kept;
|
|
19
71
|
if (options.profile === 'interactive') {
|
|
20
72
|
kept = new Set();
|
|
21
|
-
const parents = new Map();
|
|
22
|
-
for (const node of nodes) for (const child of node.childIds || []) parents.set(child, node.nodeId);
|
|
23
73
|
function keepText(node) {
|
|
24
74
|
const pending = [node];
|
|
25
75
|
while (pending.length) {
|
|
@@ -78,6 +128,8 @@ export function renderAX(nodes, token, refs, frameId, sessionId, options = snaps
|
|
|
78
128
|
['disabled', 'focused', 'expanded', 'checked', 'selected', 'required', 'invalid', 'level'].includes(prop.name)
|
|
79
129
|
).map(prop => `${prop.name}=${JSON.stringify(prop.value?.value)}`);
|
|
80
130
|
if (node.value?.value != null) states.push(`value=${JSON.stringify(node.value.value)}`);
|
|
131
|
+
const context = fields.has(role) && !node.ignored ? fieldContext(node) : null;
|
|
132
|
+
if (context && context !== name) states.push(`context=${JSON.stringify(context)}`);
|
|
81
133
|
const attributes = states.join(' ');
|
|
82
134
|
const skip = node.ignored || (!options.text && textRoles.has(role)) || (['none', 'generic'].includes(role) && !name && !attributes) ||
|
|
83
135
|
(role === 'StaticText' && name === parentName);
|
|
@@ -88,7 +140,7 @@ export function renderAX(nodes, token, refs, frameId, sessionId, options = snaps
|
|
|
88
140
|
}
|
|
89
141
|
let nextDepth = depth;
|
|
90
142
|
if (!skip) {
|
|
91
|
-
if (query == null || identityName.toLowerCase().includes(query) || String(node.value?.value ?? '').toLowerCase().includes(query)) {
|
|
143
|
+
if (query == null || identityName.toLowerCase().includes(query) || String(node.value?.value ?? '').toLowerCase().includes(query) || context?.toLowerCase().includes(query)) {
|
|
92
144
|
const ref = node.backendDOMNodeId ? `${token}:${refs.size + 1}` : null;
|
|
93
145
|
if (ref) refs.set(ref, { backendNodeId: node.backendDOMNodeId, frameId, sessionId, role: node.role?.value, name: String(node.name?.value || '') });
|
|
94
146
|
entries.push({ node: true, frameId, line: `${' '.repeat(Math.min(depth, 24))}${ref ? `[${ref}] ` : ''}${role} ${JSON.stringify(name)}${attributes ? ' ' + attributes : ''}` });
|
package/extension/text.js
CHANGED
|
@@ -1,21 +1,23 @@
|
|
|
1
|
-
import { queryElement } from './locator.js';
|
|
1
|
+
import { queryElement, targetOptions } from './locator.js';
|
|
2
2
|
|
|
3
3
|
export async function readText(cdp, value, tabId, args) {
|
|
4
4
|
const max = args.max_chars ?? 20000, offset = args.offset ?? 0, mode = args.mode ?? 'visible';
|
|
5
5
|
if (!Number.isInteger(max) || max < 1 || max > 100000) throw new Error('max_chars must be 1..100000');
|
|
6
6
|
if (!Number.isSafeInteger(offset) || offset < 0) throw new Error('offset must be 0..9007199254740991');
|
|
7
7
|
if (!['visible', 'text_content', 'value', 'editor'].includes(mode)) throw new Error('read_text mode must be visible, text_content, value or editor');
|
|
8
|
-
if (args.selector != null
|
|
9
|
-
if (args.selector_type != null && !['css', 'xpath'].includes(args.selector_type)) throw new Error('selector_type must be css or xpath');
|
|
8
|
+
if (args.selector != null || args.selector_type != null || args.name != null || args.nth != null) targetOptions(args);
|
|
10
9
|
if (mode === 'value' || mode === 'editor') {
|
|
11
10
|
if (!args.selector) throw new Error(`${mode} requires selector to identify exactly one field/editor`);
|
|
12
11
|
}
|
|
12
|
+
const options = { selector: args.selector, selector_type: args.selector_type, nth: args.nth, mode, offset, max_chars: max };
|
|
13
|
+
if (args.node) return value(await cdp(tabId, 'Runtime.callFunctionOn', { objectId: args.node.objectId, returnByValue: true,
|
|
14
|
+
functionDeclaration: `function(args) { return (${extractText.toString()})(args, () => this); }`, arguments: [{ value: options }] }));
|
|
13
15
|
return value(await cdp(tabId, 'Runtime.evaluate', { returnByValue: true, ...(args.context_id != null ? { contextId: args.context_id } : {}),
|
|
14
|
-
expression: `(${extractText.toString()})(${JSON.stringify(
|
|
16
|
+
expression: `(${extractText.toString()})(${JSON.stringify(options)},${queryElement.toString()})` }));
|
|
15
17
|
}
|
|
16
18
|
|
|
17
19
|
export function extractText(args, query) {
|
|
18
|
-
const element = args.selector ? query(args.selector, args.selector_type ?? 'css') : document.body;
|
|
20
|
+
const element = args.selector ? query(args.selector, args.selector_type ?? 'css', args.nth) : document.body;
|
|
19
21
|
let text, source = args.mode, complete = true;
|
|
20
22
|
if (args.mode === 'visible') { text = element?.innerText ?? ''; complete = false; }
|
|
21
23
|
else if (args.mode === 'text_content') { text = element?.textContent ?? ''; complete = false; }
|