@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 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 and hour — for a host to store next to its
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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"}
@@ -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;
@@ -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 };
@@ -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 };
@@ -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 };
@@ -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_` is stripped). */
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;
@@ -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;EACA,eAAe;;EAEf;;EAEA;EACA;EACA;;;;;EAKA;;;;;EAKA;EACA;;EAEA;EACA;;;YAIU"}
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"}
@@ -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_` is stripped). */
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;
@@ -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;EACA,eAAe;;EAEf;;EAEA;EACA;EACA;;;;;EAKA;;;;;EAKA;EACA;;EAEA;EACA;;;YAIU"}
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
- * 5. The referrer's host: a known search engine or social network folded to its channel, any
86
- * other site kept as its host, and null for no referrer or one that is no touch. An Android app's
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 referrer(tags, ownHosts) {
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
- * `utm_source`, a click id, an ad landing page, the product's own rules, the referrer's host;
139
- * `(direct)` when none does. `medium` is `utm_medium` as declared (lower-cased), else what that
140
- * rule implies: `(not set)` for a utm_source alone, `cpc` for a bare click id or ad landing page,
141
- * the product rule's own, `organic` / `social` / `video` / `referral` for a referrer, `(none)`
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 what a
146
- * product rule captured — even when the utm named the channel, so a tagged referral link keeps
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 that a
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 referred = referrer(tags, options.ownHosts ?? []);
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, so a link shared on Facebook
22
- * or WhatsApp is credited to what the product recognises, not to the site it was shared on.
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
- * `utm_source`, a click id, an ad landing page, the product's own rules, the referrer's host;
51
- * `(direct)` when none does. `medium` is `utm_medium` as declared (lower-cased), else what that
52
- * rule implies: `(not set)` for a utm_source alone, `cpc` for a bare click id or ad landing page,
53
- * the product rule's own, `organic` / `social` / `video` / `referral` for a referrer, `(none)`
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 what a
58
- * product rule captured — even when the utm named the channel, so a tagged referral link keeps
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 that a
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;;;;;;;YAQpB,aAAa,MAAM,cAAc;iBAE5B;;;;;;EAMf,oBAAoB;;EAEpB,iBAAiB;;;;;;;;;;wBA8HH,eAAe,iBAAiB,iBAAiB;;;;;;;;;;;;;;;;;;;;;;wBA4CjD,cAAc,MAAM,WAAW,UAAS,kBAAuB"}
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, so a link shared on Facebook
22
- * or WhatsApp is credited to what the product recognises, not to the site it was shared on.
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
- * `utm_source`, a click id, an ad landing page, the product's own rules, the referrer's host;
51
- * `(direct)` when none does. `medium` is `utm_medium` as declared (lower-cased), else what that
52
- * rule implies: `(not set)` for a utm_source alone, `cpc` for a bare click id or ad landing page,
53
- * the product rule's own, `organic` / `social` / `video` / `referral` for a referrer, `(none)`
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 what a
58
- * product rule captured — even when the utm named the channel, so a tagged referral link keeps
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 that a
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;;;;;;;YAQpB,aAAa,MAAM,cAAc;iBAE5B;;;;;;EAMf,oBAAoB;;EAEpB,iBAAiB;;;;;;;;;;wBA8HH,eAAe,iBAAiB,iBAAiB;;;;;;;;;;;;;;;;;;;;;;wBA4CjD,cAAc,MAAM,WAAW,UAAS,kBAAuB"}
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
- * 5. The referrer's host: a known search engine or social network folded to its channel, any
85
- * other site kept as its host, and null for no referrer or one that is no touch. An Android app's
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 referrer(tags, ownHosts) {
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
- * `utm_source`, a click id, an ad landing page, the product's own rules, the referrer's host;
138
- * `(direct)` when none does. `medium` is `utm_medium` as declared (lower-cased), else what that
139
- * rule implies: `(not set)` for a utm_source alone, `cpc` for a bare click id or ad landing page,
140
- * the product rule's own, `organic` / `social` / `video` / `referral` for a referrer, `(none)`
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 what a
145
- * product rule captured — even when the utm named the channel, so a tagged referral link keeps
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 that a
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 referred = referrer(tags, options.ownHosts ?? []);
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":"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"}
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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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shware/analytics",
3
- "version": "11.9.0",
3
+ "version": "11.11.0",
4
4
  "private": false,
5
5
  "repository": {
6
6
  "type": "git",