@iann29/rastro 0.4.0 → 0.6.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 +146 -24
- package/agent/integration.md +66 -29
- package/agent/manifest.json +19 -8
- package/agent/manifest.schema.json +28 -9
- package/dist/client/federation.d.ts +38 -8
- package/dist/client/federation.d.ts.map +1 -1
- package/dist/client/federation.js +18 -1
- package/dist/client/federation.js.map +1 -1
- package/dist/client/identity.d.ts +11 -0
- package/dist/client/identity.d.ts.map +1 -0
- package/dist/client/identity.js +123 -0
- package/dist/client/identity.js.map +1 -0
- package/dist/client/index.d.ts +501 -11
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +220 -4
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/api.d.ts +8 -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 +158 -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 +72 -22
- 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/people.d.ts +79 -0
- package/dist/component/people.d.ts.map +1 -0
- package/dist/component/people.js +249 -0
- package/dist/component/people.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 +35 -74
- package/dist/component/reports.d.ts.map +1 -1
- package/dist/component/reports.js +59 -14
- 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 +101 -7
- package/dist/component/schema.js +45 -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 +101 -11
- package/dist/component/validators.d.ts.map +1 -1
- package/dist/component/validators.js +62 -1
- package/dist/component/validators.js.map +1 -1
- package/dist/component/visitors.d.ts +38 -2
- package/dist/component/visitors.d.ts.map +1 -1
- package/dist/component/visitors.js +162 -42
- package/dist/component/visitors.js.map +1 -1
- package/dist/react/index.d.ts +9 -5
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +36 -5
- package/dist/react/index.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 +3 -1
- package/dist/tracker/tracker.d.ts.map +1 -1
- package/dist/tracker/tracker.js +56 -5
- package/dist/tracker/tracker.js.map +1 -1
- package/dist/tracker.min.js +1 -1
- package/docs/federation.md +24 -0
- package/docs/identity.md +307 -0
- package/docs/upgrading.md +190 -19
- package/llms.txt +11 -2
- package/package.json +5 -3
- package/src/component/_generated/api.ts +8 -0
- package/src/component/_generated/component.ts +251 -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 +100 -28
- package/src/component/origin.ts +273 -0
- package/src/component/people.ts +321 -0
- package/src/component/platforms.ts +359 -0
- package/src/component/reports.ts +84 -13
- package/src/component/sanitize.ts +39 -1
- package/src/component/schema.ts +53 -0
- package/src/component/trackedLinks.ts +384 -0
- package/src/component/useragent.ts +11 -0
- package/src/component/validators.ts +120 -0
- package/src/component/visitors.ts +232 -55
- package/src/tracker/generated.ts +6 -6
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import { v } from "convex/values";
|
|
2
|
+
import { query, mutation, type MutationCtx } from "./_generated/server.js";
|
|
3
|
+
import type { Doc, Id } from "./_generated/dataModel.js";
|
|
4
|
+
import { fail } from "./errors.js";
|
|
5
|
+
import {
|
|
6
|
+
personFilterValidator,
|
|
7
|
+
visitorProfileValidator,
|
|
8
|
+
type PersonAttributes,
|
|
9
|
+
type PersonFilter,
|
|
10
|
+
} from "./validators.js";
|
|
11
|
+
|
|
12
|
+
export const personDocumentValidator = visitorProfileValidator.extend({
|
|
13
|
+
_id: v.id("visitorProfiles"),
|
|
14
|
+
_creationTime: v.number(),
|
|
15
|
+
siteId: v.id("sites"),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export function profileSearchFields(name?: string, email?: string) {
|
|
19
|
+
return {
|
|
20
|
+
searchName: normalizeSearch(name ?? ""),
|
|
21
|
+
searchEmail: normalizeSearch(email ?? ""),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function normalizeSearch(value: string) {
|
|
26
|
+
return value.normalize("NFKC").trim().toLowerCase();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function mergePersonAttributes(
|
|
30
|
+
previous: PersonAttributes = {},
|
|
31
|
+
patch?: Record<string, string | number | boolean | null>,
|
|
32
|
+
): PersonAttributes {
|
|
33
|
+
const attributes = { ...previous };
|
|
34
|
+
for (const [key, value] of Object.entries(patch ?? {})) {
|
|
35
|
+
validateAttributeKey(key);
|
|
36
|
+
if (value === null) delete attributes[key];
|
|
37
|
+
else {
|
|
38
|
+
validateAttributeValue(value);
|
|
39
|
+
attributes[key] = value;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (
|
|
43
|
+
Object.keys(attributes).length > 32 ||
|
|
44
|
+
new TextEncoder().encode(JSON.stringify(attributes)).length > 4096
|
|
45
|
+
)
|
|
46
|
+
fail(
|
|
47
|
+
"INVALID_ARGUMENT",
|
|
48
|
+
"attributes allow at most 32 keys and 4096 UTF-8 bytes",
|
|
49
|
+
);
|
|
50
|
+
return Object.fromEntries(
|
|
51
|
+
Object.entries(attributes).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function validateAttributeKey(key: string) {
|
|
56
|
+
if (
|
|
57
|
+
!/^[A-Za-z][A-Za-z0-9_.-]{0,63}$/.test(key) ||
|
|
58
|
+
["__proto__", "constructor", "prototype"].includes(key)
|
|
59
|
+
)
|
|
60
|
+
fail(
|
|
61
|
+
"INVALID_ARGUMENT",
|
|
62
|
+
"attribute keys must start with a letter and contain up to 64 ASCII letters, digits, dots, underscores or hyphens",
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function validateAttributeValue(value: string | number | boolean) {
|
|
67
|
+
if (
|
|
68
|
+
(typeof value === "string" && value.length > 512) ||
|
|
69
|
+
(typeof value === "number" && !Number.isFinite(value))
|
|
70
|
+
)
|
|
71
|
+
fail(
|
|
72
|
+
"INVALID_ARGUMENT",
|
|
73
|
+
"attribute values require finite numbers or strings up to 512 characters",
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Index each current attribute once; ordinary reads never scan all profiles. */
|
|
78
|
+
export async function syncPersonAttributes(
|
|
79
|
+
ctx: MutationCtx,
|
|
80
|
+
siteId: Id<"sites">,
|
|
81
|
+
visitorId: string,
|
|
82
|
+
attributes: PersonAttributes,
|
|
83
|
+
) {
|
|
84
|
+
const rows = await ctx.db
|
|
85
|
+
.query("visitorAttributes")
|
|
86
|
+
.withIndex("by_siteId_and_visitorId", (q) =>
|
|
87
|
+
q.eq("siteId", siteId).eq("visitorId", visitorId),
|
|
88
|
+
)
|
|
89
|
+
.take(33);
|
|
90
|
+
const present = new Set<string>();
|
|
91
|
+
for (const row of rows) {
|
|
92
|
+
present.add(row.key);
|
|
93
|
+
if (!Object.hasOwn(attributes, row.key))
|
|
94
|
+
await ctx.db.delete("visitorAttributes", row._id);
|
|
95
|
+
else if (attributes[row.key] !== row.value)
|
|
96
|
+
await ctx.db.patch("visitorAttributes", row._id, {
|
|
97
|
+
value: attributes[row.key],
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
for (const [key, value] of Object.entries(attributes)) {
|
|
101
|
+
if (!present.has(key))
|
|
102
|
+
await ctx.db.insert("visitorAttributes", {
|
|
103
|
+
siteId,
|
|
104
|
+
visitorId,
|
|
105
|
+
key,
|
|
106
|
+
value,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function publicPerson(row: Doc<"visitorProfiles">) {
|
|
112
|
+
const { searchName: _name, searchEmail: _email, ...person } = row;
|
|
113
|
+
return person;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** An optional, bounded upgrade for profiles created before the directory. */
|
|
117
|
+
export const backfillSearch = mutation({
|
|
118
|
+
args: { siteId: v.id("sites") },
|
|
119
|
+
returns: v.object({ updated: v.number(), isDone: v.boolean() }),
|
|
120
|
+
handler: async (ctx, { siteId }) => {
|
|
121
|
+
if (!(await ctx.db.get("sites", siteId)))
|
|
122
|
+
fail("NOT_FOUND", "site not found");
|
|
123
|
+
const rows = await ctx.db
|
|
124
|
+
.query("visitorProfiles")
|
|
125
|
+
.withIndex("by_siteId_and_searchName_and_visitorId", (q) =>
|
|
126
|
+
q.eq("siteId", siteId).eq("searchName", undefined),
|
|
127
|
+
)
|
|
128
|
+
.take(65);
|
|
129
|
+
for (const row of rows.slice(0, 64)) {
|
|
130
|
+
await ctx.db.patch(
|
|
131
|
+
"visitorProfiles",
|
|
132
|
+
row._id,
|
|
133
|
+
profileSearchFields(row.name, row.email),
|
|
134
|
+
);
|
|
135
|
+
await syncPersonAttributes(
|
|
136
|
+
ctx,
|
|
137
|
+
siteId,
|
|
138
|
+
row.visitorId,
|
|
139
|
+
row.attributes ?? {},
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
return { updated: Math.min(64, rows.length), isDone: rows.length <= 64 };
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
/** Exclusive upper bound for a Unicode prefix, including supplementary characters. */
|
|
147
|
+
function afterPrefix(prefix: string) {
|
|
148
|
+
const points = Array.from(prefix);
|
|
149
|
+
for (let i = points.length - 1; i >= 0; i--) {
|
|
150
|
+
const code = points[i].codePointAt(0)!;
|
|
151
|
+
if (code < 0x10ffff)
|
|
152
|
+
return (
|
|
153
|
+
points.slice(0, i).join("") +
|
|
154
|
+
String.fromCodePoint(code + 1 === 0xd800 ? 0xe000 : code + 1)
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function normalizedFilter(filter?: PersonFilter): PersonFilter | undefined {
|
|
161
|
+
if (!filter) return undefined;
|
|
162
|
+
if (filter.field === "attribute") {
|
|
163
|
+
validateAttributeKey(filter.key);
|
|
164
|
+
validateAttributeValue(filter.value);
|
|
165
|
+
return { field: "attribute", key: filter.key, value: filter.value };
|
|
166
|
+
}
|
|
167
|
+
if (filter.prefix.length > 320)
|
|
168
|
+
fail("INVALID_ARGUMENT", "search prefix exceeds 320 characters");
|
|
169
|
+
const prefix =
|
|
170
|
+
filter.field === "visitorId"
|
|
171
|
+
? filter.prefix.trim()
|
|
172
|
+
: normalizeSearch(filter.prefix);
|
|
173
|
+
return prefix ? { field: filter.field, prefix } : undefined;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export const list = query({
|
|
177
|
+
args: {
|
|
178
|
+
siteId: v.id("sites"),
|
|
179
|
+
filter: v.optional(personFilterValidator),
|
|
180
|
+
paginationOpts: v.object({
|
|
181
|
+
cursor: v.union(v.string(), v.null()),
|
|
182
|
+
numItems: v.number(),
|
|
183
|
+
}),
|
|
184
|
+
},
|
|
185
|
+
returns: v.object({
|
|
186
|
+
page: v.array(personDocumentValidator),
|
|
187
|
+
isDone: v.boolean(),
|
|
188
|
+
continueCursor: v.string(),
|
|
189
|
+
}),
|
|
190
|
+
handler: async (ctx, args) => {
|
|
191
|
+
const filter = normalizedFilter(args.filter);
|
|
192
|
+
const scope = JSON.stringify([args.siteId, filter ?? null]);
|
|
193
|
+
const limit = args.paginationOpts.numItems;
|
|
194
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 50)
|
|
195
|
+
fail("INVALID_ARGUMENT", "numItems must be an integer between 1 and 50");
|
|
196
|
+
let after: { value: string; visitorId: string } | undefined;
|
|
197
|
+
if (args.paginationOpts.cursor !== null) {
|
|
198
|
+
try {
|
|
199
|
+
// NFKC may expand one character into several, in both scope and value.
|
|
200
|
+
if (args.paginationOpts.cursor.length > 16_384) throw new Error();
|
|
201
|
+
const cursor = JSON.parse(args.paginationOpts.cursor) as Record<
|
|
202
|
+
string,
|
|
203
|
+
unknown
|
|
204
|
+
>;
|
|
205
|
+
if (
|
|
206
|
+
cursor.v !== 1 ||
|
|
207
|
+
cursor.scope !== scope ||
|
|
208
|
+
typeof cursor.value !== "string" ||
|
|
209
|
+
typeof cursor.visitorId !== "string"
|
|
210
|
+
)
|
|
211
|
+
throw new Error();
|
|
212
|
+
after = { value: cursor.value, visitorId: cursor.visitorId };
|
|
213
|
+
if (
|
|
214
|
+
filter &&
|
|
215
|
+
filter.field !== "attribute" &&
|
|
216
|
+
!after.value.startsWith(filter.prefix)
|
|
217
|
+
)
|
|
218
|
+
throw new Error();
|
|
219
|
+
} catch {
|
|
220
|
+
fail("INVALID_ARGUMENT", "cursor does not belong to this people query");
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
let rows: Doc<"visitorProfiles">[];
|
|
224
|
+
const take = limit + 1;
|
|
225
|
+
if (filter?.field === "attribute") {
|
|
226
|
+
const matches = await ctx.db
|
|
227
|
+
.query("visitorAttributes")
|
|
228
|
+
.withIndex("by_siteId_and_key_and_value_and_visitorId", (q) =>
|
|
229
|
+
q
|
|
230
|
+
.eq("siteId", args.siteId)
|
|
231
|
+
.eq("key", filter.key)
|
|
232
|
+
.eq("value", filter.value)
|
|
233
|
+
.gt("visitorId", after?.visitorId ?? ""),
|
|
234
|
+
)
|
|
235
|
+
.take(take);
|
|
236
|
+
rows = await Promise.all(
|
|
237
|
+
matches.map(async (match) => {
|
|
238
|
+
const row = await ctx.db
|
|
239
|
+
.query("visitorProfiles")
|
|
240
|
+
.withIndex("by_siteId_and_visitorId", (q) =>
|
|
241
|
+
q.eq("siteId", args.siteId).eq("visitorId", match.visitorId),
|
|
242
|
+
)
|
|
243
|
+
.unique();
|
|
244
|
+
if (!row) fail("NOT_FOUND", "person attribute index has no profile");
|
|
245
|
+
return row;
|
|
246
|
+
}),
|
|
247
|
+
);
|
|
248
|
+
} else if (filter && filter.field !== "visitorId") {
|
|
249
|
+
const pending = await ctx.db
|
|
250
|
+
.query("visitorProfiles")
|
|
251
|
+
.withIndex("by_siteId_and_searchName_and_visitorId", (q) =>
|
|
252
|
+
q.eq("siteId", args.siteId).eq("searchName", undefined),
|
|
253
|
+
)
|
|
254
|
+
.first();
|
|
255
|
+
if (pending)
|
|
256
|
+
fail(
|
|
257
|
+
"CONFLICT",
|
|
258
|
+
"Run backfillVisitorProfiles for this site before searching existing profiles",
|
|
259
|
+
);
|
|
260
|
+
const field = filter.field === "name" ? "searchName" : "searchEmail";
|
|
261
|
+
const index =
|
|
262
|
+
filter.field === "name"
|
|
263
|
+
? "by_siteId_and_searchName_and_visitorId"
|
|
264
|
+
: "by_siteId_and_searchEmail_and_visitorId";
|
|
265
|
+
rows = after
|
|
266
|
+
? await ctx.db
|
|
267
|
+
.query("visitorProfiles")
|
|
268
|
+
.withIndex(index, (q) =>
|
|
269
|
+
q
|
|
270
|
+
.eq("siteId", args.siteId)
|
|
271
|
+
.eq(field, after.value)
|
|
272
|
+
.gt("visitorId", after.visitorId),
|
|
273
|
+
)
|
|
274
|
+
.take(take)
|
|
275
|
+
: [];
|
|
276
|
+
if (rows.length < take) {
|
|
277
|
+
const upper = afterPrefix(filter.prefix);
|
|
278
|
+
const rest = await ctx.db
|
|
279
|
+
.query("visitorProfiles")
|
|
280
|
+
.withIndex(index, (q) => {
|
|
281
|
+
const lower = after
|
|
282
|
+
? q.eq("siteId", args.siteId).gt(field, after.value)
|
|
283
|
+
: q.eq("siteId", args.siteId).gte(field, filter.prefix);
|
|
284
|
+
return upper === undefined ? lower : lower.lt(field, upper);
|
|
285
|
+
})
|
|
286
|
+
.take(take - rows.length);
|
|
287
|
+
rows.push(...rest);
|
|
288
|
+
}
|
|
289
|
+
} else {
|
|
290
|
+
const prefix = filter?.prefix ?? "";
|
|
291
|
+
const upper = afterPrefix(prefix);
|
|
292
|
+
rows = await ctx.db
|
|
293
|
+
.query("visitorProfiles")
|
|
294
|
+
.withIndex("by_siteId_and_visitorId", (q) => {
|
|
295
|
+
const lower = after
|
|
296
|
+
? q.eq("siteId", args.siteId).gt("visitorId", after.visitorId)
|
|
297
|
+
: q.eq("siteId", args.siteId).gte("visitorId", prefix);
|
|
298
|
+
return upper === undefined ? lower : lower.lt("visitorId", upper);
|
|
299
|
+
})
|
|
300
|
+
.take(take);
|
|
301
|
+
}
|
|
302
|
+
const page = rows.slice(0, limit);
|
|
303
|
+
const last = page.at(-1);
|
|
304
|
+
return {
|
|
305
|
+
page: page.map(publicPerson),
|
|
306
|
+
isDone: rows.length <= limit,
|
|
307
|
+
continueCursor: JSON.stringify({
|
|
308
|
+
v: 1,
|
|
309
|
+
scope,
|
|
310
|
+
visitorId: last?.visitorId ?? after?.visitorId ?? "",
|
|
311
|
+
value: last
|
|
312
|
+
? filter?.field === "name"
|
|
313
|
+
? last.searchName
|
|
314
|
+
: filter?.field === "email"
|
|
315
|
+
? last.searchEmail
|
|
316
|
+
: last.visitorId
|
|
317
|
+
: (after?.value ?? ""),
|
|
318
|
+
}),
|
|
319
|
+
};
|
|
320
|
+
},
|
|
321
|
+
});
|
|
@@ -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
|
+
];
|