@mparticle/web-sdk 3.26.0 → 3.27.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/dist/mparticle.common.js +1 -1
- package/dist/mparticle.esm.js +1 -1
- package/dist/mparticle.js +500 -49
- package/dist/types/src/buttonTracking/buttonDescription.d.ts +4 -1
- package/dist/types/src/buttonTracking/controls.d.ts +2 -1
- package/dist/types/src/buttonTracking/labelMasking.d.ts +1 -0
- package/dist/types/src/pageUrl.d.ts +1 -0
- package/dist/types/src/pathMasking.d.ts +7 -0
- package/package.json +1 -1
- package/src/buttonTracking/buttonClickEvent.ts +9 -3
- package/src/buttonTracking/buttonDescription.ts +319 -17
- package/src/buttonTracking/buttonTracker.ts +21 -10
- package/src/buttonTracking/checkoutIntent.ts +44 -8
- package/src/buttonTracking/controls.ts +54 -2
- package/src/buttonTracking/labelMasking.ts +6 -0
- package/src/pageUrl.ts +58 -9
- package/src/pageViewTracker.ts +6 -2
- package/src/pathMasking.ts +199 -0
|
@@ -17,7 +17,17 @@ export type CheckoutIntentKind =
|
|
|
17
17
|
| 'frame'
|
|
18
18
|
| 'cart';
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
// A checkout segment may name its kind or its version, as /express-checkout, /checkout-v2 and /checkout-external do.
|
|
21
|
+
// Other hyphenated words make it a content slug, such as /help/checkout-faq or /products/self-checkout-kiosk.
|
|
22
|
+
const checkoutPath = /(?:^|\/)(?:(?:(?:express|guest|quick|secure|complete|one-?page|single-?page|mobile)-)?checkouts?(?:-(?:v\d+|\d+|flow|external|process|step(?:-\d+)?))?|payment|order|kasse|paiement|commander?|pago)(?:\/|$)/i;
|
|
23
|
+
// A confirmation page under a checkout route, such as /checkout/thankyou/<order> or /checkouts/<token>/thank_you, comes
|
|
24
|
+
// after the confirm click. "Complete" and "success" mark one only as the last word of a segment, so /complete-checkout
|
|
25
|
+
// and /checkout/complete-registration stay checkout routes.
|
|
26
|
+
const confirmationPath = /(?:^|[\/_-])(?:thank[-_]?you|thanks|confirmation|confirmed|receipt|received)(?:[\/_-]|$)|(?:^|[\/_-])(?:complete|completed|success)(?:\/|$)/i;
|
|
27
|
+
// A bare "Register" creates an account, rather than placing the order, on a checkout's sign-in or account step or in a
|
|
28
|
+
// form that asks for a password.
|
|
29
|
+
const bareRegister = /^register(?: now)?$/;
|
|
30
|
+
const accountStepPath = /(?:^|\/)(?:register|registration|login|log-in|signin|sign-in|signup|sign-up|account|create-account)(?:\/|$)/i;
|
|
21
31
|
// English look-alikes are whole words; the other languages stay substring
|
|
22
32
|
// matches, since several of them do not separate words with spaces.
|
|
23
33
|
const englishExclusions = new RegExp(
|
|
@@ -63,7 +73,7 @@ const linkCommits = new RegExp(
|
|
|
63
73
|
')$'
|
|
64
74
|
);
|
|
65
75
|
const routeGatedCommits = new RegExp(
|
|
66
|
-
'^(?:buy now|purchase|jetzt kaufen|kaufen|commander|(?:passer|valider) (?:la |ma )?commande|finalizar (?:la |mi )?compra|' +
|
|
76
|
+
'^(?:buy now|purchase|register(?: now)?|complete (?:my |your )?registration|jetzt kaufen|kaufen|commander|(?:passer|valider) (?:la |ma )?commande|finalizar (?:la |mi )?compra|' +
|
|
67
77
|
'comprar ahora|acquista ora|nu kopen|今すぐ購入|購入する|この内容で申し込む)$'
|
|
68
78
|
);
|
|
69
79
|
const steps = new RegExp(
|
|
@@ -156,7 +166,9 @@ const EDGE =
|
|
|
156
166
|
const EDGE_SYMBOLS = new RegExp('^' + EDGE + '|' + EDGE + '$', 'g');
|
|
157
167
|
|
|
158
168
|
function getIntentRouteClass(pathname: string): 'checkout_path' | 'other' {
|
|
159
|
-
return checkoutPath.test(pathname)
|
|
169
|
+
return checkoutPath.test(pathname) && !confirmationPath.test(pathname)
|
|
170
|
+
? 'checkout_path'
|
|
171
|
+
: 'other';
|
|
160
172
|
}
|
|
161
173
|
|
|
162
174
|
const SEPARATORS = /[-_|·•]/g;
|
|
@@ -262,16 +274,40 @@ export interface ICheckoutIntent {
|
|
|
262
274
|
adds?: true;
|
|
263
275
|
}
|
|
264
276
|
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
-
function
|
|
277
|
+
// Every field the browser counts as the form's own, form-attribute fields included. Read through the native getter
|
|
278
|
+
// where the browser has one, since a field named "elements" shadows the form's own property.
|
|
279
|
+
function formFields(form: HTMLFormElement): ArrayLike<Element> {
|
|
280
|
+
const descriptor = Object.getOwnPropertyDescriptor(HTMLFormElement.prototype, 'elements');
|
|
281
|
+
return descriptor && descriptor.get ? descriptor.get.call(form) : form.elements;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// The form a button or input submits, which its form attribute can name from outside it.
|
|
285
|
+
function createsAccount(control: Element, pathname: string): boolean {
|
|
286
|
+
const form =
|
|
287
|
+
control instanceof HTMLButtonElement || control instanceof HTMLInputElement
|
|
288
|
+
? control.form
|
|
289
|
+
: control.closest('form');
|
|
290
|
+
return (
|
|
291
|
+
accountStepPath.test(pathname) ||
|
|
292
|
+
(!!form &&
|
|
293
|
+
Array.prototype.some.call(
|
|
294
|
+
formFields(form),
|
|
295
|
+
(field: Element) => field instanceof HTMLInputElement && field.type === 'password'
|
|
296
|
+
))
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// "Buy now", "Purchase" and "Register" count only on a checkout route that isn't a
|
|
301
|
+
// confirmation page, and never on a plain link, which is navigation.
|
|
302
|
+
function isCommitLabel(text: string, isLink: boolean, pathname: string, control: Element): boolean {
|
|
268
303
|
if (isLink) {
|
|
269
304
|
return linkCommits.test(text);
|
|
270
305
|
}
|
|
271
306
|
return (
|
|
272
307
|
commits.test(text) ||
|
|
273
308
|
(routeGatedCommits.test(text) &&
|
|
274
|
-
getIntentRouteClass(pathname) === 'checkout_path'
|
|
309
|
+
getIntentRouteClass(pathname) === 'checkout_path' &&
|
|
310
|
+
!(bareRegister.test(text) && createsAccount(control, pathname)))
|
|
275
311
|
);
|
|
276
312
|
}
|
|
277
313
|
|
|
@@ -311,7 +347,7 @@ export function classifyIntent(
|
|
|
311
347
|
return cart;
|
|
312
348
|
}
|
|
313
349
|
const isLink = control.matches('a[href]:not([role="button"])');
|
|
314
|
-
if (cores.some(text => isCommitLabel(text, isLink, pathname))) {
|
|
350
|
+
if (cores.some(text => isCommitLabel(text, isLink, pathname, control))) {
|
|
315
351
|
return { kind: 'commit', source: 'label' };
|
|
316
352
|
}
|
|
317
353
|
if (
|
|
@@ -283,7 +283,8 @@ export function isExcludedPath(path: Array<EventTarget | null>): boolean {
|
|
|
283
283
|
// found the path not excluded passes `included` so the walk runs once.
|
|
284
284
|
export function findControl(
|
|
285
285
|
path: Array<EventTarget | null>,
|
|
286
|
-
included = false
|
|
286
|
+
included = false,
|
|
287
|
+
limit = 12
|
|
287
288
|
): Element | undefined {
|
|
288
289
|
if (!included && isExcludedPath(path)) {
|
|
289
290
|
return undefined;
|
|
@@ -291,7 +292,7 @@ export function findControl(
|
|
|
291
292
|
let control: Element | undefined;
|
|
292
293
|
let option: Element | undefined;
|
|
293
294
|
let picked = false;
|
|
294
|
-
for (const target of path.slice(0,
|
|
295
|
+
for (const target of path.slice(0, limit)) {
|
|
295
296
|
if (control) {
|
|
296
297
|
break;
|
|
297
298
|
}
|
|
@@ -321,6 +322,57 @@ export function findControl(
|
|
|
321
322
|
return control;
|
|
322
323
|
}
|
|
323
324
|
|
|
325
|
+
// Roles that act as controls without being buttons or links: pickers, tabs,
|
|
326
|
+
// options and links run by script. A role can list fallbacks, so any of its
|
|
327
|
+
// tokens counts.
|
|
328
|
+
const ROLE_CONTROL =
|
|
329
|
+
'[role~="radio"], [role~="menuitemradio"], [role~="checkbox"], [role~="menuitemcheckbox"], ' +
|
|
330
|
+
'[role~="switch"], [role~="option"], [role~="tab"], [role~="link"]';
|
|
331
|
+
|
|
332
|
+
const TYPING_FIELD =
|
|
333
|
+
'input:not([type="submit"]):not([type="button"]):not([type="image"]):not([type="reset"]):not([type="radio"]):not([type="checkbox"]), ' +
|
|
334
|
+
'textarea, select, ' +
|
|
335
|
+
EDITABLE;
|
|
336
|
+
|
|
337
|
+
// The nearest role control on the click path, for logging the click alone:
|
|
338
|
+
// checkout intent and payment choices only ever read what findControl finds.
|
|
339
|
+
// None when anything disabled sits on the path, so a role control around or
|
|
340
|
+
// inside a disabled control never logs the click it suppressed.
|
|
341
|
+
export function findRoleControl(
|
|
342
|
+
path: Array<EventTarget | null>,
|
|
343
|
+
limit: number
|
|
344
|
+
): Element | undefined {
|
|
345
|
+
const entries = path
|
|
346
|
+
.slice(0, limit)
|
|
347
|
+
.filter((target): target is Element => target instanceof Element);
|
|
348
|
+
if (
|
|
349
|
+
entries.some(
|
|
350
|
+
target =>
|
|
351
|
+
target.matches(':disabled, [aria-disabled="true"]') ||
|
|
352
|
+
(target.classList.contains('disabled') &&
|
|
353
|
+
isWalletNamed(target))
|
|
354
|
+
)
|
|
355
|
+
) {
|
|
356
|
+
return undefined;
|
|
357
|
+
}
|
|
358
|
+
for (const target of entries) {
|
|
359
|
+
if (target.matches(ROLE_CONTROL)) {
|
|
360
|
+
// A typing field, or anything in an editable region, takes typing
|
|
361
|
+
// whatever role it carries.
|
|
362
|
+
return target.matches(TYPING_FIELD) ||
|
|
363
|
+
hasComposedAncestor(target, EDITABLE)
|
|
364
|
+
? undefined
|
|
365
|
+
: target;
|
|
366
|
+
}
|
|
367
|
+
// A click in a typing field belongs to the field, not to the role
|
|
368
|
+
// control around it.
|
|
369
|
+
if (target.matches(TYPING_FIELD)) {
|
|
370
|
+
return undefined;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
return undefined;
|
|
374
|
+
}
|
|
375
|
+
|
|
324
376
|
// Text that may hold what a shopper typed or what the page opted out: form
|
|
325
377
|
// fields, editable regions, and anything under data-mp-no-track. `own` is a
|
|
326
378
|
// box the element labels, which holds no typing of its own.
|
|
@@ -42,3 +42,9 @@ export function maskLabel(raw: string | null | undefined): string | undefined {
|
|
|
42
42
|
.trim();
|
|
43
43
|
return masked || undefined;
|
|
44
44
|
}
|
|
45
|
+
|
|
46
|
+
// Masks numbers and amounts in any script as '#', or undefined where the browser
|
|
47
|
+
// can't see every script's digits.
|
|
48
|
+
export function maskNumbers(text: string): string | undefined {
|
|
49
|
+
return digitPatterns ? text.replace(digitPatterns.number, '#') : undefined;
|
|
50
|
+
}
|
package/src/pageUrl.ts
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
import { isAllowedQueryParam } from './pageViewTracker';
|
|
2
|
+
import { pagePath, pageUrlPath } from './pathMasking';
|
|
2
3
|
import { getHref, valueof } from './utils';
|
|
3
4
|
|
|
4
5
|
// The one rule for a URL that leaves the SDK, shared with kits through
|
|
5
|
-
// `mParticle._cleanUrl`. `path` keeps scheme, host and path
|
|
6
|
+
// `mParticle._cleanUrl`. `path` keeps scheme, host and path, with any path
|
|
7
|
+
// segment that can single out one shopper as `*` (see pageUrlPath). `full` adds the
|
|
6
8
|
// query params an auto page view captures by default and the campaign
|
|
7
|
-
// attribution params, then the fragment.
|
|
9
|
+
// attribution params, then the fragment, a hash route's path masked the same way.
|
|
8
10
|
export const UrlMode = {
|
|
9
11
|
Path: 'path',
|
|
10
12
|
Full: 'full',
|
|
13
|
+
// A path for a log or an error report, masked with the click path's fuller
|
|
14
|
+
// rules, since no output reads it as a page.
|
|
15
|
+
Report: 'report',
|
|
11
16
|
} as const;
|
|
12
17
|
|
|
13
18
|
export type UrlMode = valueof<typeof UrlMode>;
|
|
@@ -79,6 +84,37 @@ export const EXCLUDED_URL_FRAGMENT_PARAMS: string[] = EXCLUDED_URL_QUERY_PARAMS.
|
|
|
79
84
|
const URL_VALUED_QUERY_PARAMS: string[] = ['ref', 'referrer'];
|
|
80
85
|
|
|
81
86
|
const USERINFO = /^((?:[a-z][a-z0-9+.-]*:)?\/\/)[^/]*@/i;
|
|
87
|
+
const ORIGIN = /^(?:[a-z][a-z0-9+.-]*:)?\/\/[^/]*/i;
|
|
88
|
+
|
|
89
|
+
// Only a URL or a path is masked. A bare value, such as `ref=spring2024`, is a
|
|
90
|
+
// code rather than a page, and stays as written.
|
|
91
|
+
const maskPath = (
|
|
92
|
+
url: string,
|
|
93
|
+
mask: (pathname: string) => string = pageUrlPath
|
|
94
|
+
): string => {
|
|
95
|
+
const origin = ORIGIN.exec(url);
|
|
96
|
+
if (origin) {
|
|
97
|
+
return origin[0] + mask(url.slice(origin[0].length));
|
|
98
|
+
}
|
|
99
|
+
return url.charAt(0) === '/' ? mask(url) : url;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
// A hash route keeps its `#` or `#!` and has its path masked as the URL's own.
|
|
103
|
+
// The `&` pairs or flags after the path lose the OAuth/OIDC ones, as a bare
|
|
104
|
+
// fragment's pairs do.
|
|
105
|
+
const maskRoute = (route: string): string => {
|
|
106
|
+
const prefix = /^#!?/.exec(route)[0];
|
|
107
|
+
const pairsStart = route.indexOf('&');
|
|
108
|
+
const pathEnd = pairsStart === -1 ? route.length : pairsStart;
|
|
109
|
+
const pairs = route
|
|
110
|
+
.slice(pathEnd)
|
|
111
|
+
.split('&')
|
|
112
|
+
.slice(1)
|
|
113
|
+
.filter(keepsFragmentPair)
|
|
114
|
+
.map(pair => `&${pair}`)
|
|
115
|
+
.join('');
|
|
116
|
+
return prefix + pageUrlPath(route.slice(prefix.length, pathEnd)) + pairs;
|
|
117
|
+
};
|
|
82
118
|
|
|
83
119
|
const decodeQueryComponent = (component: string): string =>
|
|
84
120
|
decodeURIComponent(component.replace(/\+/g, ' '));
|
|
@@ -140,21 +176,31 @@ const keepsFragmentPair = (pair: string): boolean => {
|
|
|
140
176
|
const cleanFragment = (hash: string): string => {
|
|
141
177
|
const queryStart = hash.indexOf('?');
|
|
142
178
|
const route = queryStart === -1 ? hash : hash.slice(0, queryStart);
|
|
179
|
+
// `#/…`, `#!…`, or a fragment with a `/` before any `&` or `?`, as
|
|
180
|
+
// `#orders/12345678` is in routers that drop the leading slash.
|
|
181
|
+
// A `/` counts only before any `=`, so a bare pair such as
|
|
182
|
+
// `#access_token=abc/def` is never read as a route.
|
|
183
|
+
const isHashRoute = /^#!|^#[^?&=]*\//.test(hash);
|
|
143
184
|
const isRoute =
|
|
144
|
-
|
|
185
|
+
isHashRoute || (queryStart !== -1 && route.indexOf('=') === -1);
|
|
145
186
|
|
|
146
187
|
if (isRoute && queryStart !== -1) {
|
|
147
188
|
const query = cleanQuery(hash.slice(queryStart + 1));
|
|
148
|
-
return route + (query ? `?${query}` : '');
|
|
189
|
+
return maskRoute(route) + (query ? `?${query}` : '');
|
|
149
190
|
}
|
|
150
191
|
|
|
192
|
+
// A plain anchor such as `#reviews` names a part of the page, not a route.
|
|
151
193
|
if (hash.indexOf('=') === -1) {
|
|
152
|
-
return hash;
|
|
194
|
+
return isHashRoute ? maskRoute(hash) : hash;
|
|
153
195
|
}
|
|
154
196
|
|
|
197
|
+
// A hash route's path leads its pairs, as in `#/cb&tab=2`.
|
|
155
198
|
const pairs = hash
|
|
156
199
|
.slice(1)
|
|
157
200
|
.split('&')
|
|
201
|
+
.map((pair, i) =>
|
|
202
|
+
i === 0 && isHashRoute ? maskRoute(`#${pair}`).slice(1) : pair
|
|
203
|
+
)
|
|
158
204
|
.filter(keepsFragmentPair)
|
|
159
205
|
.join('&');
|
|
160
206
|
return pairs ? `#${pairs}` : '';
|
|
@@ -172,10 +218,13 @@ export const cleanUrl = (href: string, mode: UrlMode): string => {
|
|
|
172
218
|
const hash = hashStart === -1 ? '' : href.slice(hashStart);
|
|
173
219
|
const beforeHash = hashStart === -1 ? href : href.slice(0, hashStart);
|
|
174
220
|
const queryStart = beforeHash.indexOf('?');
|
|
175
|
-
const path = (
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
221
|
+
const path = maskPath(
|
|
222
|
+
(queryStart === -1
|
|
223
|
+
? beforeHash
|
|
224
|
+
: beforeHash.slice(0, queryStart)
|
|
225
|
+
).replace(USERINFO, '$1'),
|
|
226
|
+
mode === UrlMode.Report ? pagePath : pageUrlPath
|
|
227
|
+
);
|
|
179
228
|
|
|
180
229
|
if (mode !== UrlMode.Full) {
|
|
181
230
|
return path;
|
package/src/pageViewTracker.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { IMParticleWebSDKInstance } from './mp-instance';
|
|
|
2
2
|
import { BaseEvent } from './sdkRuntimeModels';
|
|
3
3
|
import { EventType, MessageType } from './types';
|
|
4
4
|
import { Dictionary, getHref, hasOwnProp, queryStringParser } from './utils';
|
|
5
|
+
import { pagePath, pageUrlPath } from './pathMasking';
|
|
5
6
|
import {
|
|
6
7
|
subscribeToRouteChange,
|
|
7
8
|
supportsHistoryTracking,
|
|
@@ -318,7 +319,8 @@ export const buildPageViewEvent = ({
|
|
|
318
319
|
...paramsToAttributes(params),
|
|
319
320
|
hostname,
|
|
320
321
|
title,
|
|
321
|
-
path
|
|
322
|
+
// Masked here, not in the page snapshot, whose raw path tells one page from the next.
|
|
323
|
+
path: pageUrlPath(path),
|
|
322
324
|
...autoPageViewAttribute(isAutoPageView),
|
|
323
325
|
},
|
|
324
326
|
eventType: EventType.Unknown,
|
|
@@ -422,7 +424,9 @@ const describePage = (page: IPageSnapshot | null): string => {
|
|
|
422
424
|
}
|
|
423
425
|
|
|
424
426
|
const names = page.params.map(({ name }) => name);
|
|
425
|
-
|
|
427
|
+
// A log line is read by no output, so it takes the click path's fuller rules.
|
|
428
|
+
const path = pagePath(page.path);
|
|
429
|
+
return names.length ? `${path} (params: ${names.join()})` : path;
|
|
426
430
|
};
|
|
427
431
|
|
|
428
432
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// Masks the parts of a page path that can single out one shopper: pagePath for
|
|
2
|
+
// the path a click logs, pageUrlPath for the page URL and page views every event
|
|
3
|
+
// carries.
|
|
4
|
+
|
|
5
|
+
// A token of letters and digits mixed, six or more long, as session ids,
|
|
6
|
+
// hex and UUID parts and invite codes are: `A1B2C3D4E5F6`, `550e8400`.
|
|
7
|
+
const MIXED_TOKEN = /^(?=[^0-9]*[0-9])(?=[^A-Za-z]*[A-Za-z])[A-Za-z0-9]{6,}$/;
|
|
8
|
+
// Letters alone, eight or more long, switching case three times or more, as a
|
|
9
|
+
// random code does (`xKpQzRtV`) and a camel-case route word rarely does.
|
|
10
|
+
const CASE_SWITCHING_TOKEN = /^(?=[A-Za-z]{8,}$)(?:[^a-z]*[a-z]+[A-Z]){3}/;
|
|
11
|
+
// Route words whose next segment names one person or carries a one-time code,
|
|
12
|
+
// as in `/profile/jane-smith` or `/reset/abcdefgh`.
|
|
13
|
+
const PERSONAL_ROUTE = /^(?:u|user|users|profile|profiles|member|members|people|reset|reset-password|password|verify|verification|confirm|activate|invite|invitation|token|unsubscribe|magic-link)$/i;
|
|
14
|
+
|
|
15
|
+
// Route words whose next segment is one shopper's order: any with `order` or
|
|
16
|
+
// `orders` as a whole word part, as in `view-order`, `order-received` or `order_id`.
|
|
17
|
+
const ORDER_ROUTE = /(?:^|[-_])orders?(?:[-_]|$)/i;
|
|
18
|
+
// Product words that name a way to buy, not an order: `pre-order`, `on-order`,
|
|
19
|
+
// `back-order`, `made-to-order`, as the whole route word only, so
|
|
20
|
+
// `return-to-order` and `pre-order-status` stay routes.
|
|
21
|
+
const PRODUCT_ORDER_WORD = /^(?:(?:pre|on|back)[-_]|made[-_]to[-_])orders?$/i;
|
|
22
|
+
// A `;` that starts a matrix parameter, plain or encoded any number of times.
|
|
23
|
+
const MATRIX_SEPARATOR = /;|%(?:25)*3B/i;
|
|
24
|
+
// A valid escape left after decoding stopped, as when a literal `%` before it
|
|
25
|
+
// can't be decoded again. What it hides is unknown, so it counts as sensitive.
|
|
26
|
+
export const LEFTOVER_ESCAPE = /%[0-9A-F]{2}/i;
|
|
27
|
+
|
|
28
|
+
export function hidesNextSegment(route: string): boolean {
|
|
29
|
+
return (
|
|
30
|
+
PERSONAL_ROUTE.test(route) ||
|
|
31
|
+
(ORDER_ROUTE.test(route) && !PRODUCT_ORDER_WORD.test(route))
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// How many times a value is decoded at most, so one encoded twice or more reads
|
|
36
|
+
// as its plain text.
|
|
37
|
+
const MAX_DECODES = 5;
|
|
38
|
+
|
|
39
|
+
// Decodes until the text stops changing. Undefined when the first decode fails;
|
|
40
|
+
// a later failure keeps what decoded so far, as a literal `%` would.
|
|
41
|
+
export function decodeFully(text: string): string | undefined {
|
|
42
|
+
let current = text;
|
|
43
|
+
for (let i = 0; i < MAX_DECODES; i++) {
|
|
44
|
+
let next: string;
|
|
45
|
+
try {
|
|
46
|
+
next = decodeURIComponent(current);
|
|
47
|
+
} catch (e) {
|
|
48
|
+
return i === 0 ? undefined : current;
|
|
49
|
+
}
|
|
50
|
+
if (next === current) {
|
|
51
|
+
return current;
|
|
52
|
+
}
|
|
53
|
+
current = next;
|
|
54
|
+
}
|
|
55
|
+
return current;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// The route word in a raw path segment: its last part behind an encoded slash,
|
|
59
|
+
// read before any matrix parameter such as `;jsessionid=`, its `;` encoded once,
|
|
60
|
+
// twice or not at all, so a parameter's own value never hides it. Undefined when
|
|
61
|
+
// it can't be decoded, or is still encoded after MAX_DECODES.
|
|
62
|
+
function routeWord(segment: string): string | undefined {
|
|
63
|
+
let text = segment;
|
|
64
|
+
for (let i = 0; i < MAX_DECODES; i++) {
|
|
65
|
+
const route = text.split(MATRIX_SEPARATOR)[0];
|
|
66
|
+
let next: string;
|
|
67
|
+
try {
|
|
68
|
+
next = decodeURIComponent(route);
|
|
69
|
+
} catch (e) {
|
|
70
|
+
return i === 0 ? undefined : route.split('/').pop();
|
|
71
|
+
}
|
|
72
|
+
if (next === route) {
|
|
73
|
+
return route.split('/').pop();
|
|
74
|
+
}
|
|
75
|
+
text = next;
|
|
76
|
+
}
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// The last segment before index i that isn't empty, so `/orders//12345678`
|
|
81
|
+
// still reads the order route.
|
|
82
|
+
export function routeSegment(segments: string[], i: number): string {
|
|
83
|
+
let routeIndex = i - 1;
|
|
84
|
+
while (routeIndex > 0 && segments[routeIndex] === '') {
|
|
85
|
+
routeIndex--;
|
|
86
|
+
}
|
|
87
|
+
return segments[routeIndex];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// A piece with a lowercase letter right before an uppercase one, as a random
|
|
91
|
+
// code has and a Title-Case word doesn't.
|
|
92
|
+
const INNER_CASE_SWITCH = /[a-z][A-Z]/;
|
|
93
|
+
|
|
94
|
+
const isToken = (text: string): boolean =>
|
|
95
|
+
MIXED_TOKEN.test(text) || CASE_SWITCHING_TOKEN.test(text);
|
|
96
|
+
|
|
97
|
+
const mixesLettersAndDigits = (piece: string): boolean =>
|
|
98
|
+
/[0-9]/.test(piece) && /[A-Za-z]/.test(piece);
|
|
99
|
+
|
|
100
|
+
// `+` sits inside base64 tokens, so it never splits one. A token that `-` or `_`
|
|
101
|
+
// cuts into short pieces is still one when the pieces read as one joined and
|
|
102
|
+
// either a piece switches case inside it or two or more pieces mix letters and
|
|
103
|
+
// digits, as `a1b2-c3d4-e5f6` does. Title-Case-Words and plain slugs such as
|
|
104
|
+
// `iphone-15-pro` do neither.
|
|
105
|
+
export function isTokenSegment(segment: string): boolean {
|
|
106
|
+
const text = segment.replace(/\+/g, '');
|
|
107
|
+
const pieces = text.split(/[-_.~]/);
|
|
108
|
+
const joined = pieces.join('');
|
|
109
|
+
return (
|
|
110
|
+
pieces.some(isToken) ||
|
|
111
|
+
(pieces.some(piece => INNER_CASE_SWITCH.test(piece)) &&
|
|
112
|
+
isToken(joined)) ||
|
|
113
|
+
(pieces.filter(mixesLettersAndDigits).length >= 2 &&
|
|
114
|
+
MIXED_TOKEN.test(joined))
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// The path a click logs. A segment that can single out one shopper
|
|
119
|
+
// goes as `*`: one holding an @, `;` or `=`, a token, or the one after a route
|
|
120
|
+
// that names a person, carries a code or opens an order. Kept segments stay
|
|
121
|
+
// exactly as the page has them, plain numbers included, since on a product page
|
|
122
|
+
// they name the product.
|
|
123
|
+
export function pagePath(pathname: string): string {
|
|
124
|
+
const segments = pathname.split('/');
|
|
125
|
+
return segments
|
|
126
|
+
.map((segment, i) => {
|
|
127
|
+
const text = decodeFully(segment);
|
|
128
|
+
const route = i > 0 ? routeWord(routeSegment(segments, i)) : '';
|
|
129
|
+
// A route word that can't be decoded may be any route, so the
|
|
130
|
+
// segment after it is hidden too.
|
|
131
|
+
const hiddenByRoute =
|
|
132
|
+
i > 0 &&
|
|
133
|
+
!!text &&
|
|
134
|
+
(route === undefined || hidesNextSegment(route));
|
|
135
|
+
// An encoded slash hides several segments in one, so it goes whole.
|
|
136
|
+
return text === undefined ||
|
|
137
|
+
/[@;=/]/.test(text) ||
|
|
138
|
+
LEFTOVER_ESCAPE.test(text) ||
|
|
139
|
+
isTokenSegment(text) ||
|
|
140
|
+
hiddenByRoute
|
|
141
|
+
? '*'
|
|
142
|
+
: segment;
|
|
143
|
+
})
|
|
144
|
+
.join('/');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// A token long enough that no page or product name looks like it: 16 or more
|
|
148
|
+
// letters and digits mixed, as checkout tokens are, 16 or more letters
|
|
149
|
+
// switching case like a random code, or a UUID.
|
|
150
|
+
const LONG_MIXED_TOKEN = /^(?=[^0-9]*[0-9])(?=[^A-Za-z]*[A-Za-z])[A-Za-z0-9]{16,}$/;
|
|
151
|
+
const LONG_CASE_SWITCHING_TOKEN = /^(?=[A-Za-z]{16,}$)(?:[^a-z]*[a-z]+[A-Z]){3}/;
|
|
152
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
153
|
+
|
|
154
|
+
// A token that `-` or `_` cuts into shorter pieces counts when two or more
|
|
155
|
+
// pieces mix letters and digits, as `a1b2c3d4-e5f6g7h8` does and a product slug
|
|
156
|
+
// such as `acmephone-15-pro-max-256gb` doesn't.
|
|
157
|
+
function isLongToken(segment: string): boolean {
|
|
158
|
+
const pieces = segment.replace(/\+/g, '').split(/[-_.~]/);
|
|
159
|
+
return (
|
|
160
|
+
UUID.test(segment) ||
|
|
161
|
+
pieces.some(
|
|
162
|
+
piece =>
|
|
163
|
+
LONG_MIXED_TOKEN.test(piece) ||
|
|
164
|
+
LONG_CASE_SWITCHING_TOKEN.test(piece)
|
|
165
|
+
) ||
|
|
166
|
+
(pieces.filter(mixesLettersAndDigits).length >= 2 &&
|
|
167
|
+
LONG_MIXED_TOKEN.test(pieces.join('')))
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// The path every event carries, in its page URL and page views, where a
|
|
172
|
+
// wrong `*` costs every output the page it names. Only what reliably singles
|
|
173
|
+
// out one shopper goes as `*`: a segment holding an @, `;` or `=`, one that
|
|
174
|
+
// can't be decoded or still holds an escape, a long token, and the one after a
|
|
175
|
+
// route that names a person or opens an order when it holds a digit or a token.
|
|
176
|
+
// Product codes and pages such as `/profile/edit` or `/orders/history` stay.
|
|
177
|
+
export function pageUrlPath(pathname: string): string {
|
|
178
|
+
const segments = pathname.split('/');
|
|
179
|
+
return segments
|
|
180
|
+
.map((segment, i) => {
|
|
181
|
+
const text = decodeFully(segment);
|
|
182
|
+
const route = i > 0 ? routeWord(routeSegment(segments, i)) : '';
|
|
183
|
+
const holdsId =
|
|
184
|
+
!!text && (/[0-9]/.test(text) || isTokenSegment(text));
|
|
185
|
+
// A route word that can't be decoded may be any route.
|
|
186
|
+
const hiddenByRoute =
|
|
187
|
+
i > 0 &&
|
|
188
|
+
!!text &&
|
|
189
|
+
(route === undefined || (hidesNextSegment(route) && holdsId));
|
|
190
|
+
return text === undefined ||
|
|
191
|
+
/[@;=/]/.test(text) ||
|
|
192
|
+
LEFTOVER_ESCAPE.test(text) ||
|
|
193
|
+
isLongToken(text) ||
|
|
194
|
+
hiddenByRoute
|
|
195
|
+
? '*'
|
|
196
|
+
: segment;
|
|
197
|
+
})
|
|
198
|
+
.join('/');
|
|
199
|
+
}
|