@duckduckgo/autoconsent 16.63.0 → 17.0.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.
Files changed (84) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/dist/addon-firefox/background.bundle.js +1 -0
  3. package/dist/addon-firefox/compact-rules.json +1 -1
  4. package/dist/addon-firefox/content.bundle.js +162 -35
  5. package/dist/addon-firefox/manifest.json +1 -1
  6. package/dist/addon-firefox/popup.bundle.js +1 -0
  7. package/dist/addon-firefox/rule-index.json +1 -1
  8. package/dist/addon-firefox/rules.json +1 -1
  9. package/dist/addon-mv3/background.bundle.js +1 -0
  10. package/dist/addon-mv3/compact-rules.json +1 -1
  11. package/dist/addon-mv3/content.bundle.js +162 -35
  12. package/dist/addon-mv3/manifest.json +1 -1
  13. package/dist/addon-mv3/popup.bundle.js +1 -0
  14. package/dist/addon-mv3/rule-index.json +1 -1
  15. package/dist/addon-mv3/rules.json +1 -1
  16. package/dist/autoconsent.cjs.js +162 -35
  17. package/dist/autoconsent.esm.js +162 -35
  18. package/dist/autoconsent.playwright.js +165 -35
  19. package/dist/autoconsent.standalone.js +163 -36
  20. package/dist/types/heuristics.d.ts +12 -3
  21. package/dist/types/types.d.ts +12 -1
  22. package/lib/cmps/base.ts +5 -1
  23. package/lib/heuristics.ts +240 -45
  24. package/lib/types.ts +16 -1
  25. package/lib/utils.ts +1 -0
  26. package/package.json +1 -1
  27. package/readme.md +1 -1
  28. package/rules/autoconsent/asrock.json +44 -0
  29. package/rules/autoconsent/consentmo.json +5 -2
  30. package/rules/autoconsent/cookie-script.json +34 -15
  31. package/rules/autoconsent/epaas.json +18 -1
  32. package/rules/autoconsent/fnsp-cookieconsent.json +30 -0
  33. package/rules/autoconsent/fod-fujitv.json +13 -0
  34. package/rules/autoconsent/hkdir.json +33 -0
  35. package/rules/autoconsent/jokul-cookie-consent.json +28 -0
  36. package/rules/autoconsent/lego.json +27 -5
  37. package/rules/autoconsent/mars-cookies-prompt.json +20 -0
  38. package/rules/autoconsent/niedziela-pl.json +35 -0
  39. package/rules/autoconsent/nikkeibp.json +10 -0
  40. package/rules/autoconsent/onecom-cookie-banner.json +25 -0
  41. package/rules/autoconsent/oslosportslager.no.json +34 -0
  42. package/rules/autoconsent/plex.tv.json +52 -0
  43. package/rules/autoconsent/radio-canada.json +28 -0
  44. package/rules/autoconsent/regjeringen.no.json +13 -0
  45. package/rules/autoconsent/roll20.json +50 -0
  46. package/rules/autoconsent/sirdata.json +8 -3
  47. package/rules/autoconsent/site-notifications-cookie-usage.json +25 -0
  48. package/rules/autoconsent/sparebank1-optin.json +10 -0
  49. package/rules/autoconsent/tri-table.json +6 -2
  50. package/rules/autoconsent/vinnova-se.json +50 -0
  51. package/rules/autoconsent/vortex-consent.json +33 -0
  52. package/rules/compact-rules.json +1 -1
  53. package/rules/rule-index.json +1 -1
  54. package/rules/rules.json +1 -1
  55. package/tests/asrock.spec.ts +3 -0
  56. package/tests/consentmo.spec.ts +2 -0
  57. package/tests/cookie-script.spec.ts +5 -0
  58. package/tests/epaas.spec.ts +3 -0
  59. package/tests/fnsp-cookieconsent.spec.ts +9 -0
  60. package/tests/fod-fujitv.spec.ts +3 -0
  61. package/tests/hkdir.spec.ts +10 -0
  62. package/tests/jokul-cookie-consent.spec.ts +3 -0
  63. package/tests/mars-cookies-prompt.spec.ts +3 -0
  64. package/tests/niedziela-pl.spec.ts +3 -0
  65. package/tests/nikkeibp.spec.ts +8 -0
  66. package/tests/onecom-cookie-banner.spec.ts +5 -0
  67. package/tests/oslosportslager.no.spec.ts +3 -0
  68. package/tests/plex.tv.spec.ts +5 -0
  69. package/tests/radio-canada.spec.ts +15 -0
  70. package/tests/regjeringen.no.spec.ts +3 -0
  71. package/tests/roll20.spec.ts +3 -0
  72. package/tests/sirdata.spec.ts +13 -1
  73. package/tests/site-notifications-cookie-usage.spec.ts +3 -0
  74. package/tests/sparebank1-optin.spec.ts +8 -0
  75. package/tests/tri-table.spec.ts +1 -1
  76. package/tests/vinnova-se.spec.ts +6 -0
  77. package/tests/vortex-consent.spec.ts +18 -0
  78. package/tests-wtr/heuristics/get-actionable-popups.ts +41 -7
  79. package/tests-wtr/heuristics/get-potential-popups.test.ts +235 -4
  80. package/tests-wtr/heuristics/heuristic-cmp.ts +16 -1
  81. package/rules/generated/auto_CA_plex.tv_ee4.json +0 -40
  82. package/rules/generated/auto_CA_support.plex.tv_gn3.json +0 -40
  83. package/tests/generated/auto_CA_plex.tv_ee4.spec.ts +0 -2
  84. package/tests/generated/auto_CA_support.plex.tv_gn3.spec.ts +0 -6
