@digestseo/mcp-gsc 0.3.1 → 0.4.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 CHANGED
@@ -1,648 +1,668 @@
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
- }
1
+ import { querySearchAnalytics, type SearchAnalyticsRow } from './google';
2
+ import type { GoogleAccessTokenProvider } from './access-token-lifecycle';
3
+ import { getSearchConsoleCalendarDate } from './date-validation';
4
+
5
+ interface DateRanges {
6
+ currentStart: string;
7
+ currentEnd: string;
8
+ prevStart: string;
9
+ prevEnd: string;
10
+ }
11
+
12
+ interface SiteTotals {
13
+ clicks: number;
14
+ impressions: number;
15
+ ctr: number;
16
+ position: number;
17
+ }
18
+
19
+ interface QueryRow {
20
+ query: string;
21
+ clicks: number;
22
+ impressions: number;
23
+ ctr: number;
24
+ position: number;
25
+ }
26
+
27
+ interface PageRow {
28
+ page: string;
29
+ clicks: number;
30
+ impressions: number;
31
+ }
32
+
33
+ type MoverType = 'rising' | 'falling' | 'new_impressions';
34
+
35
+ interface Mover {
36
+ type: MoverType;
37
+ query: string;
38
+ currentClicks: number;
39
+ prevClicks: number;
40
+ currentImpressions: number;
41
+ prevImpressions: number;
42
+ currentPosition: number;
43
+ prevPosition: number;
44
+ change: number;
45
+ }
46
+
47
+ interface Movers {
48
+ rising: Mover[];
49
+ falling: Mover[];
50
+ newImpressions: Mover[];
51
+ }
52
+
53
+ type ActionKey =
54
+ | 'indexing_blocked'
55
+ | 'ctr_zero'
56
+ | 'investigate_drop'
57
+ | 'promote_rising'
58
+ | 'celebrate_and_double_down'
59
+ | 'publish_one_post';
60
+
61
+ interface ActionItem {
62
+ headline: string;
63
+ why: string;
64
+ how: string;
65
+ }
66
+
67
+ export function resolveWeeklyDigestEndDate(
68
+ endDate: string | undefined,
69
+ today: string,
70
+ ): string {
71
+ if (endDate) return endDate;
72
+
73
+ const latestCompleteDate = new Date(`${today}T00:00:00Z`);
74
+ latestCompleteDate.setUTCDate(latestCompleteDate.getUTCDate() - 3);
75
+ return latestCompleteDate.toISOString().slice(0, 10);
76
+ }
77
+
78
+ export async function generateWeeklyDigest(
79
+ accessTokens: GoogleAccessTokenProvider,
80
+ googleId: string,
81
+ siteUrl: string,
82
+ endDate: string,
83
+ ): Promise<string> {
84
+ const dates = computeDateRanges(endDate);
85
+ const accessToken = await accessTokens.getAccessToken(googleId);
86
+
87
+ const [
88
+ currentTotalsResponse,
89
+ prevTotalsResponse,
90
+ currentQueriesResponse,
91
+ prevQueriesResponse,
92
+ currentPagesResponse,
93
+ ] = await Promise.all([
94
+ querySearchAnalytics(accessToken, siteUrl, {
95
+ startDate: dates.currentStart,
96
+ endDate: dates.currentEnd,
97
+ dimensions: [],
98
+ rowLimit: 1,
99
+ dataState: 'all',
100
+ }),
101
+ querySearchAnalytics(accessToken, siteUrl, {
102
+ startDate: dates.prevStart,
103
+ endDate: dates.prevEnd,
104
+ dimensions: [],
105
+ rowLimit: 1,
106
+ dataState: 'all',
107
+ }),
108
+ querySearchAnalytics(accessToken, siteUrl, {
109
+ startDate: dates.currentStart,
110
+ endDate: dates.currentEnd,
111
+ dimensions: ['query'],
112
+ rowLimit: 25,
113
+ dataState: 'all',
114
+ }),
115
+ querySearchAnalytics(accessToken, siteUrl, {
116
+ startDate: dates.prevStart,
117
+ endDate: dates.prevEnd,
118
+ dimensions: ['query'],
119
+ rowLimit: 25,
120
+ dataState: 'all',
121
+ }),
122
+ querySearchAnalytics(accessToken, siteUrl, {
123
+ startDate: dates.currentStart,
124
+ endDate: dates.currentEnd,
125
+ dimensions: ['page'],
126
+ rowLimit: 10,
127
+ dataState: 'all',
128
+ }),
129
+ ]);
130
+
131
+ const currentTotals = totalsFromRow(currentTotalsResponse.rows[0]);
132
+ const prevTotals = totalsFromRow(prevTotalsResponse.rows[0]);
133
+ const currentQueries = rowsToQueries(currentQueriesResponse.rows);
134
+ const prevQueries = rowsToQueries(prevQueriesResponse.rows);
135
+ const currentPages = rowsToPages(currentPagesResponse.rows);
136
+
137
+ const movers = computeMovers(currentQueries, prevQueries);
138
+ const action = pickActionAndBuild(
139
+ currentTotals,
140
+ prevTotals,
141
+ movers,
142
+ currentQueries,
143
+ );
144
+
145
+ return renderMarkdown({
146
+ siteUrl,
147
+ dates,
148
+ currentTotals,
149
+ prevTotals,
150
+ movers,
151
+ currentPages,
152
+ action,
153
+ });
154
+ }
155
+
156
+ function computeDateRanges(endDate: string): DateRanges {
157
+ const end = new Date(`${endDate}T00:00:00Z`);
158
+ const addDays = (date: Date, n: number): Date => {
159
+ const d = new Date(date);
160
+ d.setUTCDate(d.getUTCDate() + n);
161
+ return d;
162
+ };
163
+ const toISO = (d: Date): string => d.toISOString().slice(0, 10);
164
+ return {
165
+ currentEnd: toISO(end),
166
+ currentStart: toISO(addDays(end, -6)),
167
+ prevEnd: toISO(addDays(end, -7)),
168
+ prevStart: toISO(addDays(end, -13)),
169
+ };
170
+ }
171
+
172
+ function totalsFromRow(row: SearchAnalyticsRow | undefined): SiteTotals {
173
+ if (!row) return { clicks: 0, impressions: 0, ctr: 0, position: 0 };
174
+ return {
175
+ clicks: row.clicks ?? 0,
176
+ impressions: row.impressions ?? 0,
177
+ ctr: row.ctr ?? 0,
178
+ position: row.position ?? 0,
179
+ };
180
+ }
181
+
182
+ function rowsToQueries(rows: SearchAnalyticsRow[]): QueryRow[] {
183
+ return rows.map((r) => ({
184
+ query: r.keys?.[0] ?? '',
185
+ clicks: r.clicks,
186
+ impressions: r.impressions,
187
+ ctr: r.ctr,
188
+ position: r.position,
189
+ }));
190
+ }
191
+
192
+ function rowsToPages(rows: SearchAnalyticsRow[]): PageRow[] {
193
+ return rows.map((r) => ({
194
+ page: r.keys?.[0] ?? '',
195
+ clicks: r.clicks,
196
+ impressions: r.impressions,
197
+ }));
198
+ }
199
+
200
+ function computeMovers(current: QueryRow[], prev: QueryRow[]): Movers {
201
+ const prevByQuery = new Map(prev.map((q) => [q.query, q]));
202
+ const currentByQuery = new Map(current.map((q) => [q.query, q]));
203
+ const allQueries = new Set<string>([
204
+ ...current.map((q) => q.query),
205
+ ...prev.map((q) => q.query),
206
+ ]);
207
+
208
+ const rising: Mover[] = [];
209
+ const falling: Mover[] = [];
210
+ const newImpressions: Mover[] = [];
211
+
212
+ for (const query of allQueries) {
213
+ const c = currentByQuery.get(query);
214
+ const p = prevByQuery.get(query);
215
+ const cClicks = c?.clicks ?? 0;
216
+ const pClicks = p?.clicks ?? 0;
217
+ const cImpr = c?.impressions ?? 0;
218
+ const pImpr = p?.impressions ?? 0;
219
+ const cPos = c?.position ?? 0;
220
+ const pPos = p?.position ?? 0;
221
+
222
+ if (cClicks - pClicks >= 5 && cClicks >= 2 * Math.max(pClicks, 1)) {
223
+ rising.push({
224
+ type: 'rising',
225
+ query,
226
+ currentClicks: cClicks,
227
+ prevClicks: pClicks,
228
+ currentImpressions: cImpr,
229
+ prevImpressions: pImpr,
230
+ currentPosition: cPos,
231
+ prevPosition: pPos,
232
+ change: cClicks - pClicks,
233
+ });
234
+ continue;
235
+ }
236
+ if (pClicks - cClicks >= 5 && cClicks <= pClicks / 2) {
237
+ falling.push({
238
+ type: 'falling',
239
+ query,
240
+ currentClicks: cClicks,
241
+ prevClicks: pClicks,
242
+ currentImpressions: cImpr,
243
+ prevImpressions: pImpr,
244
+ currentPosition: cPos,
245
+ prevPosition: pPos,
246
+ change: pClicks - cClicks,
247
+ });
248
+ continue;
249
+ }
250
+ if (cClicks === 0 && pClicks === 0 && cImpr >= 20 && pImpr < 5) {
251
+ newImpressions.push({
252
+ type: 'new_impressions',
253
+ query,
254
+ currentClicks: cClicks,
255
+ prevClicks: pClicks,
256
+ currentImpressions: cImpr,
257
+ prevImpressions: pImpr,
258
+ currentPosition: cPos,
259
+ prevPosition: pPos,
260
+ change: cImpr,
261
+ });
262
+ }
263
+ }
264
+
265
+ rising.sort((a, b) => b.change - a.change);
266
+ falling.sort((a, b) => b.change - a.change);
267
+ newImpressions.sort((a, b) => b.change - a.change);
268
+
269
+ return {
270
+ rising: rising.slice(0, 5),
271
+ falling: falling.slice(0, 5),
272
+ newImpressions: newImpressions.slice(0, 5),
273
+ };
274
+ }
275
+
276
+ function pickActionAndBuild(
277
+ currentTotals: SiteTotals,
278
+ prevTotals: SiteTotals,
279
+ movers: Movers,
280
+ currentQueries: QueryRow[],
281
+ ): { key: ActionKey; item: ActionItem } {
282
+ if (currentTotals.impressions === 0) {
283
+ return {
284
+ key: 'indexing_blocked',
285
+ item: {
286
+ headline: 'No Google Search impressions were recorded this week',
287
+ why: "Search Console reported zero impressions for this date range. Search Analytics alone does not tell us whether Google crawled or indexed your pages, or why they did not appear in search results. Use URL Inspection to check those signals directly.",
288
+ how: "1. In Search Console, inspect your homepage and one important page, or use this server's `urls.inspect` tool.\n2. Check the index status, last crawl, page-fetch result, robots/noindex state, and canonical URL.\n3. Fix the specific issue URL Inspection reports. If the page is indexed and fetchable, treat zero impressions as a visibility, ranking, or query-demand problem rather than assuming an indexing failure.",
289
+ },
290
+ };
291
+ }
292
+
293
+ if (currentTotals.impressions > 0 && currentTotals.clicks === 0) {
294
+ const realQueries = currentQueries.filter((q) => !isOperatorQuery(q.query));
295
+ const topRealQuery = [...realQueries].sort(
296
+ (a, b) => b.impressions - a.impressions,
297
+ )[0];
298
+
299
+ if (!topRealQuery) {
300
+ return {
301
+ key: 'ctr_zero',
302
+ item: {
303
+ headline: 'The visible query rows are search-operator checks',
304
+ why: "The query rows available to this digest are operator-style searches such as `site:` or `inurl:`. Those searches can be useful for diagnostics, but they do not prove that your impressions came from branded demand. Search Console's branded-query classification is a separate signal and may not be available for every property.",
305
+ how: "1. Open Search Console's Performance report for the same date range and review Queries and Pages together.\n2. If your property offers the Branded/Non-branded query filter, use that instead of inferring brand intent from search operators.\n3. Prioritize non-operator query/page pairs with impressions but no clicks, then compare CTR and average position before deciding whether the page, snippet, or ranking needs work.",
306
+ },
307
+ };
308
+ }
309
+
310
+ return {
311
+ key: 'ctr_zero',
312
+ item: {
313
+ headline: 'Your site recorded search impressions but no clicks',
314
+ why: `Search Console recorded ${formatNumber(currentTotals.impressions)} impressions and zero clicks for this period. Impressions are search-result appearances, not unique people, and this aggregate alone does not show whether the cause is average position, search intent, SERP features, or snippet presentation.`,
315
+ how: `1. In Search Console's Performance report, keep this same date range and filter to '${topRealQuery.query}'.\n2. Review the matching page rows, CTR, and average position, then compare them with the previous period.\n3. Change the title or meta description only if that query/page evidence suggests the snippet mismatches intent. A manual Google search can differ by location, device, and personalization, so do not treat it as authoritative reproduction.`,
316
+ },
317
+ };
318
+ }
319
+
320
+ const bigDrop = movers.falling.find((m) => m.change > 10);
321
+ if (bigDrop) {
322
+ return {
323
+ key: 'investigate_drop',
324
+ item: {
325
+ headline: 'Search traffic for one of your queries dropped this week',
326
+ why: `Search Console recorded ${bigDrop.prevClicks} clicks for '${bigDrop.query}' last week and ${bigDrop.currentClicks} this week. That is a measured decline, but Search Console alone does not establish whether the cause was ranking movement, demand, SERP features, a site change, or competition.`,
327
+ how: `1. In Search Console, filter to '${bigDrop.query}' for both weeks and compare Pages, impressions, CTR, average position, Devices, and Countries.\n2. Review site changes made before the decline and treat them as hypotheses, not proven causes.\n3. If the same page lost useful visibility across supporting metrics, investigate that page and its search intent before changing it.`,
328
+ },
329
+ };
330
+ }
331
+
332
+ const risingWithWeakerAveragePosition = movers.rising.find(
333
+ (m) => m.currentPosition > 10,
334
+ );
335
+ if (risingWithWeakerAveragePosition) {
336
+ return {
337
+ key: 'promote_rising',
338
+ item: {
339
+ headline: 'One of your queries is gaining clicks — inspect the opportunity',
340
+ why: `'${risingWithWeakerAveragePosition.query}' is bringing more clicks than last week and has an average Search Console position of ${risingWithWeakerAveragePosition.currentPosition.toFixed(1)}. Average position is not a literal current rank or page number; it averages the topmost result position across recorded impressions.`,
341
+ how: `1. In Search Console, filter to '${risingWithWeakerAveragePosition.query}' for the same date range.\n2. Review the Pages, Devices, and Countries dimensions to see where the extra clicks and impressions came from.\n3. If one page is clearly gaining useful visibility, improve that page based on the query intent and evidence you see rather than assuming an average position maps to a specific results page.`,
342
+ },
343
+ };
344
+ }
345
+
346
+ const positionImprovement = prevTotals.position - currentTotals.position;
347
+ if (prevTotals.position > 0 && currentTotals.position > 0 && positionImprovement > 2) {
348
+ return {
349
+ key: 'celebrate_and_double_down',
350
+ item: {
351
+ headline: 'Your average Search Console position improved',
352
+ why: `Average position improved by ${positionImprovement.toFixed(1)} this week. That is a useful trend signal, but it does not by itself prove that a specific content, technical, link, competitor, or algorithm change caused the movement.`,
353
+ how: "1. Compare Queries and Pages for the two periods and identify which rows contributed most to the change.\n2. Check whether clicks and impressions improved for those same rows.\n3. Only connect the movement to a site change after the query/page evidence supports that explanation.",
354
+ },
355
+ };
356
+ }
357
+
358
+ return {
359
+ key: 'publish_one_post',
360
+ item: {
361
+ headline: 'Review one evidence-backed search opportunity this week',
362
+ why: 'The available Search Console data does not show one large mover that clearly deserves priority. Use the query/page rows to choose the next action instead of assuming more publishing is automatically the answer.',
363
+ how: "1. Review Queries and Pages for impressions with few clicks or improving average position.\n2. Check whether an existing page already matches that search intent before creating anything new.\n3. Update an existing page or create a new one only when the query/page evidence supports that choice; use URL Inspection to verify index status when needed, not as a guarantee of faster indexing.",
364
+ },
365
+ };
366
+ }
367
+
368
+ function renderMarkdown(data: {
369
+ siteUrl: string;
370
+ dates: DateRanges;
371
+ currentTotals: SiteTotals;
372
+ prevTotals: SiteTotals;
373
+ movers: Movers;
374
+ currentPages: PageRow[];
375
+ action: { key: ActionKey; item: ActionItem };
376
+ }): string {
377
+ const { siteUrl, dates, currentTotals, prevTotals, movers, currentPages, action } = data;
378
+ const siteName = siteDisplayName(siteUrl);
379
+ const lines: string[] = [];
380
+
381
+ if (isWithinLast3Days(dates.currentEnd)) {
382
+ lines.push(
383
+ `> 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.`,
384
+ );
385
+ lines.push('');
386
+ }
387
+
388
+ lines.push(`# Your weekly site report — ${siteName}`);
389
+ lines.push(`### Week of ${dates.currentStart} to ${dates.currentEnd}`);
390
+ lines.push('');
391
+ lines.push('## Quick numbers');
392
+ lines.push('');
393
+ lines.push(
394
+ `- **Search-result impressions this week:** ${formatNumber(currentTotals.impressions)} (${formatPctChange(currentTotals.impressions, prevTotals.impressions)})`,
395
+ );
396
+ lines.push(
397
+ `- **Clicks from Google Search:** ${formatNumber(currentTotals.clicks)} (${formatPctChange(currentTotals.clicks, prevTotals.clicks)})`,
398
+ );
399
+ lines.push(
400
+ `- **Average Search Console position:** ${formatPosition(currentTotals.position)} (${formatPositionChange(currentTotals.position, prevTotals.position)})`,
401
+ );
402
+ lines.push('');
403
+
404
+ lines.push('## What changed this week');
405
+ lines.push('');
406
+
407
+ const hasAnyMovers =
408
+ movers.rising.length > 0 ||
409
+ movers.falling.length > 0 ||
410
+ movers.newImpressions.length > 0;
411
+
412
+ if (movers.rising.length > 0) {
413
+ lines.push('### Searches gaining traction');
414
+ lines.push('');
415
+ for (const m of movers.rising) {
416
+ lines.push(
417
+ `- **"${m.query}"** — ${m.currentClicks} clicks this week (was ${m.prevClicks} last week). Average position ${m.currentPosition.toFixed(1)}.`,
418
+ );
419
+ }
420
+ lines.push('');
421
+ }
422
+
423
+ if (movers.falling.length > 0) {
424
+ lines.push('### Searches losing traction');
425
+ lines.push('');
426
+ for (const m of movers.falling) {
427
+ lines.push(
428
+ `- **"${m.query}"** — ${m.currentClicks} clicks this week (was ${m.prevClicks} last week). Average position ${m.currentPosition.toFixed(1)}.`,
429
+ );
430
+ }
431
+ lines.push('');
432
+ }
433
+
434
+ if (movers.newImpressions.length > 0) {
435
+ lines.push('### New searches showing your site');
436
+ lines.push('');
437
+ for (const m of movers.newImpressions) {
438
+ lines.push(
439
+ `- **"${m.query}"** — ${m.currentImpressions} impressions this week (was ${m.prevImpressions} last week).`,
440
+ );
441
+ }
442
+ lines.push('');
443
+ }
444
+
445
+ if (!hasAnyMovers) {
446
+ const siteImpressionsChangePct = percentageChangeOrNull(
447
+ currentTotals.impressions,
448
+ prevTotals.impressions,
449
+ );
450
+ const siteClicksChangePct = percentageChangeOrNull(
451
+ currentTotals.clicks,
452
+ prevTotals.clicks,
453
+ );
454
+ const sitePositionDelta = currentTotals.position - prevTotals.position;
455
+
456
+ const siteHadBigMove =
457
+ siteImpressionsChangePct === null ||
458
+ siteClicksChangePct === null ||
459
+ Math.abs(siteImpressionsChangePct) >= 25 ||
460
+ Math.abs(siteClicksChangePct) >= 25 ||
461
+ Math.abs(sitePositionDelta) >= 3;
462
+
463
+ if (siteHadBigMove) {
464
+ lines.push('### Site-level changes');
465
+ lines.push('');
466
+ lines.push(
467
+ `Your overall numbers moved this week, but no single query stood out as the driver. ${describeSiteLevelMove(siteImpressionsChangePct, siteClicksChangePct, sitePositionDelta)}`,
468
+ );
469
+ lines.push('');
470
+ } else {
471
+ lines.push('### Steady week');
472
+ lines.push('');
473
+ lines.push('Nothing major changed this week. Numbers are stable.');
474
+ lines.push('');
475
+ }
476
+ }
477
+
478
+ lines.push('## Your top pages this week');
479
+ lines.push('');
480
+ const topPages = currentPages.slice(0, 5);
481
+ if (topPages.length === 0) {
482
+ lines.push('- No pages had impressions this week.');
483
+ } else {
484
+ for (const p of topPages) {
485
+ lines.push(
486
+ `- **${relativePagePath(p.page)}** — ${formatNumber(p.clicks)} clicks, ${formatNumber(p.impressions)} times shown.`,
487
+ );
488
+ }
489
+ }
490
+ lines.push('');
491
+
492
+ lines.push('## What to do this week');
493
+ lines.push('');
494
+ lines.push(`### 🎯 ${action.item.headline}`);
495
+ lines.push('');
496
+ lines.push(`**Why this matters:** ${action.item.why}`);
497
+ lines.push('');
498
+ lines.push(`**How to do it:** ${action.item.how}`);
499
+ lines.push('');
500
+ lines.push('---');
501
+ lines.push('');
502
+ lines.push(
503
+ `*This report covers ${siteUrl} from ${dates.currentStart} to ${dates.currentEnd}. Data comes from Google Search Console.*`,
504
+ );
505
+ lines.push('');
506
+ lines.push('<details>');
507
+ lines.push('<summary>What do these numbers actually mean?</summary>');
508
+ lines.push('');
509
+ lines.push(
510
+ '- **Impressions:** Your site appeared in Google Search results. This counts result appearances according to Search Console\'s reporting rules; it is not a unique-person count.',
511
+ );
512
+ lines.push('- **Clicks:** Your link was clicked from Google Search results.');
513
+ lines.push(
514
+ '- **Average position:** Search Console averages the topmost position of your result across recorded impressions. Lower values mean a higher average position, but this is not a literal current rank or results-page number.',
515
+ );
516
+ lines.push('');
517
+ lines.push('</details>');
518
+
519
+ return lines.join('\n');
520
+ }
521
+
522
+ function formatNumber(n: number): string {
523
+ return Math.round(n).toLocaleString('en-US');
524
+ }
525
+
526
+ function formatPosition(n: number): string {
527
+ if (n === 0) return '—';
528
+ return n.toFixed(1);
529
+ }
530
+
531
+ function formatPctChange(current: number, prev: number): string {
532
+ if (prev === 0 && current === 0) return 'same as last week';
533
+ if (prev === 0 && current > 0) return 'new — no data last week';
534
+ const pct = ((current - prev) / prev) * 100;
535
+ const sign = pct >= 0 ? '+' : '';
536
+ return `${sign}${pct.toFixed(0)}% vs last week`;
537
+ }
538
+
539
+ function percentageChangeOrNull(current: number, baseline: number): number | null {
540
+ if (baseline === 0) return current === 0 ? 0 : null;
541
+ return ((current - baseline) / baseline) * 100;
542
+ }
543
+
544
+ function formatPositionChange(current: number, prev: number): string {
545
+ if (prev === 0 || current === 0) return 'no comparison available';
546
+ const diff = current - prev;
547
+ if (Math.abs(diff) < 0.05) return 'same as last week';
548
+ if (diff < 0) {
549
+ return `${Math.abs(diff).toFixed(1)} positions better vs last week`;
550
+ }
551
+ return `${diff.toFixed(1)} positions worse vs last week`;
552
+ }
553
+
554
+ function siteDisplayName(siteUrl: string): string {
555
+ if (siteUrl.startsWith('sc-domain:')) {
556
+ return siteUrl.slice('sc-domain:'.length);
557
+ }
558
+ try {
559
+ return new URL(siteUrl).hostname;
560
+ } catch {
561
+ return siteUrl;
562
+ }
563
+ }
564
+
565
+ function relativePagePath(pageUrl: string): string {
566
+ try {
567
+ const u = new URL(pageUrl);
568
+ return u.pathname + u.search;
569
+ } catch {
570
+ return pageUrl;
571
+ }
572
+ }
573
+
574
+ function isOperatorQuery(q: string): boolean {
575
+ const prefixOperators =
576
+ /^(site|inurl|intitle|intext|filetype|cache|related|info|link|allintitle|allinurl|allintext|allinanchor):/i;
577
+ if (prefixOperators.test(q)) return true;
578
+ if (/ (site|inurl|intitle|intext|filetype):/i.test(q)) return true;
579
+ return false;
580
+ }
581
+
582
+ function isWithinLast3Days(endDate: string): boolean {
583
+ const todayISO = getSearchConsoleCalendarDate();
584
+ const todayUTC = new Date(`${todayISO}T00:00:00Z`);
585
+ const endUTC = new Date(`${endDate}T00:00:00Z`);
586
+ const diffDays =
587
+ (todayUTC.getTime() - endUTC.getTime()) / (1000 * 60 * 60 * 24);
588
+ return diffDays >= 0 && diffDays <= 3;
589
+ }
590
+
591
+ function describeSiteLevelMove(
592
+ impressionsChangePct: number | null,
593
+ clicksChangePct: number | null,
594
+ positionDelta: number,
595
+ ): string {
596
+ const candidates: Array<{
597
+ metric: 'impressions' | 'position' | 'clicks';
598
+ ratio: number;
599
+ }> = [];
600
+ if (impressionsChangePct === null) {
601
+ candidates.push({
602
+ metric: 'impressions',
603
+ ratio: Number.POSITIVE_INFINITY,
604
+ });
605
+ } else if (Math.abs(impressionsChangePct) >= 25) {
606
+ candidates.push({
607
+ metric: 'impressions',
608
+ ratio: Math.abs(impressionsChangePct) / 25,
609
+ });
610
+ }
611
+ if (Math.abs(positionDelta) >= 3) {
612
+ candidates.push({ metric: 'position', ratio: Math.abs(positionDelta) / 3 });
613
+ }
614
+ if (
615
+ clicksChangePct === null &&
616
+ impressionsChangePct !== null
617
+ ) {
618
+ candidates.push({
619
+ metric: 'clicks',
620
+ ratio: Number.POSITIVE_INFINITY,
621
+ });
622
+ } else if (
623
+ clicksChangePct !== null &&
624
+ impressionsChangePct !== null &&
625
+ Math.abs(clicksChangePct) >= 25 &&
626
+ Math.abs(impressionsChangePct) < 25
627
+ ) {
628
+ candidates.push({ metric: 'clicks', ratio: Math.abs(clicksChangePct) / 25 });
629
+ }
630
+
631
+ candidates.sort((a, b) => b.ratio - a.ratio);
632
+ const winner = candidates[0];
633
+ if (!winner) {
634
+ return 'Numbers moved across multiple dimensions but no single one stands out.';
635
+ }
636
+
637
+ if (winner.metric === 'impressions') {
638
+ if (impressionsChangePct === null) {
639
+ return 'Your site recorded search impressions this week after the previous period recorded none. A percentage change is unavailable from a zero baseline; inspect Queries and Pages to see where the new visibility appeared.';
640
+ }
641
+ if (impressionsChangePct >= 25) {
642
+ return `Your site recorded many more search impressions this week (+${impressionsChangePct.toFixed(0)}%), but the increase was spread across lots of small queries rather than one big winner.`;
643
+ }
644
+ return `Your site recorded fewer search impressions this week (${impressionsChangePct.toFixed(0)}%). The drop is spread across many queries rather than one specific search losing traction.`;
645
+ }
646
+
647
+ if (winner.metric === 'position') {
648
+ if (positionDelta >= 3) {
649
+ return `Your average Search Console position worsened by ${positionDelta.toFixed(1)} this week. This aggregate can move because of changes in the mix of queries, pages, devices, countries, or result types, so inspect those dimensions before assigning a cause.`;
650
+ }
651
+ return `Your average Search Console position improved by ${Math.abs(positionDelta).toFixed(1)} this week. Treat this as a trend signal rather than proof of a specific cause, and inspect the query/page rows that contributed most to the change.`;
652
+ }
653
+
654
+ if (clicksChangePct === null) {
655
+ const impressionsContext =
656
+ impressionsChangePct === null
657
+ ? 'impressions also started from a zero baseline'
658
+ : `impressions moved ${impressionsChangePct >= 0 ? '+' : ''}${impressionsChangePct.toFixed(0)}%`;
659
+ return `Your site recorded clicks this week after the previous period recorded none; a click percentage change is unavailable from a zero baseline (${impressionsContext}). Review query/page CTR and average position before assigning a cause.`;
660
+ }
661
+ if (impressionsChangePct === null) {
662
+ return `Your clicks changed sharply this week (${clicksChangePct >= 0 ? '+' : ''}${clicksChangePct.toFixed(0)}%), while impressions started from a zero baseline so an impression percentage change is unavailable. Review query/page CTR and average position before assigning a cause.`;
663
+ }
664
+ const clicksSign = clicksChangePct >= 0 ? '+' : '';
665
+ const impressionsSign =
666
+ impressionsChangePct >= 0 ? '+' : '';
667
+ return `Your clicks changed sharply this week (clicks ${clicksSign}${clicksChangePct.toFixed(0)}%, while impressions moved ${impressionsSign}${impressionsChangePct.toFixed(0)}%). Review CTR and average position by query and page before forming hypotheses about snippets, ranking, search intent, demand, or SERP features.`;
668
+ }