@thg-agentic/hyper-personalisation-web 0.0.0-stage → 3.17.1
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 +825 -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 +476 -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/decisions.js +90 -0
- package/src/device-storage.js +201 -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 +818 -0
- package/src/preview.js +44 -0
- package/src/recommendation-context.js +101 -0
- package/src/segment.js +375 -0
- package/src/session/category.js +40 -0
- package/src/session/record.js +163 -0
- package/src/session/rules.js +195 -0
- package/src/session/signals.js +146 -0
- package/src/session-id.js +62 -0
- package/src/session.js +28 -0
- package/src/settings.js +211 -0
- package/src/shape.js +111 -0
- package/src/social-proof.js +139 -0
- package/src/spec-version.js +4 -0
- package/src/storage-scope.js +159 -0
- package/src/transport.js +436 -0
- package/src/version.js +2 -0
- package/types/auto.d.ts +51 -0
- package/types/bootstrap.d.ts +154 -0
- package/types/consent.d.ts +126 -0
- package/types/datalayer.d.ts +76 -0
- package/types/events.d.ts +680 -0
- package/types/hp.d.ts +64 -0
- package/types/index.d.ts +610 -0
- package/types/segment.d.ts +68 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic experiment assignment for recommendation rails, and the fingerprint that
|
|
3
|
+
* identifies the candidate set a rail was drawn from.
|
|
4
|
+
*
|
|
5
|
+
* The arm is decided in the browser before the rail is requested (a control must not call the
|
|
6
|
+
* recommender) and recorded on the impression. It is a hash of a stable unit rather than a stored
|
|
7
|
+
* coin flip, so a visitor never moves between arms when storage is cleared. The holdout is carved
|
|
8
|
+
* off the top of the bucket space before the variants, as DecisionSlot.holdout is. Which
|
|
9
|
+
* experiments exist and who is eligible is the host's business.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Bucket resolution. 10,000 makes a 0.01% share expressible, which is finer than any real split. */
|
|
13
|
+
const DEFAULT_BUCKETS = 10000;
|
|
14
|
+
|
|
15
|
+
/** The arm name recorded for held-out traffic when the caller does not choose one. */
|
|
16
|
+
const DEFAULT_HOLDOUT_NAME = 'holdout';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 32-bit hash of a string: FNV-1a, then MurmurHash3's finaliser (fmix32), which FNV-1a needs to
|
|
20
|
+
* avalanche on short, similar ids. Not cryptographic; synchronous because assignment happens
|
|
21
|
+
* before the first render, and crypto.subtle is async.
|
|
22
|
+
*/
|
|
23
|
+
export function hash32(input) {
|
|
24
|
+
let h = 0x811c9dc5;
|
|
25
|
+
const s = String(input);
|
|
26
|
+
for (let i = 0; i < s.length; i += 1) {
|
|
27
|
+
h ^= s.charCodeAt(i);
|
|
28
|
+
// FNV prime, via shifts: `h * 16777619` overflows the double's integer range and loses bits.
|
|
29
|
+
h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0;
|
|
30
|
+
}
|
|
31
|
+
h ^= h >>> 16;
|
|
32
|
+
h = Math.imul(h, 0x85ebca6b) >>> 0;
|
|
33
|
+
h ^= h >>> 13;
|
|
34
|
+
h = Math.imul(h, 0xc2b2ae35) >>> 0;
|
|
35
|
+
h ^= h >>> 16;
|
|
36
|
+
return h >>> 0;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Which bucket a unit falls in for a given salt. Salted per experiment so concurrent experiments
|
|
41
|
+
* are independent permutations of the population.
|
|
42
|
+
*/
|
|
43
|
+
export function hashBucket(unit, salt, buckets = DEFAULT_BUCKETS) {
|
|
44
|
+
// A NUL separator prevents `ab|c` colliding with `a|bc`. Keep it an escape: a literal NUL byte
|
|
45
|
+
// makes git treat the file as binary.
|
|
46
|
+
return hash32(`${salt}\0${unit}`) % buckets;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Assign a visitor to an arm of a recommendation experiment.
|
|
51
|
+
*
|
|
52
|
+
* @param {object} options
|
|
53
|
+
* @param {string|null} options.unit The thing being bucketed — usually the anonymous id.
|
|
54
|
+
* @param {string} options.experiment Experiment name; also the default hash salt.
|
|
55
|
+
* @param {Array<{name: string, share: number}>} options.variants Arms and their shares of the
|
|
56
|
+
* NON-held-out traffic. Shares are normalised, so [1, 1] and [0.5, 0.5] mean the same thing.
|
|
57
|
+
* @param {number} [options.holdoutShare] Fraction of ALL traffic withheld from personalisation.
|
|
58
|
+
* @param {string} [options.holdoutName] Arm name recorded for held-out traffic.
|
|
59
|
+
* @param {string} [options.salt] Override the salt to keep an arm stable across a rename.
|
|
60
|
+
* @param {number} [options.buckets] Bucket resolution.
|
|
61
|
+
* @param {'anonymous_id'|'customer_id'|'session_id'|'other'} [options.unitKind] What `unit` is.
|
|
62
|
+
* @returns {{experiment: string, variant: string, holdout: boolean, assignment_bucket: number,
|
|
63
|
+
* assignment_unit_kind: string}|null} Shaped to spread straight onto the impression event.
|
|
64
|
+
* Null when there is no unit to bucket.
|
|
65
|
+
*/
|
|
66
|
+
export function assignRecommendationVariant(options) {
|
|
67
|
+
const {
|
|
68
|
+
unit,
|
|
69
|
+
experiment,
|
|
70
|
+
variants,
|
|
71
|
+
holdoutShare = 0,
|
|
72
|
+
holdoutName = DEFAULT_HOLDOUT_NAME,
|
|
73
|
+
salt,
|
|
74
|
+
buckets = DEFAULT_BUCKETS,
|
|
75
|
+
unitKind = 'anonymous_id',
|
|
76
|
+
} = options ?? {};
|
|
77
|
+
|
|
78
|
+
if (typeof experiment !== 'string' || experiment === '') {
|
|
79
|
+
throw new Error('assignRecommendationVariant: experiment is required');
|
|
80
|
+
}
|
|
81
|
+
if (!Array.isArray(variants) || variants.length === 0) {
|
|
82
|
+
throw new Error(
|
|
83
|
+
'assignRecommendationVariant: variants must be a non-empty array',
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const total = variants.reduce((sum, v) => sum + (Number(v?.share) || 0), 0);
|
|
88
|
+
if (!(total > 0)) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
'assignRecommendationVariant: variant shares must sum to more than zero',
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// No unit, no assignment, and specifically not the holdout, which would fill with every
|
|
95
|
+
// unidentifiable visitor. The caller serves the rail normally and records `variant: null`.
|
|
96
|
+
if (unit == null || unit === '') return null;
|
|
97
|
+
|
|
98
|
+
const bucket = hashBucket(unit, salt ?? experiment, buckets);
|
|
99
|
+
|
|
100
|
+
// Whole buckets, so a boundary cannot land either side of a bucket depending on float rounding.
|
|
101
|
+
const holdoutCut = Math.round(
|
|
102
|
+
Math.min(Math.max(holdoutShare, 0), 1) * buckets,
|
|
103
|
+
);
|
|
104
|
+
if (bucket < holdoutCut) {
|
|
105
|
+
return {
|
|
106
|
+
experiment,
|
|
107
|
+
variant: holdoutName,
|
|
108
|
+
holdout: true,
|
|
109
|
+
assignment_bucket: bucket,
|
|
110
|
+
assignment_unit_kind: unitKind,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const treated = buckets - holdoutCut;
|
|
115
|
+
let edge = holdoutCut;
|
|
116
|
+
for (let i = 0; i < variants.length; i += 1) {
|
|
117
|
+
edge += Math.round((treated * (Number(variants[i].share) || 0)) / total);
|
|
118
|
+
// The last arm takes whatever rounding left over, so no bucket is unassignable.
|
|
119
|
+
if (bucket < edge || i === variants.length - 1) {
|
|
120
|
+
return {
|
|
121
|
+
experiment,
|
|
122
|
+
variant: String(variants[i].name),
|
|
123
|
+
holdout: false,
|
|
124
|
+
assignment_bucket: bucket,
|
|
125
|
+
assignment_unit_kind: unitKind,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* A property holdout's split as a serving response states it: the share held out and the share of
|
|
135
|
+
* ALL shoppers personalised (the ramp, capped by what the holdout leaves; everyone else without
|
|
136
|
+
* one). The public API's `personalisedShare` computes the same.
|
|
137
|
+
*
|
|
138
|
+
* @param {{share: number, ramp?: number|null}} holdout The property's holdout.
|
|
139
|
+
* @returns {{holdout_share: number, personalised_share: number}}
|
|
140
|
+
*/
|
|
141
|
+
export function holdoutShares(holdout) {
|
|
142
|
+
const clamp = (share) => Math.min(Math.max(Number(share) || 0, 0), 1);
|
|
143
|
+
const share = clamp(holdout.share);
|
|
144
|
+
const rest = 1 - share;
|
|
145
|
+
return {
|
|
146
|
+
holdout_share: share,
|
|
147
|
+
personalised_share:
|
|
148
|
+
holdout.ramp == null ? rest : Math.min(clamp(holdout.ramp), rest),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* A shopper's group for a property's personalisation: the same answer the activation edge and the
|
|
154
|
+
* public API give, from the same hash and the same fixed ranges (spec/assignment/holdout.json holds
|
|
155
|
+
* the vectors). Below `share` the shopper is held out; below `share + ramp` personalised; above it
|
|
156
|
+
* excluded, seeing the default and counted in neither group. A null ramp personalises everyone
|
|
157
|
+
* outside the holdout. Ramping widens only the personalised range, so nobody ever moves into or out
|
|
158
|
+
* of the holdout. No unit is never held out; under a ramp it is excluded, since it could only ever
|
|
159
|
+
* count on the treated side.
|
|
160
|
+
*
|
|
161
|
+
* @param {{share: number, ramp?: number|null, salt: string}|null|undefined} holdout The property's holdout.
|
|
162
|
+
* @param {string|null|undefined} unit The anonymous id.
|
|
163
|
+
* @returns {{holdout: boolean, bucket: number|null, group: 'holdout'|'personalised'|'excluded'}}
|
|
164
|
+
*/
|
|
165
|
+
export function holdoutAssignment(holdout, unit) {
|
|
166
|
+
if (!holdout || typeof holdout.salt !== 'string')
|
|
167
|
+
return { holdout: false, bucket: null, group: 'personalised' };
|
|
168
|
+
const ramped = holdout.ramp != null;
|
|
169
|
+
if (unit == null || unit === '')
|
|
170
|
+
return {
|
|
171
|
+
holdout: false,
|
|
172
|
+
bucket: null,
|
|
173
|
+
group: ramped ? 'excluded' : 'personalised',
|
|
174
|
+
};
|
|
175
|
+
const { holdout_share: share, personalised_share: personalised } =
|
|
176
|
+
holdoutShares(holdout);
|
|
177
|
+
const bucket = hashBucket(unit, holdout.salt);
|
|
178
|
+
const cut = (s) => Math.round(s * DEFAULT_BUCKETS);
|
|
179
|
+
const group =
|
|
180
|
+
share > 0 && bucket < cut(share)
|
|
181
|
+
? 'holdout'
|
|
182
|
+
: !ramped || bucket < cut(share + personalised)
|
|
183
|
+
? 'personalised'
|
|
184
|
+
: 'excluded';
|
|
185
|
+
return { holdout: group === 'holdout', bucket, group };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* A visitor's side of a readout slot's bandit-vs-default 50/50, as the public API decides it
|
|
190
|
+
* (spec/assignment/bandit-split.json): the holdout's hash over the same anonymous id, salted with
|
|
191
|
+
* the experiment name `bandit-vs-default:<slot>`, so it is independent of the holdout and of every
|
|
192
|
+
* other slot. Buckets below 5000 are the slot's default; the rest get the bandit. Null without a
|
|
193
|
+
* slot or a unit: such a visitor is in no split.
|
|
194
|
+
*
|
|
195
|
+
* @param {string} slot The slot key.
|
|
196
|
+
* @param {string|null|undefined} unit The anonymous id.
|
|
197
|
+
* @returns {'bandit'|'default'|null}
|
|
198
|
+
*/
|
|
199
|
+
export function banditSplit(slot, unit) {
|
|
200
|
+
if (typeof slot !== 'string' || slot === '' || unit == null || unit === '')
|
|
201
|
+
return null;
|
|
202
|
+
return hashBucket(unit, `bandit-vs-default:${slot}`) < DEFAULT_BUCKETS / 2
|
|
203
|
+
? 'default'
|
|
204
|
+
: 'bandit';
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Fingerprint an ordered candidate set: the candidates the serving response carried, not the
|
|
209
|
+
* tiles rendered. Order is part of the identity. Prefixed with the count (`24-3f9a1c07`) so a
|
|
210
|
+
* truncated set is visible without a lookup.
|
|
211
|
+
*/
|
|
212
|
+
export function candidateSetFingerprint(productIds) {
|
|
213
|
+
const ids = (productIds ?? [])
|
|
214
|
+
.map((id) => String(id ?? ''))
|
|
215
|
+
.filter((id) => id !== '');
|
|
216
|
+
if (ids.length === 0) return null;
|
|
217
|
+
// Same separator, same reason: joining on a byte that cannot appear in an id keeps
|
|
218
|
+
// ['ab', 'c'] and ['a', 'bc'] distinct fingerprints.
|
|
219
|
+
return `${ids.length}-${hash32(ids.join('\0')).toString(16).padStart(8, '0')}`;
|
|
220
|
+
}
|
package/src/ga4.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GA4 items -> spec products. Shared by the client and the dataLayer adapter.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Normalise a category path to the spec's '/'-joined form, so `A / B` and `A/B` are one category.
|
|
7
|
+
*/
|
|
8
|
+
function categoryPath(value) {
|
|
9
|
+
if (value == null) return null;
|
|
10
|
+
const path = String(value)
|
|
11
|
+
.split('/')
|
|
12
|
+
.map((level) => level.trim())
|
|
13
|
+
.filter(Boolean)
|
|
14
|
+
.join('/');
|
|
15
|
+
return path === '' ? null : path;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** GA4 splits a path over item_category..item_category5; some sites send it pre-joined in item_category. */
|
|
19
|
+
function ga4Category(item) {
|
|
20
|
+
const first = item.item_category;
|
|
21
|
+
if (first != null && String(first).includes('/')) return categoryPath(first);
|
|
22
|
+
const levels = [
|
|
23
|
+
first,
|
|
24
|
+
item.item_category2,
|
|
25
|
+
item.item_category3,
|
|
26
|
+
item.item_category4,
|
|
27
|
+
item.item_category5,
|
|
28
|
+
]
|
|
29
|
+
.map((level) => (level == null ? '' : String(level).trim()))
|
|
30
|
+
.filter(Boolean);
|
|
31
|
+
return categoryPath(levels.join('/'));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const text = (v) => {
|
|
35
|
+
// An object is never a name or an id, however a tracker happens to fill the field.
|
|
36
|
+
if (v == null || typeof v === 'object') return null;
|
|
37
|
+
const trimmed = String(v).trim();
|
|
38
|
+
return trimmed === '' ? null : trimmed;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* GA4's parent id is `item_group_id` (the Merchant Center attribute GA4 items accept); `item_id`
|
|
43
|
+
* is the purchasable variant. The spec's product_id is the parent, so they cross over.
|
|
44
|
+
*/
|
|
45
|
+
/**
|
|
46
|
+
* The first usable id among candidates: a platform that sends `item_group_id: ''` has sent no
|
|
47
|
+
* parent, and letting the blank win would drop the product rather than fall back to the item.
|
|
48
|
+
*/
|
|
49
|
+
export function firstId(...candidates) {
|
|
50
|
+
for (const value of candidates) {
|
|
51
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
52
|
+
return String(value);
|
|
53
|
+
if (typeof value === 'string' && value.trim() !== '') return value.trim();
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const defaultProductId = (item) =>
|
|
59
|
+
firstId(item.item_group_id, item.item_id);
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Map a GA4 item onto a spec product.
|
|
63
|
+
*
|
|
64
|
+
* @param {object} item
|
|
65
|
+
* @param {(item: object) => unknown} [productId] Resolves the parent product id; a platform whose
|
|
66
|
+
* items name it differently passes its own, usually through a preset.
|
|
67
|
+
*/
|
|
68
|
+
export function fromGa4Item(item, productId = defaultProductId) {
|
|
69
|
+
if (!item) return null;
|
|
70
|
+
const num = (v) =>
|
|
71
|
+
v == null || v === '' || Number.isNaN(Number(v)) ? null : Number(v);
|
|
72
|
+
// Six places clears float noise (32.99 - 16.49 = 16.500000000000004) without rounding any currency.
|
|
73
|
+
const money = (v) => {
|
|
74
|
+
const n = num(v);
|
|
75
|
+
return n == null ? null : Math.round(n * 1e6) / 1e6;
|
|
76
|
+
};
|
|
77
|
+
const nonNegative = (v) => {
|
|
78
|
+
const n = money(v);
|
|
79
|
+
return n != null && n >= 0 ? n : null;
|
|
80
|
+
};
|
|
81
|
+
const count = (v) => {
|
|
82
|
+
const n = num(v);
|
|
83
|
+
return Number.isInteger(n) && n >= 0 ? n : null;
|
|
84
|
+
};
|
|
85
|
+
// Trimmed: 'Acme' and 'Acme ' would be two brands that render identically.
|
|
86
|
+
const str = text;
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
product_id: str(productId(item)),
|
|
90
|
+
sku: str(item.item_id),
|
|
91
|
+
name: str(item.item_name),
|
|
92
|
+
brand: str(item.item_brand),
|
|
93
|
+
variant: str(item.item_variant) ?? str(item.variant),
|
|
94
|
+
category: ga4Category(item),
|
|
95
|
+
price: money(item.price),
|
|
96
|
+
discount: nonNegative(item.discount),
|
|
97
|
+
// No per-line `currency`: a basket cannot mix currencies, so it is context.currency,
|
|
98
|
+
// set once per event rather than repeated on every line of every commerce event.
|
|
99
|
+
quantity: count(item.quantity),
|
|
100
|
+
// Line-level, and independent of any order-level code — GA4 says so explicitly, and only the
|
|
101
|
+
// line knows which line a promotion actually landed on.
|
|
102
|
+
coupon: str(item.coupon),
|
|
103
|
+
// Rank in a list, which only the caller knows. Never the offset in `products`: that would
|
|
104
|
+
// stamp position 1 on every single-product event and bias the position metric.
|
|
105
|
+
position: count(item.index) != null ? count(item.index) + 1 : null,
|
|
106
|
+
// `image_link` is the product feeds' (Google, Meta) name for `image_url`. Both are sent even
|
|
107
|
+
// though the catalogue has them: products absent from the feed have no other source.
|
|
108
|
+
url: str(item.url) ?? str(item.item_url) ?? null,
|
|
109
|
+
image_url: str(item.image_url) ?? str(item.image_link) ?? null,
|
|
110
|
+
// The variant a tile rendered when the shopper has chosen nothing, kept apart from `sku` so an
|
|
111
|
+
// impression is never mistaken for a selection.
|
|
112
|
+
displayed_sku: str(item.displayed_sku) ?? str(item.displayed_variant_sku),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Map GA4 items to spec products, dropping any that carry no identity: validation is per event,
|
|
118
|
+
* so one id-less row would quarantine the whole impression.
|
|
119
|
+
*/
|
|
120
|
+
export function toProducts(items, productId = defaultProductId) {
|
|
121
|
+
return (items ?? [])
|
|
122
|
+
.map((item) => fromGa4Item(item, productId))
|
|
123
|
+
.filter((p) => p?.product_id);
|
|
124
|
+
}
|
package/src/hash.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SHA-256, synchronously, so a page can hash an address in the same call that reports it and never
|
|
3
|
+
* awaits the SDK. SubtleCrypto is asynchronous only, and absent outside a secure context.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const primes = [];
|
|
7
|
+
for (let n = 2; primes.length < 64; n++) {
|
|
8
|
+
if (primes.every((p) => n % p !== 0)) primes.push(n);
|
|
9
|
+
}
|
|
10
|
+
const fraction = (x) => ((x - Math.floor(x)) * 2 ** 32) >>> 0;
|
|
11
|
+
/** FIPS 180-4: the first 32 bits of the fractional parts of the primes' square and cube roots. */
|
|
12
|
+
const INITIAL = primes.slice(0, 8).map((p) => fraction(Math.sqrt(p)));
|
|
13
|
+
const ROUND = primes.map((p) => fraction(Math.cbrt(p)));
|
|
14
|
+
|
|
15
|
+
const rotr = (x, n) => (x >>> n) | (x << (32 - n));
|
|
16
|
+
|
|
17
|
+
/** SHA-256 of a string's UTF-8 bytes, as lowercase hex. */
|
|
18
|
+
export function sha256Hex(text) {
|
|
19
|
+
const bytes = new TextEncoder().encode(String(text));
|
|
20
|
+
const words = new Uint32Array(((bytes.length + 8) >> 6) * 16 + 16);
|
|
21
|
+
bytes.forEach((byte, i) => {
|
|
22
|
+
words[i >> 2] |= byte << (24 - (i % 4) * 8);
|
|
23
|
+
});
|
|
24
|
+
words[bytes.length >> 2] |= 0x80 << (24 - (bytes.length % 4) * 8);
|
|
25
|
+
words[words.length - 2] = Math.floor(bytes.length / 2 ** 29);
|
|
26
|
+
words[words.length - 1] = bytes.length * 8;
|
|
27
|
+
|
|
28
|
+
const hash = Uint32Array.from(INITIAL);
|
|
29
|
+
const w = new Uint32Array(64);
|
|
30
|
+
for (let block = 0; block < words.length; block += 16) {
|
|
31
|
+
for (let t = 0; t < 64; t++) {
|
|
32
|
+
if (t < 16) {
|
|
33
|
+
w[t] = words[block + t];
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
const a = w[t - 15];
|
|
37
|
+
const b = w[t - 2];
|
|
38
|
+
const s0 = rotr(a, 7) ^ rotr(a, 18) ^ (a >>> 3);
|
|
39
|
+
const s1 = rotr(b, 17) ^ rotr(b, 19) ^ (b >>> 10);
|
|
40
|
+
w[t] = w[t - 16] + s0 + w[t - 7] + s1;
|
|
41
|
+
}
|
|
42
|
+
let [a, b, c, d, e, f, g, h] = hash;
|
|
43
|
+
for (let t = 0; t < 64; t++) {
|
|
44
|
+
const s1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
|
|
45
|
+
const t1 = (h + s1 + ((e & f) ^ (~e & g)) + ROUND[t] + w[t]) | 0;
|
|
46
|
+
const s0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
|
|
47
|
+
const t2 = (s0 + ((a & b) ^ (a & c) ^ (b & c))) | 0;
|
|
48
|
+
[h, g, f, e, d, c, b, a] = [
|
|
49
|
+
g,
|
|
50
|
+
f,
|
|
51
|
+
e,
|
|
52
|
+
(d + t1) | 0,
|
|
53
|
+
c,
|
|
54
|
+
b,
|
|
55
|
+
a,
|
|
56
|
+
(t1 + t2) | 0,
|
|
57
|
+
];
|
|
58
|
+
}
|
|
59
|
+
[a, b, c, d, e, f, g, h].forEach((value, i) => {
|
|
60
|
+
hash[i] += value;
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
return Array.from(hash, (x) => x.toString(16).padStart(8, '0')).join('');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* `email_sha256` for an address, now: trimmed, lowercased, hashed. Null for anything that is not an
|
|
68
|
+
* email, so a blank form field never becomes the hash of the empty string.
|
|
69
|
+
*/
|
|
70
|
+
export function emailSha256(email) {
|
|
71
|
+
if (typeof email !== 'string') return null;
|
|
72
|
+
const normalised = email.trim().toLowerCase();
|
|
73
|
+
return normalised.includes('@') ? sha256Hex(normalised) : null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const rotl = (x, n) => (x << n) | (x >>> (32 - n));
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* SHA-1 of bytes, as 20 bytes, synchronously. Only for name-based UUIDs (RFC 9562 version 5), which
|
|
80
|
+
* are defined over SHA-1; never for anything that must resist a collision.
|
|
81
|
+
*/
|
|
82
|
+
export function sha1(bytes) {
|
|
83
|
+
const words = new Uint32Array(((bytes.length + 8) >> 6) * 16 + 16);
|
|
84
|
+
bytes.forEach((byte, i) => {
|
|
85
|
+
words[i >> 2] |= byte << (24 - (i % 4) * 8);
|
|
86
|
+
});
|
|
87
|
+
words[bytes.length >> 2] |= 0x80 << (24 - (bytes.length % 4) * 8);
|
|
88
|
+
words[words.length - 2] = Math.floor(bytes.length / 2 ** 29);
|
|
89
|
+
words[words.length - 1] = bytes.length * 8;
|
|
90
|
+
|
|
91
|
+
const hash = Uint32Array.of(
|
|
92
|
+
0x67452301,
|
|
93
|
+
0xefcdab89,
|
|
94
|
+
0x98badcfe,
|
|
95
|
+
0x10325476,
|
|
96
|
+
0xc3d2e1f0,
|
|
97
|
+
);
|
|
98
|
+
const w = new Uint32Array(80);
|
|
99
|
+
for (let block = 0; block < words.length; block += 16) {
|
|
100
|
+
for (let t = 0; t < 80; t++) {
|
|
101
|
+
w[t] =
|
|
102
|
+
t < 16
|
|
103
|
+
? words[block + t]
|
|
104
|
+
: rotl(w[t - 3] ^ w[t - 8] ^ w[t - 14] ^ w[t - 16], 1);
|
|
105
|
+
}
|
|
106
|
+
let [a, b, c, d, e] = hash;
|
|
107
|
+
for (let t = 0; t < 80; t++) {
|
|
108
|
+
let f;
|
|
109
|
+
let k;
|
|
110
|
+
if (t < 20) {
|
|
111
|
+
f = (b & c) | (~b & d);
|
|
112
|
+
k = 0x5a827999;
|
|
113
|
+
} else if (t < 40) {
|
|
114
|
+
f = b ^ c ^ d;
|
|
115
|
+
k = 0x6ed9eba1;
|
|
116
|
+
} else if (t < 60) {
|
|
117
|
+
f = (b & c) | (b & d) | (c & d);
|
|
118
|
+
k = 0x8f1bbcdc;
|
|
119
|
+
} else {
|
|
120
|
+
f = b ^ c ^ d;
|
|
121
|
+
k = 0xca62c1d6;
|
|
122
|
+
}
|
|
123
|
+
const next = (rotl(a, 5) + f + e + k + w[t]) | 0;
|
|
124
|
+
[e, d, c, b, a] = [d, c, rotl(b, 30), a, next];
|
|
125
|
+
}
|
|
126
|
+
[a, b, c, d, e].forEach((value, i) => {
|
|
127
|
+
hash[i] += value;
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
const out = new Uint8Array(20);
|
|
131
|
+
hash.forEach((word, i) => {
|
|
132
|
+
out[i * 4] = word >>> 24;
|
|
133
|
+
out[i * 4 + 1] = word >>> 16;
|
|
134
|
+
out[i * 4 + 2] = word >>> 8;
|
|
135
|
+
out[i * 4 + 3] = word;
|
|
136
|
+
});
|
|
137
|
+
return out;
|
|
138
|
+
}
|
package/src/hp.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The page's client, callable from anywhere before it exists: `hp.productAdded({ … })` in any
|
|
3
|
+
* component, with no reference to pass around and nothing to await.
|
|
4
|
+
*
|
|
5
|
+
* Calls made before `bootstrap` has started the client wait here, each with the messageId and time
|
|
6
|
+
* it was made at, and replay in order once it has; calls after it go straight to the client. When
|
|
7
|
+
* the client never starts they wait for the page to go: a page that could not reach the API, or that
|
|
8
|
+
* navigated before it answered, carries them to the next page, whose bootstrap replays them (3.9,
|
|
9
|
+
* ./carry.js); a page that is off (no key, disabled, invalid config) drops them.
|
|
10
|
+
* Nothing here ever throws into the caller, and the module is tiny, so a component can use it on a
|
|
11
|
+
* site that never loads the SDK.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { keptVisit } from './recommendation-context.js';
|
|
15
|
+
import { uuid } from './shape.js';
|
|
16
|
+
|
|
17
|
+
const INSTANCE = Symbol.for('@thg-agentic/hyper-personalisation-web.bootstrap');
|
|
18
|
+
/** How bootstrap hands this queue its client, without the queue being part of the public API. */
|
|
19
|
+
const DRAIN = Symbol.for('@thg-agentic/hyper-personalisation-web.drain');
|
|
20
|
+
/** Where bootstrap leaves the store that carries this queue across a navigation. */
|
|
21
|
+
const CARRY = Symbol.for('@thg-agentic/hyper-personalisation-web.carry');
|
|
22
|
+
const REPLAY = Symbol.for('@thg-agentic/hyper-personalisation-web.replay');
|
|
23
|
+
const ASSIGNMENT = Symbol.for(
|
|
24
|
+
'@thg-agentic/hyper-personalisation-web.assignment',
|
|
25
|
+
);
|
|
26
|
+
const RECOMMENDATION_CONTEXT = Symbol.for(
|
|
27
|
+
'@thg-agentic/hyper-personalisation-web.recommendation-context',
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
/** Enough for a page's worth of interactions before the SDK loads; later calls are dropped. */
|
|
31
|
+
const MAX_QUEUED = 200;
|
|
32
|
+
|
|
33
|
+
/** The document all of this belongs to: one per module in a browser, several in a test. */
|
|
34
|
+
let page = null;
|
|
35
|
+
let queue = [];
|
|
36
|
+
/** The bootstrap the queue drains into, and what it resolved to: undefined until then. */
|
|
37
|
+
let bound = null;
|
|
38
|
+
let client;
|
|
39
|
+
|
|
40
|
+
function invoke(target, [method, args, made]) {
|
|
41
|
+
try {
|
|
42
|
+
if (made && target[REPLAY]) target[REPLAY](method, args, made);
|
|
43
|
+
else if (typeof target[method] === 'function') target[method](...args);
|
|
44
|
+
} catch (err) {
|
|
45
|
+
console.warn(`[hp] ${method} failed`, err);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function bind(pending) {
|
|
50
|
+
if (pending === bound) return;
|
|
51
|
+
bound = pending;
|
|
52
|
+
client = undefined;
|
|
53
|
+
const drain = (started) => {
|
|
54
|
+
if (bound !== pending) return;
|
|
55
|
+
client = started ?? null;
|
|
56
|
+
if (client) for (const call of queue.splice(0)) invoke(client, call);
|
|
57
|
+
};
|
|
58
|
+
Promise.resolve(pending).then(drain, () => drain(null));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The page is going before the client started: what it queued goes with it, if it may. */
|
|
62
|
+
function carry(event) {
|
|
63
|
+
if (queue.length > 0 && event.currentTarget[CARRY]?.save(queue)) queue = [];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function call(method, args) {
|
|
67
|
+
if (typeof window === 'undefined') return;
|
|
68
|
+
if (page !== window) {
|
|
69
|
+
page = window;
|
|
70
|
+
queue = [];
|
|
71
|
+
bound = null;
|
|
72
|
+
client = undefined;
|
|
73
|
+
window.addEventListener('pagehide', carry);
|
|
74
|
+
}
|
|
75
|
+
const pending = window[INSTANCE];
|
|
76
|
+
if (pending) bind(pending);
|
|
77
|
+
else {
|
|
78
|
+
bound = null;
|
|
79
|
+
client = undefined;
|
|
80
|
+
// On the window bootstrap will look at when it runs.
|
|
81
|
+
window[DRAIN] = bind;
|
|
82
|
+
}
|
|
83
|
+
if (client) return invoke(client, [method, args]);
|
|
84
|
+
if (queue.length >= MAX_QUEUED) return;
|
|
85
|
+
queue.push([
|
|
86
|
+
method,
|
|
87
|
+
args,
|
|
88
|
+
{ messageId: uuid(), timestamp: new Date().toISOString() },
|
|
89
|
+
]);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The `decision_id` HP logged for assigning `arm` to `slot` in this visitor's session, now: read
|
|
94
|
+
* from the identity cookies bootstrap was given, so it answers once bootstrap has been called,
|
|
95
|
+
* started or not. Null before that, and without a session or visitor id.
|
|
96
|
+
*/
|
|
97
|
+
function assignmentDecisionId(assignment) {
|
|
98
|
+
try {
|
|
99
|
+
return (
|
|
100
|
+
(typeof window !== 'undefined' && window[ASSIGNMENT]?.(assignment)) ||
|
|
101
|
+
null
|
|
102
|
+
);
|
|
103
|
+
} catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* This visit as the recommendations API's session context (`viewed`, `basket`, `q`, `impressed`),
|
|
110
|
+
* now: from the started client, or, before it has started, as an earlier page of the visit kept it.
|
|
111
|
+
* `{}` without personalisation consent: a request that appends it then carries no visit.
|
|
112
|
+
*/
|
|
113
|
+
function recommendationContext() {
|
|
114
|
+
if (typeof window === 'undefined') return {};
|
|
115
|
+
try {
|
|
116
|
+
const reader = window[RECOMMENDATION_CONTEXT];
|
|
117
|
+
const context = reader ? reader() : keptVisit();
|
|
118
|
+
return context && typeof context === 'object' ? context : {};
|
|
119
|
+
} catch {
|
|
120
|
+
return {};
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Every client method, callable now. `then` is not one, so `await hp` is not a call. */
|
|
125
|
+
export const hp = new Proxy(
|
|
126
|
+
{},
|
|
127
|
+
{
|
|
128
|
+
get(_, method) {
|
|
129
|
+
if (typeof method !== 'string' || method === 'then') return undefined;
|
|
130
|
+
if (method === 'assignmentDecisionId') return assignmentDecisionId;
|
|
131
|
+
if (method === 'recommendationContext') return recommendationContext;
|
|
132
|
+
return (...args) => call(method, args);
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
);
|