@@ -1,9 +1,18 @@
1
- import { ButtonData, HeuristicLevel, PopupData } from './types';
2
- export declare function getActionablePopups(mode?: HeuristicLevel, timeout?: number): PopupData[];
1
+ import { ButtonData, HeuristicLevel, HitTestGrid, PopupData } from './types';
3
2
  /**
3
+ * Popups the heuristic rule can act on in this mode, sorted so that a popup with reject comes first.
4
4
  * @param timeout time budget for the whole search, in ms
5
+ * @param hitTest hit-test the viewport before the DOM walk, to find popups the walk misses; when this finds a consent
6
+ * popup with reject, the walk is skipped
5
7
  */
6
- export declare function getPotentialPopups(timeout?: number): PopupData[];
8
+ export declare function getActionablePopups(mode?: HeuristicLevel, timeout?: number, hitTest?: boolean): PopupData[];
9
+ /**
10
+ * Popup candidates, each labelled with a regexClassification from its buttons.
11
+ * @param timeout time budget for the whole search, in ms
12
+ * @param hitTest hit-test the viewport before the DOM walk, to find popups the walk misses
13
+ * @param hitTestGrid points to hit-test, if not the default grid
14
+ */
15
+ export declare function getPotentialPopups(timeout?: number, hitTest?: boolean, hitTestGrid?: HitTestGrid): PopupData[];
7
16
  /**
8
17
  * The whole document of a frame, if it is rendered and has text. The body can have zero height when all of its
9
18
  * content is positioned, so only check visibility styles, not size.
@@ -69,6 +69,7 @@ export type Config = {
69
69
  };
70
70
  performanceLoggingEnabled: boolean;
71
71
  heuristicPopupSearchTimeout: number;
72
+ enableHeuristicHitTest: boolean;
72
73
  heuristicMode: HeuristicLevel;
73
74
  };
74
75
  export type LifecycleState = 'loading' | 'initialized' | 'waitingForInitResponse' | 'started' | 'nothingDetected' | 'cmpDetected' | 'openPopupDetected' | 'runningOptOut' | 'runningOptIn' | 'optOutSucceeded' | 'optOutFailed' | 'optInSucceeded' | 'optInFailed' | 'done';
@@ -94,7 +95,17 @@ export interface PopupData {
94
95
  text: string;
95
96
  element: HTMLElement;
96
97
  buttons: ButtonData[];
97
- regexClassification?: PopupClassification;
98
+ regexClassification: PopupClassification;
99
+ source?: 'walk' | 'hitTest' | 'frame';
100
+ hitPoints?: [number, number][];
101
+ }
102
+ /**
103
+ * Points to hit-test: columns are fractions of the viewport width, and rows are distances in px from both the top
104
+ * and the bottom edge. The middle row of the viewport is always tested.
105
+ */
106
+ export interface HitTestGrid {
107
+ columns: number[];
108
+ edgeRows: number[];
98
109
  }
