@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.
- package/CHANGELOG.md +41 -0
- package/dist/addon-firefox/background.bundle.js +1 -0
- package/dist/addon-firefox/compact-rules.json +1 -1
- package/dist/addon-firefox/content.bundle.js +162 -35
- package/dist/addon-firefox/manifest.json +1 -1
- package/dist/addon-firefox/popup.bundle.js +1 -0
- package/dist/addon-firefox/rule-index.json +1 -1
- package/dist/addon-firefox/rules.json +1 -1
- package/dist/addon-mv3/background.bundle.js +1 -0
- package/dist/addon-mv3/compact-rules.json +1 -1
- package/dist/addon-mv3/content.bundle.js +162 -35
- package/dist/addon-mv3/manifest.json +1 -1
- package/dist/addon-mv3/popup.bundle.js +1 -0
- package/dist/addon-mv3/rule-index.json +1 -1
- package/dist/addon-mv3/rules.json +1 -1
- package/dist/autoconsent.cjs.js +162 -35
- package/dist/autoconsent.esm.js +162 -35
- package/dist/autoconsent.playwright.js +165 -35
- package/dist/autoconsent.standalone.js +163 -36
- package/dist/types/heuristics.d.ts +12 -3
- package/dist/types/types.d.ts +12 -1
- package/lib/cmps/base.ts +5 -1
- package/lib/heuristics.ts +240 -45
- package/lib/types.ts +16 -1
- package/lib/utils.ts +1 -0
- package/package.json +1 -1
- package/readme.md +1 -1
- package/rules/autoconsent/asrock.json +44 -0
- package/rules/autoconsent/consentmo.json +5 -2
- package/rules/autoconsent/cookie-script.json +34 -15
- package/rules/autoconsent/epaas.json +18 -1
- package/rules/autoconsent/fnsp-cookieconsent.json +30 -0
- package/rules/autoconsent/fod-fujitv.json +13 -0
- package/rules/autoconsent/hkdir.json +33 -0
- package/rules/autoconsent/jokul-cookie-consent.json +28 -0
- package/rules/autoconsent/lego.json +27 -5
- package/rules/autoconsent/mars-cookies-prompt.json +20 -0
- package/rules/autoconsent/niedziela-pl.json +35 -0
- package/rules/autoconsent/nikkeibp.json +10 -0
- package/rules/autoconsent/onecom-cookie-banner.json +25 -0
- package/rules/autoconsent/oslosportslager.no.json +34 -0
- package/rules/autoconsent/plex.tv.json +52 -0
- package/rules/autoconsent/radio-canada.json +28 -0
- package/rules/autoconsent/regjeringen.no.json +13 -0
- package/rules/autoconsent/roll20.json +50 -0
- package/rules/autoconsent/sirdata.json +8 -3
- package/rules/autoconsent/site-notifications-cookie-usage.json +25 -0
- package/rules/autoconsent/sparebank1-optin.json +10 -0
- package/rules/autoconsent/tri-table.json +6 -2
- package/rules/autoconsent/vinnova-se.json +50 -0
- package/rules/autoconsent/vortex-consent.json +33 -0
- package/rules/compact-rules.json +1 -1
- package/rules/rule-index.json +1 -1
- package/rules/rules.json +1 -1
- package/tests/asrock.spec.ts +3 -0
- package/tests/consentmo.spec.ts +2 -0
- package/tests/cookie-script.spec.ts +5 -0
- package/tests/epaas.spec.ts +3 -0
- package/tests/fnsp-cookieconsent.spec.ts +9 -0
- package/tests/fod-fujitv.spec.ts +3 -0
- package/tests/hkdir.spec.ts +10 -0
- package/tests/jokul-cookie-consent.spec.ts +3 -0
- package/tests/mars-cookies-prompt.spec.ts +3 -0
- package/tests/niedziela-pl.spec.ts +3 -0
- package/tests/nikkeibp.spec.ts +8 -0
- package/tests/onecom-cookie-banner.spec.ts +5 -0
- package/tests/oslosportslager.no.spec.ts +3 -0
- package/tests/plex.tv.spec.ts +5 -0
- package/tests/radio-canada.spec.ts +15 -0
- package/tests/regjeringen.no.spec.ts +3 -0
- package/tests/roll20.spec.ts +3 -0
- package/tests/sirdata.spec.ts +13 -1
- package/tests/site-notifications-cookie-usage.spec.ts +3 -0
- package/tests/sparebank1-optin.spec.ts +8 -0
- package/tests/tri-table.spec.ts +1 -1
- package/tests/vinnova-se.spec.ts +6 -0
- package/tests/vortex-consent.spec.ts +18 -0
- package/tests-wtr/heuristics/get-actionable-popups.ts +41 -7
- package/tests-wtr/heuristics/get-potential-popups.test.ts +235 -4
- package/tests-wtr/heuristics/heuristic-cmp.ts +16 -1
- package/rules/generated/auto_CA_plex.tv_ee4.json +0 -40
- package/rules/generated/auto_CA_support.plex.tv_gn3.json +0 -40
- package/tests/generated/auto_CA_plex.tv_ee4.spec.ts +0 -2
- 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
|
|
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.
|
package/dist/types/types.d.ts
CHANGED
|
@@ -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
|
|
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(
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
43
|
+
function searchPopups(timeout: number, hitTest: boolean, hitTestGrid: HitTestGrid, stopOnReject = false): PopupData[] {
|
|
27
44
|
if (!isTopFrame()) {
|
|
28
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
85
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
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": [
|
|
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": "
|
|
16
|
-
"then": [
|
|
17
|
-
|
|
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": { "
|
|
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
|
-
"
|
|
28
|
-
|
|
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
|
-
{ "
|
|
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
|
-
{
|
|
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
|
],
|