@iann29/rastro 0.4.0 → 0.5.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/README.md +122 -17
- package/agent/integration.md +26 -10
- package/agent/manifest.json +10 -5
- package/agent/manifest.schema.json +14 -5
- package/dist/client/federation.d.ts +28 -8
- package/dist/client/federation.d.ts.map +1 -1
- package/dist/client/federation.js +12 -1
- package/dist/client/federation.js.map +1 -1
- package/dist/client/index.d.ts +262 -9
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +119 -2
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/api.d.ts +6 -0
- package/dist/component/_generated/api.d.ts.map +1 -1
- package/dist/component/_generated/api.js.map +1 -1
- package/dist/component/_generated/component.d.ts +71 -1
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/constants.d.ts +2 -0
- package/dist/component/constants.d.ts.map +1 -1
- package/dist/component/constants.js +7 -0
- package/dist/component/constants.js.map +1 -1
- package/dist/component/coverage.d.ts +1 -0
- package/dist/component/coverage.d.ts.map +1 -1
- package/dist/component/coverage.js +6 -1
- package/dist/component/coverage.js.map +1 -1
- package/dist/component/eventStore.d.ts +2 -0
- package/dist/component/eventStore.d.ts.map +1 -1
- package/dist/component/http.d.ts.map +1 -1
- package/dist/component/http.js +51 -1
- package/dist/component/http.js.map +1 -1
- package/dist/component/ingest.d.ts +2 -0
- package/dist/component/ingest.d.ts.map +1 -1
- package/dist/component/ingest.js +42 -3
- package/dist/component/ingest.js.map +1 -1
- package/dist/component/origin.d.ts +70 -0
- package/dist/component/origin.d.ts.map +1 -0
- package/dist/component/origin.js +230 -0
- package/dist/component/origin.js.map +1 -0
- package/dist/component/platforms.d.ts +33 -0
- package/dist/component/platforms.d.ts.map +1 -0
- package/dist/component/platforms.js +328 -0
- package/dist/component/platforms.js.map +1 -0
- package/dist/component/reports.d.ts +27 -72
- package/dist/component/reports.d.ts.map +1 -1
- package/dist/component/reports.js +51 -10
- package/dist/component/reports.js.map +1 -1
- package/dist/component/sanitize.d.ts +5 -0
- package/dist/component/sanitize.d.ts.map +1 -1
- package/dist/component/sanitize.js +33 -1
- package/dist/component/sanitize.js.map +1 -1
- package/dist/component/schema.d.ts +62 -7
- package/dist/component/schema.js +16 -1
- package/dist/component/schema.js.map +1 -1
- package/dist/component/trackedLinks.d.ts +91 -0
- package/dist/component/trackedLinks.d.ts.map +1 -0
- package/dist/component/trackedLinks.js +314 -0
- package/dist/component/trackedLinks.js.map +1 -0
- package/dist/component/useragent.d.ts +6 -0
- package/dist/component/useragent.d.ts.map +1 -1
- package/dist/component/useragent.js +9 -0
- package/dist/component/useragent.js.map +1 -1
- package/dist/component/validators.d.ts +51 -11
- package/dist/component/validators.d.ts.map +1 -1
- package/dist/component/validators.js +35 -1
- package/dist/component/validators.js.map +1 -1
- package/dist/tracker/generated.d.ts +6 -6
- package/dist/tracker/generated.d.ts.map +1 -1
- package/dist/tracker/generated.js +6 -6
- package/dist/tracker/generated.js.map +1 -1
- package/dist/tracker/tracker.d.ts +1 -0
- package/dist/tracker/tracker.d.ts.map +1 -1
- package/dist/tracker/tracker.js +16 -4
- package/dist/tracker/tracker.js.map +1 -1
- package/dist/tracker.min.js +1 -1
- package/docs/upgrading.md +152 -19
- package/llms.txt +6 -1
- package/package.json +2 -2
- package/src/component/_generated/api.ts +6 -0
- package/src/component/_generated/component.ts +152 -1
- package/src/component/constants.ts +7 -0
- package/src/component/coverage.ts +7 -1
- package/src/component/http.ts +56 -1
- package/src/component/ingest.ts +54 -1
- package/src/component/origin.ts +273 -0
- package/src/component/platforms.ts +359 -0
- package/src/component/reports.ts +74 -10
- package/src/component/sanitize.ts +39 -1
- package/src/component/schema.ts +18 -0
- package/src/component/trackedLinks.ts +384 -0
- package/src/component/useragent.ts +11 -0
- package/src/component/validators.ts +71 -0
- package/src/tracker/generated.ts +6 -6
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { CLICK_IDS, PLATFORMS, type PlatformRow } from "./platforms.js";
|
|
2
|
+
import { channelValidator, type Channel, type Evidence } from "./validators.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Where a session came from, with the rule that proved it. `platform` is an
|
|
6
|
+
* id from platforms.ts, `unknown` for a referrer or utm_source outside the
|
|
7
|
+
* table (the raw value stays in the session's `source`), or `none`.
|
|
8
|
+
*/
|
|
9
|
+
export type Origin = { platform: string; channel: Channel; evidence: Evidence };
|
|
10
|
+
|
|
11
|
+
export type OriginInput = {
|
|
12
|
+
utmSource?: string;
|
|
13
|
+
utmMedium?: string;
|
|
14
|
+
/** Click-ID key names from the landing URL, never their values. */
|
|
15
|
+
clickIds?: string[];
|
|
16
|
+
/** An external referrer: callers drop the site's own (externalReferrer). */
|
|
17
|
+
referrer?: string;
|
|
18
|
+
/** The platform whose in-app browser opened the page (inAppPlatform). */
|
|
19
|
+
inApp?: string;
|
|
20
|
+
/** The landing carried a `ref` that resolved to an affiliate. */
|
|
21
|
+
affiliate?: boolean;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const MAX_CLICK_IDS = 8;
|
|
25
|
+
const DIRECT: Origin = {
|
|
26
|
+
platform: "none",
|
|
27
|
+
channel: "direct",
|
|
28
|
+
evidence: "none",
|
|
29
|
+
};
|
|
30
|
+
const UNKNOWN_REFERRAL = { platform: "unknown", channel: "referral" } as const;
|
|
31
|
+
// Surfaces that add `fbclid` to their outbound links: a referrer or in-app
|
|
32
|
+
// browser naming one of them says which Meta app the click came from.
|
|
33
|
+
const META_FAMILY = new Set(["facebook", "instagram", "messenger", "threads"]);
|
|
34
|
+
const CHANNELS = new Set<string>(
|
|
35
|
+
channelValidator.members.map((member) => member.value),
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
type Hit = { row: PlatformRow; channel: Channel };
|
|
39
|
+
const BY_ID = new Map(PLATFORMS.map((row) => [row.id, row]));
|
|
40
|
+
const BY_HOST = new Map<string, Hit>();
|
|
41
|
+
const BY_ALIAS = new Map<string, Hit>();
|
|
42
|
+
for (const row of PLATFORMS) {
|
|
43
|
+
const paid = { row, channel: paidChannel(row.channel) ?? row.channel };
|
|
44
|
+
for (const host of [...(row.domains ?? []), ...(row.packages ?? [])]) {
|
|
45
|
+
BY_HOST.set(host, { row, channel: row.channel });
|
|
46
|
+
}
|
|
47
|
+
for (const host of row.paidDomains ?? []) BY_HOST.set(host, paid);
|
|
48
|
+
for (const alias of [row.id, ...(row.aliases ?? [])]) {
|
|
49
|
+
BY_ALIAS.set(alias, { row, channel: row.channel });
|
|
50
|
+
}
|
|
51
|
+
for (const alias of row.paidAliases ?? []) BY_ALIAS.set(alias, paid);
|
|
52
|
+
}
|
|
53
|
+
const CLICK_ID_RANK = new Map(CLICK_IDS.map(([key], rank) => [key, rank]));
|
|
54
|
+
|
|
55
|
+
// utm_medium spellings besides the channel ids, which are accepted as they
|
|
56
|
+
// are (tracked links send them). "paid" and "organic" follow the platform:
|
|
57
|
+
// search or social, whichever it is. A Map, so no utm_medium can reach
|
|
58
|
+
// Object.prototype.
|
|
59
|
+
const MEDIUMS = new Map<string, Channel | "paid" | "organic">([
|
|
60
|
+
["cpc", "paid"],
|
|
61
|
+
["ppc", "paid"],
|
|
62
|
+
["paid", "paid"],
|
|
63
|
+
["paidsearch", "paid_search"],
|
|
64
|
+
["sem", "paid_search"],
|
|
65
|
+
["paidsocial", "paid_social"],
|
|
66
|
+
["social", "organic_social"],
|
|
67
|
+
["social-media", "organic_social"],
|
|
68
|
+
["social_media", "organic_social"],
|
|
69
|
+
["e-mail", "email"],
|
|
70
|
+
["newsletter", "email"],
|
|
71
|
+
["mail", "email"],
|
|
72
|
+
["affiliates", "affiliate"],
|
|
73
|
+
["partner", "affiliate"],
|
|
74
|
+
["banner", "display"],
|
|
75
|
+
["video", "display"],
|
|
76
|
+
["cpm", "display"],
|
|
77
|
+
["whatsapp", "messaging"],
|
|
78
|
+
["sms", "messaging"],
|
|
79
|
+
["print", "offline"],
|
|
80
|
+
["qr", "offline"],
|
|
81
|
+
["organic", "organic"],
|
|
82
|
+
]);
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Classifies a session's first touch. One rule decides and the next only
|
|
86
|
+
* runs when it did not: utm_source, then click IDs, then the referrer host,
|
|
87
|
+
* then the in-app browser; an affiliate `ref` then sets the channel, keeping
|
|
88
|
+
* the platform and evidence an earlier rule found. Nothing at all is direct,
|
|
89
|
+
* the only way to be direct. Matching is exact, never by substring.
|
|
90
|
+
*/
|
|
91
|
+
export function classifyOrigin(input: OriginInput): Origin {
|
|
92
|
+
const referred = referrerPlatform(input.referrer);
|
|
93
|
+
const inApp = input.inApp === undefined ? undefined : BY_ID.get(input.inApp);
|
|
94
|
+
const origin =
|
|
95
|
+
fromUtm(input.utmSource, input.utmMedium) ??
|
|
96
|
+
fromClickIds(input.clickIds, referred?.platform, inApp?.id) ??
|
|
97
|
+
(referred && { ...referred, evidence: "referrer" as const }) ??
|
|
98
|
+
(inApp && {
|
|
99
|
+
platform: inApp.id,
|
|
100
|
+
channel: inApp.channel,
|
|
101
|
+
evidence: "inApp" as const,
|
|
102
|
+
});
|
|
103
|
+
if (input.affiliate) {
|
|
104
|
+
return origin
|
|
105
|
+
? { ...origin, channel: "affiliate" }
|
|
106
|
+
: { platform: "none", channel: "affiliate", evidence: "affiliate" };
|
|
107
|
+
}
|
|
108
|
+
return origin ?? DIRECT;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The known click-ID keys of a list, deduplicated and bounded. */
|
|
112
|
+
export function knownClickIds(keys: string[]): string[] | undefined {
|
|
113
|
+
const known = [...new Set(keys.filter((key) => CLICK_ID_RANK.has(key)))];
|
|
114
|
+
return known.length > 0 ? known.slice(0, MAX_CLICK_IDS) : undefined;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function isPlatform(id: string): boolean {
|
|
118
|
+
return BY_ID.has(id);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The platform behind a stored `source` — a `utm_source` alias, a referrer
|
|
123
|
+
* host, or an Android package, as sessions and the overview keep them.
|
|
124
|
+
* `unknown` when the table has no row for it, `none` when the session had no
|
|
125
|
+
* source at all. Matching is `classifyOrigin`'s, never by substring, so a
|
|
126
|
+
* dashboard can group raw hosts the same way the classifier does.
|
|
127
|
+
*/
|
|
128
|
+
export function platformForSource(source: string): string {
|
|
129
|
+
const value = source.trim().toLowerCase();
|
|
130
|
+
if (!value || value === "direct" || value === "internal") return "none";
|
|
131
|
+
return (BY_ALIAS.get(value) ?? hostHit(value))?.row.id ?? "unknown";
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Where a platform's traffic lands when nothing says paid or organic:
|
|
136
|
+
* `direct` for `none`, `referral` for an id outside the table.
|
|
137
|
+
*/
|
|
138
|
+
export function channelForPlatform(platform: string): Channel {
|
|
139
|
+
if (platform === "none") return DIRECT.channel;
|
|
140
|
+
return BY_ID.get(platform)?.channel ?? UNKNOWN_REFERRAL.channel;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The value the `platform` aggregate dimension carries. A session that
|
|
145
|
+
* arrived by its platform's default channel keeps the bare id; one that
|
|
146
|
+
* deviated carries `platform@channel` (`google@paid_search` for a `gclid`,
|
|
147
|
+
* `unknown@email` for a newsletter nobody's table knows), so the aggregates
|
|
148
|
+
* hold the pair without a dimension of their own. `none` is always `none`:
|
|
149
|
+
* a session with no platform is the direct row, whatever its channel.
|
|
150
|
+
*/
|
|
151
|
+
export function platformDimensionValue(origin: {
|
|
152
|
+
platform: string;
|
|
153
|
+
channel: Channel;
|
|
154
|
+
}): string {
|
|
155
|
+
if (origin.platform === "none") return "none";
|
|
156
|
+
return origin.channel === channelForPlatform(origin.platform)
|
|
157
|
+
? origin.platform
|
|
158
|
+
: `${origin.platform}@${origin.channel}`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The inverse: the platform and the channel behind a `platform` dimension
|
|
163
|
+
* value. A bare id — every session recorded before the pair was encoded
|
|
164
|
+
* included — reads as that platform's default channel, which is what it was
|
|
165
|
+
* classified with.
|
|
166
|
+
*/
|
|
167
|
+
export function splitPlatformDimension(value: string): {
|
|
168
|
+
platform: string;
|
|
169
|
+
channel: Channel;
|
|
170
|
+
} {
|
|
171
|
+
const at = value.indexOf("@");
|
|
172
|
+
const platform = at < 0 ? value : value.slice(0, at);
|
|
173
|
+
const channel = at < 0 ? "" : value.slice(at + 1);
|
|
174
|
+
return {
|
|
175
|
+
platform,
|
|
176
|
+
channel: CHANNELS.has(channel)
|
|
177
|
+
? (channel as Channel)
|
|
178
|
+
: channelForPlatform(platform),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function fromUtm(
|
|
183
|
+
utmSource: string | undefined,
|
|
184
|
+
utmMedium: string | undefined,
|
|
185
|
+
): Origin | undefined {
|
|
186
|
+
const source = utmSource?.trim().toLowerCase();
|
|
187
|
+
if (!source) return undefined;
|
|
188
|
+
// A host-shaped value (utm_source=chatgpt.com) matches like a referrer.
|
|
189
|
+
const hit =
|
|
190
|
+
BY_ALIAS.get(source) ?? (source.includes(".") ? hostHit(source) : null);
|
|
191
|
+
const declared = hit?.channel ?? UNKNOWN_REFERRAL.channel;
|
|
192
|
+
const mediumKey = utmMedium?.trim().toLowerCase() ?? "";
|
|
193
|
+
const medium = CHANNELS.has(mediumKey)
|
|
194
|
+
? (mediumKey as Channel)
|
|
195
|
+
: MEDIUMS.get(mediumKey);
|
|
196
|
+
const channel: Channel =
|
|
197
|
+
medium === "paid"
|
|
198
|
+
? (paidChannel(hit?.row.channel) ?? declared)
|
|
199
|
+
: medium === "organic"
|
|
200
|
+
? (hit?.row.channel ?? declared)
|
|
201
|
+
: (medium ?? declared);
|
|
202
|
+
return {
|
|
203
|
+
platform: hit?.row.id ?? UNKNOWN_REFERRAL.platform,
|
|
204
|
+
channel,
|
|
205
|
+
evidence: "utm",
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function fromClickIds(
|
|
210
|
+
keys: string[] | undefined,
|
|
211
|
+
referred: string | undefined,
|
|
212
|
+
inApp: string | undefined,
|
|
213
|
+
): Origin | undefined {
|
|
214
|
+
let best: (typeof CLICK_IDS)[number] | undefined;
|
|
215
|
+
for (const key of keys ?? []) {
|
|
216
|
+
const rank = CLICK_ID_RANK.get(key);
|
|
217
|
+
if (rank !== undefined && (!best || rank < CLICK_ID_RANK.get(best[0])!)) {
|
|
218
|
+
best = CLICK_IDS[rank];
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (!best) return undefined;
|
|
222
|
+
const [, platform, channel] = best;
|
|
223
|
+
// The click ID names who generated the link. A Meta id defers to the Meta
|
|
224
|
+
// app the referrer or the in-app browser names, channel included (a
|
|
225
|
+
// Messenger link is messaging); an e-mail id takes the referrer's platform.
|
|
226
|
+
if (platform === "meta") {
|
|
227
|
+
const app = [referred, inApp].find((id) => id && META_FAMILY.has(id));
|
|
228
|
+
const row = app === undefined ? undefined : BY_ID.get(app);
|
|
229
|
+
return row
|
|
230
|
+
? { platform: row.id, channel: row.channel, evidence: "clickId" }
|
|
231
|
+
: { platform, channel, evidence: "clickId" };
|
|
232
|
+
}
|
|
233
|
+
const resolved =
|
|
234
|
+
platform === "unknown" && referred && referred !== "unknown"
|
|
235
|
+
? referred
|
|
236
|
+
: platform;
|
|
237
|
+
return { platform: resolved, channel, evidence: "clickId" };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function referrerPlatform(
|
|
241
|
+
referrer: string | undefined,
|
|
242
|
+
): { platform: string; channel: Channel } | undefined {
|
|
243
|
+
if (!referrer) return undefined;
|
|
244
|
+
let host: string;
|
|
245
|
+
try {
|
|
246
|
+
host = new URL(referrer).hostname.toLowerCase().replace(/\.$/, "");
|
|
247
|
+
} catch {
|
|
248
|
+
return undefined;
|
|
249
|
+
}
|
|
250
|
+
if (!host) return undefined;
|
|
251
|
+
const hit = hostHit(host);
|
|
252
|
+
return hit
|
|
253
|
+
? { platform: hit.row.id, channel: hit.channel }
|
|
254
|
+
: UNKNOWN_REFERRAL;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** The longest listed suffix of a host (a whole label at a time), or null. */
|
|
258
|
+
function hostHit(host: string): Hit | null {
|
|
259
|
+
for (let suffix = host; suffix;) {
|
|
260
|
+
const hit = BY_HOST.get(suffix);
|
|
261
|
+
if (hit) return hit;
|
|
262
|
+
const dot = suffix.indexOf(".");
|
|
263
|
+
suffix = dot < 0 ? "" : suffix.slice(dot + 1);
|
|
264
|
+
}
|
|
265
|
+
const row = PLATFORMS.find((candidate) => candidate.hostPattern?.test(host));
|
|
266
|
+
return row ? { row, channel: row.channel } : null;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function paidChannel(channel: Channel | undefined): Channel | undefined {
|
|
270
|
+
if (channel === "organic_search") return "paid_search";
|
|
271
|
+
if (channel === "organic_social") return "paid_social";
|
|
272
|
+
return undefined;
|
|
273
|
+
}
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import type { Channel } from "./validators.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The closed table origin.ts classifies against. Bump the version whenever a
|
|
5
|
+
* row changes, and add a corpus case (origin.test.ts) for the change.
|
|
6
|
+
*/
|
|
7
|
+
export const PLATFORM_TABLE_VERSION = 1;
|
|
8
|
+
|
|
9
|
+
export type PlatformRow = {
|
|
10
|
+
id: string;
|
|
11
|
+
/** Where the platform's traffic lands when nothing says paid or organic. */
|
|
12
|
+
channel: Channel;
|
|
13
|
+
/** Registrable-domain suffixes: a host equal to one or ending in "." + it. */
|
|
14
|
+
domains?: readonly string[];
|
|
15
|
+
/** Hosts matched in full, for a family with many country domains. */
|
|
16
|
+
hostPattern?: RegExp;
|
|
17
|
+
/** Hosts that only ever send the platform's ads (the paid channel). */
|
|
18
|
+
paidDomains?: readonly string[];
|
|
19
|
+
/** Android packages, as an `android-app://<package>` referrer names them. */
|
|
20
|
+
packages?: readonly string[];
|
|
21
|
+
/** The platform's in-app browser marker in the User-Agent. */
|
|
22
|
+
agent?: RegExp;
|
|
23
|
+
/** utm_source spellings, lowercase; the id itself always matches. */
|
|
24
|
+
aliases?: readonly string[];
|
|
25
|
+
/** utm_source spellings that themselves declare the paid channel. */
|
|
26
|
+
paidAliases?: readonly string[];
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// Ordered for `inAppPlatform`: the first row whose marker matches wins, so
|
|
30
|
+
// Messenger (its iOS agent carries `FBAN/`) precedes Facebook and Threads
|
|
31
|
+
// precedes Instagram. Sources per row: "spec" is the approved proposal's
|
|
32
|
+
// table; the rest is each platform's own domains, link shims, and Play Store
|
|
33
|
+
// package ids, and in-app agents as the apps send them.
|
|
34
|
+
export const PLATFORMS: readonly PlatformRow[] = [
|
|
35
|
+
// Social.
|
|
36
|
+
{
|
|
37
|
+
// Threads: spec (threads.net → threads.com); agent is the app's codename.
|
|
38
|
+
id: "threads",
|
|
39
|
+
channel: "organic_social",
|
|
40
|
+
domains: ["threads.net", "threads.com"],
|
|
41
|
+
packages: ["com.instagram.barcelona"],
|
|
42
|
+
agent: /Barcelona/,
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
// spec.
|
|
46
|
+
id: "instagram",
|
|
47
|
+
channel: "organic_social",
|
|
48
|
+
domains: ["instagram.com"],
|
|
49
|
+
packages: ["com.instagram.android"],
|
|
50
|
+
agent: /Instagram/,
|
|
51
|
+
aliases: ["ig", "insta"],
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
// spec; + com.facebook.mlite (Messenger Lite), "msg" is Meta's
|
|
55
|
+
// {{site_source_name}} value for Messenger placements.
|
|
56
|
+
id: "messenger",
|
|
57
|
+
channel: "messaging",
|
|
58
|
+
domains: ["messenger.com", "m.me"],
|
|
59
|
+
packages: ["com.facebook.orca", "com.facebook.mlite"],
|
|
60
|
+
agent: /Orca-Android|MessengerLite|MessengerForiOS/,
|
|
61
|
+
aliases: ["msg"],
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
// spec; + fb.watch (video short links).
|
|
65
|
+
id: "facebook",
|
|
66
|
+
channel: "organic_social",
|
|
67
|
+
domains: ["facebook.com", "fb.com", "fb.me", "fb.watch"],
|
|
68
|
+
packages: ["com.facebook.katana", "com.facebook.lite"],
|
|
69
|
+
agent: /FBAN\/|FBAV\/|FB_IAB/,
|
|
70
|
+
aliases: ["fb", "face"],
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
// spec: only an `fbclid` with nothing that tells Facebook from Instagram.
|
|
74
|
+
id: "meta",
|
|
75
|
+
channel: "organic_social",
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
// spec; + TikTok Lite (com.zhiliaoapp.musically.go).
|
|
79
|
+
id: "tiktok",
|
|
80
|
+
channel: "organic_social",
|
|
81
|
+
domains: ["tiktok.com"],
|
|
82
|
+
packages: [
|
|
83
|
+
"com.zhiliaoapp.musically",
|
|
84
|
+
"com.ss.android.ugc.trill",
|
|
85
|
+
"com.zhiliaoapp.musically.go",
|
|
86
|
+
],
|
|
87
|
+
agent: /BytedanceWebview|musical_ly|TikTok/,
|
|
88
|
+
aliases: ["tt"],
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
// spec.
|
|
92
|
+
id: "kwai",
|
|
93
|
+
channel: "organic_social",
|
|
94
|
+
domains: ["kwai.com", "kwai-video.com", "kw.ai"],
|
|
95
|
+
packages: ["com.kwai.video"],
|
|
96
|
+
agent: /Kwai/,
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
// spec.
|
|
100
|
+
id: "youtube",
|
|
101
|
+
channel: "organic_social",
|
|
102
|
+
domains: ["youtube.com", "youtu.be"],
|
|
103
|
+
packages: ["com.google.android.youtube"],
|
|
104
|
+
aliases: ["yt"],
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
// spec; + TwitterAndroid (the Android app's agent).
|
|
108
|
+
id: "x",
|
|
109
|
+
channel: "organic_social",
|
|
110
|
+
domains: ["x.com", "twitter.com", "t.co"],
|
|
111
|
+
packages: ["com.twitter.android"],
|
|
112
|
+
agent: /Twitter for |TwitterAndroid/,
|
|
113
|
+
aliases: ["twitter"],
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
// spec.
|
|
117
|
+
id: "linkedin",
|
|
118
|
+
channel: "organic_social",
|
|
119
|
+
domains: ["linkedin.com", "lnkd.in"],
|
|
120
|
+
packages: ["com.linkedin.android"],
|
|
121
|
+
agent: /LinkedInApp/,
|
|
122
|
+
aliases: ["li"],
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
id: "pinterest",
|
|
126
|
+
channel: "organic_social",
|
|
127
|
+
domains: ["pinterest.com", "pin.it"],
|
|
128
|
+
packages: ["com.pinterest"],
|
|
129
|
+
agent: /Pinterest\//,
|
|
130
|
+
aliases: ["pin"],
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
id: "reddit",
|
|
134
|
+
channel: "organic_social",
|
|
135
|
+
domains: ["reddit.com", "redd.it"],
|
|
136
|
+
packages: ["com.reddit.frontpage"],
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
id: "snapchat",
|
|
140
|
+
channel: "organic_social",
|
|
141
|
+
domains: ["snapchat.com"],
|
|
142
|
+
packages: ["com.snapchat.android"],
|
|
143
|
+
agent: /Snapchat/,
|
|
144
|
+
aliases: ["snap"],
|
|
145
|
+
},
|
|
146
|
+
|
|
147
|
+
// Messaging.
|
|
148
|
+
{
|
|
149
|
+
// spec; + wl.co (WhatsApp's link shim, l.wl.co).
|
|
150
|
+
id: "whatsapp",
|
|
151
|
+
channel: "messaging",
|
|
152
|
+
domains: ["whatsapp.com", "wa.me", "wl.co"],
|
|
153
|
+
packages: ["com.whatsapp", "com.whatsapp.w4b"],
|
|
154
|
+
aliases: ["wa", "zap", "whats"],
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
// spec; + Telegram X (org.thunderdog.challegram).
|
|
158
|
+
id: "telegram",
|
|
159
|
+
channel: "messaging",
|
|
160
|
+
domains: ["t.me", "telegram.me", "telegram.org"],
|
|
161
|
+
packages: ["org.telegram.messenger", "org.thunderdog.challegram"],
|
|
162
|
+
aliases: ["tg"],
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
id: "discord",
|
|
166
|
+
channel: "messaging",
|
|
167
|
+
domains: ["discord.com", "discordapp.com", "discord.gg"],
|
|
168
|
+
packages: ["com.discord"],
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
// The Android package is `com.Slack`; referrer hosts arrive lowercase.
|
|
172
|
+
id: "slack",
|
|
173
|
+
channel: "messaging",
|
|
174
|
+
domains: ["slack.com"],
|
|
175
|
+
packages: ["com.slack"],
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
id: "line",
|
|
179
|
+
channel: "messaging",
|
|
180
|
+
domains: ["line.me"],
|
|
181
|
+
packages: ["jp.naver.line.android"],
|
|
182
|
+
agent: / Line\//,
|
|
183
|
+
},
|
|
184
|
+
|
|
185
|
+
// Search. Google's country domains match one pattern, not a suffix, so
|
|
186
|
+
// mail, docs, drive and every other google.com role host stay out of it.
|
|
187
|
+
{
|
|
188
|
+
// spec: google.<tld>, googleadservices.com, the Google app and GSA/.
|
|
189
|
+
id: "google",
|
|
190
|
+
channel: "organic_search",
|
|
191
|
+
hostPattern: /^(?:www\.)?google\.[a-z.]{2,7}$/,
|
|
192
|
+
domains: ["news.google.com"],
|
|
193
|
+
paidDomains: ["googleadservices.com"],
|
|
194
|
+
packages: ["com.google.android.googlequicksearchbox"],
|
|
195
|
+
agent: /GSA\//,
|
|
196
|
+
paidAliases: ["gads", "adwords", "google-ads", "google_ads", "googleads"],
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
id: "bing",
|
|
200
|
+
channel: "organic_search",
|
|
201
|
+
domains: ["bing.com"],
|
|
202
|
+
packages: ["com.microsoft.bing"],
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
id: "duckduckgo",
|
|
206
|
+
channel: "organic_search",
|
|
207
|
+
domains: ["duckduckgo.com"],
|
|
208
|
+
packages: ["com.duckduckgo.mobile.android"],
|
|
209
|
+
aliases: ["ddg"],
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
// Search hosts only: yahoo.com itself is a portal, mail is yahoo_mail.
|
|
213
|
+
id: "yahoo",
|
|
214
|
+
channel: "organic_search",
|
|
215
|
+
domains: ["search.yahoo.com", "search.yahoo.co.jp"],
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
id: "ecosia",
|
|
219
|
+
channel: "organic_search",
|
|
220
|
+
domains: ["ecosia.org"],
|
|
221
|
+
packages: ["com.ecosia.android"],
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
id: "yandex",
|
|
225
|
+
channel: "organic_search",
|
|
226
|
+
domains: ["yandex.ru", "yandex.com", "ya.ru"],
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
id: "baidu",
|
|
230
|
+
channel: "organic_search",
|
|
231
|
+
domains: ["baidu.com"],
|
|
232
|
+
},
|
|
233
|
+
|
|
234
|
+
// E-mail.
|
|
235
|
+
{
|
|
236
|
+
// spec.
|
|
237
|
+
id: "gmail",
|
|
238
|
+
channel: "email",
|
|
239
|
+
domains: ["mail.google.com"],
|
|
240
|
+
packages: ["com.google.android.gm"],
|
|
241
|
+
},
|
|
242
|
+
{
|
|
243
|
+
id: "outlook",
|
|
244
|
+
channel: "email",
|
|
245
|
+
domains: [
|
|
246
|
+
"outlook.com",
|
|
247
|
+
"outlook.live.com",
|
|
248
|
+
"outlook.office.com",
|
|
249
|
+
"outlook.office365.com",
|
|
250
|
+
],
|
|
251
|
+
packages: ["com.microsoft.office.outlook"],
|
|
252
|
+
aliases: ["hotmail"],
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
id: "yahoo_mail",
|
|
256
|
+
channel: "email",
|
|
257
|
+
domains: ["mail.yahoo.com"],
|
|
258
|
+
packages: ["com.yahoo.mobile.client.android.mail"],
|
|
259
|
+
aliases: ["yahoomail"],
|
|
260
|
+
},
|
|
261
|
+
|
|
262
|
+
// AI assistants.
|
|
263
|
+
{
|
|
264
|
+
// spec. ChatGPT tags the links it cites with utm_source=chatgpt.com,
|
|
265
|
+
// which the host fallback in origin.ts resolves.
|
|
266
|
+
id: "chatgpt",
|
|
267
|
+
channel: "ai",
|
|
268
|
+
domains: ["chatgpt.com", "chat.openai.com"],
|
|
269
|
+
packages: ["com.openai.chatgpt"],
|
|
270
|
+
aliases: ["openai"],
|
|
271
|
+
},
|
|
272
|
+
{
|
|
273
|
+
id: "perplexity",
|
|
274
|
+
channel: "ai",
|
|
275
|
+
domains: ["perplexity.ai"],
|
|
276
|
+
packages: ["ai.perplexity.app.android"],
|
|
277
|
+
},
|
|
278
|
+
{
|
|
279
|
+
// spec: gemini.google.com; bard is the product's former name.
|
|
280
|
+
id: "gemini",
|
|
281
|
+
channel: "ai",
|
|
282
|
+
domains: ["gemini.google.com", "bard.google.com"],
|
|
283
|
+
packages: ["com.google.android.apps.bard"],
|
|
284
|
+
aliases: ["bard"],
|
|
285
|
+
},
|
|
286
|
+
{
|
|
287
|
+
id: "copilot",
|
|
288
|
+
channel: "ai",
|
|
289
|
+
domains: ["copilot.microsoft.com"],
|
|
290
|
+
packages: ["com.microsoft.copilot"],
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
id: "claude",
|
|
294
|
+
channel: "ai",
|
|
295
|
+
domains: ["claude.ai"],
|
|
296
|
+
packages: ["com.anthropic.claude"],
|
|
297
|
+
aliases: ["anthropic"],
|
|
298
|
+
},
|
|
299
|
+
|
|
300
|
+
// Display ad servers and a known referral: github.io pages belong to their
|
|
301
|
+
// authors, so only github.com itself is GitHub.
|
|
302
|
+
{
|
|
303
|
+
id: "doubleclick",
|
|
304
|
+
channel: "display",
|
|
305
|
+
domains: ["doubleclick.net"],
|
|
306
|
+
},
|
|
307
|
+
{
|
|
308
|
+
id: "taboola",
|
|
309
|
+
channel: "display",
|
|
310
|
+
domains: ["taboola.com"],
|
|
311
|
+
},
|
|
312
|
+
{
|
|
313
|
+
id: "outbrain",
|
|
314
|
+
channel: "display",
|
|
315
|
+
domains: ["outbrain.com"],
|
|
316
|
+
},
|
|
317
|
+
{
|
|
318
|
+
id: "github",
|
|
319
|
+
channel: "referral",
|
|
320
|
+
domains: ["github.com"],
|
|
321
|
+
packages: ["com.github.android"],
|
|
322
|
+
},
|
|
323
|
+
|
|
324
|
+
// A QR code is a tracked link printed off the web: the scan carries no
|
|
325
|
+
// referrer and no app, so the link's own declaration is the only evidence.
|
|
326
|
+
{
|
|
327
|
+
id: "qr",
|
|
328
|
+
channel: "offline",
|
|
329
|
+
aliases: ["qrcode", "qr_code", "qr-code"],
|
|
330
|
+
},
|
|
331
|
+
];
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Click-ID keys the tracker may report by name, in priority order: the ad
|
|
335
|
+
* networks' own ids first, then the organic share ids, `fbclid` last because
|
|
336
|
+
* Meta adds it to organic clicks too. `unknown` platforms take the referrer's.
|
|
337
|
+
*/
|
|
338
|
+
export const CLICK_IDS: ReadonlyArray<
|
|
339
|
+
readonly [key: string, platform: string, channel: Channel]
|
|
340
|
+
> = [
|
|
341
|
+
["gclid", "google", "paid_search"],
|
|
342
|
+
["gbraid", "google", "paid_search"],
|
|
343
|
+
["wbraid", "google", "paid_search"],
|
|
344
|
+
["msclkid", "bing", "paid_search"],
|
|
345
|
+
["ttclid", "tiktok", "paid_social"],
|
|
346
|
+
["twclid", "x", "paid_social"],
|
|
347
|
+
["li_fat_id", "linkedin", "paid_social"],
|
|
348
|
+
["ScCid", "snapchat", "paid_social"],
|
|
349
|
+
["rdt_cid", "reddit", "paid_social"],
|
|
350
|
+
["dclid", "doubleclick", "display"],
|
|
351
|
+
["epik", "pinterest", "organic_social"],
|
|
352
|
+
["srsltid", "google", "organic_search"],
|
|
353
|
+
["mc_cid", "unknown", "email"],
|
|
354
|
+
["mc_eid", "unknown", "email"],
|
|
355
|
+
["_hsenc", "unknown", "email"],
|
|
356
|
+
["igsh", "instagram", "organic_social"],
|
|
357
|
+
["igshid", "instagram", "organic_social"],
|
|
358
|
+
["fbclid", "meta", "organic_social"],
|
|
359
|
+
];
|