99
110
  /**
100
111
  * The user setting for autoconsent and heuristic detection.
package/lib/cmps/base.ts CHANGED
@@ -454,7 +454,11 @@ export class AutoConsentHeuristicCMP extends AutoConsentCMPBase {
454
454
  // wait for one tick to deprioritize heavy DOM operations
455
455
  await new Promise((resolve) => setTimeout(resolve, 0));
456
456
  this.autoconsent.config.performanceLoggingEnabled && performance.mark('heuristicDetectorStart');
457
- this.popups = getActionablePopups(this.mode, this.autoconsent.config.heuristicPopupSearchTimeout);
457
+ this.popups = getActionablePopups(
458
+ this.mode,
459
+ this.autoconsent.config.heuristicPopupSearchTimeout,
460
+ this.autoconsent.config.enableHeuristicHitTest,
461
+ );
458
462
  this.autoconsent.config.performanceLoggingEnabled && performance.mark('heuristicDetectorEnd');
459
463
  this.autoconsent.config.performanceLoggingEnabled &&
460
464
  performance.measure('heuristicDetector', 'heuristicDetectorStart', 'heuristicDetectorEnd');
package/lib/heuristics.ts CHANGED
@@ -1,41 +1,116 @@
1
1
  import { classifyButtons, classifyPopup, hasHeuristicPatterns, isExcludedPopup } from './heuristic-classify';
2
- import { ButtonData, HeuristicLevel, PopupClassification, PopupData } from './types';
2
+ import { ButtonData, HeuristicLevel, HitTestGrid, PopupClassification, PopupData } from './types';
3
3
  import { isElementVisible, isTopFrame } from './utils';
4
4
 
5
5
  const BUTTON_LIKE_ELEMENT_SELECTOR = 'button, input[type="button"], input[type="submit"], a, [role="button"], [class*="button"]';
6
6
  const POPUP_SEARCH_MAX_TIME = 100;
7
7
 
8
- export function getActionablePopups(mode: HeuristicLevel = 'reject', timeout = POPUP_SEARCH_MAX_TIME): PopupData[] {
9
- const acceptedLevels: PopupClassification[] =
10
- mode === 'tier2' ? ['reject', 'tier1', 'tier2'] : mode === 'tier1' ? ['reject', 'tier1'] : ['reject'];
11
- const popups = getPotentialPopups(timeout).filter((popup) => {
12
- const popupText = popup.text?.trim();
13
- if (!popupText || isExcludedPopup(popupText) || !hasHeuristicPatterns(popupText)) {
14
- return false;
15
- }
16
- popup.regexClassification = classifyPopup(popup.buttons);
17
- return popup.regexClassification !== undefined && acceptedLevels.includes(popup.regexClassification);
18
- });
19
- // a popup with reject will always win
20
- return popups.sort((a, b) => ((a.regexClassification ?? '') > (b.regexClassification ?? '') ? 1 : -1));
8
+ // popup classifications the heuristic rule can act on, by heuristic mode
9
+ const ACCEPTED_LEVELS: Record<HeuristicLevel, PopupClassification[]> = {
10
+ off: [],
11
+ reject: ['reject'],
12
+ tier1: ['reject', 'tier1'],
13
+ tier2: ['reject', 'tier1', 'tier2'],
14
+ };
15
+
16
+ /**
17
+ * Popups the heuristic rule can act on in this mode, sorted so that a popup with reject comes first.
18
+ * @param timeout time budget for the whole search, in ms
19
+ * @param hitTest hit-test the viewport before the DOM walk, to find popups the walk misses; when this finds a consent
20
+ * popup with reject, the walk is skipped
21
+ */
22
+ export function getActionablePopups(mode: HeuristicLevel = 'reject', timeout = POPUP_SEARCH_MAX_TIME, hitTest = false): PopupData[] {
23
+ const acceptedLevels = ACCEPTED_LEVELS[mode];
24
+ const isActionable = (popup: PopupData) => acceptedLevels.includes(popup.regexClassification) && isConsentPopup(popup);
25
+ const popups = searchPopups(timeout, hitTest, DEFAULT_HIT_TEST_GRID, true).filter(isActionable);
26
+ // stable, so popups with the same classification keep their search order
27
+ return popups.sort((a, b) => classificationRank(a) - classificationRank(b));
21
28
  }
22
29
 
23
30
  /**
31
+ * Popup candidates, each labelled with a regexClassification from its buttons.
24
32
  * @param timeout time budget for the whole search, in ms
33
+ * @param hitTest hit-test the viewport before the DOM walk, to find popups the walk misses
34
+ * @param hitTestGrid points to hit-test, if not the default grid
35
+ */
36
+ export function getPotentialPopups(timeout = POPUP_SEARCH_MAX_TIME, hitTest = false, hitTestGrid = DEFAULT_HIT_TEST_GRID): PopupData[] {
37
+ return searchPopups(timeout, hitTest, hitTestGrid);
38
+ }
39
+
40
+ /**
41
+ * @param stopOnReject skip the DOM walk when hit-testing finds a consent popup with reject
25
42
  */
