@digestseo/mcp-gsc 0.3.1 → 0.4.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/.dev.vars.example +12 -8
- package/CHANGELOG.md +80 -40
- package/LICENSE +21 -21
- package/README.md +111 -96
- package/SECURITY.md +73 -36
- package/SETUP.md +217 -199
- package/package.json +27 -13
- package/scripts/glama-start.mjs +14 -3
- package/src/access-mode.ts +59 -0
- package/src/access-token-lifecycle.ts +107 -0
- package/src/cannibalization-schema.ts +15 -0
- package/src/content-decay-schema.ts +8 -0
- package/src/crypto.ts +80 -58
- package/src/date-validation.ts +84 -0
- package/src/digest.ts +632 -648
- package/src/entrypoint.ts +39 -0
- package/src/google.ts +1205 -716
- package/src/index.ts +1431 -908
- package/src/indexed-pages-range.ts +54 -0
- package/src/indexing-property-authorization.ts +119 -0
- package/src/pending-auth-state.ts +87 -0
- package/src/quick-wins-schema.ts +27 -0
- package/src/storage.ts +65 -98
- package/src/tool-rate-limit.ts +231 -0
- package/src/tool-rate-limiter-do.ts +23 -0
- package/src/write-tool-annotations.ts +43 -0
- package/wrangler.example.jsonc +54 -35
package/src/digest.ts
CHANGED
|
@@ -1,648 +1,632 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
interface
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
const
|
|
133
|
-
const
|
|
134
|
-
const
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
)
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
};
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
)
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
lines.push(
|
|
405
|
-
lines.push(
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
movers.
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
lines.push('');
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
lines.push(
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
lines.push(
|
|
496
|
-
lines.push('');
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
lines.push('');
|
|
508
|
-
|
|
509
|
-
lines.push('
|
|
510
|
-
lines.push(
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
lines.push(
|
|
514
|
-
lines.push(
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
lines.push('
|
|
518
|
-
lines.push('');
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
);
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
if (
|
|
545
|
-
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
clicksChangePct
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
}
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
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: `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.`,
|
|
327
|
+
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.`,
|
|
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: 'Publish one new piece of content this week',
|
|
362
|
+
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.',
|
|
363
|
+
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.",
|
|
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 =
|
|
447
|
+
prevTotals.impressions > 0
|
|
448
|
+
? ((currentTotals.impressions - prevTotals.impressions) /
|
|
449
|
+
prevTotals.impressions) *
|
|
450
|
+
100
|
|
451
|
+
: 0;
|
|
452
|
+
const siteClicksChangePct =
|
|
453
|
+
prevTotals.clicks > 0
|
|
454
|
+
? ((currentTotals.clicks - prevTotals.clicks) / prevTotals.clicks) *
|
|
455
|
+
100
|
|
456
|
+
: 0;
|
|
457
|
+
const sitePositionDelta = currentTotals.position - prevTotals.position;
|
|
458
|
+
|
|
459
|
+
const siteHadBigMove =
|
|
460
|
+
Math.abs(siteImpressionsChangePct) >= 25 ||
|
|
461
|
+
Math.abs(siteClicksChangePct) >= 25 ||
|
|
462
|
+
Math.abs(sitePositionDelta) >= 3;
|
|
463
|
+
|
|
464
|
+
if (siteHadBigMove) {
|
|
465
|
+
lines.push('### Site-level changes');
|
|
466
|
+
lines.push('');
|
|
467
|
+
lines.push(
|
|
468
|
+
`Your overall numbers moved this week, but no single query stood out as the driver. ${describeSiteLevelMove(siteImpressionsChangePct, siteClicksChangePct, sitePositionDelta)}`,
|
|
469
|
+
);
|
|
470
|
+
lines.push('');
|
|
471
|
+
} else {
|
|
472
|
+
lines.push('### Steady week');
|
|
473
|
+
lines.push('');
|
|
474
|
+
lines.push('Nothing major changed this week. Numbers are stable.');
|
|
475
|
+
lines.push('');
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
lines.push('## Your top pages this week');
|
|
480
|
+
lines.push('');
|
|
481
|
+
const topPages = currentPages.slice(0, 5);
|
|
482
|
+
if (topPages.length === 0) {
|
|
483
|
+
lines.push('- No pages had impressions this week.');
|
|
484
|
+
} else {
|
|
485
|
+
for (const p of topPages) {
|
|
486
|
+
lines.push(
|
|
487
|
+
`- **${relativePagePath(p.page)}** — ${formatNumber(p.clicks)} clicks, ${formatNumber(p.impressions)} times shown.`,
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
lines.push('');
|
|
492
|
+
|
|
493
|
+
lines.push('## What to do this week');
|
|
494
|
+
lines.push('');
|
|
495
|
+
lines.push(`### 🎯 ${action.item.headline}`);
|
|
496
|
+
lines.push('');
|
|
497
|
+
lines.push(`**Why this matters:** ${action.item.why}`);
|
|
498
|
+
lines.push('');
|
|
499
|
+
lines.push(`**How to do it:** ${action.item.how}`);
|
|
500
|
+
lines.push('');
|
|
501
|
+
lines.push('---');
|
|
502
|
+
lines.push('');
|
|
503
|
+
lines.push(
|
|
504
|
+
`*This report covers ${siteUrl} from ${dates.currentStart} to ${dates.currentEnd}. Data comes from Google Search Console.*`,
|
|
505
|
+
);
|
|
506
|
+
lines.push('');
|
|
507
|
+
lines.push('<details>');
|
|
508
|
+
lines.push('<summary>What do these numbers actually mean?</summary>');
|
|
509
|
+
lines.push('');
|
|
510
|
+
lines.push(
|
|
511
|
+
'- **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.',
|
|
512
|
+
);
|
|
513
|
+
lines.push('- **Clicks:** Your link was clicked from Google Search results.');
|
|
514
|
+
lines.push(
|
|
515
|
+
'- **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.',
|
|
516
|
+
);
|
|
517
|
+
lines.push('');
|
|
518
|
+
lines.push('</details>');
|
|
519
|
+
|
|
520
|
+
return lines.join('\n');
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function formatNumber(n: number): string {
|
|
524
|
+
return Math.round(n).toLocaleString('en-US');
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function formatPosition(n: number): string {
|
|
528
|
+
if (n === 0) return '—';
|
|
529
|
+
return n.toFixed(1);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function formatPctChange(current: number, prev: number): string {
|
|
533
|
+
if (prev === 0 && current === 0) return 'same as last week';
|
|
534
|
+
if (prev === 0 && current > 0) return 'new — no data last week';
|
|
535
|
+
const pct = ((current - prev) / prev) * 100;
|
|
536
|
+
const sign = pct >= 0 ? '+' : '';
|
|
537
|
+
return `${sign}${pct.toFixed(0)}% vs last week`;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function formatPositionChange(current: number, prev: number): string {
|
|
541
|
+
if (prev === 0 || current === 0) return 'no comparison available';
|
|
542
|
+
const diff = current - prev;
|
|
543
|
+
if (Math.abs(diff) < 0.05) return 'same as last week';
|
|
544
|
+
if (diff < 0) {
|
|
545
|
+
return `${Math.abs(diff).toFixed(1)} positions better vs last week`;
|
|
546
|
+
}
|
|
547
|
+
return `${diff.toFixed(1)} positions worse vs last week`;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function siteDisplayName(siteUrl: string): string {
|
|
551
|
+
if (siteUrl.startsWith('sc-domain:')) {
|
|
552
|
+
return siteUrl.slice('sc-domain:'.length);
|
|
553
|
+
}
|
|
554
|
+
try {
|
|
555
|
+
return new URL(siteUrl).hostname;
|
|
556
|
+
} catch {
|
|
557
|
+
return siteUrl;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function relativePagePath(pageUrl: string): string {
|
|
562
|
+
try {
|
|
563
|
+
const u = new URL(pageUrl);
|
|
564
|
+
return u.pathname + u.search;
|
|
565
|
+
} catch {
|
|
566
|
+
return pageUrl;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function isOperatorQuery(q: string): boolean {
|
|
571
|
+
const prefixOperators =
|
|
572
|
+
/^(site|inurl|intitle|intext|filetype|cache|related|info|link|allintitle|allinurl|allintext|allinanchor):/i;
|
|
573
|
+
if (prefixOperators.test(q)) return true;
|
|
574
|
+
if (/ (site|inurl|intitle|intext|filetype):/i.test(q)) return true;
|
|
575
|
+
return false;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function isWithinLast3Days(endDate: string): boolean {
|
|
579
|
+
const todayISO = getSearchConsoleCalendarDate();
|
|
580
|
+
const todayUTC = new Date(`${todayISO}T00:00:00Z`);
|
|
581
|
+
const endUTC = new Date(`${endDate}T00:00:00Z`);
|
|
582
|
+
const diffDays =
|
|
583
|
+
(todayUTC.getTime() - endUTC.getTime()) / (1000 * 60 * 60 * 24);
|
|
584
|
+
return diffDays >= 0 && diffDays <= 3;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function describeSiteLevelMove(
|
|
588
|
+
impressionsChangePct: number,
|
|
589
|
+
clicksChangePct: number,
|
|
590
|
+
positionDelta: number,
|
|
591
|
+
): string {
|
|
592
|
+
const candidates: Array<{
|
|
593
|
+
metric: 'impressions' | 'position' | 'clicks';
|
|
594
|
+
ratio: number;
|
|
595
|
+
}> = [];
|
|
596
|
+
if (Math.abs(impressionsChangePct) >= 25) {
|
|
597
|
+
candidates.push({
|
|
598
|
+
metric: 'impressions',
|
|
599
|
+
ratio: Math.abs(impressionsChangePct) / 25,
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
if (Math.abs(positionDelta) >= 3) {
|
|
603
|
+
candidates.push({ metric: 'position', ratio: Math.abs(positionDelta) / 3 });
|
|
604
|
+
}
|
|
605
|
+
if (Math.abs(clicksChangePct) >= 25 && Math.abs(impressionsChangePct) < 25) {
|
|
606
|
+
candidates.push({ metric: 'clicks', ratio: Math.abs(clicksChangePct) / 25 });
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
candidates.sort((a, b) => b.ratio - a.ratio);
|
|
610
|
+
const winner = candidates[0];
|
|
611
|
+
if (!winner) {
|
|
612
|
+
return 'Numbers moved across multiple dimensions but no single one stands out.';
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
if (winner.metric === 'impressions') {
|
|
616
|
+
if (impressionsChangePct >= 25) {
|
|
617
|
+
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.`;
|
|
618
|
+
}
|
|
619
|
+
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.`;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
if (winner.metric === 'position') {
|
|
623
|
+
if (positionDelta >= 3) {
|
|
624
|
+
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.`;
|
|
625
|
+
}
|
|
626
|
+
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.`;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const clicksSign = clicksChangePct >= 0 ? '+' : '';
|
|
630
|
+
const impressionsSign = impressionsChangePct >= 0 ? '+' : '';
|
|
631
|
+
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.`;
|
|
632
|
+
}
|