Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
ulixee
GitHub Repository: ulixee/secret-agent
Path: blob/main/core/injected-scripts/jsPath.ts
1028 views
1
// eslint-disable-next-line max-classes-per-file
2
import IExecJsPathResult from '@secret-agent/interfaces/IExecJsPathResult';
3
import type INodePointer from 'awaited-dom/base/INodePointer';
4
import IElementRect from '@secret-agent/interfaces/IElementRect';
5
import IPoint from '@secret-agent/interfaces/IPoint';
6
import { IJsPathError } from '@secret-agent/interfaces/IJsPathError';
7
import { INodeVisibility } from '@secret-agent/interfaces/INodeVisibility';
8
import { IJsPath, IPathStep } from 'awaited-dom/base/AwaitedPath';
9
10
const pointerFnName = '__getNodePointer__';
11
12
// eslint-disable-next-line @typescript-eslint/no-unused-vars
13
class JsPath {
14
public static async waitForScrollOffset(coordinates: [number, number], timeoutMillis: number) {
15
let left = Math.max(coordinates[0], 0);
16
const scrollWidth = document.body.scrollWidth || document.documentElement.scrollWidth;
17
const maxScrollX = Math.max(scrollWidth - window.innerWidth, 0);
18
if (left >= maxScrollX) {
19
left = maxScrollX;
20
}
21
22
let top = Math.max(coordinates[1], 0);
23
const scrollHeight = document.body.scrollHeight || document.documentElement.scrollHeight;
24
const maxScrollY = Math.max(scrollHeight - window.innerHeight, 0);
25
if (top >= maxScrollY) {
26
top = maxScrollY;
27
}
28
29
const endTime = new Date().getTime() + (timeoutMillis ?? 50);
30
let count = 0;
31
do {
32
if (Math.abs(window.scrollX - left) <= 1 && Math.abs(window.scrollY - top) <= 1) {
33
return true;
34
}
35
if (count === 2) {
36
window.scroll({ behavior: 'auto', left, top });
37
}
38
await new Promise(requestAnimationFrame);
39
count += 1;
40
} while (new Date().getTime() < endTime);
41
42
return false;
43
}
44
45
public static getWindowOffset() {
46
return {
47
innerHeight: window.innerHeight || document.documentElement.clientHeight,
48
innerWidth: window.innerWidth || document.documentElement.clientWidth,
49
scrollY: window.scrollY || document.documentElement.scrollTop,
50
scrollX: window.scrollX || document.documentElement.scrollLeft,
51
};
52
}
53
54
public static simulateOptionClick(jsPath: IJsPath): IExecJsPathResult<boolean> {
55
const objectAtPath = new ObjectAtPath(jsPath);
56
try {
57
const currentObject = objectAtPath.lookup().objectAtPath;
58
59
if (!currentObject || !(currentObject instanceof HTMLOptionElement)) {
60
return objectAtPath.toReturnError(new Error('Option element not found'));
61
}
62
63
const element = currentObject as HTMLOptionElement;
64
65
let didClick = false;
66
const values = [element.value];
67
if (element.parentNode instanceof HTMLSelectElement) {
68
const select = element.parentNode as HTMLSelectElement;
69
select.value = undefined;
70
const options = Array.from(select.options);
71
for (const option of options) {
72
option.selected = values.includes(option.value);
73
if (option.selected && !select.multiple) break;
74
}
75
76
select.dispatchEvent(new InputEvent('input', { bubbles: true }));
77
select.dispatchEvent(new Event('change', { bubbles: true }));
78
didClick = true;
79
}
80
81
return { value: didClick };
82
} catch (error) {
83
return objectAtPath.toReturnError(error);
84
}
85
}
86
87
public static async exec(
88
jsPath: IJsPath,
89
containerOffset: IPoint,
90
): Promise<IExecJsPathResult<any>> {
91
const objectAtPath = new ObjectAtPath(jsPath, containerOffset);
92
try {
93
const result = <IExecJsPathResult<any>>{
94
value: await objectAtPath.lookup().objectAtPath,
95
};
96
97
if (objectAtPath.hasNodePointerLoad && !isPrimitive(result.value)) {
98
result.nodePointer = objectAtPath.extractNodePointer();
99
}
100
101
if (
102
!objectAtPath.hasCustomMethodLookup &&
103
(result.nodePointer?.iterableIsState || result.value instanceof Node)
104
) {
105
result.value = undefined;
106
}
107
// serialize special types
108
else if (result.value && !isPrimitive(result.value) && !isPojo(result.value)) {
109
result.isValueSerialized = true;
110
result.value = TypeSerializer.replace(result.value);
111
}
112
return result;
113
} catch (error) {
114
return objectAtPath.toReturnError(error);
115
}
116
}
117
118
public static async execJsPaths(
119
jsPaths: { jsPath: IJsPath; sourceIndex: number }[],
120
containerOffset: IPoint,
121
): Promise<{ jsPath: IJsPath; result: IExecJsPathResult<any> }[]> {
122
const resultMapByPathIndex: { [index: number]: IExecJsPathResult<any>[] } = {};
123
const results: { jsPath: IJsPath; result: IExecJsPathResult<any> }[] = [];
124
125
async function runFn(queryIndex: number, jsPath: IJsPath): Promise<void> {
126
// copy into new array so original stays clean
127
const result = await JsPath.exec([...jsPath], containerOffset);
128
results.push({ jsPath, result });
129
130
(resultMapByPathIndex[queryIndex] ??= []).push(result);
131
}
132
133
for (let i = 0; i < jsPaths.length; i += 1) {
134
const { jsPath, sourceIndex } = jsPaths[i];
135
if (sourceIndex !== undefined) {
136
const parentResults = resultMapByPathIndex[sourceIndex];
137
// if we couldn't get parent results, don't try to recurse
138
if (!parentResults) continue;
139
for (const parentResult of parentResults) {
140
if (parentResult.pathError || !parentResult.nodePointer) continue;
141
if (jsPath[0] === '.') {
142
const nestedJsPath = [parentResult.nodePointer.id, ...jsPath.slice(1)];
143
await runFn(i, nestedJsPath);
144
}
145
if (jsPath[0] === '*.') {
146
if (parentResult.nodePointer.iterableIsState) {
147
for (const iterable of parentResult.nodePointer.iterableItems as INodePointer[]) {
148
const nestedJsPath = [iterable.id, ...jsPath.slice(1)];
149
await runFn(i, nestedJsPath);
150
}
151
}
152
}
153
}
154
} else {
155
await runFn(i, jsPath);
156
}
157
}
158
return results;
159
}
160
161
public static async waitForElement(
162
jsPath: IJsPath,
163
containerOffset: IPoint,
164
waitForVisible: boolean,
165
timeoutMillis: number,
166
): Promise<IExecJsPathResult<INodeVisibility>> {
167
const objectAtPath = new ObjectAtPath(jsPath, containerOffset);
168
try {
169
const end = new Date();
170
end.setTime(end.getTime() + (timeoutMillis || 0));
171
172
while (new Date() < end) {
173
try {
174
if (!objectAtPath.objectAtPath) objectAtPath.lookup();
175
176
let isElementValid = !!objectAtPath.objectAtPath;
177
let visibility: INodeVisibility = {
178
nodeExists: isElementValid,
179
};
180
if (isElementValid && waitForVisible) {
181
visibility = objectAtPath.getComputedVisibility();
182
isElementValid = visibility.isVisible;
183
}
184
185
if (isElementValid) {
186
return {
187
nodePointer: objectAtPath.extractNodePointer(),
188
value: visibility,
189
};
190
}
191
} catch (err) {
192
if (String(err).includes('not a valid selector')) throw err;
193
// can also happen if lookup path doesn't exist yet... in which case we want to keep trying
194
}
195
// eslint-disable-next-line promise/param-names
196
await new Promise(resolve1 => setTimeout(resolve1, 20));
197
await new Promise(requestAnimationFrame);
198
}
199
200
return {
201
nodePointer: objectAtPath.extractNodePointer(),
202
value: objectAtPath.getComputedVisibility(),
203
};
204
} catch (error) {
205
return objectAtPath.toReturnError(error);
206
}
207
}
208
}
209
210
// / Object At Path Class //////
211
212
class ObjectAtPath {
213
public objectAtPath: Node | any;
214
public hasNodePointerLoad: boolean;
215
public hasCustomMethodLookup = false;
216
217
private _obstructedByElement: Element;
218
private lookupStep: IPathStep;
219
private lookupStepIndex = 0;
220
private nodePointer: INodePointer;
221
222
public get closestElement(): Element {
223
if (!this.objectAtPath) return;
224
if (this.isTextNode) {
225
return this.objectAtPath.parentElement;
226
}
227
return this.objectAtPath as Element;
228
}
229
230
public get boundingClientRect() {
231
const element = this.closestElement;
232
if (!element) {
233
return { x: 0, y: 0, width: 0, height: 0, tag: 'node' };
234
}
235
236
const rect = element.getBoundingClientRect();
237
238
return {
239
y: rect.y + this.containerOffset.y,
240
x: rect.x + this.containerOffset.x,
241
height: rect.height,
242
width: rect.width,
243
tag: element.tagName?.toLowerCase(),
244
} as IElementRect;
245
}
246
247
public get obstructedByElement() {
248
if (this._obstructedByElement) return this._obstructedByElement;
249
const element = this.closestElement;
250
if (!element) return null;
251
const { x, y, width, height } = element.getBoundingClientRect();
252
const centerX = round(x + width / 2);
253
const centerY = round(y + height / 2);
254
255
this._obstructedByElement = document.elementFromPoint(centerX, centerY);
256
return this._obstructedByElement;
257
}
258
259
public get obstructedByElementId() {
260
const element = this.obstructedByElement;
261
if (!element) return null;
262
return NodeTracker.watchNode(element);
263
}
264
265
public get isObstructedByAnotherElement() {
266
const overlapping = this.obstructedByElement;
267
if (!overlapping) return false;
268
269
// adjust coordinates to get more accurate results
270
const isContained = this.closestElement.contains(overlapping);
271
if (isContained) return false;
272
273
// make sure overlapping element is visible
274
const style = getComputedStyle(overlapping);
275
if (style?.visibility === 'hidden' || style?.display === 'none' || style?.opacity === '0') {
276
return false;
277
}
278
279
// if this is another element, needs to be taking up >= 50% of element
280
const overlappingBounds = overlapping.getBoundingClientRect();
281
const thisRect = this.boundingClientRect;
282
const isOverHalfWidth = overlappingBounds.width >= thisRect.width / 2;
283
const isOverHalfHeight = overlappingBounds.height >= thisRect.height / 2;
284
return isOverHalfWidth && isOverHalfHeight;
285
}
286
287
private get isTextNode() {
288
return this.objectAtPath?.nodeType === this.objectAtPath?.TEXT_NODE;
289
}
290
291
constructor(readonly jsPath: IJsPath, readonly containerOffset: IPoint = { x: 0, y: 0 }) {
292
if (!jsPath?.length) return;
293
this.containerOffset = containerOffset;
294
295
// @ts-ignore - start listening for events since we've just looked up something on this frame
296
if ('listenForInteractionEvents' in window) window.listenForInteractionEvents();
297
298
if (
299
Array.isArray(jsPath[jsPath.length - 1]) &&
300
jsPath[jsPath.length - 1][0] === pointerFnName
301
) {
302
this.hasNodePointerLoad = true;
303
jsPath.pop();
304
}
305
}
306
307
public getComputedVisibility(): INodeVisibility {
308
this.hasNodePointerLoad = true;
309
this.nodePointer = ObjectAtPath.createNodePointer(this.objectAtPath);
310
311
const visibility: INodeVisibility = {
312
// put here first for display
313
isVisible: true,
314
nodeExists: !!this.objectAtPath,
315
};
316
if (!visibility.nodeExists) {
317
visibility.isVisible = false;
318
return visibility;
319
}
320
321
visibility.isConnected = this.objectAtPath?.isConnected === true;
322
const element = this.closestElement;
323
visibility.hasContainingElement = !!element;
324
325
if (!visibility.hasContainingElement) {
326
visibility.isVisible = false;
327
return visibility;
328
}
329
330
const style = getComputedStyle(element);
331
332
visibility.hasCssVisibility = style?.visibility !== 'hidden';
333
visibility.hasCssDisplay = style?.display !== 'none';
334
visibility.hasCssOpacity = style?.opacity !== '0';
335
visibility.isUnobstructedByOtherElements = !this.isObstructedByAnotherElement;
336
if (visibility.isUnobstructedByOtherElements === false) {
337
visibility.obstructedByElementId = this.obstructedByElementId;
338
}
339
340
const rect = this.boundingClientRect;
341
visibility.boundingClientRect = rect;
342
visibility.hasDimensions = !(rect.width === 0 && rect.height === 0);
343
visibility.isOnscreenVertical =
344
rect.y + rect.height > 0 && rect.y < window.innerHeight + this.containerOffset.y;
345
visibility.isOnscreenHorizontal =
346
rect.x + rect.width > 0 && rect.x < window.innerWidth + this.containerOffset.x;
347
348
visibility.isVisible = Object.values(visibility).every(x => x !== false);
349
return visibility;
350
}
351
352
public lookup() {
353
try {
354
// track object as we navigate so we can extract properties along the way
355
this.objectAtPath = window;
356
this.lookupStepIndex = 0;
357
if (this.jsPath[0] === 'window') {
358
this.jsPath.shift();
359
this.lookupStepIndex = 1;
360
}
361
for (const step of this.jsPath) {
362
this.lookupStep = step;
363
if (Array.isArray(step)) {
364
const [methodName, ...args] = step;
365
// extract node ids as args
366
const finalArgs = args.map(x => {
367
if (typeof x !== 'string') return x;
368
if (!x.startsWith('$$jsPath=')) return x;
369
const innerPath = JSON.parse(x.split('$$jsPath=').pop());
370
const sub = new ObjectAtPath(innerPath, this.containerOffset).lookup();
371
return sub.objectAtPath;
372
});
373
// handlers for getComputedStyle/Visibility/getNodeId/getBoundingRect
374
if (methodName.startsWith('__') && methodName.endsWith('__')) {
375
this.hasCustomMethodLookup = true;
376
this.objectAtPath = this[`${methodName.replace(/__/g, '')}`](...finalArgs);
377
} else {
378
const methodProperty = propertyName(methodName);
379
this.objectAtPath = this.objectAtPath[methodProperty](...finalArgs);
380
}
381
} else if (typeof step === 'number') {
382
this.objectAtPath = NodeTracker.getWatchedNodeWithId(step);
383
} else if (typeof step === 'string') {
384
const prop = propertyName(step);
385
this.objectAtPath = this.objectAtPath[prop];
386
} else {
387
throw new Error('unknown JsPathStep');
388
}
389
this.lookupStepIndex += 1;
390
}
391
} catch (err) {
392
// don't store the invalid path if we failed at a step
393
this.objectAtPath = null;
394
throw err;
395
}
396
397
return this;
398
}
399
400
public toReturnError(error: Error): IExecJsPathResult {
401
const pathError = <IJsPathError>{
402
error: String(error),
403
pathState: {
404
step: this.lookupStep,
405
index: this.lookupStepIndex,
406
},
407
};
408
return {
409
value: null,
410
pathError,
411
};
412
}
413
414
public extractNodePointer(): INodePointer {
415
return (this.nodePointer ??= ObjectAtPath.createNodePointer(this.objectAtPath));
416
}
417
418
private getClientRect(includeVisibilityStatus = false): IElementRect {
419
this.hasNodePointerLoad = true;
420
this.nodePointer = ObjectAtPath.createNodePointer(this.objectAtPath);
421
const box = this.boundingClientRect;
422
box.nodeVisibility = includeVisibilityStatus ? this.getComputedVisibility() : undefined;
423
424
return box;
425
}
426
427
private getNodeId(): number {
428
return NodeTracker.watchNode(this.objectAtPath);
429
}
430
431
private getComputedStyle(pseudoElement?: string): CSSStyleDeclaration {
432
return window.getComputedStyle(this.objectAtPath, pseudoElement);
433
}
434
435
public static createNodePointer(objectAtPath: any): INodePointer {
436
if (!objectAtPath) return null;
437
438
const nodeId = NodeTracker.watchNode(objectAtPath);
439
const state = {
440
id: nodeId,
441
type: objectAtPath.constructor?.name,
442
preview: generateNodePreview(objectAtPath),
443
} as INodePointer;
444
445
if (isIterableOrArray(objectAtPath)) {
446
state.iterableItems = Array.from(objectAtPath);
447
448
if (state.iterableItems.length && isCustomType(state.iterableItems[0])) {
449
state.iterableIsState = true;
450
state.iterableItems = state.iterableItems.map(x => this.createNodePointer(x));
451
}
452
}
453
454
return state;
455
}
456
}
457
458
function generateNodePreview(node: Node): string {
459
if (node.nodeType === Node.TEXT_NODE) return `#text=${node.nodeValue || ''}`;
460
461
if (node.nodeType !== Node.ELEMENT_NODE) {
462
let name = `${node.constructor.name || typeof node}`;
463
if ('length' in node) {
464
name += `(${(node as any).length})`;
465
}
466
return name;
467
}
468
const tag = node.nodeName.toLowerCase();
469
const element = node as Element;
470
471
let attrText = '';
472
for (const attr of element.attributes) {
473
const { name, value } = attr;
474
if (name === 'style') continue;
475
attrText += ` ${name}`;
476
if (value) {
477
let valueText = value;
478
if (valueText.length > 50) {
479
valueText = `${value.substr(0, 49)}\u2026`;
480
}
481
attrText += `="${valueText}"`;
482
}
483
}
484
if (emptyElementTags.has(tag)) return `<${tag}${attrText}/>`;
485
486
const children = element.childNodes;
487
let elementHasTextChildren = false;
488
if (children.length <= 5) {
489
elementHasTextChildren = true;
490
for (const child of children) {
491
if (child.nodeType !== Node.TEXT_NODE) {
492
elementHasTextChildren = false;
493
break;
494
}
495
}
496
}
497
let textContent = '';
498
if (elementHasTextChildren) {
499
textContent = element.textContent ?? '';
500
if (textContent.length > 50) {
501
textContent = `${textContent.substring(0, 49)}\u2026`;
502
}
503
} else if (children.length) {
504
textContent = '\u2026';
505
}
506
return `<${tag}${attrText}>${textContent}</${tag}>`;
507
}
508
509
const emptyElementTags = new Set([
510
'area',
511
'base',
512
'br',
513
'col',
514
'command',
515
'embed',
516
'hr',
517
'img',
518
'input',
519
'keygen',
520
'link',
521
'menuitem',
522
'meta',
523
'param',
524
'source',
525
'track',
526
'wbr',
527
]);
528
529
// / JS Path Helpers //////
530
function isPrimitive(arg) {
531
const type = typeof arg;
532
return arg == null || (type !== 'object' && type !== 'function');
533
}
534
535
function isCustomType(object) {
536
return !(
537
object instanceof Date ||
538
object instanceof ArrayBuffer ||
539
object instanceof RegExp ||
540
object instanceof Error ||
541
object instanceof BigInt ||
542
object instanceof String ||
543
object instanceof Number ||
544
object instanceof Boolean ||
545
isPrimitive(object)
546
);
547
}
548
549
function isPojo(obj) {
550
if (obj === null || typeof obj !== 'object') {
551
return false;
552
}
553
return Object.getPrototypeOf(obj) === Object.prototype;
554
}
555
556
function propertyName(name: string): string | symbol {
557
if (name.startsWith('Symbol.for')) {
558
const symbolName = name.match(/Symbol\(([\w.]+)\)/)[1];
559
return Symbol.for(symbolName);
560
}
561
return name;
562
}
563
564
function isIterableOrArray(object) {
565
// don't iterate on strings
566
if (!object || typeof object === 'string' || object instanceof String) return false;
567
return !!object[Symbol.iterator] || Array.isArray(object);
568
}
569
570
function round(num: number): number {
571
return Math.floor(100 * num) / 100;
572
}
573
574