26
- export function getPotentialPopups(timeout = POPUP_SEARCH_MAX_TIME): PopupData[] {
43
+ function searchPopups(timeout: number, hitTest: boolean, hitTestGrid: HitTestGrid, stopOnReject = false): PopupData[] {
27
44
  if (!isTopFrame()) {
28
- // do not inspect frames that are more than one level deep
29
- if (window.parent && window.parent !== window.top) {
30
- return [];
31
- }
32
- // for iframes, just take the whole document
33
- const frameElement = getFramePopupElement(document);
34
- const popup = frameElement && toPopupData(frameElement);
35
- return popup ? [popup] : [];
45
+ return getFramePopups();
36
46
  }
37
47
  const deadline = performance.now() + timeout;
38
- // fixed, sticky and dialog-like elements, with fragments expanded to their popup root
48
+ // hit-test first: it takes a few ms, while on large pages the walk can use the whole budget
49
+ const hitTestPopups = hitTest ? findPopupsByHitTest(deadline, hitTestGrid) : [];
50
+ // the walk cannot find a better popup than one with reject
51
+ if (stopOnReject && hitTestPopups.some((p) => p.regexClassification === 'reject' && isConsentPopup(p))) {
52
+ return hitTestPopups;
53
+ }
54
+ const walkPopups = collectPositionedPopups(deadline);
55
+ return [...walkPopups, ...mergeWalkOverlaps(hitTestPopups, walkPopups)];
56
+ }
57
+
58
+ // isConsentPopup scans the popup text with many patterns, and the search asks for the same popup more than once
59
+ const consentPopups = new WeakMap<PopupData, boolean>();
60
+
61
+ // reject, then tier1, then tier2; -1 for a popup no heuristic mode acts on
62
+ function classificationRank(popup: PopupData): number {
63
+ return ACCEPTED_LEVELS.tier2.indexOf(popup.regexClassification);
64
+ }
65
+
66
+ // a consent popup the heuristic rule could act on in some mode
67
+ function isConsentPopup(popup: PopupData): boolean {
68
+ if (!ACCEPTED_LEVELS.tier2.includes(popup.regexClassification)) {
69
+ return false;
70
+ }
71
+ let result = consentPopups.get(popup);
72
+ if (result === undefined) {
73
+ const popupText = popup.text.trim();
74
+ result = !!popupText && !isExcludedPopup(popupText) && hasHeuristicPatterns(popupText);
75
+ consentPopups.set(popup, result);
76
+ }
77
+ return result;
78
+ }
79
+
80
+ /**
81
+ * Where both searches found the same popup, keep the walk's candidate, with the hit points of the hit-test one.
82
+ * Keep both when the hit-test one is a better consent popup: the walk candidate is not one (such as a wrapper that also
83
+ * holds an age gate), or ranks lower (such as an accept-only fragment of a banner whose reject button the hit test found).
84
+ */
85
+ function mergeWalkOverlaps(hitTestPopups: PopupData[], walkPopups: PopupData[]): PopupData[] {
86
+ return hitTestPopups.filter((h) => {
87
+ const hitIsConsent = isConsentPopup(h);
88
+ const absorbs = (p: PopupData) => !hitIsConsent || (isConsentPopup(p) && classificationRank(p) <= classificationRank(h));
89
+ const walkPopup = walkPopups.find((p) => composedOverlaps(p.element, h.element) && absorbs(p));
90
+ if (walkPopup) {
91
+ (walkPopup.hitPoints ??= []).push(...(h.hitPoints ?? []));
92
+ }
93
+ return !walkPopup;
94
+ });
95
+ }
96
+
97
+ function getFramePopups(): PopupData[] {
98
+ // do not inspect frames that are more than one level deep
99
+ if (window.parent && window.parent !== window.top) {
100
+ return [];
101
+ }
102
+ // for iframes, just take the whole document
103
+ const frameElement = getFramePopupElement(document);
104
+ const popup = frameElement && toPopupData(frameElement);
105
+ if (!popup) {
106
+ return [];
107
+ }
108
+ popup.source = 'frame';
109
+ return [popup];
110
+ }
111
+
112
+ // fixed, sticky and dialog-like popups found by walking the DOM, with fragments expanded to their popup root
113
+ function collectPositionedPopups(deadline: number): PopupData[] {
39
114
  const { elements, shadowHosts } = getPopupLikeElements(deadline);
40
115
  const potentialPopups: PopupData[] = [];
41
116
  const expanded: HTMLElement[] = [];
@@ -52,19 +127,107 @@ export function getPotentialPopups(timeout = POPUP_SEARCH_MAX_TIME): PopupData[]
52
127
  if (root !== popup) {
53
128
  expanded.push(root.element);
54
129
  }
130
+ root.source = 'walk';
55
131
  potentialPopups.push(root);
56
132
  }
57
133
  // a fragment expanded to its popup root replaces any other candidates inside that root
58
134
  return potentialPopups.filter((p) => !expanded.some((root) => root !== p.element && composedContains(root, p.element)));
59
135
  }
60
136
 
