@thg-agentic/hyper-personalisation-web 0.0.0-stage → 3.18.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/LICENSE +21 -0
- package/README.md +860 -2
- package/dist/hp.min.js +3 -0
- package/dist/snippet.min.js +1 -0
- package/package.json +83 -3
- package/src/assignment.js +75 -0
- package/src/auto/attributes.js +16 -0
- package/src/auto/exposures.js +45 -0
- package/src/auto/lists.js +136 -0
- package/src/auto/promotions.js +104 -0
- package/src/auto/rails.js +217 -0
- package/src/auto/viewability.js +150 -0
- package/src/auto.js +13 -0
- package/src/bootstrap.js +504 -0
- package/src/carry.js +74 -0
- package/src/conformance.js +530 -0
- package/src/consent-gate.js +324 -0
- package/src/consent.js +604 -0
- package/src/context.js +439 -0
- package/src/datalayer.js +411 -0
- package/src/debug.js +45 -0
- package/src/decisions.js +90 -0
- package/src/device-storage.js +213 -0
- package/src/experiments.js +220 -0
- package/src/ga4.js +124 -0
- package/src/hash.js +138 -0
- package/src/hp.js +135 -0
- package/src/index.js +840 -0
- package/src/preview.js +44 -0
- package/src/recommendation-context.js +101 -0
- package/src/sampling.js +19 -0
- package/src/segment.js +388 -0
- package/src/session/category.js +40 -0
- package/src/session/record.js +176 -0
- package/src/session/rules.js +195 -0
- package/src/session/signals.js +150 -0
- package/src/session-id.js +62 -0
- package/src/session.js +28 -0
- package/src/settings.js +230 -0
- package/src/shape.js +120 -0
- package/src/social-proof.js +139 -0
- package/src/spec-version.js +4 -0
- package/src/storage-scope.js +164 -0
- package/src/transport.js +451 -0
- package/src/version.js +2 -0
- package/types/auto.d.ts +51 -0
- package/types/bootstrap.d.ts +165 -0
- package/types/consent.d.ts +126 -0
- package/types/datalayer.d.ts +76 -0
- package/types/events.d.ts +713 -0
- package/types/hp.d.ts +64 -0
- package/types/index.d.ts +617 -0
- package/types/segment.d.ts +68 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { firstAttr } from './attributes.js';
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_DWELL_MS,
|
|
4
|
+
DEFAULT_VISIBLE_RATIO,
|
|
5
|
+
watchViewability,
|
|
6
|
+
} from './viewability.js';
|
|
7
|
+
|
|
8
|
+
/** Read a promotion off its `data-hp-promotion*` attributes. */
|
|
9
|
+
function defaultResolve(el, index) {
|
|
10
|
+
const promotionId = firstAttr(el, 'hpPromotion');
|
|
11
|
+
if (!promotionId) return null;
|
|
12
|
+
|
|
13
|
+
return {
|
|
14
|
+
promotion_id: promotionId,
|
|
15
|
+
name: firstAttr(el, 'hpPromotionName'),
|
|
16
|
+
creative: firstAttr(el, 'hpPromotionCreative'),
|
|
17
|
+
// Segment's `position` (GA4 calls it creative_slot): a named slot, or the 1-based rank down
|
|
18
|
+
// the page as a string.
|
|
19
|
+
position: firstAttr(el, 'hpPromotionSlot') ?? String(index + 1),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Watch promotional slots and report impressions and clicks.
|
|
25
|
+
*
|
|
26
|
+
* @param {object} analytics An SDK instance from createAnalytics.
|
|
27
|
+
* @param {object} [options]
|
|
28
|
+
* @param {string} [options.selector] Which elements are promotional slots.
|
|
29
|
+
* @param {(el: Element, index: number) => object|null} [options.resolve] Element -> spec fields.
|
|
30
|
+
* Returning null skips the element, which is how a slot with no stable id opts out.
|
|
31
|
+
* @param {number} [options.visibleRatio] Fraction on screen that counts as visible.
|
|
32
|
+
* @param {number} [options.dwellMs] How long it must stay visible.
|
|
33
|
+
* @param {Document|Element} [options.root] Where to look. Defaults to the document.
|
|
34
|
+
* @returns {() => void} Teardown, for a SPA route change.
|
|
35
|
+
*/
|
|
36
|
+
export function trackPromotions(analytics, options = {}) {
|
|
37
|
+
if (typeof window === 'undefined' || typeof document === 'undefined')
|
|
38
|
+
return () => {};
|
|
39
|
+
|
|
40
|
+
const {
|
|
41
|
+
selector = '[data-hp-promotion]',
|
|
42
|
+
resolve = defaultResolve,
|
|
43
|
+
visibleRatio = DEFAULT_VISIBLE_RATIO,
|
|
44
|
+
dwellMs = DEFAULT_DWELL_MS,
|
|
45
|
+
root = document,
|
|
46
|
+
} = options;
|
|
47
|
+
|
|
48
|
+
// Keyed by promotion id, not element: a re-rendered carousel replaces the node, not the banner.
|
|
49
|
+
const seen = new Set();
|
|
50
|
+
|
|
51
|
+
const fieldsFor = (el) => {
|
|
52
|
+
// Ranked among the slots in the document now, in document order.
|
|
53
|
+
const all = Array.from(root.querySelectorAll(selector));
|
|
54
|
+
const index = all.indexOf(el);
|
|
55
|
+
try {
|
|
56
|
+
return resolve(el, index < 0 ? all.length : index);
|
|
57
|
+
} catch {
|
|
58
|
+
// A resolver that throws on one odd slot must not stop the rest of the page reporting.
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const stopWatching = watchViewability({
|
|
64
|
+
selector,
|
|
65
|
+
root,
|
|
66
|
+
visibleRatio,
|
|
67
|
+
dwellMs,
|
|
68
|
+
onViewable(el) {
|
|
69
|
+
const fields = fieldsFor(el);
|
|
70
|
+
if (!fields || seen.has(fields.promotion_id)) return false;
|
|
71
|
+
seen.add(fields.promotion_id);
|
|
72
|
+
analytics.promotionViewed(fields);
|
|
73
|
+
return true;
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Capture phase: banner components often stopPropagation, which would hide them from a bubble
|
|
78
|
+
// listener. Only a link is a click-through: a slider's arrows, a tab or a video's play button
|
|
79
|
+
// inside the slot are not.
|
|
80
|
+
const onClick = (event) => {
|
|
81
|
+
const target = event.target;
|
|
82
|
+
if (!target || typeof target.closest !== 'function') return;
|
|
83
|
+
const el = target.closest(selector);
|
|
84
|
+
const anchor = target.closest('a[href]');
|
|
85
|
+
if (!el || !anchor) return;
|
|
86
|
+
// The link must belong to the slot: inside it, or wrapping it.
|
|
87
|
+
if (!el.contains(anchor) && !anchor.contains(el)) return;
|
|
88
|
+
|
|
89
|
+
const fields = fieldsFor(el);
|
|
90
|
+
if (!fields) return;
|
|
91
|
+
|
|
92
|
+
analytics.promotionClicked({
|
|
93
|
+
...fields,
|
|
94
|
+
href: anchor.getAttribute('href'),
|
|
95
|
+
});
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
document.addEventListener('click', onClick, true);
|
|
99
|
+
|
|
100
|
+
return () => {
|
|
101
|
+
document.removeEventListener('click', onClick, true);
|
|
102
|
+
stopWatching();
|
|
103
|
+
};
|
|
104
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { firstAttr, intAttr } from './attributes.js';
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_DWELL_MS,
|
|
4
|
+
DEFAULT_VISIBLE_RATIO,
|
|
5
|
+
watchViewability,
|
|
6
|
+
} from './viewability.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Read a rail's serving metadata off its container. A rail missing its request id, placement or
|
|
10
|
+
* strategy is skipped: a placeholder would validate and silently corrupt the join.
|
|
11
|
+
*/
|
|
12
|
+
function defaultResolveRail(el) {
|
|
13
|
+
const requestId = firstAttr(el, 'hpRecoRequest');
|
|
14
|
+
const placement = firstAttr(el, 'hpRecoPlacement');
|
|
15
|
+
const strategy = firstAttr(el, 'hpRecoStrategy');
|
|
16
|
+
if (!requestId || !placement || !strategy) return null;
|
|
17
|
+
|
|
18
|
+
const holdout = firstAttr(el, 'hpRecoHoldout');
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
recommendation_request_id: requestId,
|
|
22
|
+
placement,
|
|
23
|
+
strategy,
|
|
24
|
+
recipe: firstAttr(el, 'hpRecoRecipe'),
|
|
25
|
+
algorithm_version: firstAttr(el, 'hpRecoAlgorithmVersion'),
|
|
26
|
+
experiment: firstAttr(el, 'hpRecoExperiment'),
|
|
27
|
+
variant: firstAttr(el, 'hpRecoVariant'),
|
|
28
|
+
// Absent means no holdout is running, not the treated arm, so it is not defaulted to false.
|
|
29
|
+
holdout: holdout === null ? null : holdout === 'true',
|
|
30
|
+
assignment_bucket: intAttr(el, 'hpRecoBucket'),
|
|
31
|
+
assignment_unit_kind: firstAttr(el, 'hpRecoUnitKind'),
|
|
32
|
+
candidate_set_fingerprint: firstAttr(el, 'hpRecoFingerprint'),
|
|
33
|
+
candidate_set_size: intAttr(el, 'hpRecoCandidates'),
|
|
34
|
+
ranked_by: firstAttr(el, 'hpRecoRankedBy'),
|
|
35
|
+
served_at: firstAttr(el, 'hpRecoServedAt'),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Read one tile of a rail. `position` falls back to DOM order within the rail; stamp it where a
|
|
41
|
+
* carousel positions tiles with CSS.
|
|
42
|
+
*/
|
|
43
|
+
function defaultResolveTile(el, index) {
|
|
44
|
+
const productId = firstAttr(el, 'hpRecoProduct');
|
|
45
|
+
if (!productId) return null;
|
|
46
|
+
|
|
47
|
+
const price = firstAttr(el, 'hpRecoPrice');
|
|
48
|
+
return {
|
|
49
|
+
product_id: productId,
|
|
50
|
+
sku: firstAttr(el, 'hpRecoSku'),
|
|
51
|
+
name: firstAttr(el, 'hpRecoName'),
|
|
52
|
+
brand: firstAttr(el, 'hpRecoBrand'),
|
|
53
|
+
price: price === null || Number.isNaN(Number(price)) ? null : Number(price),
|
|
54
|
+
position: intAttr(el, 'hpRecoPosition') ?? index + 1,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Watch recommendation rails and report the impression and the clicks that came out of it.
|
|
60
|
+
*
|
|
61
|
+
* One Recommendations Viewed per rail (per request id), fired when its first tile meets the
|
|
62
|
+
* viewability standard, or before the first click on one of its tiles if that comes sooner,
|
|
63
|
+
* carrying the whole ordered list. The emit is deferred one turn so tiles
|
|
64
|
+
* that became viewable together all land in `viewable`, which is a lower bound: tiles swiped in
|
|
65
|
+
* later are not added.
|
|
66
|
+
*
|
|
67
|
+
* @param {object} analytics An SDK instance from createAnalytics.
|
|
68
|
+
* @param {object} [options]
|
|
69
|
+
* @param {string} [options.selector] Which elements are recommendation rails.
|
|
70
|
+
* @param {string} [options.tileSelector] Which elements inside a rail are product tiles.
|
|
71
|
+
* @param {(el: Element) => object|null} [options.resolveRail] Rail element -> spec fields.
|
|
72
|
+
* @param {(el: Element, index: number) => object|null} [options.resolveTile] Tile -> product.
|
|
73
|
+
* @param {number} [options.visibleRatio]
|
|
74
|
+
* @param {number} [options.dwellMs]
|
|
75
|
+
* @param {Document|Element} [options.root]
|
|
76
|
+
* @returns {() => void} Teardown.
|
|
77
|
+
*/
|
|
78
|
+
export function trackRecommendationRails(analytics, options = {}) {
|
|
79
|
+
if (typeof window === 'undefined' || typeof document === 'undefined')
|
|
80
|
+
return () => {};
|
|
81
|
+
|
|
82
|
+
const {
|
|
83
|
+
selector = '[data-hp-reco-request]',
|
|
84
|
+
tileSelector = '[data-hp-reco-product]',
|
|
85
|
+
resolveRail = defaultResolveRail,
|
|
86
|
+
resolveTile = defaultResolveTile,
|
|
87
|
+
visibleRatio = DEFAULT_VISIBLE_RATIO,
|
|
88
|
+
dwellMs = DEFAULT_DWELL_MS,
|
|
89
|
+
root = document,
|
|
90
|
+
} = options;
|
|
91
|
+
|
|
92
|
+
// Keyed by request id, which survives a re-render and a rail duplicated into a sticky header.
|
|
93
|
+
const reported = new Set();
|
|
94
|
+
/** request id -> position -> viewable entry, so a tile counts once at its own rank. */
|
|
95
|
+
const viewableByRail = new Map();
|
|
96
|
+
/** request id -> deferred emit, so simultaneously-viewable tiles land on one event. */
|
|
97
|
+
const scheduled = new Map();
|
|
98
|
+
|
|
99
|
+
const safely = (fn, ...args) => {
|
|
100
|
+
try {
|
|
101
|
+
return fn(...args);
|
|
102
|
+
} catch {
|
|
103
|
+
// A resolver that throws on one odd rail must not stop the rest of the page reporting.
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const railFor = (el) =>
|
|
109
|
+
typeof el.closest === 'function' ? el.closest(selector) : null;
|
|
110
|
+
|
|
111
|
+
const tilesOf = (rail) => Array.from(rail.querySelectorAll(tileSelector));
|
|
112
|
+
|
|
113
|
+
const emit = (rail, fields) => {
|
|
114
|
+
scheduled.delete(fields.recommendation_request_id);
|
|
115
|
+
if (reported.has(fields.recommendation_request_id)) return;
|
|
116
|
+
|
|
117
|
+
const products = tilesOf(rail)
|
|
118
|
+
.map((tile, i) => safely(resolveTile, tile, i))
|
|
119
|
+
.filter((p) => p?.product_id);
|
|
120
|
+
// An empty `products` would fail validation at the ingest.
|
|
121
|
+
if (products.length === 0) return;
|
|
122
|
+
|
|
123
|
+
reported.add(fields.recommendation_request_id);
|
|
124
|
+
|
|
125
|
+
const viewable = [
|
|
126
|
+
...(viewableByRail.get(fields.recommendation_request_id)?.values() ?? []),
|
|
127
|
+
].sort((a, b) => a.position - b.position);
|
|
128
|
+
|
|
129
|
+
analytics.recommendationsViewed({ ...fields, products, viewable });
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const stopWatching = watchViewability({
|
|
133
|
+
// Tiles inside a rail only, so a listing grid on the same page is never counted as exposure.
|
|
134
|
+
selector: `${selector} ${tileSelector}`,
|
|
135
|
+
root,
|
|
136
|
+
visibleRatio,
|
|
137
|
+
dwellMs,
|
|
138
|
+
onViewable(tile) {
|
|
139
|
+
const rail = railFor(tile);
|
|
140
|
+
if (!rail) return true;
|
|
141
|
+
|
|
142
|
+
const fields = safely(resolveRail, rail);
|
|
143
|
+
if (!fields) return true;
|
|
144
|
+
|
|
145
|
+
const requestId = fields.recommendation_request_id;
|
|
146
|
+
const product = safely(resolveTile, tile, tilesOf(rail).indexOf(tile));
|
|
147
|
+
if (product?.product_id) {
|
|
148
|
+
if (!viewableByRail.has(requestId))
|
|
149
|
+
viewableByRail.set(requestId, new Map());
|
|
150
|
+
const entries = viewableByRail.get(requestId);
|
|
151
|
+
if (!entries.has(product.position)) {
|
|
152
|
+
entries.set(product.position, {
|
|
153
|
+
position: product.position,
|
|
154
|
+
product_id: product.product_id,
|
|
155
|
+
viewable_at: new Date().toISOString(),
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (!reported.has(requestId) && !scheduled.has(requestId)) {
|
|
161
|
+
scheduled.set(
|
|
162
|
+
requestId,
|
|
163
|
+
setTimeout(() => emit(rail, fields), 0),
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
return true;
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// Capture phase, as for promotions. Reported as Product Clicked carrying the request id, which
|
|
171
|
+
// says which ranked list the click came out of. A click counts when it follows a link in the
|
|
172
|
+
// tile, or lands on an affordance the markup names (`data-hp-reco-interaction="quick_buy"`); a
|
|
173
|
+
// wishlist heart or a carousel arrow is neither.
|
|
174
|
+
const onClick = (event) => {
|
|
175
|
+
const target = event.target;
|
|
176
|
+
if (!target || typeof target.closest !== 'function') return;
|
|
177
|
+
const tile = target.closest(tileSelector);
|
|
178
|
+
if (!tile) return;
|
|
179
|
+
const tagged = target.closest('[data-hp-reco-interaction]');
|
|
180
|
+
const anchor = target.closest('a[href]');
|
|
181
|
+
const onLink = anchor && (tile.contains(anchor) || anchor.contains(tile));
|
|
182
|
+
if (!(tagged && tile.contains(tagged)) && !onLink) return;
|
|
183
|
+
const rail = railFor(tile);
|
|
184
|
+
if (!rail) return;
|
|
185
|
+
|
|
186
|
+
const fields = safely(resolveRail, rail);
|
|
187
|
+
if (!fields) return;
|
|
188
|
+
const product = safely(resolveTile, tile, tilesOf(rail).indexOf(tile));
|
|
189
|
+
if (!product?.product_id) return;
|
|
190
|
+
|
|
191
|
+
// A click is proof the rail was seen, even when it came before the dwell did (a quick scroll
|
|
192
|
+
// and a tap). Its impression goes first, or the click names a request id no impression
|
|
193
|
+
// carries and joins nothing: the gap recommendation_exposure's click check fails on.
|
|
194
|
+
const requestId = fields.recommendation_request_id;
|
|
195
|
+
if (!reported.has(requestId)) {
|
|
196
|
+
clearTimeout(scheduled.get(requestId));
|
|
197
|
+
emit(rail, fields);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// No list_id: the spec keeps it for listings, and the request id is what joins the click to
|
|
201
|
+
// its rail's impression.
|
|
202
|
+
analytics.productClicked({
|
|
203
|
+
recommendation_request_id: fields.recommendation_request_id,
|
|
204
|
+
interaction: firstAttr(tagged ?? tile, 'hpRecoInteraction') ?? 'navigate',
|
|
205
|
+
products: [product],
|
|
206
|
+
});
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
document.addEventListener('click', onClick, true);
|
|
210
|
+
|
|
211
|
+
return () => {
|
|
212
|
+
document.removeEventListener('click', onClick, true);
|
|
213
|
+
for (const timer of scheduled.values()) clearTimeout(timer);
|
|
214
|
+
scheduled.clear();
|
|
215
|
+
stopWatching();
|
|
216
|
+
};
|
|
217
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 50% of pixels for one continuous second: the MRC/IAB Viewable Ad Impression standard.
|
|
3
|
+
* https://www.iab.com/wp-content/uploads/2015/06/MRC-Viewable-Ad-Impression-Measurement-Guideline.pdf
|
|
4
|
+
* The standard's 30% relaxation for large creatives is not implemented; 50% never overstates.
|
|
5
|
+
*/
|
|
6
|
+
export const DEFAULT_VISIBLE_RATIO = 0.5;
|
|
7
|
+
export const DEFAULT_DWELL_MS = 1000;
|
|
8
|
+
|
|
9
|
+
/** Callbacks at every tenth, so a ratio measured against the viewport is re-checked as it changes. */
|
|
10
|
+
const THRESHOLDS = Array.from({ length: 11 }, (_, i) => i / 10);
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Whether enough of an element is on screen. The share is of the element or of the viewport,
|
|
14
|
+
* whichever is smaller: half of a widget taller than two screens can never be visible, so measured
|
|
15
|
+
* against itself it could never count as seen while its clicks still would.
|
|
16
|
+
*/
|
|
17
|
+
function meetsRatio(entry, visibleRatio) {
|
|
18
|
+
if (!entry.isIntersecting) return false;
|
|
19
|
+
const {
|
|
20
|
+
intersectionRect: seen,
|
|
21
|
+
boundingClientRect: box,
|
|
22
|
+
rootBounds: screen,
|
|
23
|
+
} = entry;
|
|
24
|
+
if (!seen || !box) return true;
|
|
25
|
+
const shown = seen.width * seen.height;
|
|
26
|
+
const whole = box.width * box.height;
|
|
27
|
+
const fits = screen ? Math.min(whole, screen.width * screen.height) : whole;
|
|
28
|
+
return fits > 0 && shown / fits >= visibleRatio;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The viewability engine, shared by every impression this module reports so they all use one
|
|
33
|
+
* definition of "seen".
|
|
34
|
+
*
|
|
35
|
+
* @param {object} options
|
|
36
|
+
* @param {string} options.selector Which elements to watch.
|
|
37
|
+
* @param {(el: Element) => boolean} options.onViewable Called when an element meets the standard.
|
|
38
|
+
* Return true to stop watching the element.
|
|
39
|
+
* @param {number} [options.visibleRatio]
|
|
40
|
+
* @param {number} [options.dwellMs]
|
|
41
|
+
* @param {Document|Element} [options.root]
|
|
42
|
+
* @returns {() => void} Teardown.
|
|
43
|
+
*/
|
|
44
|
+
export function watchViewability(options) {
|
|
45
|
+
const {
|
|
46
|
+
selector,
|
|
47
|
+
onViewable,
|
|
48
|
+
visibleRatio = DEFAULT_VISIBLE_RATIO,
|
|
49
|
+
dwellMs = DEFAULT_DWELL_MS,
|
|
50
|
+
root = document,
|
|
51
|
+
} = options;
|
|
52
|
+
|
|
53
|
+
const pending = new Map();
|
|
54
|
+
const watched = new WeakSet();
|
|
55
|
+
/** Elements currently intersecting, so tab focus changes can restart their clocks. */
|
|
56
|
+
const visible = new Set();
|
|
57
|
+
|
|
58
|
+
// Anything that interrupts visibility clears the timer, so the dwell must be continuous.
|
|
59
|
+
const beginDwell = (el) => {
|
|
60
|
+
if (pending.has(el)) return;
|
|
61
|
+
pending.set(
|
|
62
|
+
el,
|
|
63
|
+
setTimeout(() => {
|
|
64
|
+
pending.delete(el);
|
|
65
|
+
if (onViewable(el)) {
|
|
66
|
+
impressionObserver?.unobserve(el);
|
|
67
|
+
visible.delete(el);
|
|
68
|
+
}
|
|
69
|
+
}, dwellMs),
|
|
70
|
+
);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const cancelDwell = (el) => {
|
|
74
|
+
clearTimeout(pending.get(el));
|
|
75
|
+
pending.delete(el);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const impressionObserver =
|
|
79
|
+
typeof IntersectionObserver === 'undefined'
|
|
80
|
+
? null
|
|
81
|
+
: new IntersectionObserver(
|
|
82
|
+
(entries) => {
|
|
83
|
+
for (const entry of entries) {
|
|
84
|
+
const el = entry.target;
|
|
85
|
+
if (!meetsRatio(entry, visibleRatio)) {
|
|
86
|
+
visible.delete(el);
|
|
87
|
+
cancelDwell(el);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
visible.add(el);
|
|
91
|
+
if (!isTabVisible()) continue;
|
|
92
|
+
beginDwell(el);
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
{ threshold: THRESHOLDS },
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
// IntersectionObserver ignores whether the tab is frontmost; the MRC standard requires a visible
|
|
99
|
+
// tab. Clocks are cancelled on hide and restarted on show.
|
|
100
|
+
const isTabVisible = () =>
|
|
101
|
+
typeof document === 'undefined' || document.visibilityState !== 'hidden';
|
|
102
|
+
|
|
103
|
+
const onVisibilityChange = () => {
|
|
104
|
+
if (isTabVisible()) {
|
|
105
|
+
for (const el of visible) beginDwell(el);
|
|
106
|
+
} else {
|
|
107
|
+
for (const el of visible) cancelDwell(el);
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
document.addEventListener('visibilitychange', onVisibilityChange);
|
|
112
|
+
|
|
113
|
+
const scan = () => {
|
|
114
|
+
for (const el of root.querySelectorAll(selector)) {
|
|
115
|
+
if (watched.has(el)) continue;
|
|
116
|
+
watched.add(el);
|
|
117
|
+
impressionObserver?.observe(el);
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// Widgets hydrate and lazy-load after this runs (a recommendation rail always does), so re-scan
|
|
122
|
+
// on every DOM change.
|
|
123
|
+
const mutationObserver =
|
|
124
|
+
typeof MutationObserver === 'undefined'
|
|
125
|
+
? null
|
|
126
|
+
: new MutationObserver(() => scan());
|
|
127
|
+
|
|
128
|
+
const begin = () => {
|
|
129
|
+
scan();
|
|
130
|
+
mutationObserver?.observe(root === document ? document.body : root, {
|
|
131
|
+
childList: true,
|
|
132
|
+
subtree: true,
|
|
133
|
+
});
|
|
134
|
+
};
|
|
135
|
+
// Started from <head> (a cached bootstrap, a synchronous tag) there is no body to watch yet.
|
|
136
|
+
const beforeBody = root === document && !document.body;
|
|
137
|
+
if (beforeBody)
|
|
138
|
+
document.addEventListener('DOMContentLoaded', begin, { once: true });
|
|
139
|
+
else begin();
|
|
140
|
+
|
|
141
|
+
return () => {
|
|
142
|
+
if (beforeBody) document.removeEventListener('DOMContentLoaded', begin);
|
|
143
|
+
document.removeEventListener('visibilitychange', onVisibilityChange);
|
|
144
|
+
impressionObserver?.disconnect();
|
|
145
|
+
mutationObserver?.disconnect();
|
|
146
|
+
for (const timer of pending.values()) clearTimeout(timer);
|
|
147
|
+
pending.clear();
|
|
148
|
+
visible.clear();
|
|
149
|
+
};
|
|
150
|
+
}
|
package/src/auto.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Declarative auto-instrumentation: the events you can get from markup alone.
|
|
3
|
+
*
|
|
4
|
+
* Promotion and recommendation impressions never reach a server, so the browser must report them.
|
|
5
|
+
* Slots describe themselves in the DOM and one observer reads them; `selector` and `resolve` let a
|
|
6
|
+
* host point it at its own attribute convention. A slot with no resolvable id is skipped rather
|
|
7
|
+
* than given a generated one, which would make impressions and clicks unjoinable.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export { trackExposures } from './auto/exposures.js';
|
|
11
|
+
export { trackProductLists } from './auto/lists.js';
|
|
12
|
+
export { trackPromotions } from './auto/promotions.js';
|
|
13
|
+
export { trackRecommendationRails } from './auto/rails.js';
|