Repository navigation
Expand file tree
/
Copy pathtrusted-click-element.ts
More file actions
913 lines (831 loc) · 38.4 KB
/
Copy pathtrusted-click-element.ts
File metadata and controls
913 lines (831 loc) · 38.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
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
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
import {
hit,
toRegExp,
parseCookieString,
throttle,
logMessage,
parseMatchArg,
queryShadowSelector,
spoofClickEventsIsTrusted,
triggerMainObserver,
bridgeIframeLoads,
clickElement,
doesElementContainText,
findElementWithText,
randomId,
getShadowSelectorError,
isEmptySelector,
isValidSelector,
isValidXpath,
getXpathExpression,
splitSelectors,
getFirstXpathElement,
createTrustedEventProxy,
createSpoofedClicks,
getSpoofedClicks,
} from '../helpers';
import { type Source } from './scriptlets';
/* eslint-disable max-len */
/**
* @trustedScriptlet trusted-click-element
*
* @description
* Clicks selected elements in a strict sequence, ordered by selectors passed,
* and waiting for them to render in the DOM first.
* First matched element is clicked unless `containsText` is specified.
* If `containsText` is specified, then it searches for all given selectors and clicks
* the first element containing the specified text.
* Deactivates after all elements have been clicked or by timeout (configurable).
* Click behavior can be forced to native dispatch through `extraMatch`.
*
* ### Syntax
*
* ```text
* example.com#%#//scriptlet('trusted-click-element', selectors[, extraMatch[, delay[, reload[, observerTimeout]]]])
* ```
* <!-- markdownlint-disable-next-line line-length -->
* - `selectors` — required, string with query selectors delimited by comma. The scriptlet supports `>>>` combinator to select elements inside open shadow DOM. For usage, see example below.
* Commas inside pseudo-classes, e.g. `:is(.accept, .agree)`, and quoted attribute values are not delimiters.
* Empty selectors, e.g. after a trailing comma, and ones with only a CSS comment are skipped.
* If any selector is invalid, it is logged with the reason and no element is clicked.
* XPath expressions are supported as well, if wrapped in `xpath()`, e.g. `xpath(//button[text()="Accept"])`;
* commas inside `xpath()` are not delimiters either. Relative paths without `>>>` combinator, or before it,
* are evaluated against the `html` element, so e.g. `xpath(body//button)` selects buttons of the page,
* but `xpath(html/body//button)` selects nothing. XPath can be combined with `>>>` combinator,
* then absolute paths after it, e.g. `xpath(//button)`, select elements of the whole shadow tree.
* It applies to absolute paths in predicates as well, e.g. `//title` in
* `xpath(.//button[contains(//title, "Accept")])` after `>>>` does not select the page title.
* But Chromium-based browsers before version 146 evaluate absolute paths after `>>>` against the document,
* so there `xpath(//button)` selects nothing, as elements outside of the shadow tree are skipped,
* and `//title` in a predicate selects the page title. For rules which should work in all browsers,
* use relative paths with `descendant-or-self::` axis after `>>>` instead.
* Shadow root cannot be an XPath context node, so relative paths after `>>>` are evaluated
* against each top-level element of the shadow root. Use `descendant-or-self::` axis to select elements
* at any level of the shadow DOM, e.g. `xpath(descendant-or-self::button)`, as `xpath(.//button)`
* skips top-level elements. Positional predicates of relative paths are applied to each top-level element
* separately, e.g. `xpath((descendant-or-self::button)[2])` selects the second button of a top-level element,
* unlike `xpath((//button)[2])`, which selects the second button of the shadow tree. XPath errors which occur
* only on evaluation of page elements, e.g. a type error in a predicate like `xpath(//input[count(1)])`,
* may not be detected and are not logged, and such expression selects nothing while the error occurs,
* or after `>>>` only the elements of top-level elements of the shadow root without the error.
* - `extraMatch` — optional, extra condition to check on a page;
* allows to match `cookie`, `localStorage` and specified text;
* can be set as `name:key[=value]` where `value` is optional.
* If `cookie`/`localStorage` starts with `!` then the element will only be clicked
* if specified `cookie`/`localStorage` item does not exist.
* Multiple conditions are allowed inside one `extraMatch` but they should be delimited by comma
* and each of them should match the syntax. Possible `names`:
* - `cookie` — test string or regex against cookies on a page
* - `localStorage` — check if localStorage item is present
* - `containsText` — check if clicked element contains specified text
* - `clickType` — set click behavior; supported value is `native`,
* other values are logged and the default click behavior is used
* - `isTrusted` — set which click-related events have `isTrusted` spoofed to `true`; supported value is `all`,
* other values are logged and the default is used. By default, only the clicks of the scriptlet are spoofed,
* and the clicks which labels forward from them to their controls. With `all`, the page's own events are spoofed
* as well, e.g. a click which a page handler dispatches on another element in response, but it may break
* the page, so use it only if the page does not accept the clicks of the scriptlet otherwise:
* - spoofed events are `click`, `mousedown`, `mouseup`, `mouseover`, `mouseenter`, `pointerdown`,
* `pointerup`, `pointerover` and `pointerenter`
* - events are spoofed only for listeners added by `addEventListener()` after the scriptlet has run;
* inline `on...` handlers receive spoofed events only on the clicked element, and only during an event
* of the scriptlet of the same type: that event, the click which a label forwards from it, and with `all`,
* the page's own events of that type dispatched on the clicked element meanwhile, so other page events
* may reach inline handlers unspoofed even with `all`
* - listeners receive a proxy of a spoofed event, so it is not the same object as `window.event`,
* and a page guard which compares them may still fail
* - a click which the page dispatches on a label's control during the click of the scriptlet,
* or on any element of the closed shadow root which contains such control, or on its host,
* is spoofed by default as well, as it cannot be told apart from the forwarded one
* - a label in a closed shadow root which wraps the slot of the clicked element is not found,
* as the slot is not exposed, so the click which it forwards is spoofed only with `all`
* - `all` is enabled for the whole page until it is reloaded, including the clicks of other rules,
* and may break the page, e.g. its guards which compare events, or its code which passes events
* to native methods, as listeners receive proxies of them,
* see [#582](https://github.com/AdguardTeam/Scriptlets/issues/582)
* - `isTrusted:all` can be combined with other conditions in any order, but for compatibility with older
* versions of the scriptlet, which do not support it, it should be the first or the only condition,
* e.g. `isTrusted:all, !cookie:consent`: older versions ignore it only there, but take it after another
* condition as a part of that condition's value, e.g. with `!cookie:consent, isTrusted:all` they click
* regardless of the `consent` cookie, and with `containsText:Accept, isTrusted:all` they never click
* - `delay` — optional, time in **ms** to delay scriptlet execution, defaults to instant execution.
* Must be a number less than `observerTimeout` (default 10 _seconds_)
* which can be configured.
* - `reload` — optional, string with reloadAfterClick marker and optional value. Possible values:
* - `reloadAfterClick` - reloads the page after all elements have been clicked,
* with default delay — 500ms
* - colon-separated pair `reloadAfterClick:value` where
* - `value` — time delay in milliseconds before reloading the page, after all elements
* have been clicked. Must be a number less than `observerTimeout`.
* - `observerTimeout` — optional, time in **seconds** to use
* instead default 10 seconds observer timeout.
* Must be an integer number and more than 0.
*
* <!-- markdownlint-disable line-length -->
*
* ### Examples
*
* 1. Click single element by selector
*
* ```adblock
* example.com#%#//scriptlet('trusted-click-element', 'button[name="agree"]')
* ```
*
* 1. Delay click execution by 500ms
*
* ```adblock
* example.com#%#//scriptlet('trusted-click-element', 'button[name="agree"]', '', '500')
* ```
*
* 1. Click multiple elements by selector with a delay
*
* <!-- markdownlint-disable line-length -->
*
* ```adblock
* example.com#%#//scriptlet('trusted-click-element', 'button[name="agree"], button[name="check"], input[type="submit"][value="akkoord"]', '', '500')
* ```
*
* 1. Match cookies by keys using regex and string
*
* ```adblock
* example.com#%#//scriptlet('trusted-click-element', 'button[name="agree"]', 'cookie:userConsentCommunity, cookie:/cmpconsent|cmp/')
* ```
*
* 1. Match by cookie key=value pairs using regex and string
*
* ```adblock
* example.com#%#//scriptlet('trusted-click-element', 'button[name="agree"]', 'cookie:userConsentCommunity=true, cookie:/cmpconsent|cmp/=/[a-z]{1,5}/')
* ```
*
* 1. Match by localStorage item 'promo' key
*
* ```adblock
* example.com#%#//scriptlet('trusted-click-element', 'button[name="agree"]', 'localStorage:promo')
* ```
*
* 1. Click multiple elements with delay and matching by both cookie string and localStorage item
*
* ```adblock
* example.com#%#//scriptlet('trusted-click-element', 'button[name="agree"], input[type="submit"][value="akkoord"]', 'cookie:cmpconsent, localStorage:promo', '250')
* ```
*
* 1. Click element only if clicked element contains text `Accept cookie`
*
* ```adblock
* example.com#%#//scriptlet('trusted-click-element', 'button', 'containsText:Accept cookie')
* ```
*
* 1. Click element only if cookie with name `cmpconsent` does not exist
*
* ```adblock
* example.com#%#//scriptlet('trusted-click-element', 'button[name="agree"]', '!cookie:cmpconsent')
* ```
*
* 1. Click element only if specified cookie string and localStorage item does not exist
*
* ```adblock
* example.com#%#//scriptlet('trusted-click-element', 'button[name="agree"]', '!cookie:consent, !localStorage:promo')
* ```
*
* 1. Force native click dispatch even if React handlers are detected
*
* ```adblock
* example.com#%#//scriptlet('trusted-click-element', 'button[name="agree"]', 'clickType:native')
* ```
*
* 1. Spoof `isTrusted` for all click-related events on the page, e.g. if a page handler of the click
* dispatches another click which should be trusted as well. It may break the page, see `isTrusted` above
*
* ```adblock
* example.com#%#//scriptlet('trusted-click-element', 'div.toggle', 'isTrusted:all')
* ```
*
* 1. Spoof `isTrusted` for all click-related events on the page and click element only if cookie with name `consent`
* does not exist; `isTrusted:all` should go first for compatibility with older versions, see `isTrusted` above
*
* ```adblock
* example.com#%#//scriptlet('trusted-click-element', 'div.toggle', 'isTrusted:all, !cookie:consent')
* ```
*
* 1. Click element inside open shadow DOM, which could be selected by `div > button`, but is inside shadow host element with host element selected by `article .container`
*
* ```adblock
* example.com#%#//scriptlet('trusted-click-element', 'article .container > div#host >>> div > button')
* ```
*
* 1. Click element selected by XPath expression, e.g. button containing text `Accept`
*
* ```adblock
* example.com#%#//scriptlet('trusted-click-element', 'xpath(//button[contains(text(), "Accept")])')
* ```
*
* 1. Click element at any level of open shadow DOM, selected by relative XPath expression,
* with shadow host element selected by `div#host`. Unlike absolute path `xpath(//button[text()="Accept"])`,
* it works in Chromium-based browsers before version 146 as well
*
* ```adblock
* example.com#%#//scriptlet('trusted-click-element', 'div#host >>> xpath(descendant-or-self::button[text()="Accept"])')
* ```
*
* 1. Click elements after 1000ms delay and reload page after all elements have been clicked with 200ms delay
*
* ```adblock
* example.com#%#//scriptlet('trusted-click-element', 'button[name="agree"], button[name="check"], input[type="submit"][value="akkoord"]', '', '1000', 'reloadAfterClick:200')
* ```
*
* 1. Extend observer timeout to 40 seconds to allow clicking elements that appear after user interaction
*
* ```adblock
* example.com#%#//scriptlet('trusted-click-element', 'button[name="agree"]', '', '', '', '40')
* ```
*
* <!-- markdownlint-enable line-length -->
*
* @added v1.7.3.
*/
/* eslint-enable max-len */
/**
* Object that contains information about element that can be clicked
*/
interface ElementObject {
/**
* HTML element to be clicked, or null if not found
*/
element: HTMLElement | null;
/**
* Indicates whether the element has been clicked
*/
clicked: boolean;
/**
* CSS selector or `xpath(...)` text used to find the element
*/
selectorText: string | null;
}
export function trustedClickElement(
source: Source,
selectors: string,
extraMatch = '',
delay = NaN,
reload = '',
observerTimeoutSec = NaN,
) {
if (!selectors) {
return;
}
const SHADOW_COMBINATOR = ' >>> ';
const SELECTORS_DELIMITER = ',';
// Commas inside `xpath(...)`, pseudo-classes or quoted strings are not delimiters,
// e.g. in `xpath(//button[contains(text(), "Accept")])` or `button:is(.accept, .agree)`.
// Empty selectors, e.g. after a trailing comma, or commented out ones are skipped
const parsedSelectors = splitSelectors(selectors, SELECTORS_DELIMITER)
.filter((selector) => !isEmptySelector(selector));
if (parsedSelectors.length === 0) {
logMessage(source, `Invalid selector arg: '${selectors}'`);
return;
}
// Each selector is split by the shadow combinator once, as it is queried on each DOM change
const selectorParts = new Map<string, string[]>();
parsedSelectors.forEach((selector) => {
selectorParts.set(selector, splitSelectors(selector, SHADOW_COMBINATOR));
});
// Only selectors of `parsedSelectors` are passed, so their parts are always in the map
const getSelectorParts = (selector: string): string[] => {
return selectorParts.get(selector) as string[];
};
// Selectors are validated once, to log an invalid one and exit before any hooks are installed,
// as an invalid XPath expression would select nothing, and an invalid CSS selector would throw an error
for (let i = 0; i < parsedSelectors.length; i += 1) {
const selectorError = getShadowSelectorError(getSelectorParts(parsedSelectors[i]));
if (selectorError !== null) {
logMessage(source, `Invalid selector arg: '${parsedSelectors[i]}', ${selectorError}`);
return;
}
}
/**
* Default observer timeout in seconds.
*/
const DEFAULT_OBSERVER_TIMEOUT_SEC = 10;
const THROTTLE_DELAY_MS = 20;
const STATIC_CLICK_DELAY_MS = 150;
const STATIC_RELOAD_DELAY_MS = 500;
const COOKIE_MATCH_MARKER = 'cookie:';
const LOCAL_STORAGE_MATCH_MARKER = 'localStorage:';
const TEXT_MATCH_MARKER = 'containsText:';
const CLICK_TYPE_MATCH_MARKER = 'clickType:';
const CLICK_TYPE_NATIVE = 'native';
const IS_TRUSTED_MATCH_MARKER = 'isTrusted:';
// Marker should start the pair, so the values of other pairs may contain it, e.g. text to match
const IS_TRUSTED_MATCH_REGEXP = /^!?isTrusted:/;
const IS_TRUSTED_ALL = 'all';
const RELOAD_ON_FINAL_CLICK_MARKER = 'reloadAfterClick';
const COOKIE_STRING_DELIMITER = ';';
const COLON = ':';
// Regex to split match pairs by commas, avoiding the ones included in regexes
const EXTRA_MATCH_DELIMITER = /(,\s*){1}(?=!?cookie:|!?localStorage:|containsText:|clickType:|!?isTrusted:)/;
const sleep = (delayMs: number) => {
return new Promise((resolve) => { setTimeout(resolve, delayMs); });
};
/**
* WeakMap to track closed shadow roots so queryShadowSelector can access them
* without forcing mode to 'open' (which may break some websites).
*
* @see https://github.com/AdguardTeam/Scriptlets/issues/491
*/
const closedShadowRoots = new WeakMap<Element, ShadowRoot>();
const bridgeObservers = new Set<MutationObserver>();
// Bridge observers only wake up the main observer, so they are not created once it is disconnected,
// e.g. after all elements have been found or on timeout, while the hook of `attachShadow` stays
let isBridgingShadowRoots = true;
const disconnectBridgeObservers = () => {
isBridgingShadowRoots = false;
bridgeObservers.forEach((obs) => obs.disconnect());
bridgeObservers.clear();
};
let observerTimeoutMs = DEFAULT_OBSERVER_TIMEOUT_SEC * 1000;
if (observerTimeoutSec) {
const parsedTimeout = Number(observerTimeoutSec);
if (!Number.isInteger(parsedTimeout) || parsedTimeout <= 0) {
logMessage(source, `Passed observer timeout '${observerTimeoutSec}' is invalid`);
return;
}
observerTimeoutMs = parsedTimeout * 1000;
}
let parsedDelayMs;
if (delay) {
parsedDelayMs = Number(delay);
if (!Number.isInteger(parsedDelayMs) || parsedDelayMs < 0) {
logMessage(source, `Passed delay '${delay}' is invalid`);
return;
}
if (parsedDelayMs >= observerTimeoutMs) {
const message = `Passed delay '${delay}' is bigger than ${observerTimeoutMs} ms`;
logMessage(source, message);
return;
}
}
let canClick = !parsedDelayMs;
const cookieMatches: string[] = [];
const localStorageMatches: string[] = [];
let textMatches = '';
let clickType = '';
let isAllSpoofed = false;
let isInvertedMatchCookie = false;
let isInvertedMatchLocalStorage = false;
if (extraMatch) {
// Get all match marker:value pairs from argument
const parsedExtraMatch = extraMatch
.split(EXTRA_MATCH_DELIMITER)
.map((matchStr) => matchStr.trim());
// Filter match pairs by marker
parsedExtraMatch.forEach((matchStr) => {
if (matchStr.includes(COOKIE_MATCH_MARKER)) {
const { isInvertedMatch, matchValue } = parseMatchArg(matchStr);
isInvertedMatchCookie = isInvertedMatch;
const cookieMatch = matchValue.replace(COOKIE_MATCH_MARKER, '');
cookieMatches.push(cookieMatch);
}
if (matchStr.includes(LOCAL_STORAGE_MATCH_MARKER)) {
const { isInvertedMatch, matchValue } = parseMatchArg(matchStr);
isInvertedMatchLocalStorage = isInvertedMatch;
const localStorageMatch = matchValue.replace(LOCAL_STORAGE_MATCH_MARKER, '');
localStorageMatches.push(localStorageMatch);
}
if (matchStr.includes(TEXT_MATCH_MARKER)) {
const { matchValue } = parseMatchArg(matchStr);
const textMatch = matchValue.replace(TEXT_MATCH_MARKER, '');
textMatches = textMatch;
}
if (matchStr.includes(CLICK_TYPE_MATCH_MARKER)) {
// Invalid click type is logged and the default one is used,
// so `return` only skips this match pair and the scriptlet should not exit
const { isInvertedMatch, matchValue } = parseMatchArg(matchStr);
if (isInvertedMatch) {
logMessage(source, `Passed click type '${matchStr}' is invalid`);
return;
}
const passedClickType = matchValue.replace(CLICK_TYPE_MATCH_MARKER, '');
if (passedClickType !== CLICK_TYPE_NATIVE) {
logMessage(source, `Passed click type '${passedClickType}' is invalid`);
return;
}
clickType = passedClickType;
}
if (IS_TRUSTED_MATCH_REGEXP.test(matchStr)) {
// Invalid value is logged and the default is used, like for the click type
const { isInvertedMatch, matchValue } = parseMatchArg(matchStr);
const passedIsTrusted = matchValue.replace(IS_TRUSTED_MATCH_MARKER, '');
if (isInvertedMatch || passedIsTrusted !== IS_TRUSTED_ALL) {
logMessage(source, `Passed isTrusted value '${matchStr}' is invalid`);
return;
}
isAllSpoofed = true;
}
});
}
if (cookieMatches.length > 0) {
const parsedCookieMatches = parseCookieString(cookieMatches.join(COOKIE_STRING_DELIMITER));
const parsedCookies = parseCookieString(document.cookie);
const cookieKeys = Object.keys(parsedCookies);
if (cookieKeys.length === 0) {
return;
}
const cookiesMatched = Object.keys(parsedCookieMatches).every((key) => {
// Avoid getting /.?/ result from toRegExp on undefined
// as cookie may be set without value,
// on which cookie parsing will return cookieKey:undefined pair
const valueMatch = parsedCookieMatches[key] ? toRegExp(parsedCookieMatches[key]) : null;
const keyMatch = toRegExp(key);
return cookieKeys.some((cookieKey) => {
// Regexp with `g` or `y` flag starts the search from the end of its previous match,
// e.g. of a previous key whose value does not match, so each key is tested from its start
keyMatch.lastIndex = 0;
const keysMatched = keyMatch.test(cookieKey);
if (!keysMatched) {
return false;
}
// Key matching is enough if cookie value match is not specified
if (!valueMatch) {
return true;
}
const parsedCookieValue = parsedCookies[cookieKey];
if (!parsedCookieValue) {
return false;
}
return valueMatch.test(parsedCookieValue);
});
});
const shouldRun = cookiesMatched !== isInvertedMatchCookie;
if (!shouldRun) {
return;
}
}
if (localStorageMatches.length > 0) {
const localStorageMatched = localStorageMatches
.every((str) => {
const itemValue = window.localStorage.getItem(str);
return itemValue || itemValue === '';
});
const shouldRun = localStorageMatched !== isInvertedMatchLocalStorage;
if (!shouldRun) {
return;
}
}
const textMatchRegexp = textMatches ? toRegExp(textMatches) : null;
/**
* Finds the first element matched by the selector, also inside open shadow roots and closed ones
* tracked by the scriptlet, which contains the text to match, if specified.
* Elements are found and clicked by the same query, so that text is matched for the clicked element as well.
*
* @param selector One of the parsed selectors.
*
* @returns Matched element, or null if there is none.
*/
const queryElement = (selector: string): Element | null => {
return queryShadowSelector(
getSelectorParts(selector),
document.documentElement,
textMatchRegexp,
closedShadowRoots,
);
};
/**
* Create selectors array and swap selectors to null on finding it's element
*
* Selectors / nulls should not be (re)moved from array to:
* - keep track of selectors order
* - always know on what index corresponding element should be put
* - prevent selectors from being queried multiple times
*/
let selectorsSequence: Array<string | null> = parsedSelectors;
const createElementObj = (element: any, selector?: string | null): Object => {
return {
element: element || null,
clicked: false,
selectorText: selector || null,
};
};
const elementsSequence = Array(selectorsSequence.length).fill(createElementObj(null));
/**
* Clicks an element of the sequence and marks it as clicked.
* An error of the click, e.g. thrown by a React handler of the page, is logged, and the element
* is still marked as clicked, as the page has handled the click, so the element is not clicked
* and the error is not logged again when the sequence is continued, and the next elements are clicked.
*
* @param elementObj Object of the element in the sequence.
* @param element Element to click, which may have been found again.
*/
const clickSequenceElement = (elementObj: ElementObject, element: HTMLElement): void => {
try {
clickElement(element, clickType);
} catch {
logMessage(source, `Could not click element: '${elementObj.selectorText}'`);
}
elementObj.clicked = true;
};
/**
* Attempts to find and click an element based on the provided selector data.
*
* @param elementObj - Object containing element selector information
*
*/
const findAndClickElement = (elementObj: ElementObject): void => {
try {
if (!elementObj.selectorText) {
return;
}
// Text should match as well, otherwise another element matching the selector may be clicked
const element = queryElement(elementObj.selectorText) as HTMLElement;
if (!element) {
logMessage(source, `Could not find element: '${elementObj.selectorText}'`);
return;
}
clickSequenceElement(elementObj, element);
} catch (error) {
// Errors of the click are handled by clickSequenceElement(), but e.g. the page may break
// the DOM methods used to find the element again, and the sequence should not be rejected
logMessage(source, `Could not click element: '${elementObj.selectorText}'`);
}
};
// Flag indicating if the reload is set
let shouldReloadAfterClick: boolean = false;
// Value used for reload timing
let reloadDelayMs: number = STATIC_RELOAD_DELAY_MS;
if (reload) {
// split reload option by colon
const reloadSplit = reload.split(COLON);
const reloadMarker = reloadSplit[0];
const reloadValue = reloadSplit[1];
if (reloadMarker !== RELOAD_ON_FINAL_CLICK_MARKER) {
logMessage(source, `Passed reload option '${reload}' is invalid`);
return;
}
// if reload value is set, will be used as a delay
// if reload value is not set, default value will be used
if (reloadValue) {
const passedReload = Number(reloadValue);
// check if passed reload value is a number
if (Number.isNaN(passedReload)) {
logMessage(source, `Passed reload delay value '${passedReload}' is invalid`);
return;
}
// check if passed reload value is less than observer timeout
if (passedReload > observerTimeoutMs) {
// eslint-disable-next-line max-len
logMessage(source, `Passed reload delay value '${passedReload}' is bigger than maximum ${observerTimeoutMs} ms`);
return;
}
reloadDelayMs = passedReload;
}
shouldReloadAfterClick = true;
}
/**
* Go through elementsSequence from left to right, clicking on found elements
*
* Element should not be clicked if it is already clicked,
* or a previous element is not found or clicked yet
*/
let canReload = true;
const clickElementsBySequence = async () => {
for (let i = 0; i < elementsSequence.length; i += 1) {
const elementObj = elementsSequence[i];
// Add a delay between clicks to every element except the first one
// https://github.com/AdguardTeam/Scriptlets/issues/284
if (i >= 1) {
await sleep(STATIC_CLICK_DELAY_MS);
}
// Stop clicking if that pos element is not found yet
if (!elementObj.element) {
break;
}
// Skip already clicked elements
if (!elementObj.clicked) {
// Checks if node is connected to a Document object,
// if not, try to find the element again
// https://github.com/AdguardTeam/Scriptlets/issues/391
if (elementObj.element.isConnected) {
clickSequenceElement(elementObj, elementObj.element);
} else {
findAndClickElement(elementObj);
}
}
// Stop clicking if the element is not found again, as next elements should be clicked after it
if (!elementObj.clicked) {
break;
}
}
const allElementsClicked = elementsSequence
.every((elementObj) => elementObj.clicked === true);
if (allElementsClicked) {
if (shouldReloadAfterClick && canReload) {
canReload = false;
setTimeout(() => {
window.location.reload();
}, reloadDelayMs);
}
hit(source);
}
};
const handleElement = (element: Element, i: number, selector: string) => {
const elementObj = createElementObj(element, selector);
elementsSequence[i] = elementObj;
if (canClick) {
clickElementsBySequence();
}
};
/**
* Processes a sequence of selectors, handling elements found in DOM (and shadow DOM),
* and updates the sequence.
*
* @returns The updated selectors sequence, with fulfilled selectors set to null.
*/
const fulfillAndHandleSelectors = (): Array<string | null> => {
const fulfilledSelectors: string[] = [];
selectorsSequence.forEach((selector, i) => {
if (!selector) {
return;
}
const element = queryElement(selector);
if (!element) {
return;
}
handleElement(element, i, selector);
fulfilledSelectors.push(selector);
});
// selectorsSequence should be modified after the loop to not break loop indexation
selectorsSequence = selectorsSequence.map((selector) => {
return selector && fulfilledSelectors.includes(selector)
? null
: selector;
});
return selectorsSequence;
};
/**
* Queries all selectors from queue on each mutation
*
* We start looking for elements before possible delay is over, to avoid cases
* when delay is getting off after the last mutation took place.
*
*/
const findElements = (mutations: MutationRecord[], observer: MutationObserver) => {
// TODO: try to make the function cleaner — avoid usage of selectorsSequence from the outer scope
selectorsSequence = fulfillAndHandleSelectors();
// Disconnect observer after finding all elements
const allSelectorsFulfilled = selectorsSequence.every((selector) => selector === null);
if (allSelectorsFulfilled) {
observer.disconnect();
disconnectBridgeObservers();
}
};
/**
* Initializes a `MutationObserver` to watch for changes in the DOM.
* The observer is set up to monitor changes in attributes, child nodes, and subtree.
* A timeout is set to disconnect the observer if no elements are found within the specified time.
*/
const initializeMutationObserver = () => {
const observer = new MutationObserver(throttle(findElements, THROTTLE_DELAY_MS));
observer.observe(document.documentElement, {
attributes: true,
childList: true,
subtree: true,
});
// Set timeout to disconnect observer if elements are not found within the specified time
setTimeout(() => {
observer.disconnect();
disconnectBridgeObservers();
}, observerTimeoutMs);
};
/**
* Checks if elements are already present in the DOM.
* If elements are found, they are clicked.
* If elements are not found, the observer is initialized.
*/
const checkInitialElements = () => {
const foundElements = selectorsSequence.every((selector) => {
if (!selector) {
return false;
}
const element = queryElement(selector);
return !!element;
});
if (foundElements) {
// Click previously collected elements
fulfillAndHandleSelectors();
disconnectBridgeObservers();
} else {
// Initialize MutationObserver if elements were not found initially
initializeMutationObserver();
}
};
// Spoof isTrusted for clicks dispatched by this scriptlet so that they
// appear as real user interactions to the page's event handlers,
// or for all click-related events on the page with `isTrusted:all`.
// Installed only after all early returns, but before looking for elements,
// since page listeners can only be wrapped when they are registered.
// @see https://github.com/AdguardTeam/Scriptlets/issues/491
if (!spoofClickEventsIsTrusted(isAllSpoofed)) {
// E.g. another script has made `addEventListener()` read-only or sealed `EventTarget.prototype`,
// so the scriptlet clicks without spoofing
logMessage(source, 'Cannot spoof isTrusted of clicks for event listeners, as their hook cannot be installed');
}
// If shadow combinator is present in selector, intercept attachShadow
// to track closed shadow roots and observe each new shadow root for mutations,
// bridging them to the document-level MutationObserver which cannot see inside shadow DOMs.
// Installed only after all early returns, but before looking for elements.
// Parsed selectors are checked, as ` >>> ` may be a part of a string, e.g. in `[title=" >>> "]`.
const hasShadowCombinator = parsedSelectors.some((selector) => getSelectorParts(selector).length > 1);
if (hasShadowCombinator) {
const attachShadowWrapper = (
target: typeof Element.prototype.attachShadow,
thisArg: Element,
argumentsList: any[],
) => {
const shadowRoot = Reflect.apply(target, thisArg, argumentsList);
// Track closed shadow roots so queryShadowSelector can look them up
// via the WeakMap instead of requiring elem.shadowRoot to be non-null.
// The mode is taken from the root, as the init options may return a different value on each read.
if (shadowRoot.mode === 'closed') {
closedShadowRoots.set(thisArg, shadowRoot);
}
// Closed shadow roots are still tracked, as a delayed click may look for its element again
if (!isBridgingShadowRoots) {
return shadowRoot;
}
/**
* Bridge shadow root mutations to the document-level observer.
* Without this, content added inside shadow DOMs would never trigger
* the main MutationObserver and selectors would never be re-checked.
* Also detect iframes added inside shadow roots and bridge their load events.
*
* @see {@link https://github.com/AdguardTeam/Scriptlets/issues/491}
*/
const bridgeObserver = new MutationObserver((mutations) => {
triggerMainObserver();
mutations.forEach((mutation) => {
bridgeIframeLoads(mutation.addedNodes);
});
});
bridgeObserver.observe(shadowRoot, { childList: true, subtree: true });
bridgeObservers.add(bridgeObserver);
return shadowRoot;
};
const attachShadowHandler = {
apply: attachShadowWrapper,
};
const attachShadowHook = new Proxy(window.Element.prototype.attachShadow, attachShadowHandler);
// Another script may have made `attachShadow()` read-only. Assigning it throws in strict mode
// and is ignored otherwise, as in the injected scriptlet code, so whether it is replaced is checked.
// If not, elements are still found in open shadow roots, but not in closed ones attached later,
// and changes inside shadow roots attached later do not make the scriptlet look for elements again.
try {
window.Element.prototype.attachShadow = attachShadowHook;
} catch {
// Checked below
}
if (window.Element.prototype.attachShadow !== attachShadowHook) {
logMessage(source, 'Cannot track shadow roots attached later, as attachShadow cannot be hooked');
}
}
// Run the initial check
checkInitialElements();
// If there's a delay before clicking elements, use a timeout
if (parsedDelayMs) {
setTimeout(() => {
// Click previously collected elements
clickElementsBySequence();
canClick = true;
}, parsedDelayMs);
}
}
export const trustedClickElementNames = [
'trusted-click-element',
// trusted scriptlets support no aliases
];
// eslint-disable-next-line prefer-destructuring
trustedClickElement.primaryName = trustedClickElementNames[0];
trustedClickElement.injections = [
hit,
toRegExp,
parseCookieString,
throttle,
logMessage,
parseMatchArg,
queryShadowSelector,
spoofClickEventsIsTrusted,
triggerMainObserver,
bridgeIframeLoads,
clickElement,
getShadowSelectorError,
isEmptySelector,
splitSelectors,
// following helpers are needed for helpers above
createTrustedEventProxy,
createSpoofedClicks,
getSpoofedClicks,
doesElementContainText,
findElementWithText,
isValidSelector,
isValidXpath,
getXpathExpression,
getFirstXpathElement,
randomId,
];