@m13v/seo-components 0.33.0 → 0.34.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/seo-components",
3
- "version": "0.33.0",
3
+ "version": "0.34.1",
4
4
  "scripts": {
5
5
  "build:css": "tailwind -i src/_build.css -o dist/styles.css --minify",
6
6
  "lint:mobile-spans": "node scripts/lint-mobile-spans.mjs",
@@ -16,21 +16,49 @@ export interface DmShortLinkRedirectConfig {
16
16
  posthogHostEnv?: string;
17
17
  }
18
18
 
19
+ /**
20
+ * Bot User-Agent regex. Bots (Twitter card prefetch, LinkedIn unfurl,
21
+ * Slack/Discord/Telegram/WhatsApp link-preview crawlers, generic Google/Bing
22
+ * bots) hammer /r/<code> within seconds of mint to fetch link previews. They
23
+ * inflated the legacy `clicks` counter by ~20x. When this regex matches, the
24
+ * resolver:
25
+ * 1. Skips the counter increment (passes `?bot=1` so the server side
26
+ * records the hit in post_link_clicks / dm_link_clicks with is_bot=true
27
+ * but does NOT bump post_links.clicks / dm_links.clicks).
28
+ * 2. Skips the PostHog `*_short_link_clicked` event (no fake conversions).
29
+ * 3. Still 302s to target_url so previews render.
30
+ */
31
+ const BOT_UA_RE = /bot|crawler|spider|Twitterbot|LinkedInBot|Slackbot|facebookexternalhit|Discordbot|TelegramBot|WhatsApp|Applebot|Googlebot|Bingbot|YandexBot|DuckDuckBot|redditbot|Pinterest|Embedly|Snapchat/i;
32
+
19
33
  /**
20
34
  * Factory for `GET /r/[code]`.
21
35
  *
22
- * Each per-DM short link maps to a Cal.com / Calendly URL with full UTM and
23
- * `metadata[utm_*]` so cal_bookings closes the loop on which DM produced the
24
- * booking. The cached `target_url` is frozen at mint time on the dms row, so
25
- * the resolver is a single DB read with no config.json dependency.
36
+ * Each short link maps to a destination URL. Two rails are supported:
37
+ *
38
+ * DM rail: code is minted from dm_links. Target is a Cal.com / Calendly URL
39
+ * with full UTM and metadata[utm_*] so cal_bookings closes the loop on which
40
+ * DM produced the booking. Fires `dm_short_link_clicked` in PostHog.
41
+ *
42
+ * Post rail: code is minted from post_links (public posts/comments). Target
43
+ * is typically the product homepage or a landing page. UTM params are injected
44
+ * at redirect time (utm_source, utm_medium, utm_campaign, utm_content) so
45
+ * PostHog can stitch the full funnel: post click -> get_started_click ->
46
+ * schedule_click -> checkout_success. Fires `post_short_link_clicked` in
47
+ * PostHog.
26
48
  *
27
49
  * Behavior:
28
50
  * 1. Read `code` from the route param. Reject non-alphanumeric / wrong-length.
29
- * 2. Hit `<resolverBase>/api/short-links/<code>`. The resolver increments
30
- * dms.short_link_clicks and stamps first/last click timestamps.
31
- * 3. Fire a PostHog `dm_short_link_clicked` event with dm_id, project,
32
- * platform, code, site.
33
- * 4. 302 to the resolved target_url. On miss/error, 302 to "/".
51
+ * 2. Read User-Agent. If it matches BOT_UA_RE, pass `?bot=1` to the resolver
52
+ * so it logs the hit but does NOT bump the human-facing counter, and
53
+ * skip the PostHog event.
54
+ * 3. Hit `<resolverBase>/api/short-links/<code>?bot=<0|1>`. The resolver
55
+ * always appends a row to post_link_clicks / dm_link_clicks (with
56
+ * is_bot stamped accordingly). It only increments the legacy clicks
57
+ * counter and stamps first/last click timestamps when is_bot=false.
58
+ * 4. For post rail links: inject UTM params into the target URL.
59
+ * 5. Fire the appropriate PostHog event (dm_short_link_clicked or
60
+ * post_short_link_clicked) fire-and-forget, non-blocking; skipped for bots.
61
+ * 6. 302 to the resolved target_url. On miss/error, 302 to "/".
34
62
  */
35
63
  export function createDmShortLinkRedirectHandler(config: DmShortLinkRedirectConfig) {
36
64
  const {
@@ -53,13 +81,35 @@ export function createDmShortLinkRedirectHandler(config: DmShortLinkRedirectConf
53
81
  return Response.redirect(homeUrl, 302);
54
82
  }
55
83
 
84
+ // Bot detection lives at the edge so the server side can split humans vs
85
+ // bots in post_link_clicks / dm_link_clicks. Every UTF-8 string the user
86
+ // agent presents passes through this; matched UAs do NOT count as a real
87
+ // click and do NOT fire PostHog events.
88
+ const ua = req.headers.get("user-agent") || "";
89
+ const isBot = BOT_UA_RE.test(ua);
90
+ const referrer = req.headers.get("referer") || "";
91
+
56
92
  let target: string | null = null;
57
93
  let dmId: number | null = null;
94
+ let postId: number | null = null;
95
+ let replyId: number | null = null;
58
96
  let project: string | null = null;
59
97
  let platform: string | null = null;
60
98
 
61
99
  try {
62
- const resp = await fetch(`${RESOLVER}/api/short-links/${encodeURIComponent(code)}`, {
100
+ const params = new URLSearchParams();
101
+ if (isBot) params.set("bot", "1");
102
+ // Forward UA + referrer so the server can persist them in
103
+ // *_link_clicks. We pass them through query params (not headers)
104
+ // because Next.js fetch normalizes some headers and the resolver
105
+ // is a separate origin. Truncate to keep the URL manageable.
106
+ if (ua) params.set("ua", ua.slice(0, 500));
107
+ if (referrer) params.set("ref", referrer.slice(0, 500));
108
+ const qs = params.toString();
109
+ const resolverUrl =
110
+ `${RESOLVER}/api/short-links/${encodeURIComponent(code)}` +
111
+ (qs ? `?${qs}` : "");
112
+ const resp = await fetch(resolverUrl, {
63
113
  cache: "no-store",
64
114
  signal: AbortSignal.timeout(4000),
65
115
  });
@@ -67,12 +117,16 @@ export function createDmShortLinkRedirectHandler(config: DmShortLinkRedirectConf
67
117
  const body = (await resp.json()) as {
68
118
  target_url?: string;
69
119
  dm_id?: number;
120
+ post_id?: number;
121
+ reply_id?: number;
70
122
  project?: string;
71
123
  platform?: string;
72
124
  };
73
125
  if (body.target_url) {
74
126
  target = body.target_url;
75
127
  dmId = body.dm_id ?? null;
128
+ postId = body.post_id ?? null;
129
+ replyId = body.reply_id ?? null;
76
130
  project = body.project ?? null;
77
131
  platform = body.platform ?? null;
78
132
  }
@@ -81,9 +135,32 @@ export function createDmShortLinkRedirectHandler(config: DmShortLinkRedirectConf
81
135
  console.error("[dm-short-link/redirect] resolver fetch failed:", err);
82
136
  }
83
137
 
138
+ // For post rail links (public posts/comments), inject UTM params so
139
+ // PostHog can stitch click -> conversion events. DM rail links already
140
+ // have Cal.com metadata[utm_*] attribution embedded at mint time, so we
141
+ // leave those URLs untouched.
142
+ if (target && (postId != null || replyId != null)) {
143
+ try {
144
+ const targetUrl = new URL(target);
145
+ if (!targetUrl.searchParams.has("utm_source")) {
146
+ if (platform) targetUrl.searchParams.set("utm_source", platform);
147
+ targetUrl.searchParams.set("utm_medium", "social");
148
+ if (project) targetUrl.searchParams.set("utm_campaign", project);
149
+ targetUrl.searchParams.set("utm_content", code);
150
+ target = targetUrl.toString();
151
+ }
152
+ } catch {
153
+ // Keep original target if URL parsing fails (e.g. non-HTTP scheme).
154
+ }
155
+ }
156
+
84
157
  const posthogKey = process.env[posthogKeyEnv];
85
158
  const posthogHost = (process.env[posthogHostEnv] || "https://us.i.posthog.com").replace(/\/+$/, "");
86
- if (target && posthogKey && dmId != null) {
159
+
160
+ // Skip both PostHog events for bots: they do not represent intent, and
161
+ // counting them as `*_short_link_clicked` would taint funnel stats.
162
+ // DM rail event
163
+ if (!isBot && target && posthogKey && dmId != null) {
87
164
  fetch(`${posthogHost}/i/v0/e/`, {
88
165
  method: "POST",
89
166
  headers: { "Content-Type": "application/json" },
@@ -99,6 +176,30 @@ export function createDmShortLinkRedirectHandler(config: DmShortLinkRedirectConf
99
176
  );
100
177
  }
101
178
 
179
+ // Post rail event
180
+ if (!isBot && target && posthogKey && (postId != null || replyId != null)) {
181
+ fetch(`${posthogHost}/i/v0/e/`, {
182
+ method: "POST",
183
+ headers: { "Content-Type": "application/json" },
184
+ body: JSON.stringify({
185
+ api_key: posthogKey,
186
+ event: "post_short_link_clicked",
187
+ distinct_id: `post_${postId ?? replyId}`,
188
+ timestamp: new Date().toISOString(),
189
+ properties: {
190
+ post_id: postId,
191
+ reply_id: replyId,
192
+ project,
193
+ platform,
194
+ code,
195
+ site,
196
+ },
197
+ }),
198
+ }).catch((err) =>
199
+ console.error("[dm-short-link/redirect] posthog fetch failed:", err)
200
+ );
201
+ }
202
+
102
203
  return Response.redirect(target || homeUrl, 302);
103
204
  };
104
205
  }
@@ -124,6 +124,7 @@ export interface NewsletterConfig {
124
124
  onSignup?: (email: string, resendEmailId: string | null) => Promise<void>;
125
125
  }
126
126
 
127
+
127
128
  /* ------------------------------------------------------------------ */
128
129
  /* Factory */
129
130
  /* ------------------------------------------------------------------ */