137
+ const DEFAULT_HIT_TEST_GRID: HitTestGrid = {
138
+ columns: [0.1, 0.5, 0.9],
139
+ // banners sit along the top and bottom edges and modals in the middle; rows 150px in added almost nothing in crawls
140
+ edgeRows: [30, 90],
141
+ };
142
+
143
+ /**
144
+ * Popups that are not fixed, sticky or dialog-like (for example banners in the normal page flow) are not found by
145
+ * the DOM walk. Hit-test points across the viewport and climb from the topmost element at each point instead.
146
+ * Prehide sets pointer-events: none, so a prehidden popup is only hit once prehide is lifted.
147
+ */
148
+ function findPopupsByHitTest(deadline: number, grid: HitTestGrid): PopupData[] {
149
+ // the popup that each climbed element leads to, or null if its climb found none
150
+ const climbed = new Map<Element, PopupData | null>();
151
+ const found: PopupData[] = [];
152
+ // any page element can be hit, so leave out forms
153
+ const toPopup = (el: HTMLElement) => (el.querySelector(TEXT_ENTRY_SELECTOR) ? null : toPopupRoot(el));
154
+ for (const [x, y] of getHitTestPoints(grid)) {
155
+ if (performance.now() > deadline) {
156
+ return found;
157
+ }
158
+ const path: HTMLElement[] = [];
159
+ // the cast stops TypeScript narrowing to null, as only the shouldStop closure assigns it before use
160
+ let reached = null as PopupData | null;
161
+ // stop where an earlier climb already went, and at popups already found
162
+ const shouldStop = (el: HTMLElement) => {
163
+ if (climbed.has(el)) {
164
+ reached = climbed.get(el) ?? null;
165
+ return true;
166
+ }
167
+ // the climb went inside a popup already found, or reached one of its ancestors
168
+ const overlapped = found.find((p) => composedOverlaps(p.element, el));
169
+ if (overlapped) {
170
+ reached = composedContains(overlapped.element, el) ? overlapped : null;
171
+ return true;
172
+ }
173
+ path.push(el);
174
+ return false;
175
+ };
176
+ const hit = closestHTMLElement(deepElementFromPoint(x, y));
177
+ const popup = climbToPopup(hit, MAX_HIT_TEST_CLIMB_DEPTH, toPopup, shouldStop);
178
+ if (popup) {
179
+ popup.source = 'hitTest';
180
+ found.push(popup);
181
+ reached = popup;
182
+ }
183
+ for (const el of path) {
184
+ climbed.set(el, reached);
185
+ }
186
+ if (reached) {
187
+ (reached.hitPoints ??= []).push([x, y]);
188
+ }
189
+ }
190
+ return found;
191
+ }
192
+
193
+ // the grid as [x, y] viewport points in CSS px: the top edge rows, the middle, then the bottom edge rows, each column by column
194
+ function getHitTestPoints(grid: HitTestGrid): [number, number][] {
195
+ const { innerWidth: width, innerHeight: height } = window;
196
+ const rows = [...grid.edgeRows, height / 2, ...grid.edgeRows.map((y) => height - y)].map((y) => Math.round(y));
197
+ const ys = [...new Set(rows)].filter((y) => y >= 0 && y < height);
198
+ return ys.flatMap((y) => grid.columns.map((column): [number, number] => [Math.round(column * width), y]));
199
+ }
200
+
201
+ // precaution, not yet seen in crawls: login, search and sign-up forms can carry consent wording and a "Continue" button
202
+ const TEXT_ENTRY_SELECTOR =
203
+ 'textarea, input:not([type]), input[type="text"], input[type="email"], input[type="password"], input[type="search"], input[type="tel"], input[type="number"], input[type="url"]';
204
+
205
+ function deepElementFromPoint(x: number, y: number): Element | null {
206
+ let el = document.elementFromPoint(x, y);
207
+ while (el?.shadowRoot) {
208
+ const inner = el.shadowRoot.elementFromPoint(x, y);
209
+ if (!inner || inner === el) {
210
+ break;
211
+ }
212
+ el = inner;
213
+ }
214
+ return el;
215
+ }
216
+
217
+ function closestHTMLElement(el: Element | null): HTMLElement | null {
218
+ while (el && !(el instanceof HTMLElement)) {
219
+ el = el.parentElement;
220
+ }
221
+ return el;
222
+ }
223
+
61
224
  function toPopupData(el: HTMLElement, shadowHosts?: HTMLElement[], text = getElementText(el)): PopupData | null {
62
225
  if (!text) {
63
226
  return null;
64
227
  }
65
228
  const buttons = getButtonData(el, shadowHosts);
66
229
  classifyButtons(buttons);
67
- return { text, element: el, buttons };
230
+ return { text, element: el, buttons, regexClassification: classifyPopup(buttons) };
68
231
  }
69
232
 
70
233
  // buttons beyond their labels, and at least one button the heuristics recognise (not just policy links)
@@ -81,35 +244,62 @@ function isOnlyButtonText(text: string, buttons: ButtonData[]): boolean {
81
244
  return rest.replace(/\s+/g, '').length < 10;
82
245
  }
83
246
 
