@digestseo/mcp-gsc 0.3.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/src/digest.ts ADDED
@@ -0,0 +1,648 @@
1
+ import {
2
+ GoogleRefreshTokenRevokedError,
3
+ querySearchAnalytics,
4
+ refreshAccessToken,
5
+ type SearchAnalyticsRow,
6
+ } from './google';
7
+ import { getDecryptedRefreshToken } from './storage';
8
+
9
+ interface DigestEnv {
10
+ OAUTH_KV: KVNamespace;
11
+ USER_KV: KVNamespace;
12
+ TOKEN_ENCRYPTION_KEY: string;
13
+ GOOGLE_CLIENT_ID: string;
14
+ GOOGLE_CLIENT_SECRET: string;
15
+ }
16
+
17
+ interface DateRanges {
18
+ currentStart: string;
19
+ currentEnd: string;
20
+ prevStart: string;
21
+ prevEnd: string;
22
+ }
23
+
24
+ interface SiteTotals {
25
+ clicks: number;
26
+ impressions: number;
27
+ ctr: number;
28
+ position: number;
29
+ }
30
+
31
+ interface QueryRow {
32
+ query: string;
33
+ clicks: number;
34
+ impressions: number;
35
+ ctr: number;
36
+ position: number;
37
+ }
38
+
39
+ interface PageRow {
40
+ page: string;
41
+ clicks: number;
42
+ impressions: number;
43
+ }
44
+
45
+ type MoverType = 'rising' | 'falling' | 'new_impressions';
46
+
47
+ interface Mover {
48
+ type: MoverType;
49
+ query: string;
50
+ currentClicks: number;
51
+ prevClicks: number;
52
+ currentImpressions: number;
53
+ prevImpressions: number;
54
+ currentPosition: number;
55
+ prevPosition: number;
56
+ change: number;
57
+ }
58
+
59
+ interface Movers {
60
+ rising: Mover[];
61
+ falling: Mover[];
62
+ newImpressions: Mover[];
63
+ }
64
+
65
+ type ActionKey =
66
+ | 'indexing_blocked'
67
+ | 'ctr_zero'
68
+ | 'investigate_drop'
69
+ | 'promote_rising'
70
+ | 'celebrate_and_double_down'
71
+ | 'publish_one_post';
72
+
73
+ interface ActionItem {
74
+ headline: string;
75
+ why: string;
76
+ how: string;
77
+ }
78
+
79
+ export async function generateWeeklyDigest(
80
+ env: DigestEnv,
81
+ googleId: string,
82
+ siteUrl: string,
83
+ endDate: string,
84
+ ): Promise<string> {
85
+ const dates = computeDateRanges(endDate);
86
+ const accessToken = await getAccessTokenForDigest(env, googleId);
87
+
88
+ const [
89
+ currentTotalsRaw,
90
+ prevTotalsRaw,
91
+ currentQueriesRaw,
92
+ prevQueriesRaw,
93
+ currentPagesRaw,
94
+ ] = await Promise.all([
95
+ querySearchAnalytics(accessToken, siteUrl, {
96
+ startDate: dates.currentStart,
97
+ endDate: dates.currentEnd,
98
+ dimensions: [],
99
+ rowLimit: 1,
100
+ dataState: 'all',
101
+ }),
102
+ querySearchAnalytics(accessToken, siteUrl, {
103
+ startDate: dates.prevStart,
104
+ endDate: dates.prevEnd,
105
+ dimensions: [],
106
+ rowLimit: 1,
107
+ dataState: 'all',
108
+ }),
109
+ querySearchAnalytics(accessToken, siteUrl, {
110
+ startDate: dates.currentStart,
111
+ endDate: dates.currentEnd,
112
+ dimensions: ['query'],
113
+ rowLimit: 25,
114
+ dataState: 'all',
115
+ }),
116
+ querySearchAnalytics(accessToken, siteUrl, {
117
+ startDate: dates.prevStart,
118
+ endDate: dates.prevEnd,
119
+ dimensions: ['query'],
120
+ rowLimit: 25,
121
+ dataState: 'all',
122
+ }),
123
+ querySearchAnalytics(accessToken, siteUrl, {
124
+ startDate: dates.currentStart,
125
+ endDate: dates.currentEnd,
126
+ dimensions: ['page'],
127
+ rowLimit: 10,
128
+ dataState: 'all',
129
+ }),
130
+ ]);
131
+
132
+ const currentTotals = totalsFromRow(currentTotalsRaw[0]);
133
+ const prevTotals = totalsFromRow(prevTotalsRaw[0]);
134
+ const currentQueries = rowsToQueries(currentQueriesRaw);
135
+ const prevQueries = rowsToQueries(prevQueriesRaw);
136
+ const currentPages = rowsToPages(currentPagesRaw);
137
+
138
+ const movers = computeMovers(currentQueries, prevQueries);
139
+ const action = pickActionAndBuild(
140
+ currentTotals,
141
+ prevTotals,
142
+ movers,
143
+ currentQueries,
144
+ );
145
+
146
+ return renderMarkdown({
147
+ siteUrl,
148
+ dates,
149
+ currentTotals,
150
+ prevTotals,
151
+ movers,
152
+ currentPages,
153
+ action,
154
+ });
155
+ }
156
+
157
+ async function getAccessTokenForDigest(
158
+ env: DigestEnv,
159
+ googleId: string,
160
+ ): Promise<string> {
161
+ const refreshToken = await getDecryptedRefreshToken(env, googleId);
162
+ if (!refreshToken) {
163
+ throw new GoogleRefreshTokenRevokedError();
164
+ }
165
+ const { access_token } = await refreshAccessToken(
166
+ refreshToken,
167
+ env.GOOGLE_CLIENT_ID,
168
+ env.GOOGLE_CLIENT_SECRET,
169
+ );
170
+ return access_token;
171
+ }
172
+
173
+ function computeDateRanges(endDate: string): DateRanges {
174
+ const end = new Date(`${endDate}T00:00:00Z`);
175
+ const addDays = (date: Date, n: number): Date => {
176
+ const d = new Date(date);
177
+ d.setUTCDate(d.getUTCDate() + n);
178
+ return d;
179
+ };
180
+ const toISO = (d: Date): string => d.toISOString().slice(0, 10);
181
+ return {
182
+ currentEnd: toISO(end),
183
+ currentStart: toISO(addDays(end, -6)),
184
+ prevEnd: toISO(addDays(end, -7)),
185
+ prevStart: toISO(addDays(end, -13)),
186
+ };
187
+ }
188
+
189
+ function totalsFromRow(row: SearchAnalyticsRow | undefined): SiteTotals {
190
+ if (!row) return { clicks: 0, impressions: 0, ctr: 0, position: 0 };
191
+ return {
192
+ clicks: row.clicks ?? 0,
193
+ impressions: row.impressions ?? 0,
194
+ ctr: row.ctr ?? 0,
195
+ position: row.position ?? 0,
196
+ };
197
+ }
198
+
199
+ function rowsToQueries(rows: SearchAnalyticsRow[]): QueryRow[] {
200
+ return rows.map((r) => ({
201
+ query: r.keys[0] ?? '',
202
+ clicks: r.clicks,
203
+ impressions: r.impressions,
204
+ ctr: r.ctr,
205
+ position: r.position,
206
+ }));
207
+ }
208
+
209
+ function rowsToPages(rows: SearchAnalyticsRow[]): PageRow[] {
210
+ return rows.map((r) => ({
211
+ page: r.keys[0] ?? '',
212
+ clicks: r.clicks,
213
+ impressions: r.impressions,
214
+ }));
215
+ }
216
+
217
+ function computeMovers(current: QueryRow[], prev: QueryRow[]): Movers {
218
+ const prevByQuery = new Map(prev.map((q) => [q.query, q]));
219
+ const currentByQuery = new Map(current.map((q) => [q.query, q]));
220
+ const allQueries = new Set<string>([
221
+ ...current.map((q) => q.query),
222
+ ...prev.map((q) => q.query),
223
+ ]);
224
+
225
+ const rising: Mover[] = [];
226
+ const falling: Mover[] = [];
227
+ const newImpressions: Mover[] = [];
228
+
229
+ for (const query of allQueries) {
230
+ const c = currentByQuery.get(query);
231
+ const p = prevByQuery.get(query);
232
+ const cClicks = c?.clicks ?? 0;
233
+ const pClicks = p?.clicks ?? 0;
234
+ const cImpr = c?.impressions ?? 0;
235
+ const pImpr = p?.impressions ?? 0;
236
+ const cPos = c?.position ?? 0;
237
+ const pPos = p?.position ?? 0;
238
+
239
+ if (cClicks - pClicks >= 5 && cClicks >= 2 * Math.max(pClicks, 1)) {
240
+ rising.push({
241
+ type: 'rising',
242
+ query,
243
+ currentClicks: cClicks,
244
+ prevClicks: pClicks,
245
+ currentImpressions: cImpr,
246
+ prevImpressions: pImpr,
247
+ currentPosition: cPos,
248
+ prevPosition: pPos,
249
+ change: cClicks - pClicks,
250
+ });
251
+ continue;
252
+ }
253
+ if (pClicks - cClicks >= 5 && cClicks <= pClicks / 2) {
254
+ falling.push({
255
+ type: 'falling',
256
+ query,
257
+ currentClicks: cClicks,
258
+ prevClicks: pClicks,
259
+ currentImpressions: cImpr,
260
+ prevImpressions: pImpr,
261
+ currentPosition: cPos,
262
+ prevPosition: pPos,
263
+ change: pClicks - cClicks,
264
+ });
265
+ continue;
266
+ }
267
+ if (cClicks === 0 && pClicks === 0 && cImpr >= 20 && pImpr < 5) {
268
+ newImpressions.push({
269
+ type: 'new_impressions',
270
+ query,
271
+ currentClicks: cClicks,
272
+ prevClicks: pClicks,
273
+ currentImpressions: cImpr,
274
+ prevImpressions: pImpr,
275
+ currentPosition: cPos,
276
+ prevPosition: pPos,
277
+ change: cImpr,
278
+ });
279
+ }
280
+ }
281
+
282
+ rising.sort((a, b) => b.change - a.change);
283
+ falling.sort((a, b) => b.change - a.change);
284
+ newImpressions.sort((a, b) => b.change - a.change);
285
+
286
+ return {
287
+ rising: rising.slice(0, 5),
288
+ falling: falling.slice(0, 5),
289
+ newImpressions: newImpressions.slice(0, 5),
290
+ };
291
+ }
292
+
293
+ function pickActionAndBuild(
294
+ currentTotals: SiteTotals,
295
+ prevTotals: SiteTotals,
296
+ movers: Movers,
297
+ currentQueries: QueryRow[],
298
+ ): { key: ActionKey; item: ActionItem } {
299
+ if (currentTotals.impressions === 0) {
300
+ return {
301
+ key: 'indexing_blocked',
302
+ item: {
303
+ headline: "Your site isn't showing up in Google yet",
304
+ why: "Google has crawled your site but nothing is appearing in search results. This usually means either your homepage uses JavaScript to load content (Google can't read it), or your pages haven't been submitted for indexing.",
305
+ how: "1. Open https://search.google.com/search-console and click 'URL inspection' at the top.\n2. Paste your homepage URL.\n3. Click 'Request indexing.' If it says 'URL is not on Google' and the page looks blank when you view the 'Tested page' tab, your site is JavaScript-rendered and you need to add server-side rendering. Ask your developer.",
306
+ },
307
+ };
308
+ }
309
+
310
+ if (currentTotals.impressions > 0 && currentTotals.clicks === 0) {
311
+ const realQueries = currentQueries.filter((q) => !isOperatorQuery(q.query));
312
+ const topRealQuery = [...realQueries].sort(
313
+ (a, b) => b.impressions - a.impressions,
314
+ )[0];
315
+
316
+ if (!topRealQuery) {
317
+ return {
318
+ key: 'ctr_zero',
319
+ item: {
320
+ headline:
321
+ 'Your site is mostly being found by people who already know about it',
322
+ why: "Almost all of your search impressions this week came from branded queries — people typing your domain or site name directly. That's good (people know you exist) but it means Google isn't yet showing your site to people searching for what you offer. To grow, you need to rank for queries about the topic of your site, not just the site itself.",
323
+ how: "1. Pick the single most important thing your site is about (your product, your service, the topic you write about).\n2. Write a 500-word page that answers the most basic question someone would have about that topic.\n3. Publish it. Make sure it's linked from your homepage.",
324
+ },
325
+ };
326
+ }
327
+
328
+ return {
329
+ key: 'ctr_zero',
330
+ item: {
331
+ headline: 'Your site appears in search but nobody clicks',
332
+ why: `Google showed your site to ${formatNumber(currentTotals.impressions)} people this week, but zero clicked. The most common reason is that the title and description shown in search results don't match what people are searching for, or look unappealing compared to other results on the page.`,
333
+ how: `1. Go to https://google.com and search for the query that got the most impressions this week: '${topRealQuery.query}'.\n2. Look at your result. Is the title clear? Does the description answer what someone searching for that would want to know?\n3. If not, edit the page's <title> tag and meta description to match the search intent. Re-check in a week.`,
334
+ },
335
+ };
336
+ }
337
+
338
+ const bigDrop = movers.falling.find((m) => m.change > 10);
339
+ if (bigDrop) {
340
+ return {
341
+ key: 'investigate_drop',
342
+ item: {
343
+ headline: 'Search traffic for one of your queries dropped this week',
344
+ why: `Last week, '${bigDrop.query}' brought you ${bigDrop.prevClicks} clicks. This week, only ${bigDrop.currentClicks}. A drop this size in one week usually means either: a competitor outranked you, the page itself changed, or Google changed how it shows your page.`,
345
+ how: `1. Search Google for '${bigDrop.query}' and find your page.\n2. Compare your result to the top 3 results above and below you. What do they have that you don't?\n3. Either update your page to be more useful, or accept the drop if the query isn't strategic.`,
346
+ },
347
+ };
348
+ }
349
+
350
+ const risingOnPage2 = movers.rising.find((m) => m.currentPosition > 10);
351
+ if (risingOnPage2) {
352
+ return {
353
+ key: 'promote_rising',
354
+ item: {
355
+ headline: "One of your pages is climbing — give it a push",
356
+ why: `'${risingOnPage2.query}' is bringing more clicks each week, but you're still ranking on page 2 of search results (position ${Math.round(risingOnPage2.currentPosition)}). Pages on page 1 get ~10x more clicks than pages on page 2.`,
357
+ how: `1. Find your page that ranks for this query.\n2. Add 2-3 paragraphs answering related questions someone searching for '${risingOnPage2.query}' would also want to know.\n3. Get 1 other page on your site to link to it with the text '${risingOnPage2.query}' as the link.`,
358
+ },
359
+ };
360
+ }
361
+
362
+ const positionImprovement = prevTotals.position - currentTotals.position;
363
+ if (prevTotals.position > 0 && currentTotals.position > 0 && positionImprovement > 2) {
364
+ return {
365
+ key: 'celebrate_and_double_down',
366
+ item: {
367
+ headline: "Your rankings improved — here's how to compound it",
368
+ why: `Your average position improved by ${positionImprovement.toFixed(1)} spots this week. Google noticed something positive (better content, faster page, more links). Whatever you did, do more of it.`,
369
+ how: "1. Look at what you published or changed in the last 2-4 weeks. Was it new content, technical fixes, or external links?\n2. Repeat that exact pattern on your next 2-3 pages.\n3. Skip this week's celebration and ship the next thing.",
370
+ },
371
+ };
372
+ }
373
+
374
+ return {
375
+ key: 'publish_one_post',
376
+ item: {
377
+ headline: 'Publish one new piece of content this week',
378
+ why: 'Your numbers are steady — nothing broken, nothing breakthrough. The single biggest predictor of organic search growth is consistent publishing of content people are searching for.',
379
+ how: "1. Open your GSC and look at the 'Queries' tab — find a query with at least 5 impressions but where you don't have a dedicated page.\n2. Write a 500-1000 word page directly answering that query.\n3. Publish it. Submit the URL via 'URL inspection' for faster indexing.",
380
+ },
381
+ };
382
+ }
383
+
384
+ function renderMarkdown(data: {
385
+ siteUrl: string;
386
+ dates: DateRanges;
387
+ currentTotals: SiteTotals;
388
+ prevTotals: SiteTotals;
389
+ movers: Movers;
390
+ currentPages: PageRow[];
391
+ action: { key: ActionKey; item: ActionItem };
392
+ }): string {
393
+ const { siteUrl, dates, currentTotals, prevTotals, movers, currentPages, action } = data;
394
+ const siteName = siteDisplayName(siteUrl);
395
+ const lines: string[] = [];
396
+
397
+ if (isWithinLast3Days(dates.currentEnd)) {
398
+ lines.push(
399
+ `> Note: this digest covers the last 7 days ending ${dates.currentEnd}. Google usually finalizes search data 2–3 days after it happens, so the most recent numbers may still shift slightly.`,
400
+ );
401
+ lines.push('');
402
+ }
403
+
404
+ lines.push(`# Your weekly site report — ${siteName}`);
405
+ lines.push(`### Week of ${dates.currentStart} to ${dates.currentEnd}`);
406
+ lines.push('');
407
+ lines.push('## Quick numbers');
408
+ lines.push('');
409
+ lines.push(
410
+ `- **People who saw your site in Google search this week:** ${formatNumber(currentTotals.impressions)} (${formatPctChange(currentTotals.impressions, prevTotals.impressions)})`,
411
+ );
412
+ lines.push(
413
+ `- **People who clicked through to your site:** ${formatNumber(currentTotals.clicks)} (${formatPctChange(currentTotals.clicks, prevTotals.clicks)})`,
414
+ );
415
+ lines.push(
416
+ `- **Average rank when your site appeared:** ${formatPosition(currentTotals.position)} (${formatPositionChange(currentTotals.position, prevTotals.position)})`,
417
+ );
418
+ lines.push('');
419
+
420
+ lines.push('## What changed this week');
421
+ lines.push('');
422
+
423
+ const hasAnyMovers =
424
+ movers.rising.length > 0 ||
425
+ movers.falling.length > 0 ||
426
+ movers.newImpressions.length > 0;
427
+
428
+ if (movers.rising.length > 0) {
429
+ lines.push('### Searches gaining traction');
430
+ lines.push('');
431
+ for (const m of movers.rising) {
432
+ lines.push(
433
+ `- **"${m.query}"** — ${m.currentClicks} clicks this week (was ${m.prevClicks} last week). Currently ranking #${Math.round(m.currentPosition)}.`,
434
+ );
435
+ }
436
+ lines.push('');
437
+ }
438
+
439
+ if (movers.falling.length > 0) {
440
+ lines.push('### Searches losing traction');
441
+ lines.push('');
442
+ for (const m of movers.falling) {
443
+ lines.push(
444
+ `- **"${m.query}"** — ${m.currentClicks} clicks this week (was ${m.prevClicks} last week). Currently ranking #${Math.round(m.currentPosition)}.`,
445
+ );
446
+ }
447
+ lines.push('');
448
+ }
449
+
450
+ if (movers.newImpressions.length > 0) {
451
+ lines.push('### New searches showing your site');
452
+ lines.push('');
453
+ for (const m of movers.newImpressions) {
454
+ lines.push(
455
+ `- **"${m.query}"** — ${m.currentImpressions} people saw your site in results for this search for the first time.`,
456
+ );
457
+ }
458
+ lines.push('');
459
+ }
460
+
461
+ if (!hasAnyMovers) {
462
+ const siteImpressionsChangePct =
463
+ prevTotals.impressions > 0
464
+ ? ((currentTotals.impressions - prevTotals.impressions) /
465
+ prevTotals.impressions) *
466
+ 100
467
+ : 0;
468
+ const siteClicksChangePct =
469
+ prevTotals.clicks > 0
470
+ ? ((currentTotals.clicks - prevTotals.clicks) / prevTotals.clicks) *
471
+ 100
472
+ : 0;
473
+ const sitePositionDelta = currentTotals.position - prevTotals.position;
474
+
475
+ const siteHadBigMove =
476
+ Math.abs(siteImpressionsChangePct) >= 25 ||
477
+ Math.abs(siteClicksChangePct) >= 25 ||
478
+ Math.abs(sitePositionDelta) >= 3;
479
+
480
+ if (siteHadBigMove) {
481
+ lines.push('### Site-level changes');
482
+ lines.push('');
483
+ lines.push(
484
+ `Your overall numbers moved this week, but no single query stood out as the driver. ${describeSiteLevelMove(siteImpressionsChangePct, siteClicksChangePct, sitePositionDelta)}`,
485
+ );
486
+ lines.push('');
487
+ } else {
488
+ lines.push('### Steady week');
489
+ lines.push('');
490
+ lines.push('Nothing major changed this week. Numbers are stable.');
491
+ lines.push('');
492
+ }
493
+ }
494
+
495
+ lines.push('## Your top pages this week');
496
+ lines.push('');
497
+ const topPages = currentPages.slice(0, 5);
498
+ if (topPages.length === 0) {
499
+ lines.push('- No pages had impressions this week.');
500
+ } else {
501
+ for (const p of topPages) {
502
+ lines.push(
503
+ `- **${relativePagePath(p.page)}** — ${formatNumber(p.clicks)} clicks, ${formatNumber(p.impressions)} times shown.`,
504
+ );
505
+ }
506
+ }
507
+ lines.push('');
508
+
509
+ lines.push('## What to do this week');
510
+ lines.push('');
511
+ lines.push(`### 🎯 ${action.item.headline}`);
512
+ lines.push('');
513
+ lines.push(`**Why this matters:** ${action.item.why}`);
514
+ lines.push('');
515
+ lines.push(`**How to do it:** ${action.item.how}`);
516
+ lines.push('');
517
+ lines.push('---');
518
+ lines.push('');
519
+ lines.push(
520
+ `*This report covers ${siteUrl} from ${dates.currentStart} to ${dates.currentEnd}. Data comes from Google Search Console.*`,
521
+ );
522
+ lines.push('');
523
+ lines.push('<details>');
524
+ lines.push('<summary>What do these numbers actually mean?</summary>');
525
+ lines.push('');
526
+ lines.push(
527
+ '- **Saw your site:** Google calls these "impressions." Your site appeared in someone\'s search results page.',
528
+ );
529
+ lines.push('- **Clicked through:** Your link was clicked from the search results.');
530
+ lines.push(
531
+ '- **Average rank:** Where your site appeared in search results on average. Lower is better. #1 is the top result.',
532
+ );
533
+ lines.push('');
534
+ lines.push('</details>');
535
+
536
+ return lines.join('\n');
537
+ }
538
+
539
+ function formatNumber(n: number): string {
540
+ return Math.round(n).toLocaleString('en-US');
541
+ }
542
+
543
+ function formatPosition(n: number): string {
544
+ if (n === 0) return '—';
545
+ return n.toFixed(1);
546
+ }
547
+
548
+ function formatPctChange(current: number, prev: number): string {
549
+ if (prev === 0 && current === 0) return 'same as last week';
550
+ if (prev === 0 && current > 0) return 'new — no data last week';
551
+ const pct = ((current - prev) / prev) * 100;
552
+ const sign = pct >= 0 ? '+' : '';
553
+ return `${sign}${pct.toFixed(0)}% vs last week`;
554
+ }
555
+
556
+ function formatPositionChange(current: number, prev: number): string {
557
+ if (prev === 0 || current === 0) return 'no comparison available';
558
+ const diff = current - prev;
559
+ if (Math.abs(diff) < 0.05) return 'same as last week';
560
+ if (diff < 0) {
561
+ return `${Math.abs(diff).toFixed(1)} positions better vs last week`;
562
+ }
563
+ return `${diff.toFixed(1)} positions worse vs last week`;
564
+ }
565
+
566
+ function siteDisplayName(siteUrl: string): string {
567
+ if (siteUrl.startsWith('sc-domain:')) {
568
+ return siteUrl.slice('sc-domain:'.length);
569
+ }
570
+ try {
571
+ return new URL(siteUrl).hostname;
572
+ } catch {
573
+ return siteUrl;
574
+ }
575
+ }
576
+
577
+ function relativePagePath(pageUrl: string): string {
578
+ try {
579
+ const u = new URL(pageUrl);
580
+ return u.pathname + u.search;
581
+ } catch {
582
+ return pageUrl;
583
+ }
584
+ }
585
+
586
+ function isOperatorQuery(q: string): boolean {
587
+ const prefixOperators =
588
+ /^(site|inurl|intitle|intext|filetype|cache|related|info|link|allintitle|allinurl|allintext|allinanchor):/i;
589
+ if (prefixOperators.test(q)) return true;
590
+ if (/ (site|inurl|intitle|intext|filetype):/i.test(q)) return true;
591
+ return false;
592
+ }
593
+
594
+ function isWithinLast3Days(endDate: string): boolean {
595
+ const todayISO = new Date().toISOString().slice(0, 10);
596
+ const todayUTC = new Date(`${todayISO}T00:00:00Z`);
597
+ const endUTC = new Date(`${endDate}T00:00:00Z`);
598
+ const diffDays =
599
+ (todayUTC.getTime() - endUTC.getTime()) / (1000 * 60 * 60 * 24);
600
+ return diffDays >= 0 && diffDays <= 3;
601
+ }
602
+
603
+ function describeSiteLevelMove(
604
+ impressionsChangePct: number,
605
+ clicksChangePct: number,
606
+ positionDelta: number,
607
+ ): string {
608
+ const candidates: Array<{
609
+ metric: 'impressions' | 'position' | 'clicks';
610
+ ratio: number;
611
+ }> = [];
612
+ if (Math.abs(impressionsChangePct) >= 25) {
613
+ candidates.push({
614
+ metric: 'impressions',
615
+ ratio: Math.abs(impressionsChangePct) / 25,
616
+ });
617
+ }
618
+ if (Math.abs(positionDelta) >= 3) {
619
+ candidates.push({ metric: 'position', ratio: Math.abs(positionDelta) / 3 });
620
+ }
621
+ if (Math.abs(clicksChangePct) >= 25 && Math.abs(impressionsChangePct) < 25) {
622
+ candidates.push({ metric: 'clicks', ratio: Math.abs(clicksChangePct) / 25 });
623
+ }
624
+
625
+ candidates.sort((a, b) => b.ratio - a.ratio);
626
+ const winner = candidates[0];
627
+ if (!winner) {
628
+ return 'Numbers moved across multiple dimensions but no single one stands out.';
629
+ }
630
+
631
+ if (winner.metric === 'impressions') {
632
+ if (impressionsChangePct >= 25) {
633
+ return `Google showed your site to many more people this week (+${impressionsChangePct.toFixed(0)}%), but the increase was spread across lots of small queries rather than one big winner.`;
634
+ }
635
+ return `Google showed your site to fewer people this week (${impressionsChangePct.toFixed(0)}%). The drop is spread across many queries rather than one specific search losing traction.`;
636
+ }
637
+
638
+ if (winner.metric === 'position') {
639
+ if (positionDelta >= 3) {
640
+ return `Your average rank slipped by ${positionDelta.toFixed(1)} positions this week. Many queries moved slightly lower at the same time. Likely an algorithm update or a competitor improving.`;
641
+ }
642
+ return `Your average rank improved by ${Math.abs(positionDelta).toFixed(1)} positions this week — many queries moved up at the same time. Something you did is working.`;
643
+ }
644
+
645
+ const clicksSign = clicksChangePct >= 0 ? '+' : '';
646
+ const impressionsSign = impressionsChangePct >= 0 ? '+' : '';
647
+ return `Your click rate changed sharply this week (clicks ${clicksSign}${clicksChangePct.toFixed(0)}%, but impressions only moved ${impressionsSign}${impressionsChangePct.toFixed(0)}%). Worth looking at whether something changed about how your titles or descriptions appear in search results.`;
648
+ }