@shware/analytics 11.9.0 → 11.11.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 +137 -1
- package/dist/ads/google.cjs +128 -0
- package/dist/ads/google.cjs.map +1 -0
- package/dist/ads/google.d.cts +43 -0
- package/dist/ads/google.d.cts.map +1 -0
- package/dist/ads/google.d.mts +43 -0
- package/dist/ads/google.d.mts.map +1 -0
- package/dist/ads/google.mjs +126 -0
- package/dist/ads/google.mjs.map +1 -0
- package/dist/ads/index.cjs +3 -0
- package/dist/ads/index.d.cts +2 -1
- package/dist/ads/index.d.mts +2 -1
- package/dist/ads/index.mjs +2 -1
- package/dist/ads/types.d.cts +7 -3
- package/dist/ads/types.d.cts.map +1 -1
- package/dist/ads/types.d.mts +7 -3
- package/dist/ads/types.d.mts.map +1 -1
- package/dist/attribution/classify.cjs +57 -19
- package/dist/attribution/classify.cjs.map +1 -1
- package/dist/attribution/classify.d.cts +14 -12
- package/dist/attribution/classify.d.cts.map +1 -1
- package/dist/attribution/classify.d.mts +14 -12
- package/dist/attribution/classify.d.mts.map +1 -1
- package/dist/attribution/classify.mjs +57 -19
- package/dist/attribution/classify.mjs.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -344,7 +344,8 @@ What the SDK guarantees for this to hold:
|
|
|
344
344
|
## Ad performance (`@shware/analytics/ads`)
|
|
345
345
|
|
|
346
346
|
Spend, impressions, clicks and the platforms' own conversions, read from the ad platforms'
|
|
347
|
-
reporting APIs as typed `AdPerformanceRow`s — one per ad
|
|
347
|
+
reporting APIs as typed `AdPerformanceRow`s — one per ad (or the finest level a platform reports
|
|
348
|
+
hours at) and hour — for a host to store next to its
|
|
348
349
|
sessions and compute ROAS, cost per session and the click → session landing rate.
|
|
349
350
|
|
|
350
351
|
```ts
|
|
@@ -386,6 +387,141 @@ const rows = await fetchMetaAdPerformance({
|
|
|
386
387
|
day before the first hourly day stored — a day must never hold both spread and real hourly rows,
|
|
387
388
|
as an hour without delivery is absent from the hourly data and its spread share would remain.
|
|
388
389
|
|
|
390
|
+
### Google Ads, pushed by a script
|
|
391
|
+
|
|
392
|
+
Google's reporting API needs a developer token, which needs a manager account. A
|
|
393
|
+
[Google Ads script](https://developers.google.com/google-ads/scripts) needs neither: it runs inside
|
|
394
|
+
the ad account (Tools → Bulk actions → Scripts) as the account's own user, reads the report and
|
|
395
|
+
posts it to the host, which turns the body into rows with `parseGoogleAdsScriptReport` and upserts
|
|
396
|
+
them.
|
|
397
|
+
|
|
398
|
+
```ts
|
|
399
|
+
import { GoogleAdsReportError, parseGoogleAdsScriptReport } from '@shware/analytics/ads';
|
|
400
|
+
|
|
401
|
+
// POST, behind a bearer key only the script knows
|
|
402
|
+
try {
|
|
403
|
+
await upsert(parseGoogleAdsScriptReport(await request.json()));
|
|
404
|
+
} catch (error) {
|
|
405
|
+
if (error instanceof GoogleAdsReportError) return new Response(error.message, { status: 400 });
|
|
406
|
+
throw error;
|
|
407
|
+
}
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
- **Ad groups, not ads.** Google reports hours down to the ad group (`segments.hour` is not
|
|
411
|
+
available per ad), and a Performance Max campaign has no ad group: `ad_id` is the ad group id, or
|
|
412
|
+
the campaign id for Performance Max. The script reads both, and logs a warning when they do not
|
|
413
|
+
add up to the account's spend.
|
|
414
|
+
- **Conversions** are Google's primary conversion actions, click-based (view-through ones are
|
|
415
|
+
counted apart and have no value), so they fill the total and the click-through columns alike.
|
|
416
|
+
- **Restatement.** A conversion is credited to its click's hour up to the conversion window later
|
|
417
|
+
(30 days by default). The script re-reads the last 3 days every hour and the last 30 once a day.
|
|
418
|
+
- **History.** Set `SINCE` to the first day with spend, run the script once by hand, then clear
|
|
419
|
+
it; it posts 7 days per request. Hourly data reaches back at least months — compare each day's
|
|
420
|
+
sum of hours with the day in the Google Ads UI before relying on older days.
|
|
421
|
+
|
|
422
|
+
Setting it up:
|
|
423
|
+
|
|
424
|
+
1. Tools → Bulk actions → Scripts → **+**, paste the script, set `ENDPOINT` and `KEY`.
|
|
425
|
+
2. **Authorize** it before the first run: posting to a URL needs the "connect to an external
|
|
426
|
+
service" scope, and a run without it ends at once with "Failed due to authentication errors".
|
|
427
|
+
3. Backfill (`SINCE`, above), then clear `SINCE` and save.
|
|
428
|
+
4. Back in the scripts list, set the **Frequency** column to Hourly — the editor has no schedule.
|
|
429
|
+
The results column reads "no changes" on every run: it counts changes to the account, and the
|
|
430
|
+
script makes none.
|
|
431
|
+
|
|
432
|
+
Google Ads emails the account owner when a run throws, as on a non-2xx answer.
|
|
433
|
+
|
|
434
|
+
```js
|
|
435
|
+
const ENDPOINT = 'https://example.com/v1/adPerformance/google';
|
|
436
|
+
const KEY = '<my_bearer_token>'; // the bearer key the endpoint checks
|
|
437
|
+
const DAYS = 3; // re-read every hour
|
|
438
|
+
const RESTATE_DAYS = 30; // re-read once a day, at RESTATE_HOUR in the account's time zone
|
|
439
|
+
const RESTATE_HOUR = 5;
|
|
440
|
+
const CHUNK_DAYS = 7; // days per request
|
|
441
|
+
const SINCE = ''; // a backfill's first day, 'YYYY-MM-DD'; empty for the schedule
|
|
442
|
+
|
|
443
|
+
function main() {
|
|
444
|
+
const account = AdsApp.currentAccount();
|
|
445
|
+
const timeZone = account.getTimeZone();
|
|
446
|
+
const now = new Date();
|
|
447
|
+
const today = Utilities.formatDate(now, timeZone, 'yyyy-MM-dd');
|
|
448
|
+
const hour = Number(Utilities.formatDate(now, timeZone, 'H'));
|
|
449
|
+
const since = SINCE || addDays(today, 1 - (hour === RESTATE_HOUR ? RESTATE_DAYS : DAYS));
|
|
450
|
+
|
|
451
|
+
for (let from = since; from <= today; from = addDays(from, CHUNK_DAYS)) {
|
|
452
|
+
const last = addDays(from, CHUNK_DAYS - 1);
|
|
453
|
+
const until = last < today ? last : today;
|
|
454
|
+
const range = `segments.date BETWEEN '${from}' AND '${until}'`;
|
|
455
|
+
const metrics = `segments.date, segments.hour, metrics.cost_micros, metrics.impressions,
|
|
456
|
+
metrics.clicks, metrics.conversions, metrics.conversions_value`;
|
|
457
|
+
const rows = [
|
|
458
|
+
...read(`SELECT campaign.id, campaign.name, ad_group.id, ad_group.name, ${metrics}
|
|
459
|
+
FROM ad_group WHERE ${range} AND metrics.impressions > 0`),
|
|
460
|
+
...read(`SELECT campaign.id, campaign.name, ${metrics} FROM campaign
|
|
461
|
+
WHERE ${range} AND metrics.impressions > 0
|
|
462
|
+
AND campaign.advertising_channel_type = 'PERFORMANCE_MAX'`),
|
|
463
|
+
];
|
|
464
|
+
check(rows, range, from, until);
|
|
465
|
+
post({
|
|
466
|
+
account_id: account.getCustomerId(),
|
|
467
|
+
currency: account.getCurrencyCode(),
|
|
468
|
+
time_zone: timeZone,
|
|
469
|
+
rows,
|
|
470
|
+
});
|
|
471
|
+
Logger.log('%s → %s: %s rows', from, until, rows.length);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function read(query) {
|
|
476
|
+
const rows = [];
|
|
477
|
+
const results = AdsApp.search(query);
|
|
478
|
+
while (results.hasNext()) {
|
|
479
|
+
const r = results.next();
|
|
480
|
+
rows.push({
|
|
481
|
+
campaign_id: r.campaign.id,
|
|
482
|
+
campaign_name: r.campaign.name,
|
|
483
|
+
ad_group_id: r.adGroup ? r.adGroup.id : null,
|
|
484
|
+
ad_group_name: r.adGroup ? r.adGroup.name : null,
|
|
485
|
+
date: r.segments.date,
|
|
486
|
+
hour: r.segments.hour,
|
|
487
|
+
cost_micros: r.metrics.costMicros,
|
|
488
|
+
impressions: r.metrics.impressions,
|
|
489
|
+
clicks: r.metrics.clicks,
|
|
490
|
+
conversions: r.metrics.conversions,
|
|
491
|
+
conversion_value: r.metrics.conversionsValue,
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
return rows;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/** Warns when the rows miss spend: a campaign type with neither ad groups nor Performance Max. */
|
|
498
|
+
function check(rows, range, from, until) {
|
|
499
|
+
let total = 0;
|
|
500
|
+
const results = AdsApp.search(`SELECT metrics.cost_micros FROM customer WHERE ${range}`);
|
|
501
|
+
while (results.hasNext()) total += Number(results.next().metrics.costMicros);
|
|
502
|
+
const got = rows.reduce((sum, row) => sum + Number(row.cost_micros), 0);
|
|
503
|
+
if (got !== total) Logger.log('WARNING %s → %s: read %s of %s micros', from, until, got, total);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function post(report) {
|
|
507
|
+
const response = UrlFetchApp.fetch(ENDPOINT, {
|
|
508
|
+
method: 'post',
|
|
509
|
+
contentType: 'application/json',
|
|
510
|
+
headers: { Authorization: `Bearer ${KEY}` },
|
|
511
|
+
payload: JSON.stringify(report),
|
|
512
|
+
muteHttpExceptions: true,
|
|
513
|
+
});
|
|
514
|
+
const code = response.getResponseCode();
|
|
515
|
+
if (code < 200 || code >= 300) throw new Error(`${code}: ${response.getContentText()}`);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function addDays(date, days) {
|
|
519
|
+
const moved = new Date(`${date}T00:00:00Z`);
|
|
520
|
+
moved.setUTCDate(moved.getUTCDate() + days);
|
|
521
|
+
return moved.toISOString().slice(0, 10);
|
|
522
|
+
}
|
|
523
|
+
```
|
|
524
|
+
|
|
389
525
|
## UTM params
|
|
390
526
|
|
|
391
527
|
Typed as `UTMParams` (exported from `@shware/analytics`). Value unions follow the
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
const require_attribution_classify = require("../attribution/classify.cjs");
|
|
3
|
+
require("../attribution/index.cjs");
|
|
4
|
+
const require_ads_time = require("./time.cjs");
|
|
5
|
+
let zod_mini = require("zod/mini");
|
|
6
|
+
//#region src/ads/google.ts
|
|
7
|
+
/**
|
|
8
|
+
* Google Ads delivery pushed by a Google Ads script: the script runs inside the ad account on
|
|
9
|
+
* Google's schedule, reads the hourly report with `AdsApp.search` and posts it to the host, which
|
|
10
|
+
* turns it into `AdPerformanceRow`s here. No developer token, manager account or OAuth client is
|
|
11
|
+
* needed — the script runs as the account's own user. The script is in the README.
|
|
12
|
+
*
|
|
13
|
+
* Google reports hours down to the ad group, not the ad (`segments.hour` is incompatible with
|
|
14
|
+
* `ad_group_ad`), and Performance Max campaigns have no ad groups at all; so a row is an ad group's
|
|
15
|
+
* hour, or a Performance Max campaign's, and `ad_id` holds that ad group or campaign id.
|
|
16
|
+
*
|
|
17
|
+
* https://developers.google.com/google-ads/scripts/docs/features/reports
|
|
18
|
+
* https://developers.google.com/google-ads/api/fields/v21/ad_group
|
|
19
|
+
*/
|
|
20
|
+
/** What a Google ad click is in the attribution vocabulary: a `gclid` is `google / cpc`. */
|
|
21
|
+
const CHANNEL = "google";
|
|
22
|
+
const MEDIUM = "cpc";
|
|
23
|
+
/** `AdsApp.search` returns int64 metrics as strings and doubles as numbers; either is taken. */
|
|
24
|
+
const metric = (0, zod_mini.optional)((0, zod_mini.union)([(0, zod_mini.string)(), (0, zod_mini.number)()]));
|
|
25
|
+
const reportRowSchema = (0, zod_mini.object)({
|
|
26
|
+
campaign_id: (0, zod_mini.string)(),
|
|
27
|
+
campaign_name: (0, zod_mini.optional)((0, zod_mini.nullable)((0, zod_mini.string)())),
|
|
28
|
+
/** Null for a Performance Max campaign's row. */
|
|
29
|
+
ad_group_id: (0, zod_mini.optional)((0, zod_mini.nullable)((0, zod_mini.string)())),
|
|
30
|
+
ad_group_name: (0, zod_mini.optional)((0, zod_mini.nullable)((0, zod_mini.string)())),
|
|
31
|
+
/** `segments.date`, a day in the account's time zone. */
|
|
32
|
+
date: (0, zod_mini.string)(),
|
|
33
|
+
/** `segments.hour`, 0–23, of that day. */
|
|
34
|
+
hour: (0, zod_mini.number)(),
|
|
35
|
+
cost_micros: metric,
|
|
36
|
+
impressions: metric,
|
|
37
|
+
clicks: metric,
|
|
38
|
+
conversions: metric,
|
|
39
|
+
conversion_value: metric
|
|
40
|
+
});
|
|
41
|
+
const reportSchema = (0, zod_mini.object)({
|
|
42
|
+
/** The customer id, digits only or as `123-456-7890`. */
|
|
43
|
+
account_id: (0, zod_mini.string)(),
|
|
44
|
+
currency: (0, zod_mini.string)(),
|
|
45
|
+
/** `AdsApp.currentAccount().getTimeZone()`, the IANA zone `date` and `hour` are in. */
|
|
46
|
+
time_zone: (0, zod_mini.string)(),
|
|
47
|
+
rows: (0, zod_mini.array)(reportRowSchema)
|
|
48
|
+
});
|
|
49
|
+
/** A body that is not a `GoogleAdsScriptReport`; the host answers it with a 400. */
|
|
50
|
+
var GoogleAdsReportError = class extends Error {
|
|
51
|
+
constructor(message) {
|
|
52
|
+
super(message);
|
|
53
|
+
this.name = "GoogleAdsReportError";
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
57
|
+
/** A metric as a number, absent as `fallback`; anything not numeric is an invalid report. */
|
|
58
|
+
function numeric(value, fallback, path) {
|
|
59
|
+
if (value === void 0) return fallback;
|
|
60
|
+
const n = Number(value);
|
|
61
|
+
if (!Number.isFinite(n)) throw new GoogleAdsReportError(`${path}: not a number: ${value}`);
|
|
62
|
+
return n;
|
|
63
|
+
}
|
|
64
|
+
function timeZoneOf(timeZone) {
|
|
65
|
+
try {
|
|
66
|
+
new Intl.DateTimeFormat("en-US", { timeZone });
|
|
67
|
+
} catch {
|
|
68
|
+
throw new GoogleAdsReportError(`Unknown time zone: ${timeZone}`);
|
|
69
|
+
}
|
|
70
|
+
return timeZone;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* The rows of a report a Google Ads script posted, validated, one per ad group (or Performance Max
|
|
74
|
+
* campaign) and hour. The hours are the account's, turned into UTC instants. Spend comes from
|
|
75
|
+
* `cost_micros`. Google's `conversions` are its primary conversion actions under their own
|
|
76
|
+
* attribution model, click-based (view-through conversions are counted apart, without value),
|
|
77
|
+
* so they fill both the total and the click-through columns.
|
|
78
|
+
*
|
|
79
|
+
* Google restates the past — a conversion is credited to the hour of its click, up to the
|
|
80
|
+
* conversion window (30 days by default) later — so the script re-reads a trailing window and the
|
|
81
|
+
* host upserts on (`platform`, `account_id`, `ad_id`, `hour_start`).
|
|
82
|
+
*
|
|
83
|
+
* @throws GoogleAdsReportError when the body is not a report.
|
|
84
|
+
*/
|
|
85
|
+
function parseGoogleAdsScriptReport(body) {
|
|
86
|
+
const parsed = (0, zod_mini.safeParse)(reportSchema, body);
|
|
87
|
+
if (!parsed.success) {
|
|
88
|
+
const issues = parsed.error.issues;
|
|
89
|
+
const first = issues.slice(0, 3).map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ");
|
|
90
|
+
throw new GoogleAdsReportError(`Invalid Google Ads report (${issues.length} issues): ${first}${issues.length > 3 ? "; …" : ""}`);
|
|
91
|
+
}
|
|
92
|
+
const report = parsed.data;
|
|
93
|
+
const accountId = report.account_id.replaceAll("-", "");
|
|
94
|
+
const timeZone = timeZoneOf(report.time_zone);
|
|
95
|
+
return report.rows.map((row, index) => {
|
|
96
|
+
const path = `rows.${index}`;
|
|
97
|
+
if (!DATE.test(row.date) || !Number.isInteger(row.hour) || row.hour < 0 || row.hour > 23) throw new GoogleAdsReportError(`${path}: unexpected hour ${row.date} ${row.hour}`);
|
|
98
|
+
const conversions = numeric(row.conversions, null, `${path}.conversions`);
|
|
99
|
+
const value = numeric(row.conversion_value, null, `${path}.conversion_value`);
|
|
100
|
+
return {
|
|
101
|
+
platform: "google",
|
|
102
|
+
account_id: accountId,
|
|
103
|
+
campaign_id: row.campaign_id,
|
|
104
|
+
campaign_name: row.campaign_name ?? null,
|
|
105
|
+
ad_group_id: row.ad_group_id ?? null,
|
|
106
|
+
ad_group_name: row.ad_group_name ?? null,
|
|
107
|
+
ad_id: row.ad_group_id ?? row.campaign_id,
|
|
108
|
+
ad_name: null,
|
|
109
|
+
channel: CHANNEL,
|
|
110
|
+
medium: MEDIUM,
|
|
111
|
+
channel_group: require_attribution_classify.channelGroupOf(CHANNEL, MEDIUM),
|
|
112
|
+
hour_start: require_ads_time.zonedHourToUtc(row.date, row.hour, timeZone).toISOString(),
|
|
113
|
+
currency: report.currency,
|
|
114
|
+
spend: numeric(row.cost_micros, 0, `${path}.cost_micros`) / 1e6,
|
|
115
|
+
impressions: numeric(row.impressions, 0, `${path}.impressions`),
|
|
116
|
+
clicks: numeric(row.clicks, 0, `${path}.clicks`),
|
|
117
|
+
conversions,
|
|
118
|
+
conversion_value: value,
|
|
119
|
+
click_conversions: conversions,
|
|
120
|
+
click_conversion_value: value
|
|
121
|
+
};
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
//#endregion
|
|
125
|
+
exports.GoogleAdsReportError = GoogleAdsReportError;
|
|
126
|
+
exports.parseGoogleAdsScriptReport = parseGoogleAdsScriptReport;
|
|
127
|
+
|
|
128
|
+
//# sourceMappingURL=google.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"google.cjs","names":["optional","union","string","number","object","nullable","array","safeParse","channelGroupOf","zonedHourToUtc"],"sources":["../../src/ads/google.ts"],"sourcesContent":["/**\n * Google Ads delivery pushed by a Google Ads script: the script runs inside the ad account on\n * Google's schedule, reads the hourly report with `AdsApp.search` and posts it to the host, which\n * turns it into `AdPerformanceRow`s here. No developer token, manager account or OAuth client is\n * needed — the script runs as the account's own user. The script is in the README.\n *\n * Google reports hours down to the ad group, not the ad (`segments.hour` is incompatible with\n * `ad_group_ad`), and Performance Max campaigns have no ad groups at all; so a row is an ad group's\n * hour, or a Performance Max campaign's, and `ad_id` holds that ad group or campaign id.\n *\n * https://developers.google.com/google-ads/scripts/docs/features/reports\n * https://developers.google.com/google-ads/api/fields/v21/ad_group\n */\nimport {\n type infer as Infer,\n array,\n nullable,\n number,\n object,\n optional,\n safeParse,\n string,\n union,\n} from 'zod/mini';\nimport { channelGroupOf } from '../attribution';\nimport { zonedHourToUtc } from './time';\nimport type { AdPerformanceRow } from './types';\n\n/** What a Google ad click is in the attribution vocabulary: a `gclid` is `google / cpc`. */\nconst CHANNEL = 'google';\nconst MEDIUM = 'cpc';\n\n/** `AdsApp.search` returns int64 metrics as strings and doubles as numbers; either is taken. */\nconst metric = optional(union([string(), number()]));\n\nconst reportRowSchema = object({\n campaign_id: string(),\n campaign_name: optional(nullable(string())),\n /** Null for a Performance Max campaign's row. */\n ad_group_id: optional(nullable(string())),\n ad_group_name: optional(nullable(string())),\n /** `segments.date`, a day in the account's time zone. */\n date: string(),\n /** `segments.hour`, 0–23, of that day. */\n hour: number(),\n cost_micros: metric,\n impressions: metric,\n clicks: metric,\n conversions: metric,\n conversion_value: metric,\n});\n\nconst reportSchema = object({\n /** The customer id, digits only or as `123-456-7890`. */\n account_id: string(),\n currency: string(),\n /** `AdsApp.currentAccount().getTimeZone()`, the IANA zone `date` and `hour` are in. */\n time_zone: string(),\n rows: array(reportRowSchema),\n});\n\n/** The body the Google Ads script posts: the account and its report rows, as read. */\nexport type GoogleAdsScriptReport = Infer<typeof reportSchema>;\n\n/** A body that is not a `GoogleAdsScriptReport`; the host answers it with a 400. */\nexport class GoogleAdsReportError extends Error {\n constructor(message: string) {\n super(message);\n this.name = 'GoogleAdsReportError';\n }\n}\n\nconst DATE = /^\\d{4}-\\d{2}-\\d{2}$/;\n\n/** A metric as a number, absent as `fallback`; anything not numeric is an invalid report. */\nfunction numeric<T extends number | null>(\n value: string | number | undefined,\n fallback: T,\n path: string\n): number | T {\n if (value === undefined) return fallback;\n const n = Number(value);\n if (!Number.isFinite(n)) throw new GoogleAdsReportError(`${path}: not a number: ${value}`);\n return n;\n}\n\nfunction timeZoneOf(timeZone: string): string {\n try {\n new Intl.DateTimeFormat('en-US', { timeZone });\n } catch {\n throw new GoogleAdsReportError(`Unknown time zone: ${timeZone}`);\n }\n return timeZone;\n}\n\n/**\n * The rows of a report a Google Ads script posted, validated, one per ad group (or Performance Max\n * campaign) and hour. The hours are the account's, turned into UTC instants. Spend comes from\n * `cost_micros`. Google's `conversions` are its primary conversion actions under their own\n * attribution model, click-based (view-through conversions are counted apart, without value),\n * so they fill both the total and the click-through columns.\n *\n * Google restates the past — a conversion is credited to the hour of its click, up to the\n * conversion window (30 days by default) later — so the script re-reads a trailing window and the\n * host upserts on (`platform`, `account_id`, `ad_id`, `hour_start`).\n *\n * @throws GoogleAdsReportError when the body is not a report.\n */\nexport function parseGoogleAdsScriptReport(body: unknown): AdPerformanceRow[] {\n const parsed = safeParse(reportSchema, body);\n if (!parsed.success) {\n const issues = parsed.error.issues;\n const first = issues\n .slice(0, 3)\n .map((issue) => `${issue.path.join('.')}: ${issue.message}`)\n .join('; ');\n throw new GoogleAdsReportError(\n `Invalid Google Ads report (${issues.length} issues): ${first}${issues.length > 3 ? '; …' : ''}`\n );\n }\n\n const report = parsed.data;\n const accountId = report.account_id.replaceAll('-', '');\n const timeZone = timeZoneOf(report.time_zone);\n return report.rows.map((row, index) => {\n const path = `rows.${index}`;\n if (!DATE.test(row.date) || !Number.isInteger(row.hour) || row.hour < 0 || row.hour > 23) {\n throw new GoogleAdsReportError(`${path}: unexpected hour ${row.date} ${row.hour}`);\n }\n const conversions = numeric(row.conversions, null, `${path}.conversions`);\n const value = numeric(row.conversion_value, null, `${path}.conversion_value`);\n return {\n platform: 'google',\n account_id: accountId,\n campaign_id: row.campaign_id,\n campaign_name: row.campaign_name ?? null,\n ad_group_id: row.ad_group_id ?? null,\n ad_group_name: row.ad_group_name ?? null,\n ad_id: row.ad_group_id ?? row.campaign_id,\n ad_name: null,\n channel: CHANNEL,\n medium: MEDIUM,\n channel_group: channelGroupOf(CHANNEL, MEDIUM),\n hour_start: zonedHourToUtc(row.date, row.hour, timeZone).toISOString(),\n currency: report.currency,\n spend: numeric(row.cost_micros, 0, `${path}.cost_micros`) / 1e6,\n impressions: numeric(row.impressions, 0, `${path}.impressions`),\n clicks: numeric(row.clicks, 0, `${path}.clicks`),\n conversions,\n conversion_value: value,\n click_conversions: conversions,\n click_conversion_value: value,\n };\n });\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { AdPerformanceRow } from "./types.cjs";
|
|
2
|
+
import { infer as __Infer } from "zod/mini";
|
|
3
|
+
//#region src/ads/google.d.ts
|
|
4
|
+
declare const reportSchema: import("zod/mini").ZodMiniObject<{
|
|
5
|
+
account_id: import("zod/mini").ZodMiniString<string>;
|
|
6
|
+
currency: import("zod/mini").ZodMiniString<string>;
|
|
7
|
+
time_zone: import("zod/mini").ZodMiniString<string>;
|
|
8
|
+
rows: import("zod/mini").ZodMiniArray<import("zod/mini").ZodMiniObject<{
|
|
9
|
+
campaign_id: import("zod/mini").ZodMiniString<string>;
|
|
10
|
+
campaign_name: import("zod/mini").ZodMiniOptional<import("zod/mini").ZodMiniNullable<import("zod/mini").ZodMiniString<string>>>;
|
|
11
|
+
ad_group_id: import("zod/mini").ZodMiniOptional<import("zod/mini").ZodMiniNullable<import("zod/mini").ZodMiniString<string>>>;
|
|
12
|
+
ad_group_name: import("zod/mini").ZodMiniOptional<import("zod/mini").ZodMiniNullable<import("zod/mini").ZodMiniString<string>>>;
|
|
13
|
+
date: import("zod/mini").ZodMiniString<string>;
|
|
14
|
+
hour: import("zod/mini").ZodMiniNumber<number>;
|
|
15
|
+
cost_micros: import("zod/mini").ZodMiniOptional<import("zod/mini").ZodMiniUnion<readonly [import("zod/mini").ZodMiniString<string>, import("zod/mini").ZodMiniNumber<number>]>>;
|
|
16
|
+
impressions: import("zod/mini").ZodMiniOptional<import("zod/mini").ZodMiniUnion<readonly [import("zod/mini").ZodMiniString<string>, import("zod/mini").ZodMiniNumber<number>]>>;
|
|
17
|
+
clicks: import("zod/mini").ZodMiniOptional<import("zod/mini").ZodMiniUnion<readonly [import("zod/mini").ZodMiniString<string>, import("zod/mini").ZodMiniNumber<number>]>>;
|
|
18
|
+
conversions: import("zod/mini").ZodMiniOptional<import("zod/mini").ZodMiniUnion<readonly [import("zod/mini").ZodMiniString<string>, import("zod/mini").ZodMiniNumber<number>]>>;
|
|
19
|
+
conversion_value: import("zod/mini").ZodMiniOptional<import("zod/mini").ZodMiniUnion<readonly [import("zod/mini").ZodMiniString<string>, import("zod/mini").ZodMiniNumber<number>]>>;
|
|
20
|
+
}, import("zod/v4/core").$strip>>;
|
|
21
|
+
}, import("zod/v4/core").$strip>;
|
|
22
|
+
/** The body the Google Ads script posts: the account and its report rows, as read. */
|
|
23
|
+
export type GoogleAdsScriptReport = __Infer<typeof reportSchema>;
|
|
24
|
+
/** A body that is not a `GoogleAdsScriptReport`; the host answers it with a 400. */
|
|
25
|
+
export declare class GoogleAdsReportError extends Error {
|
|
26
|
+
constructor(message: string);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The rows of a report a Google Ads script posted, validated, one per ad group (or Performance Max
|
|
30
|
+
* campaign) and hour. The hours are the account's, turned into UTC instants. Spend comes from
|
|
31
|
+
* `cost_micros`. Google's `conversions` are its primary conversion actions under their own
|
|
32
|
+
* attribution model, click-based (view-through conversions are counted apart, without value),
|
|
33
|
+
* so they fill both the total and the click-through columns.
|
|
34
|
+
*
|
|
35
|
+
* Google restates the past — a conversion is credited to the hour of its click, up to the
|
|
36
|
+
* conversion window (30 days by default) later — so the script re-reads a trailing window and the
|
|
37
|
+
* host upserts on (`platform`, `account_id`, `ad_id`, `hour_start`).
|
|
38
|
+
*
|
|
39
|
+
* @throws GoogleAdsReportError when the body is not a report.
|
|
40
|
+
*/
|
|
41
|
+
export declare function parseGoogleAdsScriptReport(body: unknown): AdPerformanceRow[];
|
|
42
|
+
//#endregion
|
|
43
|
+
//# sourceMappingURL=google.d.cts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"google.d.cts","names":["Infer"],"sources":["../../src/ads/google.ts"],"mappings":";;;cAoDM,iCAAY;;;;;;;;;;;;;;;;;yBAOhB;;YAGU,wBAAwBA,eAAa;;qBAGpC,6BAA6B;EACxC,YAAY;;;;;;;;;;;;;;;wBA0CE,2BAA2B,gBAAgB"}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { AdPerformanceRow } from "./types.mjs";
|
|
2
|
+
import { infer as __Infer } from "zod/mini";
|
|
3
|
+
//#region src/ads/google.d.ts
|
|
4
|
+
declare const reportSchema: import("zod/mini").ZodMiniObject<{
|
|
5
|
+
account_id: import("zod/mini").ZodMiniString<string>;
|
|
6
|
+
currency: import("zod/mini").ZodMiniString<string>;
|
|
7
|
+
time_zone: import("zod/mini").ZodMiniString<string>;
|
|
8
|
+
rows: import("zod/mini").ZodMiniArray<import("zod/mini").ZodMiniObject<{
|
|
9
|
+
campaign_id: import("zod/mini").ZodMiniString<string>;
|
|
10
|
+
campaign_name: import("zod/mini").ZodMiniOptional<import("zod/mini").ZodMiniNullable<import("zod/mini").ZodMiniString<string>>>;
|
|
11
|
+
ad_group_id: import("zod/mini").ZodMiniOptional<import("zod/mini").ZodMiniNullable<import("zod/mini").ZodMiniString<string>>>;
|
|
12
|
+
ad_group_name: import("zod/mini").ZodMiniOptional<import("zod/mini").ZodMiniNullable<import("zod/mini").ZodMiniString<string>>>;
|
|
13
|
+
date: import("zod/mini").ZodMiniString<string>;
|
|
14
|
+
hour: import("zod/mini").ZodMiniNumber<number>;
|
|
15
|
+
cost_micros: import("zod/mini").ZodMiniOptional<import("zod/mini").ZodMiniUnion<readonly [import("zod/mini").ZodMiniString<string>, import("zod/mini").ZodMiniNumber<number>]>>;
|
|
16
|
+
impressions: import("zod/mini").ZodMiniOptional<import("zod/mini").ZodMiniUnion<readonly [import("zod/mini").ZodMiniString<string>, import("zod/mini").ZodMiniNumber<number>]>>;
|
|
17
|
+
clicks: import("zod/mini").ZodMiniOptional<import("zod/mini").ZodMiniUnion<readonly [import("zod/mini").ZodMiniString<string>, import("zod/mini").ZodMiniNumber<number>]>>;
|
|
18
|
+
conversions: import("zod/mini").ZodMiniOptional<import("zod/mini").ZodMiniUnion<readonly [import("zod/mini").ZodMiniString<string>, import("zod/mini").ZodMiniNumber<number>]>>;
|
|
19
|
+
conversion_value: import("zod/mini").ZodMiniOptional<import("zod/mini").ZodMiniUnion<readonly [import("zod/mini").ZodMiniString<string>, import("zod/mini").ZodMiniNumber<number>]>>;
|
|
20
|
+
}, import("zod/v4/core").$strip>>;
|
|
21
|
+
}, import("zod/v4/core").$strip>;
|
|
22
|
+
/** The body the Google Ads script posts: the account and its report rows, as read. */
|
|
23
|
+
export type GoogleAdsScriptReport = __Infer<typeof reportSchema>;
|
|
24
|
+
/** A body that is not a `GoogleAdsScriptReport`; the host answers it with a 400. */
|
|
25
|
+
export declare class GoogleAdsReportError extends Error {
|
|
26
|
+
constructor(message: string);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The rows of a report a Google Ads script posted, validated, one per ad group (or Performance Max
|
|
30
|
+
* campaign) and hour. The hours are the account's, turned into UTC instants. Spend comes from
|
|
31
|
+
* `cost_micros`. Google's `conversions` are its primary conversion actions under their own
|
|
32
|
+
* attribution model, click-based (view-through conversions are counted apart, without value),
|
|
33
|
+
* so they fill both the total and the click-through columns.
|
|
34
|
+
*
|
|
35
|
+
* Google restates the past — a conversion is credited to the hour of its click, up to the
|
|
36
|
+
* conversion window (30 days by default) later — so the script re-reads a trailing window and the
|
|
37
|
+
* host upserts on (`platform`, `account_id`, `ad_id`, `hour_start`).
|
|
38
|
+
*
|
|
39
|
+
* @throws GoogleAdsReportError when the body is not a report.
|
|
40
|
+
*/
|
|
41
|
+
export declare function parseGoogleAdsScriptReport(body: unknown): AdPerformanceRow[];
|
|
42
|
+
//#endregion
|
|
43
|
+
//# sourceMappingURL=google.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"google.d.mts","names":["Infer"],"sources":["../../src/ads/google.ts"],"mappings":";;;cAoDM,iCAAY;;;;;;;;;;;;;;;;;yBAOhB;;YAGU,wBAAwBA,eAAa;;qBAGpC,6BAA6B;EACxC,YAAY;;;;;;;;;;;;;;;wBA0CE,2BAA2B,gBAAgB"}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { channelGroupOf } from "../attribution/classify.mjs";
|
|
2
|
+
import "../attribution/index.mjs";
|
|
3
|
+
import { zonedHourToUtc } from "./time.mjs";
|
|
4
|
+
import { array, nullable, number, object, optional, safeParse, string, union } from "zod/mini";
|
|
5
|
+
//#region src/ads/google.ts
|
|
6
|
+
/**
|
|
7
|
+
* Google Ads delivery pushed by a Google Ads script: the script runs inside the ad account on
|
|
8
|
+
* Google's schedule, reads the hourly report with `AdsApp.search` and posts it to the host, which
|
|
9
|
+
* turns it into `AdPerformanceRow`s here. No developer token, manager account or OAuth client is
|
|
10
|
+
* needed — the script runs as the account's own user. The script is in the README.
|
|
11
|
+
*
|
|
12
|
+
* Google reports hours down to the ad group, not the ad (`segments.hour` is incompatible with
|
|
13
|
+
* `ad_group_ad`), and Performance Max campaigns have no ad groups at all; so a row is an ad group's
|
|
14
|
+
* hour, or a Performance Max campaign's, and `ad_id` holds that ad group or campaign id.
|
|
15
|
+
*
|
|
16
|
+
* https://developers.google.com/google-ads/scripts/docs/features/reports
|
|
17
|
+
* https://developers.google.com/google-ads/api/fields/v21/ad_group
|
|
18
|
+
*/
|
|
19
|
+
/** What a Google ad click is in the attribution vocabulary: a `gclid` is `google / cpc`. */
|
|
20
|
+
const CHANNEL = "google";
|
|
21
|
+
const MEDIUM = "cpc";
|
|
22
|
+
/** `AdsApp.search` returns int64 metrics as strings and doubles as numbers; either is taken. */
|
|
23
|
+
const metric = optional(union([string(), number()]));
|
|
24
|
+
const reportRowSchema = object({
|
|
25
|
+
campaign_id: string(),
|
|
26
|
+
campaign_name: optional(nullable(string())),
|
|
27
|
+
/** Null for a Performance Max campaign's row. */
|
|
28
|
+
ad_group_id: optional(nullable(string())),
|
|
29
|
+
ad_group_name: optional(nullable(string())),
|
|
30
|
+
/** `segments.date`, a day in the account's time zone. */
|
|
31
|
+
date: string(),
|
|
32
|
+
/** `segments.hour`, 0–23, of that day. */
|
|
33
|
+
hour: number(),
|
|
34
|
+
cost_micros: metric,
|
|
35
|
+
impressions: metric,
|
|
36
|
+
clicks: metric,
|
|
37
|
+
conversions: metric,
|
|
38
|
+
conversion_value: metric
|
|
39
|
+
});
|
|
40
|
+
const reportSchema = object({
|
|
41
|
+
/** The customer id, digits only or as `123-456-7890`. */
|
|
42
|
+
account_id: string(),
|
|
43
|
+
currency: string(),
|
|
44
|
+
/** `AdsApp.currentAccount().getTimeZone()`, the IANA zone `date` and `hour` are in. */
|
|
45
|
+
time_zone: string(),
|
|
46
|
+
rows: array(reportRowSchema)
|
|
47
|
+
});
|
|
48
|
+
/** A body that is not a `GoogleAdsScriptReport`; the host answers it with a 400. */
|
|
49
|
+
var GoogleAdsReportError = class extends Error {
|
|
50
|
+
constructor(message) {
|
|
51
|
+
super(message);
|
|
52
|
+
this.name = "GoogleAdsReportError";
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
56
|
+
/** A metric as a number, absent as `fallback`; anything not numeric is an invalid report. */
|
|
57
|
+
function numeric(value, fallback, path) {
|
|
58
|
+
if (value === void 0) return fallback;
|
|
59
|
+
const n = Number(value);
|
|
60
|
+
if (!Number.isFinite(n)) throw new GoogleAdsReportError(`${path}: not a number: ${value}`);
|
|
61
|
+
return n;
|
|
62
|
+
}
|
|
63
|
+
function timeZoneOf(timeZone) {
|
|
64
|
+
try {
|
|
65
|
+
new Intl.DateTimeFormat("en-US", { timeZone });
|
|
66
|
+
} catch {
|
|
67
|
+
throw new GoogleAdsReportError(`Unknown time zone: ${timeZone}`);
|
|
68
|
+
}
|
|
69
|
+
return timeZone;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The rows of a report a Google Ads script posted, validated, one per ad group (or Performance Max
|
|
73
|
+
* campaign) and hour. The hours are the account's, turned into UTC instants. Spend comes from
|
|
74
|
+
* `cost_micros`. Google's `conversions` are its primary conversion actions under their own
|
|
75
|
+
* attribution model, click-based (view-through conversions are counted apart, without value),
|
|
76
|
+
* so they fill both the total and the click-through columns.
|
|
77
|
+
*
|
|
78
|
+
* Google restates the past — a conversion is credited to the hour of its click, up to the
|
|
79
|
+
* conversion window (30 days by default) later — so the script re-reads a trailing window and the
|
|
80
|
+
* host upserts on (`platform`, `account_id`, `ad_id`, `hour_start`).
|
|
81
|
+
*
|
|
82
|
+
* @throws GoogleAdsReportError when the body is not a report.
|
|
83
|
+
*/
|
|
84
|
+
function parseGoogleAdsScriptReport(body) {
|
|
85
|
+
const parsed = safeParse(reportSchema, body);
|
|
86
|
+
if (!parsed.success) {
|
|
87
|
+
const issues = parsed.error.issues;
|
|
88
|
+
const first = issues.slice(0, 3).map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ");
|
|
89
|
+
throw new GoogleAdsReportError(`Invalid Google Ads report (${issues.length} issues): ${first}${issues.length > 3 ? "; …" : ""}`);
|
|
90
|
+
}
|
|
91
|
+
const report = parsed.data;
|
|
92
|
+
const accountId = report.account_id.replaceAll("-", "");
|
|
93
|
+
const timeZone = timeZoneOf(report.time_zone);
|
|
94
|
+
return report.rows.map((row, index) => {
|
|
95
|
+
const path = `rows.${index}`;
|
|
96
|
+
if (!DATE.test(row.date) || !Number.isInteger(row.hour) || row.hour < 0 || row.hour > 23) throw new GoogleAdsReportError(`${path}: unexpected hour ${row.date} ${row.hour}`);
|
|
97
|
+
const conversions = numeric(row.conversions, null, `${path}.conversions`);
|
|
98
|
+
const value = numeric(row.conversion_value, null, `${path}.conversion_value`);
|
|
99
|
+
return {
|
|
100
|
+
platform: "google",
|
|
101
|
+
account_id: accountId,
|
|
102
|
+
campaign_id: row.campaign_id,
|
|
103
|
+
campaign_name: row.campaign_name ?? null,
|
|
104
|
+
ad_group_id: row.ad_group_id ?? null,
|
|
105
|
+
ad_group_name: row.ad_group_name ?? null,
|
|
106
|
+
ad_id: row.ad_group_id ?? row.campaign_id,
|
|
107
|
+
ad_name: null,
|
|
108
|
+
channel: CHANNEL,
|
|
109
|
+
medium: MEDIUM,
|
|
110
|
+
channel_group: channelGroupOf(CHANNEL, MEDIUM),
|
|
111
|
+
hour_start: zonedHourToUtc(row.date, row.hour, timeZone).toISOString(),
|
|
112
|
+
currency: report.currency,
|
|
113
|
+
spend: numeric(row.cost_micros, 0, `${path}.cost_micros`) / 1e6,
|
|
114
|
+
impressions: numeric(row.impressions, 0, `${path}.impressions`),
|
|
115
|
+
clicks: numeric(row.clicks, 0, `${path}.clicks`),
|
|
116
|
+
conversions,
|
|
117
|
+
conversion_value: value,
|
|
118
|
+
click_conversions: conversions,
|
|
119
|
+
click_conversion_value: value
|
|
120
|
+
};
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
//#endregion
|
|
124
|
+
export { GoogleAdsReportError, parseGoogleAdsScriptReport };
|
|
125
|
+
|
|
126
|
+
//# sourceMappingURL=google.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"google.mjs","names":[],"sources":["../../src/ads/google.ts"],"sourcesContent":["/**\n * Google Ads delivery pushed by a Google Ads script: the script runs inside the ad account on\n * Google's schedule, reads the hourly report with `AdsApp.search` and posts it to the host, which\n * turns it into `AdPerformanceRow`s here. No developer token, manager account or OAuth client is\n * needed — the script runs as the account's own user. The script is in the README.\n *\n * Google reports hours down to the ad group, not the ad (`segments.hour` is incompatible with\n * `ad_group_ad`), and Performance Max campaigns have no ad groups at all; so a row is an ad group's\n * hour, or a Performance Max campaign's, and `ad_id` holds that ad group or campaign id.\n *\n * https://developers.google.com/google-ads/scripts/docs/features/reports\n * https://developers.google.com/google-ads/api/fields/v21/ad_group\n */\nimport {\n type infer as Infer,\n array,\n nullable,\n number,\n object,\n optional,\n safeParse,\n string,\n union,\n} from 'zod/mini';\nimport { channelGroupOf } from '../attribution';\nimport { zonedHourToUtc } from './time';\nimport type { AdPerformanceRow } from './types';\n\n/** What a Google ad click is in the attribution vocabulary: a `gclid` is `google / cpc`. */\nconst CHANNEL = 'google';\nconst MEDIUM = 'cpc';\n\n/** `AdsApp.search` returns int64 metrics as strings and doubles as numbers; either is taken. */\nconst metric = optional(union([string(), number()]));\n\nconst reportRowSchema = object({\n campaign_id: string(),\n campaign_name: optional(nullable(string())),\n /** Null for a Performance Max campaign's row. */\n ad_group_id: optional(nullable(string())),\n ad_group_name: optional(nullable(string())),\n /** `segments.date`, a day in the account's time zone. */\n date: string(),\n /** `segments.hour`, 0–23, of that day. */\n hour: number(),\n cost_micros: metric,\n impressions: metric,\n clicks: metric,\n conversions: metric,\n conversion_value: metric,\n});\n\nconst reportSchema = object({\n /** The customer id, digits only or as `123-456-7890`. */\n account_id: string(),\n currency: string(),\n /** `AdsApp.currentAccount().getTimeZone()`, the IANA zone `date` and `hour` are in. */\n time_zone: string(),\n rows: array(reportRowSchema),\n});\n\n/** The body the Google Ads script posts: the account and its report rows, as read. */\nexport type GoogleAdsScriptReport = Infer<typeof reportSchema>;\n\n/** A body that is not a `GoogleAdsScriptReport`; the host answers it with a 400. */\nexport class GoogleAdsReportError extends Error {\n constructor(message: string) {\n super(message);\n this.name = 'GoogleAdsReportError';\n }\n}\n\nconst DATE = /^\\d{4}-\\d{2}-\\d{2}$/;\n\n/** A metric as a number, absent as `fallback`; anything not numeric is an invalid report. */\nfunction numeric<T extends number | null>(\n value: string | number | undefined,\n fallback: T,\n path: string\n): number | T {\n if (value === undefined) return fallback;\n const n = Number(value);\n if (!Number.isFinite(n)) throw new GoogleAdsReportError(`${path}: not a number: ${value}`);\n return n;\n}\n\nfunction timeZoneOf(timeZone: string): string {\n try {\n new Intl.DateTimeFormat('en-US', { timeZone });\n } catch {\n throw new GoogleAdsReportError(`Unknown time zone: ${timeZone}`);\n }\n return timeZone;\n}\n\n/**\n * The rows of a report a Google Ads script posted, validated, one per ad group (or Performance Max\n * campaign) and hour. The hours are the account's, turned into UTC instants. Spend comes from\n * `cost_micros`. Google's `conversions` are its primary conversion actions under their own\n * attribution model, click-based (view-through conversions are counted apart, without value),\n * so they fill both the total and the click-through columns.\n *\n * Google restates the past — a conversion is credited to the hour of its click, up to the\n * conversion window (30 days by default) later — so the script re-reads a trailing window and the\n * host upserts on (`platform`, `account_id`, `ad_id`, `hour_start`).\n *\n * @throws GoogleAdsReportError when the body is not a report.\n */\nexport function parseGoogleAdsScriptReport(body: unknown): AdPerformanceRow[] {\n const parsed = safeParse(reportSchema, body);\n if (!parsed.success) {\n const issues = parsed.error.issues;\n const first = issues\n .slice(0, 3)\n .map((issue) => `${issue.path.join('.')}: ${issue.message}`)\n .join('; ');\n throw new GoogleAdsReportError(\n `Invalid Google Ads report (${issues.length} issues): ${first}${issues.length > 3 ? '; …' : ''}`\n );\n }\n\n const report = parsed.data;\n const accountId = report.account_id.replaceAll('-', '');\n const timeZone = timeZoneOf(report.time_zone);\n return report.rows.map((row, index) => {\n const path = `rows.${index}`;\n if (!DATE.test(row.date) || !Number.isInteger(row.hour) || row.hour < 0 || row.hour > 23) {\n throw new GoogleAdsReportError(`${path}: unexpected hour ${row.date} ${row.hour}`);\n }\n const conversions = numeric(row.conversions, null, `${path}.conversions`);\n const value = numeric(row.conversion_value, null, `${path}.conversion_value`);\n return {\n platform: 'google',\n account_id: accountId,\n campaign_id: row.campaign_id,\n campaign_name: row.campaign_name ?? null,\n ad_group_id: row.ad_group_id ?? null,\n ad_group_name: row.ad_group_name ?? null,\n ad_id: row.ad_group_id ?? row.campaign_id,\n ad_name: null,\n channel: CHANNEL,\n medium: MEDIUM,\n channel_group: channelGroupOf(CHANNEL, MEDIUM),\n hour_start: zonedHourToUtc(row.date, row.hour, timeZone).toISOString(),\n currency: report.currency,\n spend: numeric(row.cost_micros, 0, `${path}.cost_micros`) / 1e6,\n impressions: numeric(row.impressions, 0, `${path}.impressions`),\n clicks: numeric(row.clicks, 0, `${path}.clicks`),\n conversions,\n conversion_value: value,\n click_conversions: conversions,\n click_conversion_value: value,\n };\n });\n}\n"],"mappings":"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"}
|
package/dist/ads/index.cjs
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
2
|
const require_ads_time = require("./time.cjs");
|
|
3
|
+
const require_ads_google = require("./google.cjs");
|
|
3
4
|
const require_ads_meta = require("./meta.cjs");
|
|
5
|
+
exports.GoogleAdsReportError = require_ads_google.GoogleAdsReportError;
|
|
4
6
|
exports.MetaAdsApiError = require_ads_meta.MetaAdsApiError;
|
|
5
7
|
exports.addDays = require_ads_time.addDays;
|
|
6
8
|
exports.addMonths = require_ads_time.addMonths;
|
|
7
9
|
exports.fetchMetaAdPerformance = require_ads_meta.fetchMetaAdPerformance;
|
|
8
10
|
exports.fetchMetaAdPerformanceHistory = require_ads_meta.fetchMetaAdPerformanceHistory;
|
|
11
|
+
exports.parseGoogleAdsScriptReport = require_ads_google.parseGoogleAdsScriptReport;
|
|
9
12
|
exports.spread = require_ads_meta.spread;
|
|
10
13
|
exports.todayIn = require_ads_time.todayIn;
|
|
11
14
|
exports.zonedHourToUtc = require_ads_time.zonedHourToUtc;
|
package/dist/ads/index.d.cts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { AdPerformanceRow, AdPlatform, ReportDate } from "./types.cjs";
|
|
2
|
+
import { GoogleAdsReportError, GoogleAdsScriptReport, parseGoogleAdsScriptReport } from "./google.cjs";
|
|
2
3
|
import { MetaAdPerformanceOptions, MetaAdsApiError, fetchMetaAdPerformance, fetchMetaAdPerformanceHistory, spread } from "./meta.cjs";
|
|
3
4
|
import { addDays, addMonths, todayIn, zonedHourToUtc } from "./time.cjs";
|
|
4
|
-
export { type AdPerformanceRow, type AdPlatform, MetaAdPerformanceOptions, MetaAdsApiError, type ReportDate, addDays, addMonths, fetchMetaAdPerformance, fetchMetaAdPerformanceHistory, spread, todayIn, zonedHourToUtc };
|
|
5
|
+
export { type AdPerformanceRow, type AdPlatform, GoogleAdsReportError, GoogleAdsScriptReport, MetaAdPerformanceOptions, MetaAdsApiError, type ReportDate, addDays, addMonths, fetchMetaAdPerformance, fetchMetaAdPerformanceHistory, parseGoogleAdsScriptReport, spread, todayIn, zonedHourToUtc };
|
package/dist/ads/index.d.mts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { AdPerformanceRow, AdPlatform, ReportDate } from "./types.mjs";
|
|
2
|
+
import { GoogleAdsReportError, GoogleAdsScriptReport, parseGoogleAdsScriptReport } from "./google.mjs";
|
|
2
3
|
import { MetaAdPerformanceOptions, MetaAdsApiError, fetchMetaAdPerformance, fetchMetaAdPerformanceHistory, spread } from "./meta.mjs";
|
|
3
4
|
import { addDays, addMonths, todayIn, zonedHourToUtc } from "./time.mjs";
|
|
4
|
-
export { type AdPerformanceRow, type AdPlatform, MetaAdPerformanceOptions, MetaAdsApiError, type ReportDate, addDays, addMonths, fetchMetaAdPerformance, fetchMetaAdPerformanceHistory, spread, todayIn, zonedHourToUtc };
|
|
5
|
+
export { type AdPerformanceRow, type AdPlatform, GoogleAdsReportError, GoogleAdsScriptReport, MetaAdPerformanceOptions, MetaAdsApiError, type ReportDate, addDays, addMonths, fetchMetaAdPerformance, fetchMetaAdPerformanceHistory, parseGoogleAdsScriptReport, spread, todayIn, zonedHourToUtc };
|
package/dist/ads/index.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
import { addDays, addMonths, todayIn, zonedHourToUtc } from "./time.mjs";
|
|
2
|
+
import { GoogleAdsReportError, parseGoogleAdsScriptReport } from "./google.mjs";
|
|
2
3
|
import { MetaAdsApiError, fetchMetaAdPerformance, fetchMetaAdPerformanceHistory, spread } from "./meta.mjs";
|
|
3
|
-
export { MetaAdsApiError, addDays, addMonths, fetchMetaAdPerformance, fetchMetaAdPerformanceHistory, spread, todayIn, zonedHourToUtc };
|
|
4
|
+
export { GoogleAdsReportError, MetaAdsApiError, addDays, addMonths, fetchMetaAdPerformance, fetchMetaAdPerformanceHistory, parseGoogleAdsScriptReport, spread, todayIn, zonedHourToUtc };
|
package/dist/ads/types.d.cts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { ChannelGroup } from "../attribution/vocabulary.cjs";
|
|
2
2
|
//#region src/ads/types.d.ts
|
|
3
3
|
/** The ad platforms whose reporting APIs this module reads. */
|
|
4
|
-
export type AdPlatform = 'meta';
|
|
4
|
+
export type AdPlatform = 'meta' | 'google';
|
|
5
5
|
/**
|
|
6
6
|
* One row of ad delivery: one ad over one hour. The row a host stores in its `ad_performance`
|
|
7
7
|
* table, unique on (`platform`, `account_id`, `ad_id`, `hour_start`).
|
|
@@ -18,13 +18,17 @@ export type AdPlatform = 'meta';
|
|
|
18
18
|
export interface AdPerformanceRow {
|
|
19
19
|
/** The reporting API the row came from; `channel` is what it joins attribution on. */
|
|
20
20
|
platform: AdPlatform;
|
|
21
|
-
/** The platform's account id, without a prefix (Meta's `act_
|
|
21
|
+
/** The platform's account id, without a prefix or dashes (Meta's `act_`, Google's `123-`). */
|
|
22
22
|
account_id: string;
|
|
23
23
|
campaign_id: string;
|
|
24
24
|
campaign_name: string | null;
|
|
25
|
-
/** The level between campaign and ad: Meta's ad set. */
|
|
25
|
+
/** The level between campaign and ad: Meta's ad set, Google's ad group. */
|
|
26
26
|
ad_group_id: string | null;
|
|
27
27
|
ad_group_name: string | null;
|
|
28
|
+
/**
|
|
29
|
+
* The finest level the platform reports hours at: the ad on Meta; on Google the ad group, or the
|
|
30
|
+
* campaign for Performance Max, which has none.
|
|
31
|
+
*/
|
|
28
32
|
ad_id: string;
|
|
29
33
|
ad_name: string | null;
|
|
30
34
|
channel: string;
|
package/dist/ads/types.d.cts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.cts","names":[],"sources":["../../src/ads/types.ts"],"mappings":";;;YAGY;;;;;;;;;;;;;;iBAeK;;EAEf,UAAU;;EAEV;EACA;EACA;;EAEA;EACA;EACA;EACA;EACA;EACA
|
|
1
|
+
{"version":3,"file":"types.d.cts","names":[],"sources":["../../src/ads/types.ts"],"mappings":";;;YAGY;;;;;;;;;;;;;;iBAeK;;EAEf,UAAU;;EAEV;EACA;EACA;;EAEA;EACA;;;;;EAKA;EACA;EACA;EACA;EACA,eAAe;;EAEf;;EAEA;EACA;EACA;;;;;EAKA;;;;;EAKA;EACA;;EAEA;EACA;;;YAIU"}
|
package/dist/ads/types.d.mts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { ChannelGroup } from "../attribution/vocabulary.mjs";
|
|
2
2
|
//#region src/ads/types.d.ts
|
|
3
3
|
/** The ad platforms whose reporting APIs this module reads. */
|
|
4
|
-
export type AdPlatform = 'meta';
|
|
4
|
+
export type AdPlatform = 'meta' | 'google';
|
|
5
5
|
/**
|
|
6
6
|
* One row of ad delivery: one ad over one hour. The row a host stores in its `ad_performance`
|
|
7
7
|
* table, unique on (`platform`, `account_id`, `ad_id`, `hour_start`).
|
|
@@ -18,13 +18,17 @@ export type AdPlatform = 'meta';
|
|
|
18
18
|
export interface AdPerformanceRow {
|
|
19
19
|
/** The reporting API the row came from; `channel` is what it joins attribution on. */
|
|
20
20
|
platform: AdPlatform;
|
|
21
|
-
/** The platform's account id, without a prefix (Meta's `act_
|
|
21
|
+
/** The platform's account id, without a prefix or dashes (Meta's `act_`, Google's `123-`). */
|
|
22
22
|
account_id: string;
|
|
23
23
|
campaign_id: string;
|
|
24
24
|
campaign_name: string | null;
|
|
25
|
-
/** The level between campaign and ad: Meta's ad set. */
|
|
25
|
+
/** The level between campaign and ad: Meta's ad set, Google's ad group. */
|
|
26
26
|
ad_group_id: string | null;
|
|
27
27
|
ad_group_name: string | null;
|
|
28
|
+
/**
|
|
29
|
+
* The finest level the platform reports hours at: the ad on Meta; on Google the ad group, or the
|
|
30
|
+
* campaign for Performance Max, which has none.
|
|
31
|
+
*/
|
|
28
32
|
ad_id: string;
|
|
29
33
|
ad_name: string | null;
|
|
30
34
|
channel: string;
|
package/dist/ads/types.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.mts","names":[],"sources":["../../src/ads/types.ts"],"mappings":";;;YAGY;;;;;;;;;;;;;;iBAeK;;EAEf,UAAU;;EAEV;EACA;EACA;;EAEA;EACA;EACA;EACA;EACA;EACA
|
|
1
|
+
{"version":3,"file":"types.d.mts","names":[],"sources":["../../src/ads/types.ts"],"mappings":";;;YAGY;;;;;;;;;;;;;;iBAeK;;EAEf,UAAU;;EAEV;EACA;EACA;;EAEA;EACA;;;;;EAKA;EACA;EACA;EACA;EACA,eAAe;;EAEf;;EAEA;EACA;EACA;;;;;EAKA;;;;;EAKA;EACA;;EAEA;EACA;;;YAIU"}
|
|
@@ -82,15 +82,10 @@ function productTouch(tags, rules) {
|
|
|
82
82
|
return null;
|
|
83
83
|
}
|
|
84
84
|
/**
|
|
85
|
-
*
|
|
86
|
-
* other site kept as its host, and null for
|
|
87
|
-
* referrer (`android-app://<package>`) is read as the site it stands for (`ANDROID_APP_HOSTS`).
|
|
85
|
+
* A referring site's host as a touch: a known search engine or social network folded to its
|
|
86
|
+
* channel, any other site kept as its host, and null for a host that is no touch.
|
|
88
87
|
*/
|
|
89
|
-
function
|
|
90
|
-
const url = text(tags, "page_referrer");
|
|
91
|
-
const app = url ? androidAppOf.exec(url)?.[1]?.toLowerCase() : void 0;
|
|
92
|
-
const host = app ? require_attribution_vocabulary.ANDROID_APP_HOSTS[app] ?? app : url ? referrerHostOf.exec(url)?.[1]?.toLowerCase() : void 0;
|
|
93
|
-
if (!host) return null;
|
|
88
|
+
function siteTouch(host, ownHosts) {
|
|
94
89
|
if (require_attribution_vocabulary.REFERRERS_NOT_A_TOUCH.some((pattern) => pattern.test(host))) return null;
|
|
95
90
|
if (ownHosts.some((pattern) => pattern.test(host))) return null;
|
|
96
91
|
const site = require_attribution_vocabulary.REFERRER_SITES.find(([, , pattern]) => pattern.test(host));
|
|
@@ -103,6 +98,47 @@ function referrer(tags, ownHosts) {
|
|
|
103
98
|
};
|
|
104
99
|
}
|
|
105
100
|
/**
|
|
101
|
+
* 5. The referrer's host, read as `siteTouch`; null for no referrer. An Android app's referrer
|
|
102
|
+
* (`android-app://<package>`) is read as the site it stands for (`ANDROID_APP_HOSTS`).
|
|
103
|
+
*/
|
|
104
|
+
function referrer(tags, ownHosts) {
|
|
105
|
+
const url = text(tags, "page_referrer");
|
|
106
|
+
const app = url ? androidAppOf.exec(url)?.[1]?.toLowerCase() : void 0;
|
|
107
|
+
const host = app ? require_attribution_vocabulary.ANDROID_APP_HOSTS[app] ?? app : url ? referrerHostOf.exec(url)?.[1]?.toLowerCase() : void 0;
|
|
108
|
+
return host ? siteTouch(host, ownHosts) : null;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* 6. The `ref` parameter of the landing page, when the session has no referrer at all: the
|
|
112
|
+
* convention of sites that link out — Product Hunt's `?ref=producthunt`, newsletters,
|
|
113
|
+
* directories, "built with" badges. It names the site when the browser does not: a click from a
|
|
114
|
+
* mail client, an in-app browser or a page under `no-referrer`. With a referrer it is not read —
|
|
115
|
+
* the referrer is where the click happened, and the `ref` only where the link was first written:
|
|
116
|
+
* a search engine indexes the URL with its `?ref=`, another directory copies the link, and the
|
|
117
|
+
* `ref` would credit the first site with the visit the second one sent; an internal link's
|
|
118
|
+
* `?ref=header` would credit the page's own nav. A site's name (`producthunt`) is kept as the
|
|
119
|
+
* channel, with the `utm_source` aliases folded; a host or URL (`producthunt.com`,
|
|
120
|
+
* `https://news.ycombinator.com/`) is read as a referrer's host, so the same site lands on the
|
|
121
|
+
* same channel either way. Ranks with the referrer: nobody paid for that link.
|
|
122
|
+
*/
|
|
123
|
+
function refParam(tags, ownHosts) {
|
|
124
|
+
const location = text(tags, "page_location");
|
|
125
|
+
if (!location || text(tags, "page_referrer")) return null;
|
|
126
|
+
let value;
|
|
127
|
+
try {
|
|
128
|
+
value = new URL(location, "https://localhost").searchParams.get("ref");
|
|
129
|
+
} catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
const ref = value?.trim().toLowerCase().split(/[?&#]/)[0]?.replace(/\/+$/, "");
|
|
133
|
+
if (!ref) return null;
|
|
134
|
+
const host = referrerHostOf.exec(ref)?.[1] ?? (ref.includes(".") ? ref.split(/[/:]/)[0] : void 0);
|
|
135
|
+
if (host) return siteTouch(host, ownHosts);
|
|
136
|
+
return {
|
|
137
|
+
channel: require_attribution_vocabulary.SOURCE_ALIASES[ref] ?? ref,
|
|
138
|
+
medium: "referral"
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
106
142
|
* GA4's default channel group of a (channel, medium) pair, with GA4's rule that the source
|
|
107
143
|
* decides too: `linkedin / (not set)` is Organic Social and `email / promo` is Email, because
|
|
108
144
|
* GA4 matches its site lists on the source, not only the medium. Two additions to GA4: Meta's
|
|
@@ -134,19 +170,20 @@ function channelGroupOf(channel, medium) {
|
|
|
134
170
|
* Reads the tags a session arrived with (its `session_start` tags) as one touch. Run once, when
|
|
135
171
|
* the session is written; the result is stored, and the views only read it.
|
|
136
172
|
*
|
|
137
|
-
* The rules, in order, the first that says something naming the channel: an explicit
|
|
138
|
-
*
|
|
139
|
-
* `(direct)` when none does. `medium` is `utm_medium` as
|
|
140
|
-
* rule implies: `(not set)` for a utm_source alone, `cpc`
|
|
141
|
-
* the product rule's own, `
|
|
173
|
+
* The rules, in order, the first that says something naming the channel: an explicit `utm_source`,
|
|
174
|
+
* a click id, an ad landing page, the product's own rules, the referrer's host, the landing page's
|
|
175
|
+
* `?ref=` when there is no referrer; `(direct)` when none does. `medium` is `utm_medium` as
|
|
176
|
+
* declared (lower-cased), else what that rule implies: `(not set)` for a utm_source alone, `cpc`
|
|
177
|
+
* for a bare click id or ad landing page, the product rule's own, `referral` for a `?ref=` site
|
|
178
|
+
* name, `organic` / `social` / `video` / `referral` for a referrer (or a host in `?ref=`), `(none)`
|
|
142
179
|
* for direct. An ad-only click id of the channel itself (`'ads'` in `CLICK_ID_CHANNELS`) or its ad
|
|
143
180
|
* landing page makes it `cpc` when the medium was left out or puts the touch in an organic group:
|
|
144
181
|
* the click was paid whatever the tag said — a Reddit ad tagged `utm_medium=social`, a ChatGPT ad
|
|
145
|
-
* keeping the `utm_source=chatgpt.com` of its organic links. `campaign` is `utm_campaign`, else
|
|
146
|
-
* product rule captured — even when the utm named the channel, so a tagged referral link
|
|
147
|
-
* its code. `priority` is the tier of the rule that named the channel (`TOUCH_PRIORITY`):
|
|
148
|
-
* campaign touches, then the product's own as they declare, then the referrer — except
|
|
149
|
-
* campaign rule whose touch lands in an organic group, referral (`utm_source=chatgpt.com`,
|
|
182
|
+
* keeping the `utm_source=chatgpt.com` of its organic links. `campaign` is `utm_campaign`, else
|
|
183
|
+
* what a product rule captured — even when the utm named the channel, so a tagged referral link
|
|
184
|
+
* keeps its code. `priority` is the tier of the rule that named the channel (`TOUCH_PRIORITY`):
|
|
185
|
+
* campaign touches, then the product's own as they declare, then the referrer and `?ref=` — except
|
|
186
|
+
* that a campaign rule whose touch lands in an organic group, referral (`utm_source=chatgpt.com`,
|
|
150
187
|
* `utm_medium=organic`) or email ranks with a referrer: nobody paid for that click, and it should
|
|
151
188
|
* not take the credit from an ad clicked earlier in the attribution window. When a product rule
|
|
152
189
|
* matched too, the stronger of the two tiers holds.
|
|
@@ -156,7 +193,8 @@ function classifyTouch(tags, options = {}) {
|
|
|
156
193
|
const click = clickChannel(tags);
|
|
157
194
|
const landing = landingChannel(tags);
|
|
158
195
|
const product = productTouch(tags, options.rules ?? []);
|
|
159
|
-
const
|
|
196
|
+
const ownHosts = options.ownHosts ?? [];
|
|
197
|
+
const referred = referrer(tags, ownHosts) ?? refParam(tags, ownHosts);
|
|
160
198
|
const channel = utm ?? click ?? landing ?? product?.channel ?? referred?.channel ?? "(direct)";
|
|
161
199
|
const namedMedium = text(tags, "utm_medium")?.toLowerCase() ?? null ?? (utm ? "(not set)" : click || landing ? "cpc" : product?.medium ?? referred?.medium ?? "(none)");
|
|
162
200
|
const medium = (adClickChannel(tags) === channel || landing === channel) && (namedMedium === "(not set)" || ORGANIC_GROUPS.has(channelGroupOf(channel, namedMedium))) ? "cpc" : namedMedium;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"classify.cjs","names":["META_PLACEMENTS","REFERRER_SITES","SOURCE_ALIASES","CLICK_ID_CHANNELS","AD_LANDING_PAGE","ANDROID_APP_HOSTS","REFERRERS_NOT_A_TOUCH","DISPLAY_MEDIUMS","PAID_MEDIUM","EMAIL_MEDIUM","EMAIL_MEDIUMS","PUSH_MEDIUM","TOUCH_PRIORITY"],"sources":["../../src/attribution/classify.ts"],"sourcesContent":["import type { TrackTags } from '../track/types';\nimport {\n AD_LANDING_PAGE,\n ANDROID_APP_HOSTS,\n CLICK_ID_CHANNELS,\n type ChannelGroup,\n DIRECT_CHANNEL,\n DISPLAY_MEDIUMS,\n EMAIL_MEDIUM,\n EMAIL_MEDIUMS,\n MEDIUM_NOT_SET,\n META_PLACEMENTS,\n NO_MEDIUM,\n PAID_MEDIUM,\n PUSH_MEDIUM,\n REFERRERS_NOT_A_TOUCH,\n REFERRER_SITES,\n SOURCE_ALIASES,\n TOUCH_PRIORITY,\n} from './vocabulary';\n\n/** What a session's tags say about how it arrived: the touch, as the session row stores it. */\nexport interface Touch {\n /** The source, as GA calls it: a `Channel`, a `utm_source` outside the list, a referring host, or `(direct)`. */\n channel: string;\n /** How, as GA calls it: `utm_medium` as declared, else what the rule that named the channel implies; `(none)` for direct. */\n medium: string;\n /** GA4's default channel group of the two. */\n channel_group: ChannelGroup;\n /** `utm_campaign`, else what a product rule captured (a referral code); null when neither. */\n campaign: string | null;\n /** `TOUCH_PRIORITY` of the rule that named the channel; null for direct, which is no touch. */\n priority: number | null;\n}\n\n/** What a product rule names, when it recognises the session; the channel group is derived. */\nexport type ProductTouch = Omit<Touch, 'channel_group'>;\n\n/**\n * A product's own rule: a landing page convention, a code in the URL. Tried after the campaign\n * rules (utm, click id, ad landing page) and before the referrer, so a link shared on Facebook\n * or WhatsApp is credited to what the product recognises, not to the site it was shared on.\n * Returns null when the session is not one of its.\n */\nexport type TouchRule = (tags: TrackTags) => ProductTouch | null;\n\nexport interface ClassifyOptions {\n /**\n * The product's own hosts, as patterns over the lower-cased referrer host: a session that\n * starts from an internal link after the session timeout refers to them, which is navigation,\n * not acquisition. The SDK adds the payment and sign-in providers (`REFERRERS_NOT_A_TOUCH`).\n */\n ownHosts?: readonly RegExp[];\n /** The product's rules, in order; the first that names the session wins. */\n rules?: readonly TouchRule[];\n}\n\nconst metaPlacements = new Set<string>(META_PLACEMENTS);\n/** The groups no one paid for: a campaign rule landing in one of them ranks with a referrer. */\nconst ORGANIC_GROUPS = new Set<ChannelGroup>([\n 'organic_search',\n 'organic_social',\n 'organic_video',\n 'organic_ai',\n 'referral',\n]);\n/**\n * The groups a campaign rule ranks with a referrer in: the organic ones, and the messages we send\n * — email, SMS, push. They reach people we already know — often because an ad brought them — and\n * their links (a welcome mail, a trial reminder) come between that ad and the purchase, so they\n * must not take the ad's credit. With no ad in the window they still win as the latest touch.\n */\nconst REFERRER_TIER_GROUPS = new Set<ChannelGroup>([\n ...ORGANIC_GROUPS,\n 'email',\n 'sms',\n 'mobile_push',\n]);\nconst referrerHostOf = /^https?:\\/\\/([^/:?#]+)/i;\nconst androidAppOf = /^android-app:\\/\\/([^/?#]+)/i;\nconst channelsBy = (medium: string) =>\n new Set<string>(REFERRER_SITES.filter(([, m]) => m === medium).map(([channel]) => channel));\nconst searchChannels = channelsBy('organic');\nconst socialChannels = channelsBy('social');\nconst videoChannels = channelsBy('video');\nconst aiChannels = channelsBy('ai');\n\n/**\n * A tag as text; absent when missing, empty, or the strings a broken template writes (`undefined`,\n * `null`). A macro the ad platform did not expand (`{{campaign_name}}`, `__CAMPAIGN_NAME__`) is\n * kept as written, so the misspelt template shows in the reports and gets fixed.\n */\nfunction text(tags: TrackTags, key: string): string | null {\n const value = tags[key];\n if (value === undefined || value === null) return null;\n const s = String(value);\n return s === '' || s === 'undefined' || s === 'null' ? null : s;\n}\n\n/**\n * 1. What the campaign said: `utm_source`, with the aliases folded. It comes before the click\n * ids — unlike GA4, where a usable `gclid` always wins over the utm (Universal Analytics' \"manual\n * tagging overrides auto-tagging\" has no GA4 counterpart): a utm is written for this link by whoever placed it, while a click id\n * can be added by someone else — Meta puts `fbclid` on organic links too, and a shared or\n * forwarded ad link keeps the click id of the ad. When the two disagree, the utm is the one that\n * was right: an Instagram profile link carrying `fbclid`, a newsletter link copied from an ad.\n * The one exception is an ad-only click id of the utm's own channel, see `classifyTouch`.\n */\nfunction utmChannel(tags: TrackTags): string | null {\n // A source with the rest of the query glued on (`email&utm_medium=promo`, `toolify/`,\n // `x?utm_source=x`): a link built by hand or double-encoded. Keep the source.\n const source = text(tags, 'utm_source')?.toLowerCase().split(/[?&#]/)[0]?.replace(/\\/+$/, '');\n if (!source) return null;\n return (SOURCE_ALIASES as Record<string, string>)[source] ?? source;\n}\n\n/**\n * 2. A click id in this page's URL — the tags never fill one from the cookie an earlier click\n * left (that is `_gcl_aw`, `_fbc`, …). Only a value counts, not the key: clients before mid-2025\n * wrote `fbclid: null` (`URLSearchParams.get` of a missing parameter) into every page's tags, and\n * reading the key alone named those sessions Meta clicks — Google ad clicks among them, as `fbclid`\n * is listed first. A platform always puts a value on its click.\n */\nfunction clickChannel(tags: TrackTags): string | null {\n return CLICK_ID_CHANNELS.find(([key]) => text(tags, key) !== null)?.[1] ?? null;\n}\n\n/** The channel of the first click id in the tags that its platform puts on ad clicks only. */\nfunction adClickChannel(tags: TrackTags): string | null {\n return (\n CLICK_ID_CHANNELS.find(([key, , on]) => on === 'ads' && text(tags, key) !== null)?.[1] ?? null\n );\n}\n\n/** 3. A landing page reserved for one channel's ads. */\nfunction landingChannel(tags: TrackTags): string | null {\n const location = text(tags, 'page_location');\n return location ? (AD_LANDING_PAGE.exec(location)?.[1] ?? null) : null;\n}\n\n/** 4. The product's rules, in order. */\nfunction productTouch(tags: TrackTags, rules: readonly TouchRule[]): ProductTouch | null {\n for (const rule of rules) {\n const touch = rule(tags);\n if (touch) return touch;\n }\n return null;\n}\n\n/**\n * 5. The referrer's host: a known search engine or social network folded to its channel, any\n * other site kept as its host, and null for no referrer or one that is no touch. An Android app's\n * referrer (`android-app://<package>`) is read as the site it stands for (`ANDROID_APP_HOSTS`).\n */\nfunction referrer(\n tags: TrackTags,\n ownHosts: readonly RegExp[]\n): { channel: string; medium: string } | null {\n const url = text(tags, 'page_referrer');\n const app = url ? androidAppOf.exec(url)?.[1]?.toLowerCase() : undefined;\n const host = app\n ? (ANDROID_APP_HOSTS[app] ?? app)\n : url\n ? referrerHostOf.exec(url)?.[1]?.toLowerCase()\n : undefined;\n if (!host) return null;\n if (REFERRERS_NOT_A_TOUCH.some((pattern) => pattern.test(host))) return null;\n if (ownHosts.some((pattern) => pattern.test(host))) return null;\n const site = REFERRER_SITES.find(([, , pattern]) => pattern.test(host));\n return site ? { channel: site[0], medium: site[1] } : { channel: host, medium: 'referral' };\n}\n\n/**\n * GA4's default channel group of a (channel, medium) pair, with GA4's rule that the source\n * decides too: `linkedin / (not set)` is Organic Social and `email / promo` is Email, because\n * GA4 matches its site lists on the source, not only the medium. Two additions to GA4: Meta's\n * placement names as a medium are a paid Meta click (see `META_PLACEMENTS`), and the AI\n * assistants have `organic_ai`. Checked in GA4's order — paid before organic, the organic groups before\n * email, then referral, SMS and push — so a pair that fits two rules lands where GA4 would put it.\n */\nexport function channelGroupOf(channel: string, medium: string): ChannelGroup {\n if (channel === DIRECT_CHANNEL) return 'direct';\n if ((DISPLAY_MEDIUMS as readonly string[]).includes(medium)) return 'display';\n // A Meta placement as the medium is a Meta ad whatever the source was tagged as.\n if (metaPlacements.has(medium)) return 'paid_social';\n if (PAID_MEDIUM.test(medium)) {\n if (socialChannels.has(channel) || videoChannels.has(channel)) return 'paid_social';\n if (searchChannels.has(channel)) return 'paid_search';\n return 'paid_other';\n }\n if (medium === 'social' || socialChannels.has(channel)) return 'organic_social';\n if (medium === 'video' || videoChannels.has(channel)) return 'organic_video';\n if (medium === 'ai' || aiChannels.has(channel)) return 'organic_ai';\n if (medium === 'organic' || searchChannels.has(channel)) return 'organic_search';\n if (EMAIL_MEDIUM.test(medium) || (EMAIL_MEDIUMS as readonly string[]).includes(channel))\n return 'email';\n if (medium === 'affiliate') return 'affiliate';\n if (medium === 'referral') return 'referral';\n if (medium === 'sms' || channel === 'sms') return 'sms';\n if (PUSH_MEDIUM.test(medium) || channel === 'firebase') return 'mobile_push';\n return 'unassigned';\n}\n\n/**\n * Reads the tags a session arrived with (its `session_start` tags) as one touch. Run once, when\n * the session is written; the result is stored, and the views only read it.\n *\n * The rules, in order, the first that says something naming the channel: an explicit\n * `utm_source`, a click id, an ad landing page, the product's own rules, the referrer's host;\n * `(direct)` when none does. `medium` is `utm_medium` as declared (lower-cased), else what that\n * rule implies: `(not set)` for a utm_source alone, `cpc` for a bare click id or ad landing page,\n * the product rule's own, `organic` / `social` / `video` / `referral` for a referrer, `(none)`\n * for direct. An ad-only click id of the channel itself (`'ads'` in `CLICK_ID_CHANNELS`) or its ad\n * landing page makes it `cpc` when the medium was left out or puts the touch in an organic group:\n * the click was paid whatever the tag said — a Reddit ad tagged `utm_medium=social`, a ChatGPT ad\n * keeping the `utm_source=chatgpt.com` of its organic links. `campaign` is `utm_campaign`, else what a\n * product rule captured — even when the utm named the channel, so a tagged referral link keeps\n * its code. `priority` is the tier of the rule that named the channel (`TOUCH_PRIORITY`):\n * campaign touches, then the product's own as they declare, then the referrer — except that a\n * campaign rule whose touch lands in an organic group, referral (`utm_source=chatgpt.com`,\n * `utm_medium=organic`) or email ranks with a referrer: nobody paid for that click, and it should\n * not take the credit from an ad clicked earlier in the attribution window. When a product rule\n * matched too, the stronger of the two tiers holds.\n */\nexport function classifyTouch(tags: TrackTags, options: ClassifyOptions = {}): Touch {\n const utm = utmChannel(tags);\n const click = clickChannel(tags);\n const landing = landingChannel(tags);\n const product = productTouch(tags, options.rules ?? []);\n const referred = referrer(tags, options.ownHosts ?? []);\n\n const channel =\n utm ?? click ?? landing ?? product?.channel ?? referred?.channel ?? DIRECT_CHANNEL;\n const declaredMedium = text(tags, 'utm_medium')?.toLowerCase() ?? null;\n const namedMedium =\n declaredMedium ??\n (utm\n ? MEDIUM_NOT_SET\n : click || landing\n ? 'cpc'\n : (product?.medium ?? referred?.medium ?? NO_MEDIUM));\n // What proves a paid click on this channel: an ad-only click id of it, or its ad landing page.\n // Another channel's says nothing about this one (an ad link copied and shared under a utm of\n // its own), and `fbclid` is on organic Meta links too, so neither counts here.\n const paid = adClickChannel(tags) === channel || landing === channel;\n const medium =\n paid &&\n (namedMedium === MEDIUM_NOT_SET || ORGANIC_GROUPS.has(channelGroupOf(channel, namedMedium)))\n ? 'cpc'\n : namedMedium;\n const channelGroup = channelGroupOf(channel, medium);\n const campaign = text(tags, 'utm_campaign') ?? product?.campaign ?? null;\n const campaignTier =\n utm || click || landing\n ? REFERRER_TIER_GROUPS.has(channelGroup)\n ? TOUCH_PRIORITY.referrer\n : TOUCH_PRIORITY.campaign\n : null;\n // The stronger of the campaign rule's tier and the product rule's own: a referral link shared\n // under `utm_source=whatsapp` is named by the utm, and still ranks as the programme declares.\n const ranked = [campaignTier, product?.priority ?? null].filter((tier) => tier !== null);\n const priority =\n ranked.length > 0 ? Math.min(...ranked) : referred ? TOUCH_PRIORITY.referrer : null;\n\n return { channel, medium, channel_group: channelGroup, campaign, priority };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"classify.cjs","names":["META_PLACEMENTS","REFERRER_SITES","SOURCE_ALIASES","CLICK_ID_CHANNELS","AD_LANDING_PAGE","REFERRERS_NOT_A_TOUCH","ANDROID_APP_HOSTS","DISPLAY_MEDIUMS","PAID_MEDIUM","EMAIL_MEDIUM","EMAIL_MEDIUMS","PUSH_MEDIUM","TOUCH_PRIORITY"],"sources":["../../src/attribution/classify.ts"],"sourcesContent":["import type { TrackTags } from '../track/types';\nimport {\n AD_LANDING_PAGE,\n ANDROID_APP_HOSTS,\n CLICK_ID_CHANNELS,\n type ChannelGroup,\n DIRECT_CHANNEL,\n DISPLAY_MEDIUMS,\n EMAIL_MEDIUM,\n EMAIL_MEDIUMS,\n MEDIUM_NOT_SET,\n META_PLACEMENTS,\n NO_MEDIUM,\n PAID_MEDIUM,\n PUSH_MEDIUM,\n REFERRERS_NOT_A_TOUCH,\n REFERRER_SITES,\n SOURCE_ALIASES,\n TOUCH_PRIORITY,\n} from './vocabulary';\n\n/** What a session's tags say about how it arrived: the touch, as the session row stores it. */\nexport interface Touch {\n /** The source, as GA calls it: a `Channel`, a `utm_source` outside the list, a referring host, or `(direct)`. */\n channel: string;\n /** How, as GA calls it: `utm_medium` as declared, else what the rule that named the channel implies; `(none)` for direct. */\n medium: string;\n /** GA4's default channel group of the two. */\n channel_group: ChannelGroup;\n /** `utm_campaign`, else what a product rule captured (a referral code); null when neither. */\n campaign: string | null;\n /** `TOUCH_PRIORITY` of the rule that named the channel; null for direct, which is no touch. */\n priority: number | null;\n}\n\n/** What a product rule names, when it recognises the session; the channel group is derived. */\nexport type ProductTouch = Omit<Touch, 'channel_group'>;\n\n/**\n * A product's own rule: a landing page convention, a code in the URL. Tried after the campaign\n * rules (utm, click id, ad landing page) and before the referrer and `?ref=`, so a link shared on\n * Facebook or WhatsApp is credited to what the product recognises, not to the site it was shared\n * on — and a product whose `?ref=` carries a referral code, not a site, claims it here first.\n * Returns null when the session is not one of its.\n */\nexport type TouchRule = (tags: TrackTags) => ProductTouch | null;\n\nexport interface ClassifyOptions {\n /**\n * The product's own hosts, as patterns over the lower-cased referrer host: a session that\n * starts from an internal link after the session timeout refers to them, which is navigation,\n * not acquisition. The SDK adds the payment and sign-in providers (`REFERRERS_NOT_A_TOUCH`).\n */\n ownHosts?: readonly RegExp[];\n /** The product's rules, in order; the first that names the session wins. */\n rules?: readonly TouchRule[];\n}\n\n/** What a site names, from the referrer or `?ref=`: no campaign, and the referrer's tier. */\ntype SiteTouch = Pick<Touch, 'channel' | 'medium'>;\n\nconst metaPlacements = new Set<string>(META_PLACEMENTS);\n/** The groups no one paid for: a campaign rule landing in one of them ranks with a referrer. */\nconst ORGANIC_GROUPS = new Set<ChannelGroup>([\n 'organic_search',\n 'organic_social',\n 'organic_video',\n 'organic_ai',\n 'referral',\n]);\n/**\n * The groups a campaign rule ranks with a referrer in: the organic ones, and the messages we send\n * — email, SMS, push. They reach people we already know — often because an ad brought them — and\n * their links (a welcome mail, a trial reminder) come between that ad and the purchase, so they\n * must not take the ad's credit. With no ad in the window they still win as the latest touch.\n */\nconst REFERRER_TIER_GROUPS = new Set<ChannelGroup>([\n ...ORGANIC_GROUPS,\n 'email',\n 'sms',\n 'mobile_push',\n]);\nconst referrerHostOf = /^https?:\\/\\/([^/:?#]+)/i;\nconst androidAppOf = /^android-app:\\/\\/([^/?#]+)/i;\nconst channelsBy = (medium: string) =>\n new Set<string>(REFERRER_SITES.filter(([, m]) => m === medium).map(([channel]) => channel));\nconst searchChannels = channelsBy('organic');\nconst socialChannels = channelsBy('social');\nconst videoChannels = channelsBy('video');\nconst aiChannels = channelsBy('ai');\n\n/**\n * A tag as text; absent when missing, empty, or the strings a broken template writes (`undefined`,\n * `null`). A macro the ad platform did not expand (`{{campaign_name}}`, `__CAMPAIGN_NAME__`) is\n * kept as written, so the misspelt template shows in the reports and gets fixed.\n */\nfunction text(tags: TrackTags, key: string): string | null {\n const value = tags[key];\n if (value === undefined || value === null) return null;\n const s = String(value);\n return s === '' || s === 'undefined' || s === 'null' ? null : s;\n}\n\n/**\n * 1. What the campaign said: `utm_source`, with the aliases folded. It comes before the click\n * ids — unlike GA4, where a usable `gclid` always wins over the utm (Universal Analytics' \"manual\n * tagging overrides auto-tagging\" has no GA4 counterpart): a utm is written for this link by whoever placed it, while a click id\n * can be added by someone else — Meta puts `fbclid` on organic links too, and a shared or\n * forwarded ad link keeps the click id of the ad. When the two disagree, the utm is the one that\n * was right: an Instagram profile link carrying `fbclid`, a newsletter link copied from an ad.\n * The one exception is an ad-only click id of the utm's own channel, see `classifyTouch`.\n */\nfunction utmChannel(tags: TrackTags): string | null {\n // A source with the rest of the query glued on (`email&utm_medium=promo`, `toolify/`,\n // `x?utm_source=x`): a link built by hand or double-encoded. Keep the source.\n const source = text(tags, 'utm_source')?.toLowerCase().split(/[?&#]/)[0]?.replace(/\\/+$/, '');\n if (!source) return null;\n return (SOURCE_ALIASES as Record<string, string>)[source] ?? source;\n}\n\n/**\n * 2. A click id in this page's URL — the tags never fill one from the cookie an earlier click\n * left (that is `_gcl_aw`, `_fbc`, …). Only a value counts, not the key: clients before mid-2025\n * wrote `fbclid: null` (`URLSearchParams.get` of a missing parameter) into every page's tags, and\n * reading the key alone named those sessions Meta clicks — Google ad clicks among them, as `fbclid`\n * is listed first. A platform always puts a value on its click.\n */\nfunction clickChannel(tags: TrackTags): string | null {\n return CLICK_ID_CHANNELS.find(([key]) => text(tags, key) !== null)?.[1] ?? null;\n}\n\n/** The channel of the first click id in the tags that its platform puts on ad clicks only. */\nfunction adClickChannel(tags: TrackTags): string | null {\n return (\n CLICK_ID_CHANNELS.find(([key, , on]) => on === 'ads' && text(tags, key) !== null)?.[1] ?? null\n );\n}\n\n/** 3. A landing page reserved for one channel's ads. */\nfunction landingChannel(tags: TrackTags): string | null {\n const location = text(tags, 'page_location');\n return location ? (AD_LANDING_PAGE.exec(location)?.[1] ?? null) : null;\n}\n\n/** 4. The product's rules, in order. */\nfunction productTouch(tags: TrackTags, rules: readonly TouchRule[]): ProductTouch | null {\n for (const rule of rules) {\n const touch = rule(tags);\n if (touch) return touch;\n }\n return null;\n}\n\n/**\n * A referring site's host as a touch: a known search engine or social network folded to its\n * channel, any other site kept as its host, and null for a host that is no touch.\n */\nfunction siteTouch(host: string, ownHosts: readonly RegExp[]): SiteTouch | null {\n if (REFERRERS_NOT_A_TOUCH.some((pattern) => pattern.test(host))) return null;\n if (ownHosts.some((pattern) => pattern.test(host))) return null;\n const site = REFERRER_SITES.find(([, , pattern]) => pattern.test(host));\n return site ? { channel: site[0], medium: site[1] } : { channel: host, medium: 'referral' };\n}\n\n/**\n * 5. The referrer's host, read as `siteTouch`; null for no referrer. An Android app's referrer\n * (`android-app://<package>`) is read as the site it stands for (`ANDROID_APP_HOSTS`).\n */\nfunction referrer(tags: TrackTags, ownHosts: readonly RegExp[]): SiteTouch | null {\n const url = text(tags, 'page_referrer');\n const app = url ? androidAppOf.exec(url)?.[1]?.toLowerCase() : undefined;\n const host = app\n ? (ANDROID_APP_HOSTS[app] ?? app)\n : url\n ? referrerHostOf.exec(url)?.[1]?.toLowerCase()\n : undefined;\n return host ? siteTouch(host, ownHosts) : null;\n}\n\n/**\n * 6. The `ref` parameter of the landing page, when the session has no referrer at all: the\n * convention of sites that link out — Product Hunt's `?ref=producthunt`, newsletters,\n * directories, \"built with\" badges. It names the site when the browser does not: a click from a\n * mail client, an in-app browser or a page under `no-referrer`. With a referrer it is not read —\n * the referrer is where the click happened, and the `ref` only where the link was first written:\n * a search engine indexes the URL with its `?ref=`, another directory copies the link, and the\n * `ref` would credit the first site with the visit the second one sent; an internal link's\n * `?ref=header` would credit the page's own nav. A site's name (`producthunt`) is kept as the\n * channel, with the `utm_source` aliases folded; a host or URL (`producthunt.com`,\n * `https://news.ycombinator.com/`) is read as a referrer's host, so the same site lands on the\n * same channel either way. Ranks with the referrer: nobody paid for that link.\n */\nfunction refParam(tags: TrackTags, ownHosts: readonly RegExp[]): SiteTouch | null {\n const location = text(tags, 'page_location');\n if (!location || text(tags, 'page_referrer')) return null;\n let value: string | null;\n try {\n value = new URL(location, 'https://localhost').searchParams.get('ref');\n } catch {\n return null;\n }\n // A value with the rest of the query glued on (`producthunt?utm_source=x`), as `utmChannel`\n // keeps the source of one; a bare host loses its port and path, as the referrer's does.\n const ref = value?.trim().toLowerCase().split(/[?&#]/)[0]?.replace(/\\/+$/, '');\n if (!ref) return null;\n const host =\n referrerHostOf.exec(ref)?.[1] ?? (ref.includes('.') ? ref.split(/[/:]/)[0] : undefined);\n if (host) return siteTouch(host, ownHosts);\n return {\n channel: (SOURCE_ALIASES as Record<string, string>)[ref] ?? ref,\n medium: 'referral',\n };\n}\n\n/**\n * GA4's default channel group of a (channel, medium) pair, with GA4's rule that the source\n * decides too: `linkedin / (not set)` is Organic Social and `email / promo` is Email, because\n * GA4 matches its site lists on the source, not only the medium. Two additions to GA4: Meta's\n * placement names as a medium are a paid Meta click (see `META_PLACEMENTS`), and the AI\n * assistants have `organic_ai`. Checked in GA4's order — paid before organic, the organic groups before\n * email, then referral, SMS and push — so a pair that fits two rules lands where GA4 would put it.\n */\nexport function channelGroupOf(channel: string, medium: string): ChannelGroup {\n if (channel === DIRECT_CHANNEL) return 'direct';\n if ((DISPLAY_MEDIUMS as readonly string[]).includes(medium)) return 'display';\n // A Meta placement as the medium is a Meta ad whatever the source was tagged as.\n if (metaPlacements.has(medium)) return 'paid_social';\n if (PAID_MEDIUM.test(medium)) {\n if (socialChannels.has(channel) || videoChannels.has(channel)) return 'paid_social';\n if (searchChannels.has(channel)) return 'paid_search';\n return 'paid_other';\n }\n if (medium === 'social' || socialChannels.has(channel)) return 'organic_social';\n if (medium === 'video' || videoChannels.has(channel)) return 'organic_video';\n if (medium === 'ai' || aiChannels.has(channel)) return 'organic_ai';\n if (medium === 'organic' || searchChannels.has(channel)) return 'organic_search';\n if (EMAIL_MEDIUM.test(medium) || (EMAIL_MEDIUMS as readonly string[]).includes(channel))\n return 'email';\n if (medium === 'affiliate') return 'affiliate';\n if (medium === 'referral') return 'referral';\n if (medium === 'sms' || channel === 'sms') return 'sms';\n if (PUSH_MEDIUM.test(medium) || channel === 'firebase') return 'mobile_push';\n return 'unassigned';\n}\n\n/**\n * Reads the tags a session arrived with (its `session_start` tags) as one touch. Run once, when\n * the session is written; the result is stored, and the views only read it.\n *\n * The rules, in order, the first that says something naming the channel: an explicit `utm_source`,\n * a click id, an ad landing page, the product's own rules, the referrer's host, the landing page's\n * `?ref=` when there is no referrer; `(direct)` when none does. `medium` is `utm_medium` as\n * declared (lower-cased), else what that rule implies: `(not set)` for a utm_source alone, `cpc`\n * for a bare click id or ad landing page, the product rule's own, `referral` for a `?ref=` site\n * name, `organic` / `social` / `video` / `referral` for a referrer (or a host in `?ref=`), `(none)`\n * for direct. An ad-only click id of the channel itself (`'ads'` in `CLICK_ID_CHANNELS`) or its ad\n * landing page makes it `cpc` when the medium was left out or puts the touch in an organic group:\n * the click was paid whatever the tag said — a Reddit ad tagged `utm_medium=social`, a ChatGPT ad\n * keeping the `utm_source=chatgpt.com` of its organic links. `campaign` is `utm_campaign`, else\n * what a product rule captured — even when the utm named the channel, so a tagged referral link\n * keeps its code. `priority` is the tier of the rule that named the channel (`TOUCH_PRIORITY`):\n * campaign touches, then the product's own as they declare, then the referrer and `?ref=` — except\n * that a campaign rule whose touch lands in an organic group, referral (`utm_source=chatgpt.com`,\n * `utm_medium=organic`) or email ranks with a referrer: nobody paid for that click, and it should\n * not take the credit from an ad clicked earlier in the attribution window. When a product rule\n * matched too, the stronger of the two tiers holds.\n */\nexport function classifyTouch(tags: TrackTags, options: ClassifyOptions = {}): Touch {\n const utm = utmChannel(tags);\n const click = clickChannel(tags);\n const landing = landingChannel(tags);\n const product = productTouch(tags, options.rules ?? []);\n const ownHosts = options.ownHosts ?? [];\n const referred = referrer(tags, ownHosts) ?? refParam(tags, ownHosts);\n\n const channel =\n utm ?? click ?? landing ?? product?.channel ?? referred?.channel ?? DIRECT_CHANNEL;\n const declaredMedium = text(tags, 'utm_medium')?.toLowerCase() ?? null;\n const namedMedium =\n declaredMedium ??\n (utm\n ? MEDIUM_NOT_SET\n : click || landing\n ? 'cpc'\n : (product?.medium ?? referred?.medium ?? NO_MEDIUM));\n // What proves a paid click on this channel: an ad-only click id of it, or its ad landing page.\n // Another channel's says nothing about this one (an ad link copied and shared under a utm of\n // its own), and `fbclid` is on organic Meta links too, so neither counts here.\n const paid = adClickChannel(tags) === channel || landing === channel;\n const medium =\n paid &&\n (namedMedium === MEDIUM_NOT_SET || ORGANIC_GROUPS.has(channelGroupOf(channel, namedMedium)))\n ? 'cpc'\n : namedMedium;\n const channelGroup = channelGroupOf(channel, medium);\n const campaign = text(tags, 'utm_campaign') ?? product?.campaign ?? null;\n const campaignTier =\n utm || click || landing\n ? REFERRER_TIER_GROUPS.has(channelGroup)\n ? TOUCH_PRIORITY.referrer\n : TOUCH_PRIORITY.campaign\n : null;\n // The stronger of the campaign rule's tier and the product rule's own: a referral link shared\n // under `utm_source=whatsapp` is named by the utm, and still ranks as the programme declares.\n const ranked = [campaignTier, product?.priority ?? null].filter((tier) => tier !== null);\n const priority =\n ranked.length > 0 ? Math.min(...ranked) : referred ? TOUCH_PRIORITY.referrer : null;\n\n return { channel, medium, channel_group: channelGroup, campaign, priority };\n}\n"],"mappings":"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"}
|
|
@@ -18,8 +18,9 @@ export interface Touch {
|
|
|
18
18
|
export type ProductTouch = Omit<Touch, 'channel_group'>;
|
|
19
19
|
/**
|
|
20
20
|
* A product's own rule: a landing page convention, a code in the URL. Tried after the campaign
|
|
21
|
-
* rules (utm, click id, ad landing page) and before the referrer
|
|
22
|
-
* or WhatsApp is credited to what the product recognises, not to the site it was shared
|
|
21
|
+
* rules (utm, click id, ad landing page) and before the referrer and `?ref=`, so a link shared on
|
|
22
|
+
* Facebook or WhatsApp is credited to what the product recognises, not to the site it was shared
|
|
23
|
+
* on — and a product whose `?ref=` carries a referral code, not a site, claims it here first.
|
|
23
24
|
* Returns null when the session is not one of its.
|
|
24
25
|
*/
|
|
25
26
|
export type TouchRule = (tags: TrackTags) => ProductTouch | null;
|
|
@@ -46,19 +47,20 @@ export declare function channelGroupOf(channel: string, medium: string): Channel
|
|
|
46
47
|
* Reads the tags a session arrived with (its `session_start` tags) as one touch. Run once, when
|
|
47
48
|
* the session is written; the result is stored, and the views only read it.
|
|
48
49
|
*
|
|
49
|
-
* The rules, in order, the first that says something naming the channel: an explicit
|
|
50
|
-
*
|
|
51
|
-
* `(direct)` when none does. `medium` is `utm_medium` as
|
|
52
|
-
* rule implies: `(not set)` for a utm_source alone, `cpc`
|
|
53
|
-
* the product rule's own, `
|
|
50
|
+
* The rules, in order, the first that says something naming the channel: an explicit `utm_source`,
|
|
51
|
+
* a click id, an ad landing page, the product's own rules, the referrer's host, the landing page's
|
|
52
|
+
* `?ref=` when there is no referrer; `(direct)` when none does. `medium` is `utm_medium` as
|
|
53
|
+
* declared (lower-cased), else what that rule implies: `(not set)` for a utm_source alone, `cpc`
|
|
54
|
+
* for a bare click id or ad landing page, the product rule's own, `referral` for a `?ref=` site
|
|
55
|
+
* name, `organic` / `social` / `video` / `referral` for a referrer (or a host in `?ref=`), `(none)`
|
|
54
56
|
* for direct. An ad-only click id of the channel itself (`'ads'` in `CLICK_ID_CHANNELS`) or its ad
|
|
55
57
|
* landing page makes it `cpc` when the medium was left out or puts the touch in an organic group:
|
|
56
58
|
* the click was paid whatever the tag said — a Reddit ad tagged `utm_medium=social`, a ChatGPT ad
|
|
57
|
-
* keeping the `utm_source=chatgpt.com` of its organic links. `campaign` is `utm_campaign`, else
|
|
58
|
-
* product rule captured — even when the utm named the channel, so a tagged referral link
|
|
59
|
-
* its code. `priority` is the tier of the rule that named the channel (`TOUCH_PRIORITY`):
|
|
60
|
-
* campaign touches, then the product's own as they declare, then the referrer — except
|
|
61
|
-
* campaign rule whose touch lands in an organic group, referral (`utm_source=chatgpt.com`,
|
|
59
|
+
* keeping the `utm_source=chatgpt.com` of its organic links. `campaign` is `utm_campaign`, else
|
|
60
|
+
* what a product rule captured — even when the utm named the channel, so a tagged referral link
|
|
61
|
+
* keeps its code. `priority` is the tier of the rule that named the channel (`TOUCH_PRIORITY`):
|
|
62
|
+
* campaign touches, then the product's own as they declare, then the referrer and `?ref=` — except
|
|
63
|
+
* that a campaign rule whose touch lands in an organic group, referral (`utm_source=chatgpt.com`,
|
|
62
64
|
* `utm_medium=organic`) or email ranks with a referrer: nobody paid for that click, and it should
|
|
63
65
|
* not take the credit from an ad clicked earlier in the attribution window. When a product rule
|
|
64
66
|
* matched too, the stronger of the two tiers holds.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"classify.d.cts","names":[],"sources":["../../src/attribution/classify.ts"],"mappings":";;;;iBAsBiB;;EAEf;;EAEA;;EAEA,eAAe;;EAEf;;EAEA;;;YAIU,eAAe,KAAK
|
|
1
|
+
{"version":3,"file":"classify.d.cts","names":[],"sources":["../../src/attribution/classify.ts"],"mappings":";;;;iBAsBiB;;EAEf;;EAEA;;EAEA,eAAe;;EAEf;;EAEA;;;YAIU,eAAe,KAAK;;;;;;;;YASpB,aAAa,MAAM,cAAc;iBAE5B;;;;;;EAMf,oBAAoB;;EAEpB,iBAAiB;;;;;;;;;;wBAuKH,eAAe,iBAAiB,iBAAiB;;;;;;;;;;;;;;;;;;;;;;;wBA6CjD,cAAc,MAAM,WAAW,UAAS,kBAAuB"}
|
|
@@ -18,8 +18,9 @@ export interface Touch {
|
|
|
18
18
|
export type ProductTouch = Omit<Touch, 'channel_group'>;
|
|
19
19
|
/**
|
|
20
20
|
* A product's own rule: a landing page convention, a code in the URL. Tried after the campaign
|
|
21
|
-
* rules (utm, click id, ad landing page) and before the referrer
|
|
22
|
-
* or WhatsApp is credited to what the product recognises, not to the site it was shared
|
|
21
|
+
* rules (utm, click id, ad landing page) and before the referrer and `?ref=`, so a link shared on
|
|
22
|
+
* Facebook or WhatsApp is credited to what the product recognises, not to the site it was shared
|
|
23
|
+
* on — and a product whose `?ref=` carries a referral code, not a site, claims it here first.
|
|
23
24
|
* Returns null when the session is not one of its.
|
|
24
25
|
*/
|
|
25
26
|
export type TouchRule = (tags: TrackTags) => ProductTouch | null;
|
|
@@ -46,19 +47,20 @@ export declare function channelGroupOf(channel: string, medium: string): Channel
|
|
|
46
47
|
* Reads the tags a session arrived with (its `session_start` tags) as one touch. Run once, when
|
|
47
48
|
* the session is written; the result is stored, and the views only read it.
|
|
48
49
|
*
|
|
49
|
-
* The rules, in order, the first that says something naming the channel: an explicit
|
|
50
|
-
*
|
|
51
|
-
* `(direct)` when none does. `medium` is `utm_medium` as
|
|
52
|
-
* rule implies: `(not set)` for a utm_source alone, `cpc`
|
|
53
|
-
* the product rule's own, `
|
|
50
|
+
* The rules, in order, the first that says something naming the channel: an explicit `utm_source`,
|
|
51
|
+
* a click id, an ad landing page, the product's own rules, the referrer's host, the landing page's
|
|
52
|
+
* `?ref=` when there is no referrer; `(direct)` when none does. `medium` is `utm_medium` as
|
|
53
|
+
* declared (lower-cased), else what that rule implies: `(not set)` for a utm_source alone, `cpc`
|
|
54
|
+
* for a bare click id or ad landing page, the product rule's own, `referral` for a `?ref=` site
|
|
55
|
+
* name, `organic` / `social` / `video` / `referral` for a referrer (or a host in `?ref=`), `(none)`
|
|
54
56
|
* for direct. An ad-only click id of the channel itself (`'ads'` in `CLICK_ID_CHANNELS`) or its ad
|
|
55
57
|
* landing page makes it `cpc` when the medium was left out or puts the touch in an organic group:
|
|
56
58
|
* the click was paid whatever the tag said — a Reddit ad tagged `utm_medium=social`, a ChatGPT ad
|
|
57
|
-
* keeping the `utm_source=chatgpt.com` of its organic links. `campaign` is `utm_campaign`, else
|
|
58
|
-
* product rule captured — even when the utm named the channel, so a tagged referral link
|
|
59
|
-
* its code. `priority` is the tier of the rule that named the channel (`TOUCH_PRIORITY`):
|
|
60
|
-
* campaign touches, then the product's own as they declare, then the referrer — except
|
|
61
|
-
* campaign rule whose touch lands in an organic group, referral (`utm_source=chatgpt.com`,
|
|
59
|
+
* keeping the `utm_source=chatgpt.com` of its organic links. `campaign` is `utm_campaign`, else
|
|
60
|
+
* what a product rule captured — even when the utm named the channel, so a tagged referral link
|
|
61
|
+
* keeps its code. `priority` is the tier of the rule that named the channel (`TOUCH_PRIORITY`):
|
|
62
|
+
* campaign touches, then the product's own as they declare, then the referrer and `?ref=` — except
|
|
63
|
+
* that a campaign rule whose touch lands in an organic group, referral (`utm_source=chatgpt.com`,
|
|
62
64
|
* `utm_medium=organic`) or email ranks with a referrer: nobody paid for that click, and it should
|
|
63
65
|
* not take the credit from an ad clicked earlier in the attribution window. When a product rule
|
|
64
66
|
* matched too, the stronger of the two tiers holds.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"classify.d.mts","names":[],"sources":["../../src/attribution/classify.ts"],"mappings":";;;;iBAsBiB;;EAEf;;EAEA;;EAEA,eAAe;;EAEf;;EAEA;;;YAIU,eAAe,KAAK
|
|
1
|
+
{"version":3,"file":"classify.d.mts","names":[],"sources":["../../src/attribution/classify.ts"],"mappings":";;;;iBAsBiB;;EAEf;;EAEA;;EAEA,eAAe;;EAEf;;EAEA;;;YAIU,eAAe,KAAK;;;;;;;;YASpB,aAAa,MAAM,cAAc;iBAE5B;;;;;;EAMf,oBAAoB;;EAEpB,iBAAiB;;;;;;;;;;wBAuKH,eAAe,iBAAiB,iBAAiB;;;;;;;;;;;;;;;;;;;;;;;wBA6CjD,cAAc,MAAM,WAAW,UAAS,kBAAuB"}
|
|
@@ -81,15 +81,10 @@ function productTouch(tags, rules) {
|
|
|
81
81
|
return null;
|
|
82
82
|
}
|
|
83
83
|
/**
|
|
84
|
-
*
|
|
85
|
-
* other site kept as its host, and null for
|
|
86
|
-
* referrer (`android-app://<package>`) is read as the site it stands for (`ANDROID_APP_HOSTS`).
|
|
84
|
+
* A referring site's host as a touch: a known search engine or social network folded to its
|
|
85
|
+
* channel, any other site kept as its host, and null for a host that is no touch.
|
|
87
86
|
*/
|
|
88
|
-
function
|
|
89
|
-
const url = text(tags, "page_referrer");
|
|
90
|
-
const app = url ? androidAppOf.exec(url)?.[1]?.toLowerCase() : void 0;
|
|
91
|
-
const host = app ? ANDROID_APP_HOSTS[app] ?? app : url ? referrerHostOf.exec(url)?.[1]?.toLowerCase() : void 0;
|
|
92
|
-
if (!host) return null;
|
|
87
|
+
function siteTouch(host, ownHosts) {
|
|
93
88
|
if (REFERRERS_NOT_A_TOUCH.some((pattern) => pattern.test(host))) return null;
|
|
94
89
|
if (ownHosts.some((pattern) => pattern.test(host))) return null;
|
|
95
90
|
const site = REFERRER_SITES.find(([, , pattern]) => pattern.test(host));
|
|
@@ -102,6 +97,47 @@ function referrer(tags, ownHosts) {
|
|
|
102
97
|
};
|
|
103
98
|
}
|
|
104
99
|
/**
|
|
100
|
+
* 5. The referrer's host, read as `siteTouch`; null for no referrer. An Android app's referrer
|
|
101
|
+
* (`android-app://<package>`) is read as the site it stands for (`ANDROID_APP_HOSTS`).
|
|
102
|
+
*/
|
|
103
|
+
function referrer(tags, ownHosts) {
|
|
104
|
+
const url = text(tags, "page_referrer");
|
|
105
|
+
const app = url ? androidAppOf.exec(url)?.[1]?.toLowerCase() : void 0;
|
|
106
|
+
const host = app ? ANDROID_APP_HOSTS[app] ?? app : url ? referrerHostOf.exec(url)?.[1]?.toLowerCase() : void 0;
|
|
107
|
+
return host ? siteTouch(host, ownHosts) : null;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* 6. The `ref` parameter of the landing page, when the session has no referrer at all: the
|
|
111
|
+
* convention of sites that link out — Product Hunt's `?ref=producthunt`, newsletters,
|
|
112
|
+
* directories, "built with" badges. It names the site when the browser does not: a click from a
|
|
113
|
+
* mail client, an in-app browser or a page under `no-referrer`. With a referrer it is not read —
|
|
114
|
+
* the referrer is where the click happened, and the `ref` only where the link was first written:
|
|
115
|
+
* a search engine indexes the URL with its `?ref=`, another directory copies the link, and the
|
|
116
|
+
* `ref` would credit the first site with the visit the second one sent; an internal link's
|
|
117
|
+
* `?ref=header` would credit the page's own nav. A site's name (`producthunt`) is kept as the
|
|
118
|
+
* channel, with the `utm_source` aliases folded; a host or URL (`producthunt.com`,
|
|
119
|
+
* `https://news.ycombinator.com/`) is read as a referrer's host, so the same site lands on the
|
|
120
|
+
* same channel either way. Ranks with the referrer: nobody paid for that link.
|
|
121
|
+
*/
|
|
122
|
+
function refParam(tags, ownHosts) {
|
|
123
|
+
const location = text(tags, "page_location");
|
|
124
|
+
if (!location || text(tags, "page_referrer")) return null;
|
|
125
|
+
let value;
|
|
126
|
+
try {
|
|
127
|
+
value = new URL(location, "https://localhost").searchParams.get("ref");
|
|
128
|
+
} catch {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
const ref = value?.trim().toLowerCase().split(/[?&#]/)[0]?.replace(/\/+$/, "");
|
|
132
|
+
if (!ref) return null;
|
|
133
|
+
const host = referrerHostOf.exec(ref)?.[1] ?? (ref.includes(".") ? ref.split(/[/:]/)[0] : void 0);
|
|
134
|
+
if (host) return siteTouch(host, ownHosts);
|
|
135
|
+
return {
|
|
136
|
+
channel: SOURCE_ALIASES[ref] ?? ref,
|
|
137
|
+
medium: "referral"
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
105
141
|
* GA4's default channel group of a (channel, medium) pair, with GA4's rule that the source
|
|
106
142
|
* decides too: `linkedin / (not set)` is Organic Social and `email / promo` is Email, because
|
|
107
143
|
* GA4 matches its site lists on the source, not only the medium. Two additions to GA4: Meta's
|
|
@@ -133,19 +169,20 @@ function channelGroupOf(channel, medium) {
|
|
|
133
169
|
* Reads the tags a session arrived with (its `session_start` tags) as one touch. Run once, when
|
|
134
170
|
* the session is written; the result is stored, and the views only read it.
|
|
135
171
|
*
|
|
136
|
-
* The rules, in order, the first that says something naming the channel: an explicit
|
|
137
|
-
*
|
|
138
|
-
* `(direct)` when none does. `medium` is `utm_medium` as
|
|
139
|
-
* rule implies: `(not set)` for a utm_source alone, `cpc`
|
|
140
|
-
* the product rule's own, `
|
|
172
|
+
* The rules, in order, the first that says something naming the channel: an explicit `utm_source`,
|
|
173
|
+
* a click id, an ad landing page, the product's own rules, the referrer's host, the landing page's
|
|
174
|
+
* `?ref=` when there is no referrer; `(direct)` when none does. `medium` is `utm_medium` as
|
|
175
|
+
* declared (lower-cased), else what that rule implies: `(not set)` for a utm_source alone, `cpc`
|
|
176
|
+
* for a bare click id or ad landing page, the product rule's own, `referral` for a `?ref=` site
|
|
177
|
+
* name, `organic` / `social` / `video` / `referral` for a referrer (or a host in `?ref=`), `(none)`
|
|
141
178
|
* for direct. An ad-only click id of the channel itself (`'ads'` in `CLICK_ID_CHANNELS`) or its ad
|
|
142
179
|
* landing page makes it `cpc` when the medium was left out or puts the touch in an organic group:
|
|
143
180
|
* the click was paid whatever the tag said — a Reddit ad tagged `utm_medium=social`, a ChatGPT ad
|
|
144
|
-
* keeping the `utm_source=chatgpt.com` of its organic links. `campaign` is `utm_campaign`, else
|
|
145
|
-
* product rule captured — even when the utm named the channel, so a tagged referral link
|
|
146
|
-
* its code. `priority` is the tier of the rule that named the channel (`TOUCH_PRIORITY`):
|
|
147
|
-
* campaign touches, then the product's own as they declare, then the referrer — except
|
|
148
|
-
* campaign rule whose touch lands in an organic group, referral (`utm_source=chatgpt.com`,
|
|
181
|
+
* keeping the `utm_source=chatgpt.com` of its organic links. `campaign` is `utm_campaign`, else
|
|
182
|
+
* what a product rule captured — even when the utm named the channel, so a tagged referral link
|
|
183
|
+
* keeps its code. `priority` is the tier of the rule that named the channel (`TOUCH_PRIORITY`):
|
|
184
|
+
* campaign touches, then the product's own as they declare, then the referrer and `?ref=` — except
|
|
185
|
+
* that a campaign rule whose touch lands in an organic group, referral (`utm_source=chatgpt.com`,
|
|
149
186
|
* `utm_medium=organic`) or email ranks with a referrer: nobody paid for that click, and it should
|
|
150
187
|
* not take the credit from an ad clicked earlier in the attribution window. When a product rule
|
|
151
188
|
* matched too, the stronger of the two tiers holds.
|
|
@@ -155,7 +192,8 @@ function classifyTouch(tags, options = {}) {
|
|
|
155
192
|
const click = clickChannel(tags);
|
|
156
193
|
const landing = landingChannel(tags);
|
|
157
194
|
const product = productTouch(tags, options.rules ?? []);
|
|
158
|
-
const
|
|
195
|
+
const ownHosts = options.ownHosts ?? [];
|
|
196
|
+
const referred = referrer(tags, ownHosts) ?? refParam(tags, ownHosts);
|
|
159
197
|
const channel = utm ?? click ?? landing ?? product?.channel ?? referred?.channel ?? "(direct)";
|
|
160
198
|
const namedMedium = text(tags, "utm_medium")?.toLowerCase() ?? null ?? (utm ? "(not set)" : click || landing ? "cpc" : product?.medium ?? referred?.medium ?? "(none)");
|
|
161
199
|
const medium = (adClickChannel(tags) === channel || landing === channel) && (namedMedium === "(not set)" || ORGANIC_GROUPS.has(channelGroupOf(channel, namedMedium))) ? "cpc" : namedMedium;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"classify.mjs","names":[],"sources":["../../src/attribution/classify.ts"],"sourcesContent":["import type { TrackTags } from '../track/types';\nimport {\n AD_LANDING_PAGE,\n ANDROID_APP_HOSTS,\n CLICK_ID_CHANNELS,\n type ChannelGroup,\n DIRECT_CHANNEL,\n DISPLAY_MEDIUMS,\n EMAIL_MEDIUM,\n EMAIL_MEDIUMS,\n MEDIUM_NOT_SET,\n META_PLACEMENTS,\n NO_MEDIUM,\n PAID_MEDIUM,\n PUSH_MEDIUM,\n REFERRERS_NOT_A_TOUCH,\n REFERRER_SITES,\n SOURCE_ALIASES,\n TOUCH_PRIORITY,\n} from './vocabulary';\n\n/** What a session's tags say about how it arrived: the touch, as the session row stores it. */\nexport interface Touch {\n /** The source, as GA calls it: a `Channel`, a `utm_source` outside the list, a referring host, or `(direct)`. */\n channel: string;\n /** How, as GA calls it: `utm_medium` as declared, else what the rule that named the channel implies; `(none)` for direct. */\n medium: string;\n /** GA4's default channel group of the two. */\n channel_group: ChannelGroup;\n /** `utm_campaign`, else what a product rule captured (a referral code); null when neither. */\n campaign: string | null;\n /** `TOUCH_PRIORITY` of the rule that named the channel; null for direct, which is no touch. */\n priority: number | null;\n}\n\n/** What a product rule names, when it recognises the session; the channel group is derived. */\nexport type ProductTouch = Omit<Touch, 'channel_group'>;\n\n/**\n * A product's own rule: a landing page convention, a code in the URL. Tried after the campaign\n * rules (utm, click id, ad landing page) and before the referrer, so a link shared on Facebook\n * or WhatsApp is credited to what the product recognises, not to the site it was shared on.\n * Returns null when the session is not one of its.\n */\nexport type TouchRule = (tags: TrackTags) => ProductTouch | null;\n\nexport interface ClassifyOptions {\n /**\n * The product's own hosts, as patterns over the lower-cased referrer host: a session that\n * starts from an internal link after the session timeout refers to them, which is navigation,\n * not acquisition. The SDK adds the payment and sign-in providers (`REFERRERS_NOT_A_TOUCH`).\n */\n ownHosts?: readonly RegExp[];\n /** The product's rules, in order; the first that names the session wins. */\n rules?: readonly TouchRule[];\n}\n\nconst metaPlacements = new Set<string>(META_PLACEMENTS);\n/** The groups no one paid for: a campaign rule landing in one of them ranks with a referrer. */\nconst ORGANIC_GROUPS = new Set<ChannelGroup>([\n 'organic_search',\n 'organic_social',\n 'organic_video',\n 'organic_ai',\n 'referral',\n]);\n/**\n * The groups a campaign rule ranks with a referrer in: the organic ones, and the messages we send\n * — email, SMS, push. They reach people we already know — often because an ad brought them — and\n * their links (a welcome mail, a trial reminder) come between that ad and the purchase, so they\n * must not take the ad's credit. With no ad in the window they still win as the latest touch.\n */\nconst REFERRER_TIER_GROUPS = new Set<ChannelGroup>([\n ...ORGANIC_GROUPS,\n 'email',\n 'sms',\n 'mobile_push',\n]);\nconst referrerHostOf = /^https?:\\/\\/([^/:?#]+)/i;\nconst androidAppOf = /^android-app:\\/\\/([^/?#]+)/i;\nconst channelsBy = (medium: string) =>\n new Set<string>(REFERRER_SITES.filter(([, m]) => m === medium).map(([channel]) => channel));\nconst searchChannels = channelsBy('organic');\nconst socialChannels = channelsBy('social');\nconst videoChannels = channelsBy('video');\nconst aiChannels = channelsBy('ai');\n\n/**\n * A tag as text; absent when missing, empty, or the strings a broken template writes (`undefined`,\n * `null`). A macro the ad platform did not expand (`{{campaign_name}}`, `__CAMPAIGN_NAME__`) is\n * kept as written, so the misspelt template shows in the reports and gets fixed.\n */\nfunction text(tags: TrackTags, key: string): string | null {\n const value = tags[key];\n if (value === undefined || value === null) return null;\n const s = String(value);\n return s === '' || s === 'undefined' || s === 'null' ? null : s;\n}\n\n/**\n * 1. What the campaign said: `utm_source`, with the aliases folded. It comes before the click\n * ids — unlike GA4, where a usable `gclid` always wins over the utm (Universal Analytics' \"manual\n * tagging overrides auto-tagging\" has no GA4 counterpart): a utm is written for this link by whoever placed it, while a click id\n * can be added by someone else — Meta puts `fbclid` on organic links too, and a shared or\n * forwarded ad link keeps the click id of the ad. When the two disagree, the utm is the one that\n * was right: an Instagram profile link carrying `fbclid`, a newsletter link copied from an ad.\n * The one exception is an ad-only click id of the utm's own channel, see `classifyTouch`.\n */\nfunction utmChannel(tags: TrackTags): string | null {\n // A source with the rest of the query glued on (`email&utm_medium=promo`, `toolify/`,\n // `x?utm_source=x`): a link built by hand or double-encoded. Keep the source.\n const source = text(tags, 'utm_source')?.toLowerCase().split(/[?&#]/)[0]?.replace(/\\/+$/, '');\n if (!source) return null;\n return (SOURCE_ALIASES as Record<string, string>)[source] ?? source;\n}\n\n/**\n * 2. A click id in this page's URL — the tags never fill one from the cookie an earlier click\n * left (that is `_gcl_aw`, `_fbc`, …). Only a value counts, not the key: clients before mid-2025\n * wrote `fbclid: null` (`URLSearchParams.get` of a missing parameter) into every page's tags, and\n * reading the key alone named those sessions Meta clicks — Google ad clicks among them, as `fbclid`\n * is listed first. A platform always puts a value on its click.\n */\nfunction clickChannel(tags: TrackTags): string | null {\n return CLICK_ID_CHANNELS.find(([key]) => text(tags, key) !== null)?.[1] ?? null;\n}\n\n/** The channel of the first click id in the tags that its platform puts on ad clicks only. */\nfunction adClickChannel(tags: TrackTags): string | null {\n return (\n CLICK_ID_CHANNELS.find(([key, , on]) => on === 'ads' && text(tags, key) !== null)?.[1] ?? null\n );\n}\n\n/** 3. A landing page reserved for one channel's ads. */\nfunction landingChannel(tags: TrackTags): string | null {\n const location = text(tags, 'page_location');\n return location ? (AD_LANDING_PAGE.exec(location)?.[1] ?? null) : null;\n}\n\n/** 4. The product's rules, in order. */\nfunction productTouch(tags: TrackTags, rules: readonly TouchRule[]): ProductTouch | null {\n for (const rule of rules) {\n const touch = rule(tags);\n if (touch) return touch;\n }\n return null;\n}\n\n/**\n * 5. The referrer's host: a known search engine or social network folded to its channel, any\n * other site kept as its host, and null for no referrer or one that is no touch. An Android app's\n * referrer (`android-app://<package>`) is read as the site it stands for (`ANDROID_APP_HOSTS`).\n */\nfunction referrer(\n tags: TrackTags,\n ownHosts: readonly RegExp[]\n): { channel: string; medium: string } | null {\n const url = text(tags, 'page_referrer');\n const app = url ? androidAppOf.exec(url)?.[1]?.toLowerCase() : undefined;\n const host = app\n ? (ANDROID_APP_HOSTS[app] ?? app)\n : url\n ? referrerHostOf.exec(url)?.[1]?.toLowerCase()\n : undefined;\n if (!host) return null;\n if (REFERRERS_NOT_A_TOUCH.some((pattern) => pattern.test(host))) return null;\n if (ownHosts.some((pattern) => pattern.test(host))) return null;\n const site = REFERRER_SITES.find(([, , pattern]) => pattern.test(host));\n return site ? { channel: site[0], medium: site[1] } : { channel: host, medium: 'referral' };\n}\n\n/**\n * GA4's default channel group of a (channel, medium) pair, with GA4's rule that the source\n * decides too: `linkedin / (not set)` is Organic Social and `email / promo` is Email, because\n * GA4 matches its site lists on the source, not only the medium. Two additions to GA4: Meta's\n * placement names as a medium are a paid Meta click (see `META_PLACEMENTS`), and the AI\n * assistants have `organic_ai`. Checked in GA4's order — paid before organic, the organic groups before\n * email, then referral, SMS and push — so a pair that fits two rules lands where GA4 would put it.\n */\nexport function channelGroupOf(channel: string, medium: string): ChannelGroup {\n if (channel === DIRECT_CHANNEL) return 'direct';\n if ((DISPLAY_MEDIUMS as readonly string[]).includes(medium)) return 'display';\n // A Meta placement as the medium is a Meta ad whatever the source was tagged as.\n if (metaPlacements.has(medium)) return 'paid_social';\n if (PAID_MEDIUM.test(medium)) {\n if (socialChannels.has(channel) || videoChannels.has(channel)) return 'paid_social';\n if (searchChannels.has(channel)) return 'paid_search';\n return 'paid_other';\n }\n if (medium === 'social' || socialChannels.has(channel)) return 'organic_social';\n if (medium === 'video' || videoChannels.has(channel)) return 'organic_video';\n if (medium === 'ai' || aiChannels.has(channel)) return 'organic_ai';\n if (medium === 'organic' || searchChannels.has(channel)) return 'organic_search';\n if (EMAIL_MEDIUM.test(medium) || (EMAIL_MEDIUMS as readonly string[]).includes(channel))\n return 'email';\n if (medium === 'affiliate') return 'affiliate';\n if (medium === 'referral') return 'referral';\n if (medium === 'sms' || channel === 'sms') return 'sms';\n if (PUSH_MEDIUM.test(medium) || channel === 'firebase') return 'mobile_push';\n return 'unassigned';\n}\n\n/**\n * Reads the tags a session arrived with (its `session_start` tags) as one touch. Run once, when\n * the session is written; the result is stored, and the views only read it.\n *\n * The rules, in order, the first that says something naming the channel: an explicit\n * `utm_source`, a click id, an ad landing page, the product's own rules, the referrer's host;\n * `(direct)` when none does. `medium` is `utm_medium` as declared (lower-cased), else what that\n * rule implies: `(not set)` for a utm_source alone, `cpc` for a bare click id or ad landing page,\n * the product rule's own, `organic` / `social` / `video` / `referral` for a referrer, `(none)`\n * for direct. An ad-only click id of the channel itself (`'ads'` in `CLICK_ID_CHANNELS`) or its ad\n * landing page makes it `cpc` when the medium was left out or puts the touch in an organic group:\n * the click was paid whatever the tag said — a Reddit ad tagged `utm_medium=social`, a ChatGPT ad\n * keeping the `utm_source=chatgpt.com` of its organic links. `campaign` is `utm_campaign`, else what a\n * product rule captured — even when the utm named the channel, so a tagged referral link keeps\n * its code. `priority` is the tier of the rule that named the channel (`TOUCH_PRIORITY`):\n * campaign touches, then the product's own as they declare, then the referrer — except that a\n * campaign rule whose touch lands in an organic group, referral (`utm_source=chatgpt.com`,\n * `utm_medium=organic`) or email ranks with a referrer: nobody paid for that click, and it should\n * not take the credit from an ad clicked earlier in the attribution window. When a product rule\n * matched too, the stronger of the two tiers holds.\n */\nexport function classifyTouch(tags: TrackTags, options: ClassifyOptions = {}): Touch {\n const utm = utmChannel(tags);\n const click = clickChannel(tags);\n const landing = landingChannel(tags);\n const product = productTouch(tags, options.rules ?? []);\n const referred = referrer(tags, options.ownHosts ?? []);\n\n const channel =\n utm ?? click ?? landing ?? product?.channel ?? referred?.channel ?? DIRECT_CHANNEL;\n const declaredMedium = text(tags, 'utm_medium')?.toLowerCase() ?? null;\n const namedMedium =\n declaredMedium ??\n (utm\n ? MEDIUM_NOT_SET\n : click || landing\n ? 'cpc'\n : (product?.medium ?? referred?.medium ?? NO_MEDIUM));\n // What proves a paid click on this channel: an ad-only click id of it, or its ad landing page.\n // Another channel's says nothing about this one (an ad link copied and shared under a utm of\n // its own), and `fbclid` is on organic Meta links too, so neither counts here.\n const paid = adClickChannel(tags) === channel || landing === channel;\n const medium =\n paid &&\n (namedMedium === MEDIUM_NOT_SET || ORGANIC_GROUPS.has(channelGroupOf(channel, namedMedium)))\n ? 'cpc'\n : namedMedium;\n const channelGroup = channelGroupOf(channel, medium);\n const campaign = text(tags, 'utm_campaign') ?? product?.campaign ?? null;\n const campaignTier =\n utm || click || landing\n ? REFERRER_TIER_GROUPS.has(channelGroup)\n ? TOUCH_PRIORITY.referrer\n : TOUCH_PRIORITY.campaign\n : null;\n // The stronger of the campaign rule's tier and the product rule's own: a referral link shared\n // under `utm_source=whatsapp` is named by the utm, and still ranks as the programme declares.\n const ranked = [campaignTier, product?.priority ?? null].filter((tier) => tier !== null);\n const priority =\n ranked.length > 0 ? Math.min(...ranked) : referred ? TOUCH_PRIORITY.referrer : null;\n\n return { channel, medium, channel_group: channelGroup, campaign, priority };\n}\n"],"mappings":";;AAyDA,MAAM,iBAAiB,IAAI,IAAY,eAAe;;AAEtD,MAAM,iCAAiB,IAAI,IAAkB;CAC3C;CACA;CACA;CACA;CACA;AACF,CAAC;;;;;;;AAOD,MAAM,uCAAuB,IAAI,IAAkB;CACjD,GAAG;CACH;CACA;CACA;AACF,CAAC;AACD,MAAM,iBAAiB;AACvB,MAAM,eAAe;AACrB,MAAM,cAAc,WAClB,IAAI,IAAY,eAAe,QAAQ,GAAG,OAAO,MAAM,MAAM,CAAC,CAAC,KAAK,CAAC,aAAa,OAAO,CAAC;AAC5F,MAAM,iBAAiB,WAAW,SAAS;AAC3C,MAAM,iBAAiB,WAAW,QAAQ;AAC1C,MAAM,gBAAgB,WAAW,OAAO;AACxC,MAAM,aAAa,WAAW,IAAI;;;;;;AAOlC,SAAS,KAAK,MAAiB,KAA4B;CACzD,MAAM,QAAQ,KAAK;CACnB,IAAI,UAAU,KAAA,KAAa,UAAU,MAAM,OAAO;CAClD,MAAM,IAAI,OAAO,KAAK;CACtB,OAAO,MAAM,MAAM,MAAM,eAAe,MAAM,SAAS,OAAO;AAChE;;;;;;;;;;AAWA,SAAS,WAAW,MAAgC;CAGlD,MAAM,SAAS,KAAK,MAAM,YAAY,CAAC,EAAE,YAAY,CAAC,CAAC,MAAM,OAAO,CAAC,CAAC,EAAE,EAAE,QAAQ,QAAQ,EAAE;CAC5F,IAAI,CAAC,QAAQ,OAAO;CACpB,OAAQ,eAA0C,WAAW;AAC/D;;;;;;;;AASA,SAAS,aAAa,MAAgC;CACpD,OAAO,kBAAkB,MAAM,CAAC,SAAS,KAAK,MAAM,GAAG,MAAM,IAAI,CAAC,GAAG,MAAM;AAC7E;;AAGA,SAAS,eAAe,MAAgC;CACtD,OACE,kBAAkB,MAAM,CAAC,OAAO,QAAQ,OAAO,SAAS,KAAK,MAAM,GAAG,MAAM,IAAI,CAAC,GAAG,MAAM;AAE9F;;AAGA,SAAS,eAAe,MAAgC;CACtD,MAAM,WAAW,KAAK,MAAM,eAAe;CAC3C,OAAO,WAAY,gBAAgB,KAAK,QAAQ,CAAC,GAAG,MAAM,OAAQ;AACpE;;AAGA,SAAS,aAAa,MAAiB,OAAkD;CACvF,KAAK,MAAM,QAAQ,OAAO;EACxB,MAAM,QAAQ,KAAK,IAAI;EACvB,IAAI,OAAO,OAAO;CACpB;CACA,OAAO;AACT;;;;;;AAOA,SAAS,SACP,MACA,UAC4C;CAC5C,MAAM,MAAM,KAAK,MAAM,eAAe;CACtC,MAAM,MAAM,MAAM,aAAa,KAAK,GAAG,CAAC,GAAG,EAAE,EAAE,YAAY,IAAI,KAAA;CAC/D,MAAM,OAAO,MACR,kBAAkB,QAAQ,MAC3B,MACE,eAAe,KAAK,GAAG,CAAC,GAAG,EAAE,EAAE,YAAY,IAC3C,KAAA;CACN,IAAI,CAAC,MAAM,OAAO;CAClB,IAAI,sBAAsB,MAAM,YAAY,QAAQ,KAAK,IAAI,CAAC,GAAG,OAAO;CACxE,IAAI,SAAS,MAAM,YAAY,QAAQ,KAAK,IAAI,CAAC,GAAG,OAAO;CAC3D,MAAM,OAAO,eAAe,MAAM,KAAK,aAAa,QAAQ,KAAK,IAAI,CAAC;CACtE,OAAO,OAAO;EAAE,SAAS,KAAK;EAAI,QAAQ,KAAK;CAAG,IAAI;EAAE,SAAS;EAAM,QAAQ;CAAW;AAC5F;;;;;;;;;AAUA,SAAgB,eAAe,SAAiB,QAA8B;CAC5E,IAAI,YAAA,YAA4B,OAAO;CACvC,IAAK,gBAAsC,SAAS,MAAM,GAAG,OAAO;CAEpE,IAAI,eAAe,IAAI,MAAM,GAAG,OAAO;CACvC,IAAI,YAAY,KAAK,MAAM,GAAG;EAC5B,IAAI,eAAe,IAAI,OAAO,KAAK,cAAc,IAAI,OAAO,GAAG,OAAO;EACtE,IAAI,eAAe,IAAI,OAAO,GAAG,OAAO;EACxC,OAAO;CACT;CACA,IAAI,WAAW,YAAY,eAAe,IAAI,OAAO,GAAG,OAAO;CAC/D,IAAI,WAAW,WAAW,cAAc,IAAI,OAAO,GAAG,OAAO;CAC7D,IAAI,WAAW,QAAQ,WAAW,IAAI,OAAO,GAAG,OAAO;CACvD,IAAI,WAAW,aAAa,eAAe,IAAI,OAAO,GAAG,OAAO;CAChE,IAAI,aAAa,KAAK,MAAM,KAAM,cAAoC,SAAS,OAAO,GACpF,OAAO;CACT,IAAI,WAAW,aAAa,OAAO;CACnC,IAAI,WAAW,YAAY,OAAO;CAClC,IAAI,WAAW,SAAS,YAAY,OAAO,OAAO;CAClD,IAAI,YAAY,KAAK,MAAM,KAAK,YAAY,YAAY,OAAO;CAC/D,OAAO;AACT;;;;;;;;;;;;;;;;;;;;;;AAuBA,SAAgB,cAAc,MAAiB,UAA2B,CAAC,GAAU;CACnF,MAAM,MAAM,WAAW,IAAI;CAC3B,MAAM,QAAQ,aAAa,IAAI;CAC/B,MAAM,UAAU,eAAe,IAAI;CACnC,MAAM,UAAU,aAAa,MAAM,QAAQ,SAAS,CAAC,CAAC;CACtD,MAAM,WAAW,SAAS,MAAM,QAAQ,YAAY,CAAC,CAAC;CAEtD,MAAM,UACJ,OAAO,SAAS,WAAW,SAAS,WAAW,UAAU,WAAA;CAE3D,MAAM,cADiB,KAAK,MAAM,YAAY,CAAC,EAAE,YAAY,KAAK,SAG/D,MAAA,cAEG,SAAS,UACP,QACC,SAAS,UAAU,UAAU,UAAA;CAKtC,MAAM,UADO,eAAe,IAAI,MAAM,WAAW,YAAY,aAG1D,gBAAA,eAAkC,eAAe,IAAI,eAAe,SAAS,WAAW,CAAC,KACtF,QACA;CACN,MAAM,eAAe,eAAe,SAAS,MAAM;CACnD,MAAM,WAAW,KAAK,MAAM,cAAc,KAAK,SAAS,YAAY;CASpE,MAAM,SAAS,CAPb,OAAO,SAAS,UACZ,qBAAqB,IAAI,YAAY,IACnC,eAAe,WACf,eAAe,WACjB,MAGwB,SAAS,YAAY,IAAI,CAAC,CAAC,QAAQ,SAAS,SAAS,IAAI;CAIvF,OAAO;EAAE;EAAS;EAAQ,eAAe;EAAc;EAAU,UAF/D,OAAO,SAAS,IAAI,KAAK,IAAI,GAAG,MAAM,IAAI,WAAW,eAAe,WAAW;CAEP;AAC5E"}
|
|
1
|
+
{"version":3,"file":"classify.mjs","names":[],"sources":["../../src/attribution/classify.ts"],"sourcesContent":["import type { TrackTags } from '../track/types';\nimport {\n AD_LANDING_PAGE,\n ANDROID_APP_HOSTS,\n CLICK_ID_CHANNELS,\n type ChannelGroup,\n DIRECT_CHANNEL,\n DISPLAY_MEDIUMS,\n EMAIL_MEDIUM,\n EMAIL_MEDIUMS,\n MEDIUM_NOT_SET,\n META_PLACEMENTS,\n NO_MEDIUM,\n PAID_MEDIUM,\n PUSH_MEDIUM,\n REFERRERS_NOT_A_TOUCH,\n REFERRER_SITES,\n SOURCE_ALIASES,\n TOUCH_PRIORITY,\n} from './vocabulary';\n\n/** What a session's tags say about how it arrived: the touch, as the session row stores it. */\nexport interface Touch {\n /** The source, as GA calls it: a `Channel`, a `utm_source` outside the list, a referring host, or `(direct)`. */\n channel: string;\n /** How, as GA calls it: `utm_medium` as declared, else what the rule that named the channel implies; `(none)` for direct. */\n medium: string;\n /** GA4's default channel group of the two. */\n channel_group: ChannelGroup;\n /** `utm_campaign`, else what a product rule captured (a referral code); null when neither. */\n campaign: string | null;\n /** `TOUCH_PRIORITY` of the rule that named the channel; null for direct, which is no touch. */\n priority: number | null;\n}\n\n/** What a product rule names, when it recognises the session; the channel group is derived. */\nexport type ProductTouch = Omit<Touch, 'channel_group'>;\n\n/**\n * A product's own rule: a landing page convention, a code in the URL. Tried after the campaign\n * rules (utm, click id, ad landing page) and before the referrer and `?ref=`, so a link shared on\n * Facebook or WhatsApp is credited to what the product recognises, not to the site it was shared\n * on — and a product whose `?ref=` carries a referral code, not a site, claims it here first.\n * Returns null when the session is not one of its.\n */\nexport type TouchRule = (tags: TrackTags) => ProductTouch | null;\n\nexport interface ClassifyOptions {\n /**\n * The product's own hosts, as patterns over the lower-cased referrer host: a session that\n * starts from an internal link after the session timeout refers to them, which is navigation,\n * not acquisition. The SDK adds the payment and sign-in providers (`REFERRERS_NOT_A_TOUCH`).\n */\n ownHosts?: readonly RegExp[];\n /** The product's rules, in order; the first that names the session wins. */\n rules?: readonly TouchRule[];\n}\n\n/** What a site names, from the referrer or `?ref=`: no campaign, and the referrer's tier. */\ntype SiteTouch = Pick<Touch, 'channel' | 'medium'>;\n\nconst metaPlacements = new Set<string>(META_PLACEMENTS);\n/** The groups no one paid for: a campaign rule landing in one of them ranks with a referrer. */\nconst ORGANIC_GROUPS = new Set<ChannelGroup>([\n 'organic_search',\n 'organic_social',\n 'organic_video',\n 'organic_ai',\n 'referral',\n]);\n/**\n * The groups a campaign rule ranks with a referrer in: the organic ones, and the messages we send\n * — email, SMS, push. They reach people we already know — often because an ad brought them — and\n * their links (a welcome mail, a trial reminder) come between that ad and the purchase, so they\n * must not take the ad's credit. With no ad in the window they still win as the latest touch.\n */\nconst REFERRER_TIER_GROUPS = new Set<ChannelGroup>([\n ...ORGANIC_GROUPS,\n 'email',\n 'sms',\n 'mobile_push',\n]);\nconst referrerHostOf = /^https?:\\/\\/([^/:?#]+)/i;\nconst androidAppOf = /^android-app:\\/\\/([^/?#]+)/i;\nconst channelsBy = (medium: string) =>\n new Set<string>(REFERRER_SITES.filter(([, m]) => m === medium).map(([channel]) => channel));\nconst searchChannels = channelsBy('organic');\nconst socialChannels = channelsBy('social');\nconst videoChannels = channelsBy('video');\nconst aiChannels = channelsBy('ai');\n\n/**\n * A tag as text; absent when missing, empty, or the strings a broken template writes (`undefined`,\n * `null`). A macro the ad platform did not expand (`{{campaign_name}}`, `__CAMPAIGN_NAME__`) is\n * kept as written, so the misspelt template shows in the reports and gets fixed.\n */\nfunction text(tags: TrackTags, key: string): string | null {\n const value = tags[key];\n if (value === undefined || value === null) return null;\n const s = String(value);\n return s === '' || s === 'undefined' || s === 'null' ? null : s;\n}\n\n/**\n * 1. What the campaign said: `utm_source`, with the aliases folded. It comes before the click\n * ids — unlike GA4, where a usable `gclid` always wins over the utm (Universal Analytics' \"manual\n * tagging overrides auto-tagging\" has no GA4 counterpart): a utm is written for this link by whoever placed it, while a click id\n * can be added by someone else — Meta puts `fbclid` on organic links too, and a shared or\n * forwarded ad link keeps the click id of the ad. When the two disagree, the utm is the one that\n * was right: an Instagram profile link carrying `fbclid`, a newsletter link copied from an ad.\n * The one exception is an ad-only click id of the utm's own channel, see `classifyTouch`.\n */\nfunction utmChannel(tags: TrackTags): string | null {\n // A source with the rest of the query glued on (`email&utm_medium=promo`, `toolify/`,\n // `x?utm_source=x`): a link built by hand or double-encoded. Keep the source.\n const source = text(tags, 'utm_source')?.toLowerCase().split(/[?&#]/)[0]?.replace(/\\/+$/, '');\n if (!source) return null;\n return (SOURCE_ALIASES as Record<string, string>)[source] ?? source;\n}\n\n/**\n * 2. A click id in this page's URL — the tags never fill one from the cookie an earlier click\n * left (that is `_gcl_aw`, `_fbc`, …). Only a value counts, not the key: clients before mid-2025\n * wrote `fbclid: null` (`URLSearchParams.get` of a missing parameter) into every page's tags, and\n * reading the key alone named those sessions Meta clicks — Google ad clicks among them, as `fbclid`\n * is listed first. A platform always puts a value on its click.\n */\nfunction clickChannel(tags: TrackTags): string | null {\n return CLICK_ID_CHANNELS.find(([key]) => text(tags, key) !== null)?.[1] ?? null;\n}\n\n/** The channel of the first click id in the tags that its platform puts on ad clicks only. */\nfunction adClickChannel(tags: TrackTags): string | null {\n return (\n CLICK_ID_CHANNELS.find(([key, , on]) => on === 'ads' && text(tags, key) !== null)?.[1] ?? null\n );\n}\n\n/** 3. A landing page reserved for one channel's ads. */\nfunction landingChannel(tags: TrackTags): string | null {\n const location = text(tags, 'page_location');\n return location ? (AD_LANDING_PAGE.exec(location)?.[1] ?? null) : null;\n}\n\n/** 4. The product's rules, in order. */\nfunction productTouch(tags: TrackTags, rules: readonly TouchRule[]): ProductTouch | null {\n for (const rule of rules) {\n const touch = rule(tags);\n if (touch) return touch;\n }\n return null;\n}\n\n/**\n * A referring site's host as a touch: a known search engine or social network folded to its\n * channel, any other site kept as its host, and null for a host that is no touch.\n */\nfunction siteTouch(host: string, ownHosts: readonly RegExp[]): SiteTouch | null {\n if (REFERRERS_NOT_A_TOUCH.some((pattern) => pattern.test(host))) return null;\n if (ownHosts.some((pattern) => pattern.test(host))) return null;\n const site = REFERRER_SITES.find(([, , pattern]) => pattern.test(host));\n return site ? { channel: site[0], medium: site[1] } : { channel: host, medium: 'referral' };\n}\n\n/**\n * 5. The referrer's host, read as `siteTouch`; null for no referrer. An Android app's referrer\n * (`android-app://<package>`) is read as the site it stands for (`ANDROID_APP_HOSTS`).\n */\nfunction referrer(tags: TrackTags, ownHosts: readonly RegExp[]): SiteTouch | null {\n const url = text(tags, 'page_referrer');\n const app = url ? androidAppOf.exec(url)?.[1]?.toLowerCase() : undefined;\n const host = app\n ? (ANDROID_APP_HOSTS[app] ?? app)\n : url\n ? referrerHostOf.exec(url)?.[1]?.toLowerCase()\n : undefined;\n return host ? siteTouch(host, ownHosts) : null;\n}\n\n/**\n * 6. The `ref` parameter of the landing page, when the session has no referrer at all: the\n * convention of sites that link out — Product Hunt's `?ref=producthunt`, newsletters,\n * directories, \"built with\" badges. It names the site when the browser does not: a click from a\n * mail client, an in-app browser or a page under `no-referrer`. With a referrer it is not read —\n * the referrer is where the click happened, and the `ref` only where the link was first written:\n * a search engine indexes the URL with its `?ref=`, another directory copies the link, and the\n * `ref` would credit the first site with the visit the second one sent; an internal link's\n * `?ref=header` would credit the page's own nav. A site's name (`producthunt`) is kept as the\n * channel, with the `utm_source` aliases folded; a host or URL (`producthunt.com`,\n * `https://news.ycombinator.com/`) is read as a referrer's host, so the same site lands on the\n * same channel either way. Ranks with the referrer: nobody paid for that link.\n */\nfunction refParam(tags: TrackTags, ownHosts: readonly RegExp[]): SiteTouch | null {\n const location = text(tags, 'page_location');\n if (!location || text(tags, 'page_referrer')) return null;\n let value: string | null;\n try {\n value = new URL(location, 'https://localhost').searchParams.get('ref');\n } catch {\n return null;\n }\n // A value with the rest of the query glued on (`producthunt?utm_source=x`), as `utmChannel`\n // keeps the source of one; a bare host loses its port and path, as the referrer's does.\n const ref = value?.trim().toLowerCase().split(/[?&#]/)[0]?.replace(/\\/+$/, '');\n if (!ref) return null;\n const host =\n referrerHostOf.exec(ref)?.[1] ?? (ref.includes('.') ? ref.split(/[/:]/)[0] : undefined);\n if (host) return siteTouch(host, ownHosts);\n return {\n channel: (SOURCE_ALIASES as Record<string, string>)[ref] ?? ref,\n medium: 'referral',\n };\n}\n\n/**\n * GA4's default channel group of a (channel, medium) pair, with GA4's rule that the source\n * decides too: `linkedin / (not set)` is Organic Social and `email / promo` is Email, because\n * GA4 matches its site lists on the source, not only the medium. Two additions to GA4: Meta's\n * placement names as a medium are a paid Meta click (see `META_PLACEMENTS`), and the AI\n * assistants have `organic_ai`. Checked in GA4's order — paid before organic, the organic groups before\n * email, then referral, SMS and push — so a pair that fits two rules lands where GA4 would put it.\n */\nexport function channelGroupOf(channel: string, medium: string): ChannelGroup {\n if (channel === DIRECT_CHANNEL) return 'direct';\n if ((DISPLAY_MEDIUMS as readonly string[]).includes(medium)) return 'display';\n // A Meta placement as the medium is a Meta ad whatever the source was tagged as.\n if (metaPlacements.has(medium)) return 'paid_social';\n if (PAID_MEDIUM.test(medium)) {\n if (socialChannels.has(channel) || videoChannels.has(channel)) return 'paid_social';\n if (searchChannels.has(channel)) return 'paid_search';\n return 'paid_other';\n }\n if (medium === 'social' || socialChannels.has(channel)) return 'organic_social';\n if (medium === 'video' || videoChannels.has(channel)) return 'organic_video';\n if (medium === 'ai' || aiChannels.has(channel)) return 'organic_ai';\n if (medium === 'organic' || searchChannels.has(channel)) return 'organic_search';\n if (EMAIL_MEDIUM.test(medium) || (EMAIL_MEDIUMS as readonly string[]).includes(channel))\n return 'email';\n if (medium === 'affiliate') return 'affiliate';\n if (medium === 'referral') return 'referral';\n if (medium === 'sms' || channel === 'sms') return 'sms';\n if (PUSH_MEDIUM.test(medium) || channel === 'firebase') return 'mobile_push';\n return 'unassigned';\n}\n\n/**\n * Reads the tags a session arrived with (its `session_start` tags) as one touch. Run once, when\n * the session is written; the result is stored, and the views only read it.\n *\n * The rules, in order, the first that says something naming the channel: an explicit `utm_source`,\n * a click id, an ad landing page, the product's own rules, the referrer's host, the landing page's\n * `?ref=` when there is no referrer; `(direct)` when none does. `medium` is `utm_medium` as\n * declared (lower-cased), else what that rule implies: `(not set)` for a utm_source alone, `cpc`\n * for a bare click id or ad landing page, the product rule's own, `referral` for a `?ref=` site\n * name, `organic` / `social` / `video` / `referral` for a referrer (or a host in `?ref=`), `(none)`\n * for direct. An ad-only click id of the channel itself (`'ads'` in `CLICK_ID_CHANNELS`) or its ad\n * landing page makes it `cpc` when the medium was left out or puts the touch in an organic group:\n * the click was paid whatever the tag said — a Reddit ad tagged `utm_medium=social`, a ChatGPT ad\n * keeping the `utm_source=chatgpt.com` of its organic links. `campaign` is `utm_campaign`, else\n * what a product rule captured — even when the utm named the channel, so a tagged referral link\n * keeps its code. `priority` is the tier of the rule that named the channel (`TOUCH_PRIORITY`):\n * campaign touches, then the product's own as they declare, then the referrer and `?ref=` — except\n * that a campaign rule whose touch lands in an organic group, referral (`utm_source=chatgpt.com`,\n * `utm_medium=organic`) or email ranks with a referrer: nobody paid for that click, and it should\n * not take the credit from an ad clicked earlier in the attribution window. When a product rule\n * matched too, the stronger of the two tiers holds.\n */\nexport function classifyTouch(tags: TrackTags, options: ClassifyOptions = {}): Touch {\n const utm = utmChannel(tags);\n const click = clickChannel(tags);\n const landing = landingChannel(tags);\n const product = productTouch(tags, options.rules ?? []);\n const ownHosts = options.ownHosts ?? [];\n const referred = referrer(tags, ownHosts) ?? refParam(tags, ownHosts);\n\n const channel =\n utm ?? click ?? landing ?? product?.channel ?? referred?.channel ?? DIRECT_CHANNEL;\n const declaredMedium = text(tags, 'utm_medium')?.toLowerCase() ?? null;\n const namedMedium =\n declaredMedium ??\n (utm\n ? MEDIUM_NOT_SET\n : click || landing\n ? 'cpc'\n : (product?.medium ?? referred?.medium ?? NO_MEDIUM));\n // What proves a paid click on this channel: an ad-only click id of it, or its ad landing page.\n // Another channel's says nothing about this one (an ad link copied and shared under a utm of\n // its own), and `fbclid` is on organic Meta links too, so neither counts here.\n const paid = adClickChannel(tags) === channel || landing === channel;\n const medium =\n paid &&\n (namedMedium === MEDIUM_NOT_SET || ORGANIC_GROUPS.has(channelGroupOf(channel, namedMedium)))\n ? 'cpc'\n : namedMedium;\n const channelGroup = channelGroupOf(channel, medium);\n const campaign = text(tags, 'utm_campaign') ?? product?.campaign ?? null;\n const campaignTier =\n utm || click || landing\n ? REFERRER_TIER_GROUPS.has(channelGroup)\n ? TOUCH_PRIORITY.referrer\n : TOUCH_PRIORITY.campaign\n : null;\n // The stronger of the campaign rule's tier and the product rule's own: a referral link shared\n // under `utm_source=whatsapp` is named by the utm, and still ranks as the programme declares.\n const ranked = [campaignTier, product?.priority ?? null].filter((tier) => tier !== null);\n const priority =\n ranked.length > 0 ? Math.min(...ranked) : referred ? TOUCH_PRIORITY.referrer : null;\n\n return { channel, medium, channel_group: channelGroup, campaign, priority };\n}\n"],"mappings":"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"}
|