@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/.dev.vars.example +12 -8
- package/CHANGELOG.md +94 -40
- package/LICENSE +21 -21
- package/README.md +115 -98
- 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 +668 -648
- package/src/entrypoint.ts +39 -0
- package/src/google.ts +1334 -716
- package/src/index.ts +1667 -913
- 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 +44 -0
- package/src/result-bounds.ts +91 -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,668 @@
|
|
|
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
|
-
|
|
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
|
-
|
|
511
|
-
|
|
512
|
-
lines.push('');
|
|
513
|
-
lines.push(
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
lines.push('');
|
|
517
|
-
lines.push('
|
|
518
|
-
|
|
519
|
-
lines.
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
);
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
);
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
return
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
function
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
return
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
return
|
|
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
|
-
const
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
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
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
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
|
+
}
|