@shware/analytics 11.10.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
@@ -419,11 +419,21 @@ try {
419
419
  it; it posts 7 days per request. Hourly data reaches back at least months — compare each day's
420
420
  sum of hours with the day in the Google Ads UI before relying on older days.
421
421
 
422
- Schedule it hourly. Google Ads emails the account owner when a run throws, as on a non-2xx answer.
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.
423
433
 
424
434
  ```js
425
- const ENDPOINT = 'https://api.example.com/ads/google';
426
- const KEY = '…'; // the bearer key the endpoint checks
435
+ const ENDPOINT = 'https://example.com/v1/adPerformance/google';
436
+ const KEY = '<my_bearer_token>'; // the bearer key the endpoint checks
427
437
  const DAYS = 3; // re-read every hour
428
438
  const RESTATE_DAYS = 30; // re-read once a day, at RESTATE_HOUR in the account's time zone
429
439
  const RESTATE_HOUR = 5;
@@ -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.10.0",
3
+ "version": "11.11.0",
4
4
  "private": false,
5
5
  "repository": {
6
6
  "type": "git",