84
- // Bounds how far expansion climbs. The stop at page-level containers is the main guard against false positives;
85
- // this limit only keeps the search cheap on deep trees.
247
+ /**
248
+ * The element as a popup, if it has consent text and popup buttons.
249
+ */
250
+ function toPopupRoot(el: HTMLElement, shadowHosts?: HTMLElement[]): PopupData | null {
251
+ // check the text first: it is cheaper than collecting buttons
252
+ const text = getElementText(el);
253
+ if (!text || !hasHeuristicPatterns(text)) {
254
+ return null;
255
+ }
256
+ const popup = toPopupData(el, shadowHosts, text);
257
+ return popup && hasPopupButtons(text, popup.buttons) ? popup : null;
258
+ }
259
+
260
+ // Bound how far climbs reach. The page-level stop in climbToPopup is the main guard against false positives;
261
+ // these limits only keep the search cheap on deep trees.
86
262
  const MAX_FRAGMENT_EXPANSION_DEPTH = 6;
263
+ const MAX_HIT_TEST_CLIMB_DEPTH = 8;
264
+
265
+ /**
266
+ * Climb from el through up to maxDepth composed ancestors, and return the first popup that toPopup accepts.
267
+ */
268
+ function climbToPopup(
269
+ el: HTMLElement | null,
270
+ maxDepth: number,
271
+ toPopup: (el: HTMLElement) => PopupData | null,
272
+ shouldStop?: (el: HTMLElement) => boolean,
273
+ ): PopupData | null {
274
+ for (let depth = 0; el && depth < maxDepth; depth++, el = getComposedParent(el)) {
275
+ // stop before reaching page-level containers, whose text includes unrelated page copy
276
+ if (
277
+ shouldStop?.(el) ||
278
+ el === document.body ||
279
+ el === document.documentElement ||
280
+ el.getBoundingClientRect().height > window.innerHeight
281
+ ) {
282
+ return null;
283
+ }
284
+ const popup = toPopup(el);
285
+ if (popup) {
286
+ return popup;
287
+ }
288
+ }
289
+ return null;
290
+ }
87
291
 
88
292
  /**
89
293
  * A positioned candidate is often only part of a popup, such as a sticky button bar or heading.
90
294
  * If it does not look like a consent popup on its own, use the nearest ancestor that does.
91
295
  */
92
296
  function expandPopupFragment(popup: PopupData, shadowHosts?: HTMLElement[]): PopupData {
297
+ // the same acceptance check as toPopupRoot, on the buttons already collected
93
298
  if (hasHeuristicPatterns(popup.text) && hasPopupButtons(popup.text, popup.buttons)) {
94
299
  return popup;
95
300
  }
96
- let el = getComposedParent(popup.element);
97
- for (let depth = 0; el && depth < MAX_FRAGMENT_EXPANSION_DEPTH; depth++, el = getComposedParent(el)) {
98
- // stop before reaching page-level containers, whose text includes unrelated page copy
99
- if (el === document.body || el === document.documentElement || el.getBoundingClientRect().height > window.innerHeight) {
100
- break;
101
- }
102
- // check the text first: it is cheaper than collecting buttons
103
- const text = getElementText(el);
104
- if (!text || !hasHeuristicPatterns(text)) {
105
- continue;
106
- }
107
- const root = toPopupData(el, shadowHosts, text);
108
- if (root && hasPopupButtons(text, root.buttons)) {
109
- return root;
110
- }
111
- }
112
- return popup;
301
+ const parent = getComposedParent(popup.element);
302
+ return climbToPopup(parent, MAX_FRAGMENT_EXPANSION_DEPTH, (el) => toPopupRoot(el, shadowHosts)) ?? popup;
113
303
  }
114
304
 
115
305
  function getComposedParent(el: HTMLElement): HTMLElement | null {
@@ -161,6 +351,11 @@ function isRenderedTextElement(el: Element): el is HTMLElement {
161
351
  );
162
352
  }
163
353
 
354
+ // either element contains the other, across shadow roots
355
+ function composedOverlaps(a: Node, b: Node): boolean {
356
+ return composedContains(a, b) || composedContains(b, a);
357
+ }
358
+
164
359
  /**
165
360
  * Like Node.contains, but also looks through shadow root boundaries.
166
361
  */
