@digestseo/mcp-gsc 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/google.ts ADDED
@@ -0,0 +1,716 @@
1
+ export const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
2
+ export const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
3
+ export const GOOGLE_USERINFO_URL = 'https://www.googleapis.com/oauth2/v2/userinfo';
4
+
5
+ export const SCOPES = [
6
+ 'openid',
7
+ 'email',
8
+ 'https://www.googleapis.com/auth/webmasters',
9
+ 'https://www.googleapis.com/auth/indexing',
10
+ ];
11
+
12
+ export function buildAuthUrl(
13
+ clientId: string,
14
+ redirectUri: string,
15
+ state: string,
16
+ ): string {
17
+ const params = new URLSearchParams({
18
+ client_id: clientId,
19
+ redirect_uri: redirectUri,
20
+ response_type: 'code',
21
+ scope: SCOPES.join(' '),
22
+ access_type: 'offline',
23
+ prompt: 'consent',
24
+ state,
25
+ });
26
+ return `${GOOGLE_AUTH_URL}?${params.toString()}`;
27
+ }
28
+
29
+ export interface GoogleTokenResponse {
30
+ access_token: string;
31
+ refresh_token: string;
32
+ expires_in: number;
33
+ id_token?: string;
34
+ token_type: string;
35
+ scope: string;
36
+ }
37
+
38
+ export const GSC_ACCESS_REVOKED_MESSAGE =
39
+ 'Google access revoked. Please reconnect this server in your MCP client (e.g. Claude.ai → Settings → Connectors).';
40
+
41
+ export class GoogleRefreshTokenRevokedError extends Error {
42
+ constructor() {
43
+ super(GSC_ACCESS_REVOKED_MESSAGE);
44
+ this.name = 'GoogleRefreshTokenRevokedError';
45
+ }
46
+ }
47
+
48
+ export async function exchangeCodeForTokens(
49
+ code: string,
50
+ clientId: string,
51
+ clientSecret: string,
52
+ redirectUri: string,
53
+ ): Promise<GoogleTokenResponse> {
54
+ const body = new URLSearchParams({
55
+ code,
56
+ client_id: clientId,
57
+ client_secret: clientSecret,
58
+ redirect_uri: redirectUri,
59
+ grant_type: 'authorization_code',
60
+ });
61
+ const resp = await fetch(GOOGLE_TOKEN_URL, {
62
+ method: 'POST',
63
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
64
+ body,
65
+ });
66
+ if (!resp.ok) {
67
+ const text = await resp.text();
68
+ throw new Error(`Token exchange failed: ${resp.status} ${text}`);
69
+ }
70
+ const data = (await resp.json()) as Partial<GoogleTokenResponse>;
71
+ if (!data.refresh_token) {
72
+ throw new Error('Google did not return a refresh_token');
73
+ }
74
+ return data as GoogleTokenResponse;
75
+ }
76
+
77
+ export interface GoogleUserInfo {
78
+ id: string;
79
+ email: string;
80
+ verified_email?: boolean;
81
+ }
82
+
83
+ export async function fetchGoogleUserInfo(
84
+ accessToken: string,
85
+ ): Promise<GoogleUserInfo> {
86
+ const resp = await fetch(GOOGLE_USERINFO_URL, {
87
+ headers: { authorization: `Bearer ${accessToken}` },
88
+ });
89
+ if (!resp.ok) {
90
+ const text = await resp.text();
91
+ throw new Error(`Userinfo fetch failed: ${resp.status} ${text}`);
92
+ }
93
+ return (await resp.json()) as GoogleUserInfo;
94
+ }
95
+
96
+ export async function refreshAccessToken(
97
+ refreshToken: string,
98
+ clientId: string,
99
+ clientSecret: string,
100
+ ): Promise<{ access_token: string; expires_in: number }> {
101
+ const body = new URLSearchParams({
102
+ refresh_token: refreshToken,
103
+ client_id: clientId,
104
+ client_secret: clientSecret,
105
+ grant_type: 'refresh_token',
106
+ });
107
+ const resp = await fetch(GOOGLE_TOKEN_URL, {
108
+ method: 'POST',
109
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
110
+ body,
111
+ });
112
+ if (!resp.ok) {
113
+ let parsedError: string | undefined;
114
+ try {
115
+ const errBody = (await resp.json()) as { error?: string };
116
+ parsedError = errBody.error;
117
+ } catch {
118
+ // body was not JSON
119
+ }
120
+ if (parsedError === 'invalid_grant') {
121
+ throw new GoogleRefreshTokenRevokedError();
122
+ }
123
+ throw new Error(`Failed to refresh Google access token: ${resp.status}`);
124
+ }
125
+ const data = (await resp.json()) as {
126
+ access_token: string;
127
+ expires_in: number;
128
+ };
129
+ return { access_token: data.access_token, expires_in: data.expires_in };
130
+ }
131
+
132
+ export interface SiteEntry {
133
+ siteUrl: string;
134
+ permissionLevel: string;
135
+ }
136
+
137
+ export async function listSites(accessToken: string): Promise<SiteEntry[]> {
138
+ const resp = await fetch('https://www.googleapis.com/webmasters/v3/sites', {
139
+ headers: { authorization: `Bearer ${accessToken}` },
140
+ });
141
+ if (resp.status === 401) {
142
+ throw new Error(GSC_ACCESS_REVOKED_MESSAGE);
143
+ }
144
+ if (!resp.ok) {
145
+ const text = await resp.text();
146
+ throw new Error(`List sites failed: ${resp.status} ${text}`);
147
+ }
148
+ const data = (await resp.json()) as { siteEntry?: SiteEntry[] };
149
+ return data.siteEntry ?? [];
150
+ }
151
+
152
+ export type SearchDimension =
153
+ | 'query'
154
+ | 'page'
155
+ | 'country'
156
+ | 'device'
157
+ | 'date'
158
+ | 'searchAppearance';
159
+
160
+ export type SearchType =
161
+ | 'web'
162
+ | 'image'
163
+ | 'video'
164
+ | 'news'
165
+ | 'discover'
166
+ | 'googleNews';
167
+
168
+ export type AggregationType = 'auto' | 'byPage' | 'byProperty';
169
+
170
+ export type DataState = 'all' | 'final';
171
+
172
+ export type FilterDimension =
173
+ | 'query'
174
+ | 'page'
175
+ | 'country'
176
+ | 'device'
177
+ | 'searchAppearance';
178
+
179
+ export type FilterOperator =
180
+ | 'equals'
181
+ | 'notEquals'
182
+ | 'contains'
183
+ | 'notContains'
184
+ | 'includingRegex'
185
+ | 'excludingRegex';
186
+
187
+ export interface DimensionFilter {
188
+ dimension: FilterDimension;
189
+ operator: FilterOperator;
190
+ expression: string;
191
+ }
192
+
193
+ export interface DimensionFilterGroup {
194
+ groupType: 'and';
195
+ filters: DimensionFilter[];
196
+ }
197
+
198
+ export interface SearchAnalyticsQuery {
199
+ startDate: string;
200
+ endDate: string;
201
+ dimensions: SearchDimension[];
202
+ rowLimit: number;
203
+ startRow?: number;
204
+ dataState?: DataState;
205
+ type?: SearchType;
206
+ aggregationType?: AggregationType;
207
+ dimensionFilterGroups?: DimensionFilterGroup[];
208
+ }
209
+
210
+ export interface SearchAnalyticsRow {
211
+ keys: string[];
212
+ clicks: number;
213
+ impressions: number;
214
+ ctr: number;
215
+ position: number;
216
+ }
217
+
218
+ export async function inspectUrl(
219
+ accessToken: string,
220
+ siteUrl: string,
221
+ inspectionUrl: string,
222
+ languageCode?: string,
223
+ ): Promise<unknown> {
224
+ const body: Record<string, string> = {
225
+ inspectionUrl,
226
+ siteUrl,
227
+ };
228
+ if (languageCode) body.languageCode = languageCode;
229
+ const resp = await fetch(
230
+ 'https://searchconsole.googleapis.com/v1/urlInspection/index:inspect',
231
+ {
232
+ method: 'POST',
233
+ headers: {
234
+ authorization: `Bearer ${accessToken}`,
235
+ 'content-type': 'application/json',
236
+ },
237
+ body: JSON.stringify(body),
238
+ },
239
+ );
240
+ if (resp.status === 401) {
241
+ throw new Error(GSC_ACCESS_REVOKED_MESSAGE);
242
+ }
243
+ if (!resp.ok) {
244
+ const text = await resp.text();
245
+ throw new Error(`URL inspection failed: ${resp.status} ${text}`);
246
+ }
247
+ const data = (await resp.json()) as { inspectionResult?: unknown };
248
+ return data.inspectionResult ?? null;
249
+ }
250
+
251
+ export interface SitemapEntry {
252
+ path: string;
253
+ lastSubmitted?: string;
254
+ isPending?: boolean;
255
+ isSitemapsIndex?: boolean;
256
+ type?: string;
257
+ lastDownloaded?: string;
258
+ warnings?: string;
259
+ errors?: string;
260
+ contents?: Array<{
261
+ type: string;
262
+ submitted: string;
263
+ indexed: string;
264
+ }>;
265
+ }
266
+
267
+ export async function listSitemaps(
268
+ accessToken: string,
269
+ siteUrl: string,
270
+ ): Promise<SitemapEntry[]> {
271
+ const encoded = encodeURIComponent(siteUrl);
272
+ const resp = await fetch(
273
+ `https://www.googleapis.com/webmasters/v3/sites/${encoded}/sitemaps`,
274
+ {
275
+ headers: { authorization: `Bearer ${accessToken}` },
276
+ },
277
+ );
278
+ if (resp.status === 401) {
279
+ throw new Error(GSC_ACCESS_REVOKED_MESSAGE);
280
+ }
281
+ if (!resp.ok) {
282
+ const text = await resp.text();
283
+ throw new Error(`List sitemaps failed: ${resp.status} ${text}`);
284
+ }
285
+ const data = (await resp.json()) as { sitemap?: SitemapEntry[] };
286
+ return data.sitemap ?? [];
287
+ }
288
+
289
+ export async function querySearchAnalytics(
290
+ accessToken: string,
291
+ siteUrl: string,
292
+ body: SearchAnalyticsQuery,
293
+ ): Promise<SearchAnalyticsRow[]> {
294
+ const encoded = encodeURIComponent(siteUrl);
295
+ const resp = await fetch(
296
+ `https://www.googleapis.com/webmasters/v3/sites/${encoded}/searchAnalytics/query`,
297
+ {
298
+ method: 'POST',
299
+ headers: {
300
+ authorization: `Bearer ${accessToken}`,
301
+ 'content-type': 'application/json',
302
+ },
303
+ body: JSON.stringify(body),
304
+ },
305
+ );
306
+ if (resp.status === 401) {
307
+ throw new Error(GSC_ACCESS_REVOKED_MESSAGE);
308
+ }
309
+ if (!resp.ok) {
310
+ const text = await resp.text();
311
+ throw new Error(`Search analytics query failed: ${resp.status} ${text}`);
312
+ }
313
+ const data = (await resp.json()) as { rows?: SearchAnalyticsRow[] };
314
+ return data.rows ?? [];
315
+ }
316
+
317
+ export async function addSite(
318
+ accessToken: string,
319
+ siteUrl: string,
320
+ ): Promise<void> {
321
+ const encoded = encodeURIComponent(siteUrl);
322
+ const resp = await fetch(
323
+ `https://www.googleapis.com/webmasters/v3/sites/${encoded}`,
324
+ {
325
+ method: 'PUT',
326
+ headers: { authorization: `Bearer ${accessToken}` },
327
+ },
328
+ );
329
+ if (resp.status === 401) {
330
+ throw new Error(GSC_ACCESS_REVOKED_MESSAGE);
331
+ }
332
+ if (!resp.ok) {
333
+ const text = await resp.text();
334
+ throw new Error(`Add site failed: ${resp.status} ${text}`);
335
+ }
336
+ }
337
+
338
+ export async function deleteSite(
339
+ accessToken: string,
340
+ siteUrl: string,
341
+ ): Promise<void> {
342
+ const encoded = encodeURIComponent(siteUrl);
343
+ const resp = await fetch(
344
+ `https://www.googleapis.com/webmasters/v3/sites/${encoded}`,
345
+ {
346
+ method: 'DELETE',
347
+ headers: { authorization: `Bearer ${accessToken}` },
348
+ },
349
+ );
350
+ if (resp.status === 401) {
351
+ throw new Error(GSC_ACCESS_REVOKED_MESSAGE);
352
+ }
353
+ if (!resp.ok) {
354
+ const text = await resp.text();
355
+ throw new Error(`Delete site failed: ${resp.status} ${text}`);
356
+ }
357
+ }
358
+
359
+ export async function submitSitemap(
360
+ accessToken: string,
361
+ siteUrl: string,
362
+ feedpath: string,
363
+ ): Promise<void> {
364
+ const encodedSite = encodeURIComponent(siteUrl);
365
+ const encodedFeed = encodeURIComponent(feedpath);
366
+ const resp = await fetch(
367
+ `https://www.googleapis.com/webmasters/v3/sites/${encodedSite}/sitemaps/${encodedFeed}`,
368
+ {
369
+ method: 'PUT',
370
+ headers: { authorization: `Bearer ${accessToken}` },
371
+ },
372
+ );
373
+ if (resp.status === 401) {
374
+ throw new Error(GSC_ACCESS_REVOKED_MESSAGE);
375
+ }
376
+ if (!resp.ok) {
377
+ const text = await resp.text();
378
+ throw new Error(`Submit sitemap failed: ${resp.status} ${text}`);
379
+ }
380
+ }
381
+
382
+ export async function deleteSitemap(
383
+ accessToken: string,
384
+ siteUrl: string,
385
+ feedpath: string,
386
+ ): Promise<void> {
387
+ const encodedSite = encodeURIComponent(siteUrl);
388
+ const encodedFeed = encodeURIComponent(feedpath);
389
+ const resp = await fetch(
390
+ `https://www.googleapis.com/webmasters/v3/sites/${encodedSite}/sitemaps/${encodedFeed}`,
391
+ {
392
+ method: 'DELETE',
393
+ headers: { authorization: `Bearer ${accessToken}` },
394
+ },
395
+ );
396
+ if (resp.status === 401) {
397
+ throw new Error(GSC_ACCESS_REVOKED_MESSAGE);
398
+ }
399
+ if (!resp.ok) {
400
+ const text = await resp.text();
401
+ throw new Error(`Delete sitemap failed: ${resp.status} ${text}`);
402
+ }
403
+ }
404
+
405
+ export async function getSitemap(
406
+ accessToken: string,
407
+ siteUrl: string,
408
+ feedpath: string,
409
+ ): Promise<SitemapEntry> {
410
+ const encodedSite = encodeURIComponent(siteUrl);
411
+ const encodedFeed = encodeURIComponent(feedpath);
412
+ const resp = await fetch(
413
+ `https://www.googleapis.com/webmasters/v3/sites/${encodedSite}/sitemaps/${encodedFeed}`,
414
+ {
415
+ headers: { authorization: `Bearer ${accessToken}` },
416
+ },
417
+ );
418
+ if (resp.status === 401) {
419
+ throw new Error(GSC_ACCESS_REVOKED_MESSAGE);
420
+ }
421
+ if (!resp.ok) {
422
+ const text = await resp.text();
423
+ throw new Error(`Get sitemap failed: ${resp.status} ${text}`);
424
+ }
425
+ return (await resp.json()) as SitemapEntry;
426
+ }
427
+
428
+ export interface QuickWinResult {
429
+ query: string;
430
+ page: string;
431
+ clicks: number;
432
+ impressions: number;
433
+ ctr: number;
434
+ position: number;
435
+ }
436
+
437
+ export function processQuickWins(
438
+ rows: SearchAnalyticsRow[],
439
+ minImpressions: number,
440
+ minPosition: number,
441
+ maxPosition: number,
442
+ ): QuickWinResult[] {
443
+ return rows
444
+ .filter(
445
+ (row) =>
446
+ row.keys.length >= 2 &&
447
+ row.position >= minPosition &&
448
+ row.position <= maxPosition &&
449
+ row.impressions >= minImpressions
450
+ )
451
+ .sort((a, b) => b.impressions - a.impressions)
452
+ .slice(0, 100)
453
+ .map((row) => ({
454
+ query: row.keys[0],
455
+ page: row.keys[1],
456
+ clicks: row.clicks,
457
+ impressions: row.impressions,
458
+ ctr: row.ctr,
459
+ position: row.position,
460
+ }));
461
+ }
462
+
463
+ export interface CannibalizationPage {
464
+ page: string;
465
+ clicks: number;
466
+ impressions: number;
467
+ ctr: number;
468
+ position: number;
469
+ impression_share: number;
470
+ }
471
+
472
+ export interface CannibalizationResult {
473
+ query: string;
474
+ total_clicks: number;
475
+ total_impressions: number;
476
+ pages: CannibalizationPage[];
477
+ }
478
+
479
+ export function processCannibalization(
480
+ rows: SearchAnalyticsRow[],
481
+ minImpressions: number,
482
+ minPagePercentage: number,
483
+ ): CannibalizationResult[] {
484
+ const queryGroups = new Map<string, Array<{ page: string; clicks: number; impressions: number; ctr: number; position: number }>>();
485
+ for (const row of rows) {
486
+ if (row.keys.length < 2) continue;
487
+ const query = row.keys[0];
488
+ const page = row.keys[1];
489
+ if (!queryGroups.has(query)) {
490
+ queryGroups.set(query, []);
491
+ }
492
+ queryGroups.get(query)!.push({
493
+ page,
494
+ clicks: row.clicks,
495
+ impressions: row.impressions,
496
+ ctr: row.ctr,
497
+ position: row.position,
498
+ });
499
+ }
500
+
501
+ const cannibalizationCandidates: CannibalizationResult[] = [];
502
+
503
+ for (const [query, pages] of queryGroups.entries()) {
504
+ const totalImpressions = pages.reduce((sum, p) => sum + p.impressions, 0);
505
+ const totalClicks = pages.reduce((sum, p) => sum + p.clicks, 0);
506
+
507
+ const competingPages = pages
508
+ .map((p) => ({
509
+ ...p,
510
+ impression_share: Math.round((p.impressions / totalImpressions) * 1000) / 10,
511
+ }))
512
+ .filter((p) => p.impressions >= minImpressions && p.impression_share >= minPagePercentage);
513
+
514
+ if (competingPages.length >= 2) {
515
+ cannibalizationCandidates.push({
516
+ query,
517
+ total_clicks: totalClicks,
518
+ total_impressions: totalImpressions,
519
+ pages: competingPages.sort((a, b) => b.impressions - a.impressions),
520
+ });
521
+ }
522
+ }
523
+
524
+ return cannibalizationCandidates
525
+ .sort((a, b) => b.total_impressions - a.total_impressions)
526
+ .slice(0, 100);
527
+ }
528
+
529
+ export interface DecayPageResult {
530
+ page: string;
531
+ previous_clicks: number;
532
+ recent_clicks: number;
533
+ click_difference: number;
534
+ click_decay_percentage: number;
535
+ previous_impressions: number;
536
+ recent_impressions: number;
537
+ impression_difference: number;
538
+ previous_position: number;
539
+ recent_position: number;
540
+ }
541
+
542
+ export function processContentDecay(
543
+ recentRows: SearchAnalyticsRow[],
544
+ previousRows: SearchAnalyticsRow[],
545
+ ): DecayPageResult[] {
546
+ const recentMap = new Map<string, { clicks: number; impressions: number; ctr: number; position: number }>();
547
+ for (const row of recentRows) {
548
+ if (row.keys.length < 1) continue;
549
+ recentMap.set(row.keys[0], {
550
+ clicks: row.clicks,
551
+ impressions: row.impressions,
552
+ ctr: row.ctr,
553
+ position: row.position,
554
+ });
555
+ }
556
+
557
+ const decayCandidates: DecayPageResult[] = [];
558
+
559
+ for (const row of previousRows) {
560
+ if (row.keys.length < 1) continue;
561
+ const page = row.keys[0];
562
+ const prevClicks = row.clicks;
563
+ const prevImps = row.impressions;
564
+ const prevPos = row.position;
565
+
566
+ const recent = recentMap.get(page);
567
+ const recClicks = recent ? recent.clicks : 0;
568
+ const recImps = recent ? recent.impressions : 0;
569
+ const recPos = recent ? recent.position : 0;
570
+
571
+ const clickDiff = recClicks - prevClicks;
572
+ const impDiff = recImps - prevImps;
573
+
574
+ if (clickDiff < 0) {
575
+ decayCandidates.push({
576
+ page,
577
+ previous_clicks: prevClicks,
578
+ recent_clicks: recClicks,
579
+ click_difference: clickDiff,
580
+ click_decay_percentage: prevClicks > 0 ? Math.round((Math.abs(clickDiff) / prevClicks) * 1000) / 10 : 0,
581
+ previous_impressions: prevImps,
582
+ recent_impressions: recImps,
583
+ impression_difference: impDiff,
584
+ previous_position: prevPos,
585
+ recent_position: recPos,
586
+ });
587
+ }
588
+ }
589
+
590
+ return decayCandidates
591
+ .sort((a, b) => a.click_difference - b.click_difference)
592
+ .slice(0, 100);
593
+ }
594
+
595
+ export async function requestIndexing(
596
+ accessToken: string,
597
+ url: string,
598
+ ): Promise<unknown> {
599
+ const resp = await fetch(
600
+ 'https://indexing.googleapis.com/v3/urlNotifications:publish',
601
+ {
602
+ method: 'POST',
603
+ headers: {
604
+ authorization: `Bearer ${accessToken}`,
605
+ 'content-type': 'application/json',
606
+ },
607
+ body: JSON.stringify({
608
+ url,
609
+ type: 'URL_UPDATED',
610
+ }),
611
+ },
612
+ );
613
+ if (resp.status === 401) {
614
+ throw new Error(GSC_ACCESS_REVOKED_MESSAGE);
615
+ }
616
+ if (!resp.ok) {
617
+ const text = await resp.text();
618
+ throw new Error(`Request indexing failed: ${resp.status} ${text}`);
619
+ }
620
+ return await resp.json();
621
+ }
622
+
623
+ export interface PerformanceComparisonRow {
624
+ key: string;
625
+ period_a: { clicks: number; impressions: number; ctr: number; position: number };
626
+ period_b: { clicks: number; impressions: number; ctr: number; position: number };
627
+ diff: {
628
+ clicks: number;
629
+ clicks_percentage: number;
630
+ impressions: number;
631
+ impressions_percentage: number;
632
+ ctr: number;
633
+ position: number;
634
+ };
635
+ }
636
+
637
+ export function processPerformanceComparison(
638
+ rowsA: SearchAnalyticsRow[],
639
+ rowsB: SearchAnalyticsRow[],
640
+ ): PerformanceComparisonRow[] {
641
+ const mapB = new Map<string, { clicks: number; impressions: number; ctr: number; position: number }>();
642
+ for (const row of rowsB) {
643
+ if (row.keys.length < 1) continue;
644
+ mapB.set(row.keys[0], {
645
+ clicks: row.clicks,
646
+ impressions: row.impressions,
647
+ ctr: row.ctr,
648
+ position: row.position,
649
+ });
650
+ }
651
+
652
+ const comparison: PerformanceComparisonRow[] = [];
653
+
654
+ for (const row of rowsA) {
655
+ if (row.keys.length < 1) continue;
656
+ const key = row.keys[0];
657
+ const clicksA = row.clicks;
658
+ const impsA = row.impressions;
659
+ const ctrA = row.ctr;
660
+ const posA = row.position;
661
+
662
+ const b = mapB.get(key) || { clicks: 0, impressions: 0, ctr: 0, position: 0 };
663
+ const clicksB = b.clicks;
664
+ const impsB = b.impressions;
665
+ const ctrB = b.ctr;
666
+ const posB = b.position;
667
+
668
+ const clicksDiff = clicksA - clicksB;
669
+ const impsDiff = impsA - impsB;
670
+
671
+ comparison.push({
672
+ key,
673
+ period_a: { clicks: clicksA, impressions: impsA, ctr: ctrA, position: posA },
674
+ period_b: { clicks: clicksB, impressions: impsB, ctr: ctrB, position: posB },
675
+ diff: {
676
+ clicks: clicksDiff,
677
+ clicks_percentage: clicksB > 0 ? Math.round((clicksDiff / clicksB) * 1000) / 10 : 0,
678
+ impressions: impsDiff,
679
+ impressions_percentage: impsB > 0 ? Math.round((impsDiff / impsB) * 1000) / 10 : 0,
680
+ ctr: Math.round((ctrA - ctrB) * 1000) / 1000,
681
+ position: Math.round((posA - posB) * 10) / 10,
682
+ },
683
+ });
684
+ }
685
+
686
+ const mapAKeys = new Set(rowsA.map((r) => r.keys[0]));
687
+ for (const row of rowsB) {
688
+ if (row.keys.length < 1) continue;
689
+ const key = row.keys[0];
690
+ if (mapAKeys.has(key)) continue;
691
+
692
+ const clicksB = row.clicks;
693
+ const impsB = row.impressions;
694
+ const ctrB = row.ctr;
695
+ const posB = row.position;
696
+
697
+ comparison.push({
698
+ key,
699
+ period_a: { clicks: 0, impressions: 0, ctr: 0, position: 0 },
700
+ period_b: { clicks: clicksB, impressions: impsB, ctr: ctrB, position: posB },
701
+ diff: {
702
+ clicks: -clicksB,
703
+ clicks_percentage: -100,
704
+ impressions: -impsB,
705
+ impressions_percentage: -100,
706
+ ctr: -ctrB,
707
+ position: -posB,
708
+ },
709
+ });
710
+ }
711
+
712
+ return comparison.sort((a, b) => b.period_a.clicks - a.period_a.clicks);
713
+ }
714
+
715
+
716
+