@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/google.ts
CHANGED
|
@@ -1,716 +1,1205 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
export
|
|
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
|
-
export
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
export
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
const
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
export type
|
|
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
|
-
if (
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
const
|
|
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
|
-
const
|
|
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
|
-
export
|
|
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
|
-
const
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
)
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
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
|
-
|
|
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
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
const
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_GSC_ACCESS_MODE,
|
|
3
|
+
getGoogleOAuthScopes,
|
|
4
|
+
type GscAccessMode,
|
|
5
|
+
} from './access-mode';
|
|
6
|
+
|
|
7
|
+
export const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
|
|
8
|
+
export const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
|
|
9
|
+
export const GOOGLE_USERINFO_URL = 'https://www.googleapis.com/oauth2/v2/userinfo';
|
|
10
|
+
|
|
11
|
+
// Kept as the default-mode export for compatibility with existing callers.
|
|
12
|
+
export const SCOPES = getGoogleOAuthScopes(DEFAULT_GSC_ACCESS_MODE);
|
|
13
|
+
|
|
14
|
+
export function buildAuthUrl(
|
|
15
|
+
clientId: string,
|
|
16
|
+
redirectUri: string,
|
|
17
|
+
state: string,
|
|
18
|
+
accessMode: GscAccessMode = DEFAULT_GSC_ACCESS_MODE,
|
|
19
|
+
): string {
|
|
20
|
+
const params = new URLSearchParams({
|
|
21
|
+
client_id: clientId,
|
|
22
|
+
redirect_uri: redirectUri,
|
|
23
|
+
response_type: 'code',
|
|
24
|
+
scope: getGoogleOAuthScopes(accessMode).join(' '),
|
|
25
|
+
access_type: 'offline',
|
|
26
|
+
prompt: 'consent',
|
|
27
|
+
state,
|
|
28
|
+
});
|
|
29
|
+
return `${GOOGLE_AUTH_URL}?${params.toString()}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface GoogleTokenResponse {
|
|
33
|
+
access_token: string;
|
|
34
|
+
refresh_token: string;
|
|
35
|
+
expires_in: number;
|
|
36
|
+
id_token?: string;
|
|
37
|
+
token_type: string;
|
|
38
|
+
scope: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const MCP_RECONNECT_INSTRUCTION =
|
|
42
|
+
"Please reconnect this server from your MCP client's connector or app settings.";
|
|
43
|
+
|
|
44
|
+
export const GSC_ACCESS_REVOKED_MESSAGE =
|
|
45
|
+
`Google access revoked. ${MCP_RECONNECT_INSTRUCTION}`;
|
|
46
|
+
|
|
47
|
+
export class GoogleRefreshTokenRevokedError extends Error {
|
|
48
|
+
constructor() {
|
|
49
|
+
super(GSC_ACCESS_REVOKED_MESSAGE);
|
|
50
|
+
this.name = 'GoogleRefreshTokenRevokedError';
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function exchangeCodeForTokens(
|
|
55
|
+
code: string,
|
|
56
|
+
clientId: string,
|
|
57
|
+
clientSecret: string,
|
|
58
|
+
redirectUri: string,
|
|
59
|
+
): Promise<GoogleTokenResponse> {
|
|
60
|
+
const body = new URLSearchParams({
|
|
61
|
+
code,
|
|
62
|
+
client_id: clientId,
|
|
63
|
+
client_secret: clientSecret,
|
|
64
|
+
redirect_uri: redirectUri,
|
|
65
|
+
grant_type: 'authorization_code',
|
|
66
|
+
});
|
|
67
|
+
const resp = await fetch(GOOGLE_TOKEN_URL, {
|
|
68
|
+
method: 'POST',
|
|
69
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
70
|
+
body,
|
|
71
|
+
});
|
|
72
|
+
if (!resp.ok) {
|
|
73
|
+
const text = await resp.text();
|
|
74
|
+
throw new Error(`Token exchange failed: ${resp.status} ${text}`);
|
|
75
|
+
}
|
|
76
|
+
const data = (await resp.json()) as Partial<GoogleTokenResponse>;
|
|
77
|
+
if (!data.refresh_token) {
|
|
78
|
+
throw new Error('Google did not return a refresh_token');
|
|
79
|
+
}
|
|
80
|
+
return data as GoogleTokenResponse;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface GoogleUserInfo {
|
|
84
|
+
id: string;
|
|
85
|
+
email: string;
|
|
86
|
+
verified_email?: boolean;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function fetchGoogleUserInfo(
|
|
90
|
+
accessToken: string,
|
|
91
|
+
): Promise<GoogleUserInfo> {
|
|
92
|
+
const resp = await fetch(GOOGLE_USERINFO_URL, {
|
|
93
|
+
headers: { authorization: `Bearer ${accessToken}` },
|
|
94
|
+
});
|
|
95
|
+
if (!resp.ok) {
|
|
96
|
+
const text = await resp.text();
|
|
97
|
+
throw new Error(`Userinfo fetch failed: ${resp.status} ${text}`);
|
|
98
|
+
}
|
|
99
|
+
return (await resp.json()) as GoogleUserInfo;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function refreshAccessToken(
|
|
103
|
+
refreshToken: string,
|
|
104
|
+
clientId: string,
|
|
105
|
+
clientSecret: string,
|
|
106
|
+
): Promise<{ access_token: string; expires_in: number }> {
|
|
107
|
+
const body = new URLSearchParams({
|
|
108
|
+
refresh_token: refreshToken,
|
|
109
|
+
client_id: clientId,
|
|
110
|
+
client_secret: clientSecret,
|
|
111
|
+
grant_type: 'refresh_token',
|
|
112
|
+
});
|
|
113
|
+
const resp = await fetch(GOOGLE_TOKEN_URL, {
|
|
114
|
+
method: 'POST',
|
|
115
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
116
|
+
body,
|
|
117
|
+
});
|
|
118
|
+
if (!resp.ok) {
|
|
119
|
+
let parsedError: string | undefined;
|
|
120
|
+
try {
|
|
121
|
+
const errBody = (await resp.json()) as { error?: string };
|
|
122
|
+
parsedError = errBody.error;
|
|
123
|
+
} catch {
|
|
124
|
+
// body was not JSON
|
|
125
|
+
}
|
|
126
|
+
if (parsedError === 'invalid_grant') {
|
|
127
|
+
throw new GoogleRefreshTokenRevokedError();
|
|
128
|
+
}
|
|
129
|
+
throw new Error(`Failed to refresh Google access token: ${resp.status}`);
|
|
130
|
+
}
|
|
131
|
+
const data = (await resp.json()) as {
|
|
132
|
+
access_token: string;
|
|
133
|
+
expires_in: number;
|
|
134
|
+
};
|
|
135
|
+
return { access_token: data.access_token, expires_in: data.expires_in };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface SiteEntry {
|
|
139
|
+
siteUrl: string;
|
|
140
|
+
permissionLevel: string;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export async function listSites(accessToken: string): Promise<SiteEntry[]> {
|
|
144
|
+
const resp = await fetch('https://www.googleapis.com/webmasters/v3/sites', {
|
|
145
|
+
headers: { authorization: `Bearer ${accessToken}` },
|
|
146
|
+
});
|
|
147
|
+
if (resp.status === 401) {
|
|
148
|
+
throw new Error(GSC_ACCESS_REVOKED_MESSAGE);
|
|
149
|
+
}
|
|
150
|
+
if (!resp.ok) {
|
|
151
|
+
const text = await resp.text();
|
|
152
|
+
throw new Error(`List sites failed: ${resp.status} ${text}`);
|
|
153
|
+
}
|
|
154
|
+
const data = (await resp.json()) as { siteEntry?: SiteEntry[] };
|
|
155
|
+
return data.siteEntry ?? [];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export async function getSite(
|
|
159
|
+
accessToken: string,
|
|
160
|
+
siteUrl: string,
|
|
161
|
+
): Promise<SiteEntry> {
|
|
162
|
+
const encoded = encodeURIComponent(siteUrl);
|
|
163
|
+
const resp = await fetch(
|
|
164
|
+
`https://www.googleapis.com/webmasters/v3/sites/${encoded}`,
|
|
165
|
+
{
|
|
166
|
+
headers: { authorization: `Bearer ${accessToken}` },
|
|
167
|
+
},
|
|
168
|
+
);
|
|
169
|
+
if (resp.status === 401) {
|
|
170
|
+
throw new Error(GSC_ACCESS_REVOKED_MESSAGE);
|
|
171
|
+
}
|
|
172
|
+
if (!resp.ok) {
|
|
173
|
+
const text = await resp.text();
|
|
174
|
+
throw new Error(`Get site failed: ${resp.status} ${text}`);
|
|
175
|
+
}
|
|
176
|
+
return (await resp.json()) as SiteEntry;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export type SearchDimension =
|
|
180
|
+
| 'query'
|
|
181
|
+
| 'page'
|
|
182
|
+
| 'country'
|
|
183
|
+
| 'device'
|
|
184
|
+
| 'date'
|
|
185
|
+
| 'hour'
|
|
186
|
+
| 'searchAppearance';
|
|
187
|
+
|
|
188
|
+
export type SearchType =
|
|
189
|
+
| 'web'
|
|
190
|
+
| 'image'
|
|
191
|
+
| 'video'
|
|
192
|
+
| 'news'
|
|
193
|
+
| 'discover'
|
|
194
|
+
| 'googleNews';
|
|
195
|
+
|
|
196
|
+
export type AggregationType =
|
|
197
|
+
| 'auto'
|
|
198
|
+
| 'byNewsShowcasePanel'
|
|
199
|
+
| 'byPage'
|
|
200
|
+
| 'byProperty';
|
|
201
|
+
|
|
202
|
+
export type DataState = 'all' | 'final' | 'hourly_all';
|
|
203
|
+
|
|
204
|
+
export type FilterDimension =
|
|
205
|
+
| 'query'
|
|
206
|
+
| 'page'
|
|
207
|
+
| 'country'
|
|
208
|
+
| 'device'
|
|
209
|
+
| 'searchAppearance';
|
|
210
|
+
|
|
211
|
+
export type FilterOperator =
|
|
212
|
+
| 'equals'
|
|
213
|
+
| 'notEquals'
|
|
214
|
+
| 'contains'
|
|
215
|
+
| 'notContains'
|
|
216
|
+
| 'includingRegex'
|
|
217
|
+
| 'excludingRegex';
|
|
218
|
+
|
|
219
|
+
export interface DimensionFilter {
|
|
220
|
+
dimension: FilterDimension;
|
|
221
|
+
operator: FilterOperator;
|
|
222
|
+
expression: string;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export interface DimensionFilterGroup {
|
|
226
|
+
groupType: 'and';
|
|
227
|
+
filters: DimensionFilter[];
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export interface SearchAnalyticsQuery {
|
|
231
|
+
startDate: string;
|
|
232
|
+
endDate: string;
|
|
233
|
+
dimensions: SearchDimension[];
|
|
234
|
+
rowLimit: number;
|
|
235
|
+
startRow?: number;
|
|
236
|
+
dataState?: DataState;
|
|
237
|
+
type?: SearchType;
|
|
238
|
+
aggregationType?: AggregationType;
|
|
239
|
+
dimensionFilterGroups?: DimensionFilterGroup[];
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function assertSearchAnalyticsQueryCompatible(body: SearchAnalyticsQuery): void {
|
|
243
|
+
if (new Set(body.dimensions).size !== body.dimensions.length) {
|
|
244
|
+
throw new Error('Search Analytics dimensions must not contain duplicates.');
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const filters = body.dimensionFilterGroups?.flatMap((group) => group.filters) ?? [];
|
|
248
|
+
const hasPageGroupingOrFilter =
|
|
249
|
+
body.dimensions.includes('page') || filters.some((filter) => filter.dimension === 'page');
|
|
250
|
+
|
|
251
|
+
for (const filter of filters) {
|
|
252
|
+
if (filter.expression.length > 4096) {
|
|
253
|
+
throw new Error('Search Analytics filter expressions must be at most 4096 characters.');
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (body.aggregationType === 'byProperty') {
|
|
258
|
+
if (hasPageGroupingOrFilter) {
|
|
259
|
+
throw new Error(
|
|
260
|
+
'Search Analytics aggregationType byProperty cannot be combined with page grouping or filtering.',
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
if (body.type === 'discover' || body.type === 'googleNews') {
|
|
264
|
+
throw new Error(
|
|
265
|
+
'Search Analytics aggregationType byProperty is not supported for discover or googleNews.',
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (body.aggregationType === 'byNewsShowcasePanel') {
|
|
271
|
+
if (body.type !== 'discover' && body.type !== 'googleNews') {
|
|
272
|
+
throw new Error(
|
|
273
|
+
'Search Analytics aggregationType byNewsShowcasePanel requires type discover or googleNews.',
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
if (hasPageGroupingOrFilter) {
|
|
277
|
+
throw new Error(
|
|
278
|
+
'Search Analytics aggregationType byNewsShowcasePanel cannot be combined with page grouping or filtering.',
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const searchAppearanceFilters = filters.filter(
|
|
283
|
+
(filter) => filter.dimension === 'searchAppearance',
|
|
284
|
+
);
|
|
285
|
+
const hasRequiredNewsShowcaseFilter = searchAppearanceFilters.some(
|
|
286
|
+
(filter) => filter.operator === 'equals' && filter.expression === 'NEWS_SHOWCASE',
|
|
287
|
+
);
|
|
288
|
+
const hasOtherSearchAppearanceFilter = searchAppearanceFilters.some(
|
|
289
|
+
(filter) => filter.operator !== 'equals' || filter.expression !== 'NEWS_SHOWCASE',
|
|
290
|
+
);
|
|
291
|
+
if (!hasRequiredNewsShowcaseFilter || hasOtherSearchAppearanceFilter) {
|
|
292
|
+
throw new Error(
|
|
293
|
+
'Search Analytics aggregationType byNewsShowcasePanel requires exactly the NEWS_SHOWCASE searchAppearance filter and no other searchAppearance filter.',
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export type PaginatedSearchAnalyticsQuery = Omit<
|
|
300
|
+
SearchAnalyticsQuery,
|
|
301
|
+
'rowLimit'
|
|
302
|
+
>;
|
|
303
|
+
|
|
304
|
+
export interface SearchAnalyticsRow {
|
|
305
|
+
keys: string[];
|
|
306
|
+
clicks: number;
|
|
307
|
+
impressions: number;
|
|
308
|
+
ctr: number;
|
|
309
|
+
position: number;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export interface SearchAnalyticsResponseMetadata {
|
|
313
|
+
first_incomplete_date?: string;
|
|
314
|
+
first_incomplete_hour?: string;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export interface SearchAnalyticsResponse {
|
|
318
|
+
rows: SearchAnalyticsRow[];
|
|
319
|
+
responseAggregationType?: string;
|
|
320
|
+
metadata?: SearchAnalyticsResponseMetadata;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** The maximum page size accepted by the Search Analytics API. */
|
|
324
|
+
export const SEARCH_ANALYTICS_PAGE_SIZE = 25_000;
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Limits each higher-level analysis source query to four API pages. This
|
|
328
|
+
* bounds Worker runtime, memory, and Search Analytics quota use; it does not
|
|
329
|
+
* make Search Console's internally limited results exhaustive.
|
|
330
|
+
*/
|
|
331
|
+
export const MAX_PAGINATED_SEARCH_ANALYTICS_ROWS = 100_000;
|
|
332
|
+
|
|
333
|
+
export interface PaginatedSearchAnalyticsResult {
|
|
334
|
+
rows: SearchAnalyticsRow[];
|
|
335
|
+
pagesFetched: number;
|
|
336
|
+
localLimitReached: boolean;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
interface SearchAnalyticsPaginationOptions {
|
|
340
|
+
/** May lower the hard ceiling for a caller, but never raise it. */
|
|
341
|
+
maxRows?: number;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export async function inspectUrl(
|
|
345
|
+
accessToken: string,
|
|
346
|
+
siteUrl: string,
|
|
347
|
+
inspectionUrl: string,
|
|
348
|
+
languageCode?: string,
|
|
349
|
+
): Promise<unknown> {
|
|
350
|
+
const body: Record<string, string> = {
|
|
351
|
+
inspectionUrl,
|
|
352
|
+
siteUrl,
|
|
353
|
+
};
|
|
354
|
+
if (languageCode) body.languageCode = languageCode;
|
|
355
|
+
const resp = await fetch(
|
|
356
|
+
'https://searchconsole.googleapis.com/v1/urlInspection/index:inspect',
|
|
357
|
+
{
|
|
358
|
+
method: 'POST',
|
|
359
|
+
headers: {
|
|
360
|
+
authorization: `Bearer ${accessToken}`,
|
|
361
|
+
'content-type': 'application/json',
|
|
362
|
+
},
|
|
363
|
+
body: JSON.stringify(body),
|
|
364
|
+
},
|
|
365
|
+
);
|
|
366
|
+
if (resp.status === 401) {
|
|
367
|
+
throw new Error(GSC_ACCESS_REVOKED_MESSAGE);
|
|
368
|
+
}
|
|
369
|
+
if (!resp.ok) {
|
|
370
|
+
const text = await resp.text();
|
|
371
|
+
throw new Error(`URL inspection failed: ${resp.status} ${text}`);
|
|
372
|
+
}
|
|
373
|
+
const data = (await resp.json()) as { inspectionResult?: unknown };
|
|
374
|
+
return data.inspectionResult ?? null;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export interface UrlInspectionBatchResult {
|
|
378
|
+
inspectionUrl: string;
|
|
379
|
+
inspectionResult?: unknown;
|
|
380
|
+
error?: string;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export async function inspectUrlsSequentially(
|
|
384
|
+
accessToken: string,
|
|
385
|
+
siteUrl: string,
|
|
386
|
+
inspectionUrls: readonly string[],
|
|
387
|
+
languageCode?: string,
|
|
388
|
+
): Promise<UrlInspectionBatchResult[]> {
|
|
389
|
+
const results: UrlInspectionBatchResult[] = [];
|
|
390
|
+
|
|
391
|
+
for (const inspectionUrl of inspectionUrls) {
|
|
392
|
+
try {
|
|
393
|
+
results.push({
|
|
394
|
+
inspectionUrl,
|
|
395
|
+
inspectionResult: await inspectUrl(
|
|
396
|
+
accessToken,
|
|
397
|
+
siteUrl,
|
|
398
|
+
inspectionUrl,
|
|
399
|
+
languageCode,
|
|
400
|
+
),
|
|
401
|
+
});
|
|
402
|
+
} catch (error) {
|
|
403
|
+
if (error instanceof Error && error.message === GSC_ACCESS_REVOKED_MESSAGE) {
|
|
404
|
+
throw error;
|
|
405
|
+
}
|
|
406
|
+
results.push({
|
|
407
|
+
inspectionUrl,
|
|
408
|
+
error: error instanceof Error ? error.message : 'URL inspection failed.',
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
return results;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
export interface SitemapEntry {
|
|
417
|
+
path: string;
|
|
418
|
+
lastSubmitted?: string | null;
|
|
419
|
+
isPending?: boolean | null;
|
|
420
|
+
isSitemapsIndex?: boolean | null;
|
|
421
|
+
type?: string | null;
|
|
422
|
+
lastDownloaded?: string | null;
|
|
423
|
+
warnings?: string | null;
|
|
424
|
+
errors?: string | null;
|
|
425
|
+
contents?: Array<{
|
|
426
|
+
type: string;
|
|
427
|
+
submitted: string;
|
|
428
|
+
}> | null;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function normalizeSitemap(entry: SitemapEntry): SitemapEntry {
|
|
432
|
+
if (!entry.contents) return entry;
|
|
433
|
+
|
|
434
|
+
// Google marks contents[].indexed as deprecated. Do not expose that stale
|
|
435
|
+
// field to MCP clients as an indexed-page count.
|
|
436
|
+
return {
|
|
437
|
+
...entry,
|
|
438
|
+
contents: entry.contents.map(({ type, submitted }) => ({ type, submitted })),
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
export async function listSitemaps(
|
|
443
|
+
accessToken: string,
|
|
444
|
+
siteUrl: string,
|
|
445
|
+
sitemapIndex?: string,
|
|
446
|
+
): Promise<SitemapEntry[]> {
|
|
447
|
+
const encoded = encodeURIComponent(siteUrl);
|
|
448
|
+
const query = sitemapIndex
|
|
449
|
+
? `?sitemapIndex=${encodeURIComponent(sitemapIndex)}`
|
|
450
|
+
: '';
|
|
451
|
+
const resp = await fetch(
|
|
452
|
+
`https://www.googleapis.com/webmasters/v3/sites/${encoded}/sitemaps${query}`,
|
|
453
|
+
{
|
|
454
|
+
headers: { authorization: `Bearer ${accessToken}` },
|
|
455
|
+
},
|
|
456
|
+
);
|
|
457
|
+
if (resp.status === 401) {
|
|
458
|
+
throw new Error(GSC_ACCESS_REVOKED_MESSAGE);
|
|
459
|
+
}
|
|
460
|
+
if (!resp.ok) {
|
|
461
|
+
const text = await resp.text();
|
|
462
|
+
throw new Error(`List sitemaps failed: ${resp.status} ${text}`);
|
|
463
|
+
}
|
|
464
|
+
const data = (await resp.json()) as { sitemap?: SitemapEntry[] };
|
|
465
|
+
return (data.sitemap ?? []).map(normalizeSitemap);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
export async function querySearchAnalytics(
|
|
469
|
+
accessToken: string,
|
|
470
|
+
siteUrl: string,
|
|
471
|
+
body: SearchAnalyticsQuery,
|
|
472
|
+
): Promise<SearchAnalyticsResponse> {
|
|
473
|
+
assertSearchAnalyticsQueryCompatible(body);
|
|
474
|
+
const encoded = encodeURIComponent(siteUrl);
|
|
475
|
+
const resp = await fetch(
|
|
476
|
+
`https://www.googleapis.com/webmasters/v3/sites/${encoded}/searchAnalytics/query`,
|
|
477
|
+
{
|
|
478
|
+
method: 'POST',
|
|
479
|
+
headers: {
|
|
480
|
+
authorization: `Bearer ${accessToken}`,
|
|
481
|
+
'content-type': 'application/json',
|
|
482
|
+
},
|
|
483
|
+
body: JSON.stringify(body),
|
|
484
|
+
},
|
|
485
|
+
);
|
|
486
|
+
if (resp.status === 401) {
|
|
487
|
+
throw new Error(GSC_ACCESS_REVOKED_MESSAGE);
|
|
488
|
+
}
|
|
489
|
+
if (!resp.ok) {
|
|
490
|
+
const text = await resp.text();
|
|
491
|
+
throw new Error(`Search analytics query failed: ${resp.status} ${text}`);
|
|
492
|
+
}
|
|
493
|
+
const data = (await resp.json()) as {
|
|
494
|
+
rows?: SearchAnalyticsRow[];
|
|
495
|
+
responseAggregationType?: string;
|
|
496
|
+
metadata?: SearchAnalyticsResponseMetadata;
|
|
497
|
+
};
|
|
498
|
+
return {
|
|
499
|
+
rows: data.rows ?? [],
|
|
500
|
+
...(data.responseAggregationType !== undefined
|
|
501
|
+
? { responseAggregationType: data.responseAggregationType }
|
|
502
|
+
: {}),
|
|
503
|
+
...(data.metadata !== undefined ? { metadata: data.metadata } : {}),
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Fetches Search Analytics rows in 25,000-row pages for higher-level tools.
|
|
509
|
+
* A full final page conservatively reports localLimitReached because the API
|
|
510
|
+
* can have more rows even when Search Console's own internal limits apply.
|
|
511
|
+
*/
|
|
512
|
+
export async function querySearchAnalyticsPaginated(
|
|
513
|
+
accessToken: string,
|
|
514
|
+
siteUrl: string,
|
|
515
|
+
body: PaginatedSearchAnalyticsQuery,
|
|
516
|
+
options: SearchAnalyticsPaginationOptions = {},
|
|
517
|
+
): Promise<PaginatedSearchAnalyticsResult> {
|
|
518
|
+
const requestedMaximum = options.maxRows ?? MAX_PAGINATED_SEARCH_ANALYTICS_ROWS;
|
|
519
|
+
const maximumRows = Math.min(requestedMaximum, MAX_PAGINATED_SEARCH_ANALYTICS_ROWS);
|
|
520
|
+
if (!Number.isInteger(maximumRows) || maximumRows < 1) {
|
|
521
|
+
throw new Error('maxRows must be a positive integer.');
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const rows: SearchAnalyticsRow[] = [];
|
|
525
|
+
let startRow = body.startRow ?? 0;
|
|
526
|
+
let pagesFetched = 0;
|
|
527
|
+
|
|
528
|
+
while (rows.length < maximumRows) {
|
|
529
|
+
const requestedRows = Math.min(
|
|
530
|
+
SEARCH_ANALYTICS_PAGE_SIZE,
|
|
531
|
+
maximumRows - rows.length,
|
|
532
|
+
);
|
|
533
|
+
const page = await querySearchAnalytics(accessToken, siteUrl, {
|
|
534
|
+
...body,
|
|
535
|
+
rowLimit: requestedRows,
|
|
536
|
+
startRow,
|
|
537
|
+
});
|
|
538
|
+
pagesFetched += 1;
|
|
539
|
+
|
|
540
|
+
const acceptedRows = page.rows.slice(0, requestedRows);
|
|
541
|
+
rows.push(...acceptedRows);
|
|
542
|
+
if (page.rows.length < requestedRows) {
|
|
543
|
+
return { rows, pagesFetched, localLimitReached: false };
|
|
544
|
+
}
|
|
545
|
+
if (rows.length >= maximumRows || page.rows.length > requestedRows) {
|
|
546
|
+
return { rows, pagesFetched, localLimitReached: true };
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
startRow += page.rows.length;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
return { rows, pagesFetched, localLimitReached: true };
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
export async function addSite(
|
|
556
|
+
accessToken: string,
|
|
557
|
+
siteUrl: string,
|
|
558
|
+
): Promise<void> {
|
|
559
|
+
const encoded = encodeURIComponent(siteUrl);
|
|
560
|
+
const resp = await fetch(
|
|
561
|
+
`https://www.googleapis.com/webmasters/v3/sites/${encoded}`,
|
|
562
|
+
{
|
|
563
|
+
method: 'PUT',
|
|
564
|
+
headers: { authorization: `Bearer ${accessToken}` },
|
|
565
|
+
},
|
|
566
|
+
);
|
|
567
|
+
if (resp.status === 401) {
|
|
568
|
+
throw new Error(GSC_ACCESS_REVOKED_MESSAGE);
|
|
569
|
+
}
|
|
570
|
+
if (!resp.ok) {
|
|
571
|
+
const text = await resp.text();
|
|
572
|
+
throw new Error(`Add site failed: ${resp.status} ${text}`);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
export async function deleteSite(
|
|
577
|
+
accessToken: string,
|
|
578
|
+
siteUrl: string,
|
|
579
|
+
): Promise<void> {
|
|
580
|
+
const encoded = encodeURIComponent(siteUrl);
|
|
581
|
+
const resp = await fetch(
|
|
582
|
+
`https://www.googleapis.com/webmasters/v3/sites/${encoded}`,
|
|
583
|
+
{
|
|
584
|
+
method: 'DELETE',
|
|
585
|
+
headers: { authorization: `Bearer ${accessToken}` },
|
|
586
|
+
},
|
|
587
|
+
);
|
|
588
|
+
if (resp.status === 401) {
|
|
589
|
+
throw new Error(GSC_ACCESS_REVOKED_MESSAGE);
|
|
590
|
+
}
|
|
591
|
+
if (!resp.ok) {
|
|
592
|
+
const text = await resp.text();
|
|
593
|
+
throw new Error(`Delete site failed: ${resp.status} ${text}`);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
export async function submitSitemap(
|
|
598
|
+
accessToken: string,
|
|
599
|
+
siteUrl: string,
|
|
600
|
+
feedpath: string,
|
|
601
|
+
): Promise<void> {
|
|
602
|
+
const encodedSite = encodeURIComponent(siteUrl);
|
|
603
|
+
const encodedFeed = encodeURIComponent(feedpath);
|
|
604
|
+
const resp = await fetch(
|
|
605
|
+
`https://www.googleapis.com/webmasters/v3/sites/${encodedSite}/sitemaps/${encodedFeed}`,
|
|
606
|
+
{
|
|
607
|
+
method: 'PUT',
|
|
608
|
+
headers: { authorization: `Bearer ${accessToken}` },
|
|
609
|
+
},
|
|
610
|
+
);
|
|
611
|
+
if (resp.status === 401) {
|
|
612
|
+
throw new Error(GSC_ACCESS_REVOKED_MESSAGE);
|
|
613
|
+
}
|
|
614
|
+
if (!resp.ok) {
|
|
615
|
+
const text = await resp.text();
|
|
616
|
+
throw new Error(`Submit sitemap failed: ${resp.status} ${text}`);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
export async function deleteSitemap(
|
|
621
|
+
accessToken: string,
|
|
622
|
+
siteUrl: string,
|
|
623
|
+
feedpath: string,
|
|
624
|
+
): Promise<void> {
|
|
625
|
+
const encodedSite = encodeURIComponent(siteUrl);
|
|
626
|
+
const encodedFeed = encodeURIComponent(feedpath);
|
|
627
|
+
const resp = await fetch(
|
|
628
|
+
`https://www.googleapis.com/webmasters/v3/sites/${encodedSite}/sitemaps/${encodedFeed}`,
|
|
629
|
+
{
|
|
630
|
+
method: 'DELETE',
|
|
631
|
+
headers: { authorization: `Bearer ${accessToken}` },
|
|
632
|
+
},
|
|
633
|
+
);
|
|
634
|
+
if (resp.status === 401) {
|
|
635
|
+
throw new Error(GSC_ACCESS_REVOKED_MESSAGE);
|
|
636
|
+
}
|
|
637
|
+
if (!resp.ok) {
|
|
638
|
+
const text = await resp.text();
|
|
639
|
+
throw new Error(`Delete sitemap failed: ${resp.status} ${text}`);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
export async function getSitemap(
|
|
644
|
+
accessToken: string,
|
|
645
|
+
siteUrl: string,
|
|
646
|
+
feedpath: string,
|
|
647
|
+
): Promise<SitemapEntry> {
|
|
648
|
+
const encodedSite = encodeURIComponent(siteUrl);
|
|
649
|
+
const encodedFeed = encodeURIComponent(feedpath);
|
|
650
|
+
const resp = await fetch(
|
|
651
|
+
`https://www.googleapis.com/webmasters/v3/sites/${encodedSite}/sitemaps/${encodedFeed}`,
|
|
652
|
+
{
|
|
653
|
+
headers: { authorization: `Bearer ${accessToken}` },
|
|
654
|
+
},
|
|
655
|
+
);
|
|
656
|
+
if (resp.status === 401) {
|
|
657
|
+
throw new Error(GSC_ACCESS_REVOKED_MESSAGE);
|
|
658
|
+
}
|
|
659
|
+
if (!resp.ok) {
|
|
660
|
+
const text = await resp.text();
|
|
661
|
+
throw new Error(`Get sitemap failed: ${resp.status} ${text}`);
|
|
662
|
+
}
|
|
663
|
+
return normalizeSitemap((await resp.json()) as SitemapEntry);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
export interface QuickWinResult {
|
|
667
|
+
query: string;
|
|
668
|
+
page: string;
|
|
669
|
+
clicks: number;
|
|
670
|
+
impressions: number;
|
|
671
|
+
ctr: number;
|
|
672
|
+
position: number;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
export function processQuickWins(
|
|
676
|
+
rows: SearchAnalyticsRow[],
|
|
677
|
+
minImpressions: number,
|
|
678
|
+
minPosition: number,
|
|
679
|
+
maxPosition: number,
|
|
680
|
+
): QuickWinResult[] {
|
|
681
|
+
return rows
|
|
682
|
+
.filter(
|
|
683
|
+
(row) =>
|
|
684
|
+
row.keys.length >= 2 &&
|
|
685
|
+
row.position >= minPosition &&
|
|
686
|
+
row.position <= maxPosition &&
|
|
687
|
+
row.impressions >= minImpressions
|
|
688
|
+
)
|
|
689
|
+
.sort((a, b) => b.impressions - a.impressions)
|
|
690
|
+
.slice(0, 100)
|
|
691
|
+
.map((row) => ({
|
|
692
|
+
query: row.keys[0],
|
|
693
|
+
page: row.keys[1],
|
|
694
|
+
clicks: row.clicks,
|
|
695
|
+
impressions: row.impressions,
|
|
696
|
+
ctr: row.ctr,
|
|
697
|
+
position: row.position,
|
|
698
|
+
}));
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
export interface CannibalizationPage {
|
|
702
|
+
page: string;
|
|
703
|
+
clicks: number;
|
|
704
|
+
impressions: number;
|
|
705
|
+
ctr: number;
|
|
706
|
+
position: number;
|
|
707
|
+
impression_share: number;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
export interface CannibalizationResult {
|
|
711
|
+
query: string;
|
|
712
|
+
total_clicks: number;
|
|
713
|
+
total_impressions: number;
|
|
714
|
+
pages: CannibalizationPage[];
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
export function processCannibalization(
|
|
718
|
+
rows: SearchAnalyticsRow[],
|
|
719
|
+
minImpressions: number,
|
|
720
|
+
minPagePercentage: number,
|
|
721
|
+
): CannibalizationResult[] {
|
|
722
|
+
const queryGroups = new Map<string, Array<{ page: string; clicks: number; impressions: number; ctr: number; position: number }>>();
|
|
723
|
+
for (const row of rows) {
|
|
724
|
+
if (row.keys.length < 2) continue;
|
|
725
|
+
const query = row.keys[0];
|
|
726
|
+
const page = row.keys[1];
|
|
727
|
+
if (!queryGroups.has(query)) {
|
|
728
|
+
queryGroups.set(query, []);
|
|
729
|
+
}
|
|
730
|
+
queryGroups.get(query)!.push({
|
|
731
|
+
page,
|
|
732
|
+
clicks: row.clicks,
|
|
733
|
+
impressions: row.impressions,
|
|
734
|
+
ctr: row.ctr,
|
|
735
|
+
position: row.position,
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
const cannibalizationCandidates: CannibalizationResult[] = [];
|
|
740
|
+
|
|
741
|
+
for (const [query, pages] of queryGroups.entries()) {
|
|
742
|
+
const totalImpressions = pages.reduce((sum, p) => sum + p.impressions, 0);
|
|
743
|
+
const totalClicks = pages.reduce((sum, p) => sum + p.clicks, 0);
|
|
744
|
+
|
|
745
|
+
const competingPages = pages
|
|
746
|
+
.map((p) => ({
|
|
747
|
+
...p,
|
|
748
|
+
impression_share: Math.round((p.impressions / totalImpressions) * 1000) / 10,
|
|
749
|
+
}))
|
|
750
|
+
.filter((p) => p.impressions >= minImpressions && p.impression_share >= minPagePercentage);
|
|
751
|
+
|
|
752
|
+
if (competingPages.length >= 2) {
|
|
753
|
+
cannibalizationCandidates.push({
|
|
754
|
+
query,
|
|
755
|
+
total_clicks: totalClicks,
|
|
756
|
+
total_impressions: totalImpressions,
|
|
757
|
+
pages: competingPages.sort((a, b) => b.impressions - a.impressions),
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
return cannibalizationCandidates
|
|
763
|
+
.sort((a, b) => b.total_impressions - a.total_impressions)
|
|
764
|
+
.slice(0, 100);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
export interface DecayPageResult {
|
|
768
|
+
page: string;
|
|
769
|
+
previous_clicks: number;
|
|
770
|
+
recent_clicks: number;
|
|
771
|
+
click_difference: number;
|
|
772
|
+
click_decay_percentage: number;
|
|
773
|
+
previous_impressions: number;
|
|
774
|
+
recent_impressions: number;
|
|
775
|
+
impression_difference: number;
|
|
776
|
+
previous_position: number;
|
|
777
|
+
recent_position: number;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
export function processContentDecay(
|
|
781
|
+
recentRows: SearchAnalyticsRow[],
|
|
782
|
+
previousRows: SearchAnalyticsRow[],
|
|
783
|
+
): DecayPageResult[] {
|
|
784
|
+
const recentMap = new Map<string, { clicks: number; impressions: number; ctr: number; position: number }>();
|
|
785
|
+
for (const row of recentRows) {
|
|
786
|
+
if (row.keys.length < 1) continue;
|
|
787
|
+
recentMap.set(row.keys[0], {
|
|
788
|
+
clicks: row.clicks,
|
|
789
|
+
impressions: row.impressions,
|
|
790
|
+
ctr: row.ctr,
|
|
791
|
+
position: row.position,
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
const decayCandidates: DecayPageResult[] = [];
|
|
796
|
+
|
|
797
|
+
for (const row of previousRows) {
|
|
798
|
+
if (row.keys.length < 1) continue;
|
|
799
|
+
const page = row.keys[0];
|
|
800
|
+
const prevClicks = row.clicks;
|
|
801
|
+
const prevImps = row.impressions;
|
|
802
|
+
const prevPos = row.position;
|
|
803
|
+
|
|
804
|
+
const recent = recentMap.get(page);
|
|
805
|
+
const recClicks = recent ? recent.clicks : 0;
|
|
806
|
+
const recImps = recent ? recent.impressions : 0;
|
|
807
|
+
const recPos = recent ? recent.position : 0;
|
|
808
|
+
|
|
809
|
+
const clickDiff = recClicks - prevClicks;
|
|
810
|
+
const impDiff = recImps - prevImps;
|
|
811
|
+
|
|
812
|
+
if (clickDiff < 0) {
|
|
813
|
+
decayCandidates.push({
|
|
814
|
+
page,
|
|
815
|
+
previous_clicks: prevClicks,
|
|
816
|
+
recent_clicks: recClicks,
|
|
817
|
+
click_difference: clickDiff,
|
|
818
|
+
click_decay_percentage: prevClicks > 0 ? Math.round((Math.abs(clickDiff) / prevClicks) * 1000) / 10 : 0,
|
|
819
|
+
previous_impressions: prevImps,
|
|
820
|
+
recent_impressions: recImps,
|
|
821
|
+
impression_difference: impDiff,
|
|
822
|
+
previous_position: prevPos,
|
|
823
|
+
recent_position: recPos,
|
|
824
|
+
});
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
return decayCandidates
|
|
829
|
+
.sort((a, b) => a.click_difference - b.click_difference)
|
|
830
|
+
.slice(0, 100);
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// Google's Indexing API is not a general-purpose submission API. It is
|
|
834
|
+
// currently documented as supporting only job posting pages and livestream
|
|
835
|
+
// pages: https://developers.google.com/search/apis/indexing-api/v3/quickstart
|
|
836
|
+
const INDEXING_SCOPE_MESSAGE =
|
|
837
|
+
"Google's Indexing API only accepts job posting pages (JobPosting structured data) " +
|
|
838
|
+
'and livestream pages (BroadcastEvent structured data inside VideoObject). ' +
|
|
839
|
+
'It is not available for general webpage submission.';
|
|
840
|
+
|
|
841
|
+
function collectSchemaTypes(node: unknown, types: Set<string>): void {
|
|
842
|
+
if (Array.isArray(node)) {
|
|
843
|
+
for (const item of node) collectSchemaTypes(item, types);
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
if (!node || typeof node !== 'object') return;
|
|
847
|
+
const obj = node as Record<string, unknown>;
|
|
848
|
+
const type = obj['@type'];
|
|
849
|
+
if (typeof type === 'string') types.add(type);
|
|
850
|
+
else if (Array.isArray(type)) {
|
|
851
|
+
for (const t of type) if (typeof t === 'string') types.add(t);
|
|
852
|
+
}
|
|
853
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
854
|
+
if (key === '@type') continue;
|
|
855
|
+
collectSchemaTypes(value, types);
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/** True if `node` is (or contains, anywhere below it) a node typed BroadcastEvent. */
|
|
860
|
+
function containsBroadcastEvent(node: unknown): boolean {
|
|
861
|
+
if (Array.isArray(node)) return node.some(containsBroadcastEvent);
|
|
862
|
+
if (!node || typeof node !== 'object') return false;
|
|
863
|
+
const obj = node as Record<string, unknown>;
|
|
864
|
+
const types = new Set<string>();
|
|
865
|
+
const type = obj['@type'];
|
|
866
|
+
if (typeof type === 'string') types.add(type);
|
|
867
|
+
else if (Array.isArray(type)) {
|
|
868
|
+
for (const t of type) if (typeof t === 'string') types.add(t);
|
|
869
|
+
}
|
|
870
|
+
if (types.has('BroadcastEvent')) return true;
|
|
871
|
+
return Object.values(obj).some(containsBroadcastEvent);
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/** True if `node` is (or contains, anywhere below it) a VideoObject with a nested BroadcastEvent. */
|
|
875
|
+
function hasEligibleVideoBroadcast(node: unknown): boolean {
|
|
876
|
+
if (Array.isArray(node)) return node.some(hasEligibleVideoBroadcast);
|
|
877
|
+
if (!node || typeof node !== 'object') return false;
|
|
878
|
+
const obj = node as Record<string, unknown>;
|
|
879
|
+
const types = new Set<string>();
|
|
880
|
+
const type = obj['@type'];
|
|
881
|
+
if (typeof type === 'string') types.add(type);
|
|
882
|
+
else if (Array.isArray(type)) {
|
|
883
|
+
for (const t of type) if (typeof t === 'string') types.add(t);
|
|
884
|
+
}
|
|
885
|
+
if (types.has('VideoObject') && containsBroadcastEvent(obj)) return true;
|
|
886
|
+
return Object.values(obj).some(hasEligibleVideoBroadcast);
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
export interface IndexingEligibility {
|
|
890
|
+
eligible: boolean;
|
|
891
|
+
reason?: string;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/**
|
|
895
|
+
* JSON-LD can appear well after the document head, so eligibility checks scan
|
|
896
|
+
* up to 1 MiB of HTML rather than assuming the first few kilobytes are enough.
|
|
897
|
+
*/
|
|
898
|
+
export const INDEXING_ELIGIBILITY_MAX_RESPONSE_BYTES = 1024 * 1024;
|
|
899
|
+
export const INDEXING_ELIGIBILITY_TIMEOUT_MS = 10_000;
|
|
900
|
+
|
|
901
|
+
interface IndexingEligibilityOptions {
|
|
902
|
+
timeoutMs?: number;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
function isHttpUrl(url: string): boolean {
|
|
906
|
+
try {
|
|
907
|
+
const parsed = new URL(url);
|
|
908
|
+
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
|
|
909
|
+
} catch {
|
|
910
|
+
return false;
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
async function cancelResponseBody(response: Response): Promise<void> {
|
|
915
|
+
try {
|
|
916
|
+
await response.body?.cancel();
|
|
917
|
+
} catch {
|
|
918
|
+
// Cancellation is best-effort after a rejected response.
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
async function readBoundedResponseText(response: Response): Promise<string> {
|
|
923
|
+
const reader = response.body?.getReader();
|
|
924
|
+
if (!reader) return '';
|
|
925
|
+
|
|
926
|
+
const chunks: Uint8Array[] = [];
|
|
927
|
+
let totalBytes = 0;
|
|
928
|
+
let completed = false;
|
|
929
|
+
try {
|
|
930
|
+
while (true) {
|
|
931
|
+
const { done, value } = await reader.read();
|
|
932
|
+
if (done) {
|
|
933
|
+
completed = true;
|
|
934
|
+
break;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
totalBytes += value.byteLength;
|
|
938
|
+
if (totalBytes > INDEXING_ELIGIBILITY_MAX_RESPONSE_BYTES) {
|
|
939
|
+
throw new Error(
|
|
940
|
+
`Response body exceeds the ${INDEXING_ELIGIBILITY_MAX_RESPONSE_BYTES}-byte eligibility limit.`,
|
|
941
|
+
);
|
|
942
|
+
}
|
|
943
|
+
chunks.push(value);
|
|
944
|
+
}
|
|
945
|
+
} finally {
|
|
946
|
+
if (!completed) {
|
|
947
|
+
try {
|
|
948
|
+
await reader.cancel();
|
|
949
|
+
} catch {
|
|
950
|
+
// The stream may already have been cancelled or aborted.
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
reader.releaseLock();
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
const bytes = new Uint8Array(totalBytes);
|
|
957
|
+
let offset = 0;
|
|
958
|
+
for (const chunk of chunks) {
|
|
959
|
+
bytes.set(chunk, offset);
|
|
960
|
+
offset += chunk.byteLength;
|
|
961
|
+
}
|
|
962
|
+
return new TextDecoder().decode(bytes);
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
/**
|
|
966
|
+
* Checks whether `url` carries the structured data Google's Indexing API
|
|
967
|
+
* currently supports (JobPosting, or BroadcastEvent nested in VideoObject),
|
|
968
|
+
* by inspecting its JSON-LD. Best-effort: if the page can't be fetched or
|
|
969
|
+
* parsed, eligibility can't be confirmed and the URL is treated as
|
|
970
|
+
* ineligible rather than silently submitted.
|
|
971
|
+
*/
|
|
972
|
+
export async function checkIndexingEligibility(
|
|
973
|
+
url: string,
|
|
974
|
+
{ timeoutMs = INDEXING_ELIGIBILITY_TIMEOUT_MS }: IndexingEligibilityOptions = {},
|
|
975
|
+
): Promise<IndexingEligibility> {
|
|
976
|
+
if (!isHttpUrl(url)) {
|
|
977
|
+
return {
|
|
978
|
+
eligible: false,
|
|
979
|
+
reason: `${url} must be a valid HTTP or HTTPS URL. ${INDEXING_SCOPE_MESSAGE}`,
|
|
980
|
+
};
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
let html: string;
|
|
984
|
+
const controller = new AbortController();
|
|
985
|
+
let timedOut = false;
|
|
986
|
+
const timeout = setTimeout(() => {
|
|
987
|
+
timedOut = true;
|
|
988
|
+
controller.abort();
|
|
989
|
+
}, timeoutMs);
|
|
990
|
+
|
|
991
|
+
try {
|
|
992
|
+
const resp = await fetch(url, {
|
|
993
|
+
signal: controller.signal,
|
|
994
|
+
// Reject redirects rather than following a caller-controlled chain.
|
|
995
|
+
redirect: 'manual',
|
|
996
|
+
headers: {
|
|
997
|
+
'user-agent':
|
|
998
|
+
'Mozilla/5.0 (compatible; DigestSEO-GSC-MCP/1.0; +https://github.com/AKzar1el/mcp-gsc)',
|
|
999
|
+
},
|
|
1000
|
+
});
|
|
1001
|
+
if (!resp.ok) {
|
|
1002
|
+
await cancelResponseBody(resp);
|
|
1003
|
+
return {
|
|
1004
|
+
eligible: false,
|
|
1005
|
+
reason: `Could not fetch ${url} to check indexing eligibility (HTTP ${resp.status}). ${INDEXING_SCOPE_MESSAGE}`,
|
|
1006
|
+
};
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
const contentType = resp.headers.get('content-type')?.split(';', 1)[0].trim().toLowerCase();
|
|
1010
|
+
if (
|
|
1011
|
+
contentType &&
|
|
1012
|
+
contentType !== 'text/html' &&
|
|
1013
|
+
contentType !== 'application/xhtml+xml'
|
|
1014
|
+
) {
|
|
1015
|
+
await cancelResponseBody(resp);
|
|
1016
|
+
return {
|
|
1017
|
+
eligible: false,
|
|
1018
|
+
reason: `Could not fetch ${url} to check indexing eligibility (expected HTML, received ${contentType}). ${INDEXING_SCOPE_MESSAGE}`,
|
|
1019
|
+
};
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
const contentLength = Number(resp.headers.get('content-length'));
|
|
1023
|
+
if (
|
|
1024
|
+
Number.isFinite(contentLength) &&
|
|
1025
|
+
contentLength > INDEXING_ELIGIBILITY_MAX_RESPONSE_BYTES
|
|
1026
|
+
) {
|
|
1027
|
+
await cancelResponseBody(resp);
|
|
1028
|
+
return {
|
|
1029
|
+
eligible: false,
|
|
1030
|
+
reason: `Could not fetch ${url} to check indexing eligibility (response exceeds the ${INDEXING_ELIGIBILITY_MAX_RESPONSE_BYTES}-byte limit). ${INDEXING_SCOPE_MESSAGE}`,
|
|
1031
|
+
};
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
html = await readBoundedResponseText(resp);
|
|
1035
|
+
} catch (err) {
|
|
1036
|
+
const detail = timedOut
|
|
1037
|
+
? `timed out after ${timeoutMs}ms`
|
|
1038
|
+
: (err as Error).message;
|
|
1039
|
+
return {
|
|
1040
|
+
eligible: false,
|
|
1041
|
+
reason: `Could not fetch ${url} to check indexing eligibility (${detail}). ${INDEXING_SCOPE_MESSAGE}`,
|
|
1042
|
+
};
|
|
1043
|
+
} finally {
|
|
1044
|
+
clearTimeout(timeout);
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
const types = new Set<string>();
|
|
1048
|
+
let hasVideoBroadcast = false;
|
|
1049
|
+
const scriptPattern = /<script[^>]+type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
|
|
1050
|
+
let match: RegExpExecArray | null;
|
|
1051
|
+
while ((match = scriptPattern.exec(html)) !== null) {
|
|
1052
|
+
try {
|
|
1053
|
+
const parsed = JSON.parse(match[1]);
|
|
1054
|
+
collectSchemaTypes(parsed, types);
|
|
1055
|
+
if (hasEligibleVideoBroadcast(parsed)) hasVideoBroadcast = true;
|
|
1056
|
+
} catch {
|
|
1057
|
+
// Ignore malformed JSON-LD blocks and keep scanning the rest of the page.
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
if (types.has('JobPosting') || hasVideoBroadcast) {
|
|
1062
|
+
return { eligible: true };
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
return {
|
|
1066
|
+
eligible: false,
|
|
1067
|
+
reason: `${url} does not appear to contain JobPosting structured data or a BroadcastEvent inside VideoObject. ${INDEXING_SCOPE_MESSAGE}`,
|
|
1068
|
+
};
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
export async function requestIndexing(
|
|
1072
|
+
accessToken: string,
|
|
1073
|
+
url: string,
|
|
1074
|
+
): Promise<unknown> {
|
|
1075
|
+
const eligibility = await checkIndexingEligibility(url);
|
|
1076
|
+
if (!eligibility.eligible) {
|
|
1077
|
+
throw new Error(eligibility.reason);
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
const resp = await fetch(
|
|
1081
|
+
'https://indexing.googleapis.com/v3/urlNotifications:publish',
|
|
1082
|
+
{
|
|
1083
|
+
method: 'POST',
|
|
1084
|
+
headers: {
|
|
1085
|
+
authorization: `Bearer ${accessToken}`,
|
|
1086
|
+
'content-type': 'application/json',
|
|
1087
|
+
},
|
|
1088
|
+
body: JSON.stringify({
|
|
1089
|
+
url,
|
|
1090
|
+
type: 'URL_UPDATED',
|
|
1091
|
+
}),
|
|
1092
|
+
},
|
|
1093
|
+
);
|
|
1094
|
+
if (resp.status === 401) {
|
|
1095
|
+
throw new Error(GSC_ACCESS_REVOKED_MESSAGE);
|
|
1096
|
+
}
|
|
1097
|
+
if (!resp.ok) {
|
|
1098
|
+
const text = await resp.text();
|
|
1099
|
+
throw new Error(`Request indexing failed: ${resp.status} ${text}`);
|
|
1100
|
+
}
|
|
1101
|
+
return await resp.json();
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
export interface PerformanceComparisonRow {
|
|
1105
|
+
key: string;
|
|
1106
|
+
period_a: { clicks: number; impressions: number; ctr: number; position: number };
|
|
1107
|
+
period_b: { clicks: number; impressions: number; ctr: number; position: number };
|
|
1108
|
+
diff: {
|
|
1109
|
+
clicks: number;
|
|
1110
|
+
clicks_percentage: number | null;
|
|
1111
|
+
impressions: number;
|
|
1112
|
+
impressions_percentage: number | null;
|
|
1113
|
+
ctr: number;
|
|
1114
|
+
position: number;
|
|
1115
|
+
};
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
function calculatePercentageChange(current: number, baseline: number): number | null {
|
|
1119
|
+
if (baseline === 0) {
|
|
1120
|
+
return current === 0 ? 0 : null;
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
return Math.round(((current - baseline) / baseline) * 1000) / 10;
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
export function processPerformanceComparison(
|
|
1127
|
+
rowsA: SearchAnalyticsRow[],
|
|
1128
|
+
rowsB: SearchAnalyticsRow[],
|
|
1129
|
+
): PerformanceComparisonRow[] {
|
|
1130
|
+
const mapB = new Map<string, { clicks: number; impressions: number; ctr: number; position: number }>();
|
|
1131
|
+
for (const row of rowsB) {
|
|
1132
|
+
if (row.keys.length < 1) continue;
|
|
1133
|
+
mapB.set(row.keys[0], {
|
|
1134
|
+
clicks: row.clicks,
|
|
1135
|
+
impressions: row.impressions,
|
|
1136
|
+
ctr: row.ctr,
|
|
1137
|
+
position: row.position,
|
|
1138
|
+
});
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
const comparison: PerformanceComparisonRow[] = [];
|
|
1142
|
+
|
|
1143
|
+
for (const row of rowsA) {
|
|
1144
|
+
if (row.keys.length < 1) continue;
|
|
1145
|
+
const key = row.keys[0];
|
|
1146
|
+
const clicksA = row.clicks;
|
|
1147
|
+
const impsA = row.impressions;
|
|
1148
|
+
const ctrA = row.ctr;
|
|
1149
|
+
const posA = row.position;
|
|
1150
|
+
|
|
1151
|
+
const b = mapB.get(key) || { clicks: 0, impressions: 0, ctr: 0, position: 0 };
|
|
1152
|
+
const clicksB = b.clicks;
|
|
1153
|
+
const impsB = b.impressions;
|
|
1154
|
+
const ctrB = b.ctr;
|
|
1155
|
+
const posB = b.position;
|
|
1156
|
+
|
|
1157
|
+
const clicksDiff = clicksA - clicksB;
|
|
1158
|
+
const impsDiff = impsA - impsB;
|
|
1159
|
+
|
|
1160
|
+
comparison.push({
|
|
1161
|
+
key,
|
|
1162
|
+
period_a: { clicks: clicksA, impressions: impsA, ctr: ctrA, position: posA },
|
|
1163
|
+
period_b: { clicks: clicksB, impressions: impsB, ctr: ctrB, position: posB },
|
|
1164
|
+
diff: {
|
|
1165
|
+
clicks: clicksDiff,
|
|
1166
|
+
clicks_percentage: calculatePercentageChange(clicksA, clicksB),
|
|
1167
|
+
impressions: impsDiff,
|
|
1168
|
+
impressions_percentage: calculatePercentageChange(impsA, impsB),
|
|
1169
|
+
ctr: Math.round((ctrA - ctrB) * 1000) / 1000,
|
|
1170
|
+
position: Math.round((posA - posB) * 10) / 10,
|
|
1171
|
+
},
|
|
1172
|
+
});
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
const mapAKeys = new Set(rowsA.map((r) => r.keys[0]));
|
|
1176
|
+
for (const row of rowsB) {
|
|
1177
|
+
if (row.keys.length < 1) continue;
|
|
1178
|
+
const key = row.keys[0];
|
|
1179
|
+
if (mapAKeys.has(key)) continue;
|
|
1180
|
+
|
|
1181
|
+
const clicksB = row.clicks;
|
|
1182
|
+
const impsB = row.impressions;
|
|
1183
|
+
const ctrB = row.ctr;
|
|
1184
|
+
const posB = row.position;
|
|
1185
|
+
|
|
1186
|
+
comparison.push({
|
|
1187
|
+
key,
|
|
1188
|
+
period_a: { clicks: 0, impressions: 0, ctr: 0, position: 0 },
|
|
1189
|
+
period_b: { clicks: clicksB, impressions: impsB, ctr: ctrB, position: posB },
|
|
1190
|
+
diff: {
|
|
1191
|
+
clicks: -clicksB,
|
|
1192
|
+
clicks_percentage: calculatePercentageChange(0, clicksB),
|
|
1193
|
+
impressions: -impsB,
|
|
1194
|
+
impressions_percentage: calculatePercentageChange(0, impsB),
|
|
1195
|
+
ctr: -ctrB,
|
|
1196
|
+
position: -posB,
|
|
1197
|
+
},
|
|
1198
|
+
});
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
return comparison.sort((a, b) => b.period_a.clicks - a.period_a.clicks);
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
|
|
1205
|
+
|