@@ -207,7 +402,7 @@ export function isDialogLikeElement(node: HTMLElement): boolean {
207
402
 
208
403
  /**
209
404
  * Heuristic to get all elements that look like "popups"
210
- * TODO: popups in the normal page flow are only found when part of them is fixed, sticky or dialog-like
405
+ * Popups that are not positioned are found by hit-testing instead, when enabled (findPopupsByHitTest).
211
406
  */
212
407
  function getPopupLikeElements(deadline: number): { elements: HTMLElement[]; shadowHosts: HTMLElement[] } {
213
408
  const found: HTMLElement[] = [];
package/lib/types.ts CHANGED
@@ -81,6 +81,7 @@ export type Config = {
81
81
  };
82
82
  performanceLoggingEnabled: boolean;
83
83
  heuristicPopupSearchTimeout: number;
84
+ enableHeuristicHitTest: boolean; // hit-test the viewport before the DOM walk, to find popups the walk misses (e.g. not fixed, sticky or dialog-like)
84
85
  heuristicMode: HeuristicLevel; // controls the behavior of the heuristic popup detection.
85
86
  };
86
87
 
@@ -125,7 +126,21 @@ export interface PopupData {
125
126
  text: string;
126
127
  element: HTMLElement;
127
128
  buttons: ButtonData[];
128
- regexClassification?: PopupClassification;
129
+ // from the buttons alone; whether the popup text is consent text is checked separately
130
+ regexClassification: PopupClassification;
131
+ // which search found the popup: the DOM walk, the viewport hit test, or the whole document of a frame
132
+ source?: 'walk' | 'hitTest' | 'frame';
133
+ // viewport points ([x, y] in CSS px) whose hit test reached this popup
134
+ hitPoints?: [number, number][];
135
+ }
136
+
137
+ /**
138
+ * Points to hit-test: columns are fractions of the viewport width, and rows are distances in px from both the top
139
+ * and the bottom edge. The middle row of the viewport is always tested.
140
+ */
141
+ export interface HitTestGrid {
142
+ columns: number[];
143
+ edgeRows: number[];
129
144
  }
130
145
 
131
146
  /**
package/lib/utils.ts CHANGED
@@ -109,6 +109,7 @@ export function normalizeConfig(providedConfig: any): Config {
109
109
  },
110
110
  performanceLoggingEnabled: false,
111
111
  heuristicPopupSearchTimeout: 100,
112
+ enableHeuristicHitTest: false,
112
113
  heuristicMode: 'off', // heuristic disabled by default
113
114
  };
114
115
  const updatedConfig: Config = copyObject(defaultConfig);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@duckduckgo/autoconsent",
3
- "version": "16.63.0",
3
+ "version": "17.0.0",
4
4
  "description": "",
5
5
  "types": "./dist/types/web.d.ts",
6
6
  "exports": {
package/readme.md CHANGED
@@ -42,7 +42,7 @@ import { checkHeuristicPatterns, classifyButtonTextRegex, classifyButtons, class
42
42
  classifyButtonTextRegex('Reject all'); // 'reject'
43
43
  ```
44
44
 
45
- `dist/autoconsent.playwright.js` also exposes `window.autoconsentHeuristics.getPotentialPopups(timeout)` and `getButtonData(element)`, so other scripts injected into the same world can reuse autoconsent's popup and button discovery.
45
+ `dist/autoconsent.playwright.js` also exposes `window.autoconsentHeuristics.getPotentialPopups(timeout, hitTest)` and `getButtonData(element)`, so other scripts injected into the same world can reuse autoconsent's popup and button discovery. Each potential popup is labelled with the `regexClassification` of its buttons; `getActionablePopups` keeps the consent popups among them that the heuristic mode can act on.
46
46
 
47
47
  ## Browser extension
48
48
 
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "asrock",
3
+ "vendorUrl": "https://www.asrock.com/",
4
+ "runContext": {
5
+ "urlPattern": "^https?://(\\w+\\.)?asrock\\.com/"
6
+ },
7
+ "prehideSelectors": [],
8
+ "detectCmp": [
9
+ {
10
+ "exists": "#AcceptCookies2 #MrktConfirm"
11
+ }
12
+ ],
13
+ "detectPopup": [
14
+ {
15
+ "visible": "#AcceptCookies"
16
+ }
17
+ ],
18
+ "optIn": [
19
+ {
20
+ "waitForThenClick": "#AcceptCookies input[onclick*=SetCookie]"
21
+ }
22
+ ],
23
+ "optOut": [
24
+ {
25
+ "waitForThenClick": "#AcceptCookies input[onclick*=AcceptCookies2]"
26
+ },
27
+ {
28
+ "waitForVisible": "#AcceptCookies2"
29
+ },
30
+ {
31
+ "click": "#AcceptCookies2 input[type=checkbox]:checked",
32
+ "all": true,
33
+ "optional": true
34
+ },
35
+ {
36
+ "click": "#AcceptCookies2 input[onclick*=SetCookie]"
37
+ }
38
+ ],
39
+ "test": [
40
+ {
41
+ "cookieContains": "MrktConfirm=false"
42
+ }
43
+ ]
44
+ }
@@ -55,7 +55,7 @@
55
55
  {
56
56
  "waitForVisible": [
57
57
  "csm-cookie-consent",
58
- ".csm-wrapper .cc-deny, .csm-wrapper [data-csm-button-type='accept_selected']"
58
+ ".csm-wrapper .cc-deny, .csm-wrapper [data-csm-button-type='accept_selected'], .csm-wrapper [data-preferences-csm-button-type='accept_selected']"
59
59
  ]
60
60
  },
61
61
  {
@@ -69,7 +69,10 @@
69
69
  ],
70
70
  "else": [
71
71
  {
72
- "click": ["csm-cookie-consent", ".csm-wrapper [data-csm-button-type='accept_selected']"]
72
+ "click": [
73
+ "csm-cookie-consent",
74
+ ".csm-wrapper [data-csm-button-type='accept_selected'], .csm-wrapper [data-preferences-csm-button-type='accept_selected']"
75
+ ]
73
76
  }
74
77
  ]
75
78
  }
@@ -9,27 +9,46 @@
9
9
  "if": { "exists": "#cookiescript_reject" },
10
10
  "then": [{ "wait": 100 }, { "click": "#cookiescript_reject" }],
11
11
  "else": [
12
- { "click": "#cookiescript_manage" },
13
- { "waitForVisible": ".cookiescript_fsd_main, #cookiescript_save", "timeout": 3000, "optional": true },
14
12
  {
15
- "if": { "exists": ".cookiescript_fsd_main" },
16
- "then": [{ "waitForThenClick": "#cookiescript_reject" }],
17
- "else": [
13
+ "if": { "exists": "#cookiescript_manage" },
14
+ "then": [
15
+ { "click": "#cookiescript_manage" },
16
+ { "waitForVisible": ".cookiescript_fsd_main, #cookiescript_save", "timeout": 3000, "optional": true },
18
17
  {
19
- "if": { "visible": "#cookiescript_save" },
20
- "then": [
18
+ "if": { "exists": ".cookiescript_fsd_main" },
19
+ "then": [{ "waitForThenClick": "#cookiescript_reject" }],
20
+ "else": [
21
21
  {
22
- "if": {
23
- "exists": "#cookiescript_injected input.cookiescript_checkbox_input:checked:not(:disabled), .cookiescript_banner_container input.cokiescript_checkbox:checked:not(:disabled)"
24
- },
22
+ "if": { "visible": "#cookiescript_save" },
25
23
  "then": [
26
24
  {
27
- "click": "#cookiescript_injected input.cookiescript_checkbox_input:checked:not(:disabled), .cookiescript_banner_container input.cokiescript_checkbox:checked:not(:disabled)",
28
- "all": true
29
- }
30
- ]
25
+ "if": {
26
+ "exists": "#cookiescript_injected input.cookiescript_checkbox_input:checked:not(:disabled), .cookiescript_banner_container input.cokiescript_checkbox:checked:not(:disabled)"
27
+ },
28
+ "then": [
29
+ {
30
+ "click": "#cookiescript_injected input.cookiescript_checkbox_input:checked:not(:disabled), .cookiescript_banner_container input.cokiescript_checkbox:checked:not(:disabled)",
31
+ "all": true
32
+ }
33
+ ]
34
+ },
35
+ { "waitForThenClick": "#cookiescript_save", "retry": 6, "retryInterval": 1000 }
36
+ ],
37
+ "else": [{ "click": "#cookiescript_close, .cookiescript_close_btn" }]
38
+ }
39
+ ]
40
+ }
41
+ ],
42
+ "else": [
43
+ {
44
+ "if": { "exists": "#cookiescript_save" },
45
+ "then": [
46
+ {
47
+ "click": "#cookiescript_injected input.cookiescript_checkbox_input:checked:not(:disabled), .cookiescript_banner_container input.cokiescript_checkbox:checked:not(:disabled)",
48
+ "all": true,
49
+ "optional": true
31
50
  },
32
- { "waitForThenClick": "#cookiescript_save", "retry": 6, "retryInterval": 1000 }
51
+ { "click": "#cookiescript_save" }
33
52
  ],
34
53
  "else": [{ "click": "#cookiescript_close, .cookiescript_close_btn" }]
35
54
  }
@@ -11,7 +11,24 @@
11
11
  "else": [
12
12
  { "waitForThenClick": ["epaas-consent-drawer-shell", "button.personalize-button"] },
13
13
  { "waitFor": ["epaas-consent-drawer-shell", "epaas-policy-page-shell"] },
14
- { "waitForThenClick": ["epaas-consent-drawer-shell", "button.save-button"] }
14
+ {
15
+ "waitFor": ["epaas-consent-drawer-shell", "epaas-policy-page-shell", "label[class*='category-header__switch']"],
16
+ "optional": true
17
+ },
18
+ {
19
+ "click": ["epaas-consent-drawer-shell", "epaas-policy-page-shell", "label.category-header__switch--on input"],
20
+ "all": true,
21
+ "optional": true
22
+ },
23
+ { "waitFor": ["epaas-consent-drawer-shell", "section.consentDrawer.layered button.accept-button"] },
24
+ {
25
+ "if": { "exists": ["epaas-consent-drawer-shell", "button.save-button"] },
26
+ "then": [{ "click": ["epaas-consent-drawer-shell", "button.save-button"] }],
27
+ "else": [
28
+ { "waitFor": ["epaas-consent-drawer-shell", "epaas-policy-page-shell", "label.category-header__switch--off"] },
29
+ { "click": ["epaas-consent-drawer-shell", "button.accept-button"] }
30
+ ]
31
+ }
15
32
  ]
16
33
  }
17
34
  ],