@digestseo/mcp-gsc 0.4.0 → 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/CHANGELOG.md +14 -0
- package/README.md +6 -4
- package/package.json +1 -1
- package/src/digest.ts +60 -24
- package/src/google.ts +156 -27
- package/src/index.ts +303 -72
- package/src/quick-wins-schema.ts +17 -0
- package/src/result-bounds.ts +91 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.4.1] - 2026-09-21
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
- Large Search Analytics, comparison, cannibalization, quick-win, content-decay, page/query drilldown, and impression-page proxy responses now use deterministic bounded output with explicit pagination/truncation metadata instead of risking oversized MCP structured content.
|
|
14
|
+
- Content-decay assessment now requires meaningful click evidence plus supporting impression or average-position deterioration for a `likely_decay` classification, while separately identifying weak evidence and improving visibility with click volatility.
|
|
15
|
+
- Weekly-digest recommendations now keep measured Search Console observations separate from causal hypotheses and avoid unsupported publishing or indexing-speed prescriptions.
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
- True site-total `analytics.query` calls with `dimensions: []` now accept Google's valid aggregate rows when the `keys` field is omitted.
|
|
19
|
+
- Query/page drilldowns now expose usable paging inputs instead of reporting pagination metadata that callers could not advance.
|
|
20
|
+
- `indexing.list_pages` now makes its impressions-based proxy semantics explicit and safely pages large result sets.
|
|
21
|
+
|
|
8
22
|
## [0.4.0] - 2026-09-21
|
|
9
23
|
|
|
10
24
|
### Added
|
package/README.md
CHANGED
|
@@ -73,13 +73,13 @@ By default (`GSC_ACCESS_MODE=readwrite`), this server exposes 21 tools. Read-onl
|
|
|
73
73
|
| **`server.capabilities`** | Read | List every tool this server exposes and report whether your Google connection is currently authenticated (`connected` / `not_connected`). Takes no arguments — a good first call for discovery. |
|
|
74
74
|
| **`sites.list`** | Read | List the Search Console properties the connected Google account can access (`siteUrl`, `permissionLevel`). |
|
|
75
75
|
| **`sites.get`** | Read | Retrieve one exact Search Console property and the connected account's permission level for it. |
|
|
76
|
-
| **`analytics.query`** | Read | Impressions, clicks, CTR, and average position over a date range, with dimensions, filters, pagination, and selectable search type. |
|
|
77
|
-
| **`insights.page_queries`** / **`insights.query_pages`** | Read | Drill from one exact page to its Search Console queries, or from one exact query to the pages Google surfaced for it. |
|
|
76
|
+
| **`analytics.query`** | Read | Impressions, clicks, CTR, and average position over a date range, with dimensions, filters, safe pagination, and selectable search type. Pass `dimensions: []` for true site totals; Google's aggregate row may omit `keys`. |
|
|
77
|
+
| **`insights.page_queries`** / **`insights.query_pages`** | Read | Drill from one exact page to its Search Console queries, or from one exact query to the pages Google surfaced for it, with `row_limit` / `start_row` pagination. |
|
|
78
78
|
| **`urls.inspect`** | Read | Google's URL Inspection report for a single page. |
|
|
79
79
|
| **`urls.inspect_many`** | Read | Inspect up to 10 URLs sequentially in one call. Each URL still consumes one Google URL Inspection request and one unit of the server's shared inspection safety budget. |
|
|
80
80
|
| **`sitemaps.list`** / **`sitemaps.get`** | Read | List submitted sitemaps or retrieve one sitemap's details. |
|
|
81
|
-
| **`insights.quick_wins`** / **`insights.cannibalization`** / **`insights.content_decay`** | Read | Surface
|
|
82
|
-
| **`indexing.list_pages`** / **`analytics.compare`** | Read | Analyze pages receiving impressions and compare two periods, optionally using the same search type and query/page/country/device/search-appearance filters for both periods. |
|
|
81
|
+
| **`insights.quick_wins`** / **`insights.cannibalization`** / **`insights.content_decay`** | Read | Surface striking-distance opportunities, query/page overlap, and evidence-ranked click declines. Content-decay results distinguish likely decay from weak evidence and improving visibility with click volatility. |
|
|
82
|
+
| **`indexing.list_pages`** / **`analytics.compare`** | Read | Analyze pages receiving Search Console impressions and compare two periods, optionally using the same search type and query/page/country/device/search-appearance filters for both periods. `indexing.list_pages` is performance data, not index coverage: a missing URL may still be indexed; use `urls.inspect` / `urls.inspect_many` for URL-level index status. |
|
|
83
83
|
| **`reports.weekly_digest`** | Read | Generate a plain-language seven-day performance report with movers, top pages, and one recommended action. |
|
|
84
84
|
| **`sites.add`** / **`sites.delete`** | Write | Add or remove a Search Console property. |
|
|
85
85
|
| **`sitemaps.submit`** / **`sitemaps.delete`** | Write | Submit or remove a sitemap. |
|
|
@@ -87,6 +87,8 @@ By default (`GSC_ACCESS_MODE=readwrite`), this server exposes 21 tools. Read-onl
|
|
|
87
87
|
|
|
88
88
|
Read-write mode requests the Google Search Console read-write and Indexing API scopes. Read-only mode requests only `https://www.googleapis.com/auth/webmasters.readonly` (plus `openid` and `email`) and does not register the five write tools. Read-write remains the default so existing deployments retain their current behavior; see [SETUP.md](SETUP.md) to select a mode before connecting users.
|
|
89
89
|
|
|
90
|
+
Large analytics responses are bounded at the MCP boundary instead of being generated and then discarded by clients with structured-content limits. List-style tools expose `start_row` / `next_start_row` where applicable; ranked analytical tools expose `limit`, `start_row`, and `result_page` metadata including `has_more`, `truncated`, and `byte_limit_reached`. Continue paging while `has_more` is true rather than assuming one response is complete.
|
|
91
|
+
|
|
90
92
|
> **`indexing.request` eligibility.** Google's Indexing API is not a general-purpose page submission tool — as of this writing, Google's own documentation limits it to two content types: pages with `JobPosting` structured data, and livestream pages with `BroadcastEvent` structured data nested inside `VideoObject`. Before submitting, the server fetches the target URL and checks its JSON-LD for one of those two types; if neither is present (or the page can't be fetched), it returns an error explaining why the URL is ineligible instead of calling the Indexing API. A successful submission is only an acknowledgment that Google received the notification — it does not guarantee the URL will be indexed.
|
|
91
93
|
|
|
92
94
|
## What you can ask
|
package/package.json
CHANGED
package/src/digest.ts
CHANGED
|
@@ -181,7 +181,7 @@ function totalsFromRow(row: SearchAnalyticsRow | undefined): SiteTotals {
|
|
|
181
181
|
|
|
182
182
|
function rowsToQueries(rows: SearchAnalyticsRow[]): QueryRow[] {
|
|
183
183
|
return rows.map((r) => ({
|
|
184
|
-
query: r.keys[0] ?? '',
|
|
184
|
+
query: r.keys?.[0] ?? '',
|
|
185
185
|
clicks: r.clicks,
|
|
186
186
|
impressions: r.impressions,
|
|
187
187
|
ctr: r.ctr,
|
|
@@ -191,7 +191,7 @@ function rowsToQueries(rows: SearchAnalyticsRow[]): QueryRow[] {
|
|
|
191
191
|
|
|
192
192
|
function rowsToPages(rows: SearchAnalyticsRow[]): PageRow[] {
|
|
193
193
|
return rows.map((r) => ({
|
|
194
|
-
page: r.keys[0] ?? '',
|
|
194
|
+
page: r.keys?.[0] ?? '',
|
|
195
195
|
clicks: r.clicks,
|
|
196
196
|
impressions: r.impressions,
|
|
197
197
|
}));
|
|
@@ -323,8 +323,8 @@ function pickActionAndBuild(
|
|
|
323
323
|
key: 'investigate_drop',
|
|
324
324
|
item: {
|
|
325
325
|
headline: 'Search traffic for one of your queries dropped this week',
|
|
326
|
-
why: `
|
|
327
|
-
how: `1. Search
|
|
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
328
|
},
|
|
329
329
|
};
|
|
330
330
|
}
|
|
@@ -358,9 +358,9 @@ function pickActionAndBuild(
|
|
|
358
358
|
return {
|
|
359
359
|
key: 'publish_one_post',
|
|
360
360
|
item: {
|
|
361
|
-
headline: '
|
|
362
|
-
why: '
|
|
363
|
-
how: "1.
|
|
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
364
|
},
|
|
365
365
|
};
|
|
366
366
|
}
|
|
@@ -443,20 +443,19 @@ function renderMarkdown(data: {
|
|
|
443
443
|
}
|
|
444
444
|
|
|
445
445
|
if (!hasAnyMovers) {
|
|
446
|
-
const siteImpressionsChangePct =
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
? ((currentTotals.clicks - prevTotals.clicks) / prevTotals.clicks) *
|
|
455
|
-
100
|
|
456
|
-
: 0;
|
|
446
|
+
const siteImpressionsChangePct = percentageChangeOrNull(
|
|
447
|
+
currentTotals.impressions,
|
|
448
|
+
prevTotals.impressions,
|
|
449
|
+
);
|
|
450
|
+
const siteClicksChangePct = percentageChangeOrNull(
|
|
451
|
+
currentTotals.clicks,
|
|
452
|
+
prevTotals.clicks,
|
|
453
|
+
);
|
|
457
454
|
const sitePositionDelta = currentTotals.position - prevTotals.position;
|
|
458
455
|
|
|
459
456
|
const siteHadBigMove =
|
|
457
|
+
siteImpressionsChangePct === null ||
|
|
458
|
+
siteClicksChangePct === null ||
|
|
460
459
|
Math.abs(siteImpressionsChangePct) >= 25 ||
|
|
461
460
|
Math.abs(siteClicksChangePct) >= 25 ||
|
|
462
461
|
Math.abs(sitePositionDelta) >= 3;
|
|
@@ -537,6 +536,11 @@ function formatPctChange(current: number, prev: number): string {
|
|
|
537
536
|
return `${sign}${pct.toFixed(0)}% vs last week`;
|
|
538
537
|
}
|
|
539
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
|
+
|
|
540
544
|
function formatPositionChange(current: number, prev: number): string {
|
|
541
545
|
if (prev === 0 || current === 0) return 'no comparison available';
|
|
542
546
|
const diff = current - prev;
|
|
@@ -585,15 +589,20 @@ function isWithinLast3Days(endDate: string): boolean {
|
|
|
585
589
|
}
|
|
586
590
|
|
|
587
591
|
function describeSiteLevelMove(
|
|
588
|
-
impressionsChangePct: number,
|
|
589
|
-
clicksChangePct: number,
|
|
592
|
+
impressionsChangePct: number | null,
|
|
593
|
+
clicksChangePct: number | null,
|
|
590
594
|
positionDelta: number,
|
|
591
595
|
): string {
|
|
592
596
|
const candidates: Array<{
|
|
593
597
|
metric: 'impressions' | 'position' | 'clicks';
|
|
594
598
|
ratio: number;
|
|
595
599
|
}> = [];
|
|
596
|
-
if (
|
|
600
|
+
if (impressionsChangePct === null) {
|
|
601
|
+
candidates.push({
|
|
602
|
+
metric: 'impressions',
|
|
603
|
+
ratio: Number.POSITIVE_INFINITY,
|
|
604
|
+
});
|
|
605
|
+
} else if (Math.abs(impressionsChangePct) >= 25) {
|
|
597
606
|
candidates.push({
|
|
598
607
|
metric: 'impressions',
|
|
599
608
|
ratio: Math.abs(impressionsChangePct) / 25,
|
|
@@ -602,7 +611,20 @@ function describeSiteLevelMove(
|
|
|
602
611
|
if (Math.abs(positionDelta) >= 3) {
|
|
603
612
|
candidates.push({ metric: 'position', ratio: Math.abs(positionDelta) / 3 });
|
|
604
613
|
}
|
|
605
|
-
if (
|
|
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
|
+
) {
|
|
606
628
|
candidates.push({ metric: 'clicks', ratio: Math.abs(clicksChangePct) / 25 });
|
|
607
629
|
}
|
|
608
630
|
|
|
@@ -613,6 +635,9 @@ function describeSiteLevelMove(
|
|
|
613
635
|
}
|
|
614
636
|
|
|
615
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
|
+
}
|
|
616
641
|
if (impressionsChangePct >= 25) {
|
|
617
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.`;
|
|
618
643
|
}
|
|
@@ -626,7 +651,18 @@ function describeSiteLevelMove(
|
|
|
626
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.`;
|
|
627
652
|
}
|
|
628
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
|
+
}
|
|
629
664
|
const clicksSign = clicksChangePct >= 0 ? '+' : '';
|
|
630
|
-
const impressionsSign =
|
|
631
|
-
|
|
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.`;
|
|
632
668
|
}
|
package/src/google.ts
CHANGED
|
@@ -302,7 +302,11 @@ export type PaginatedSearchAnalyticsQuery = Omit<
|
|
|
302
302
|
>;
|
|
303
303
|
|
|
304
304
|
export interface SearchAnalyticsRow {
|
|
305
|
-
|
|
305
|
+
/**
|
|
306
|
+
* Dimension values returned by Google. Aggregate queries with
|
|
307
|
+
* dimensions: [] legitimately omit this field.
|
|
308
|
+
*/
|
|
309
|
+
keys?: string[];
|
|
306
310
|
clicks: number;
|
|
307
311
|
impressions: number;
|
|
308
312
|
ctr: number;
|
|
@@ -681,16 +685,21 @@ export function processQuickWins(
|
|
|
681
685
|
return rows
|
|
682
686
|
.filter(
|
|
683
687
|
(row) =>
|
|
684
|
-
row.keys
|
|
688
|
+
(row.keys?.length ?? 0) >= 2 &&
|
|
685
689
|
row.position >= minPosition &&
|
|
686
690
|
row.position <= maxPosition &&
|
|
687
691
|
row.impressions >= minImpressions
|
|
688
692
|
)
|
|
689
|
-
.sort(
|
|
690
|
-
|
|
693
|
+
.sort(
|
|
694
|
+
(a, b) =>
|
|
695
|
+
b.impressions - a.impressions ||
|
|
696
|
+
a.position - b.position ||
|
|
697
|
+
(a.keys?.[0] ?? '').localeCompare(b.keys?.[0] ?? '') ||
|
|
698
|
+
(a.keys?.[1] ?? '').localeCompare(b.keys?.[1] ?? ''),
|
|
699
|
+
)
|
|
691
700
|
.map((row) => ({
|
|
692
|
-
query: row.keys[0],
|
|
693
|
-
page: row.keys[1],
|
|
701
|
+
query: row.keys![0],
|
|
702
|
+
page: row.keys![1],
|
|
694
703
|
clicks: row.clicks,
|
|
695
704
|
impressions: row.impressions,
|
|
696
705
|
ctr: row.ctr,
|
|
@@ -721,9 +730,9 @@ export function processCannibalization(
|
|
|
721
730
|
): CannibalizationResult[] {
|
|
722
731
|
const queryGroups = new Map<string, Array<{ page: string; clicks: number; impressions: number; ctr: number; position: number }>>();
|
|
723
732
|
for (const row of rows) {
|
|
724
|
-
if (row.keys
|
|
725
|
-
const query = row.keys[0];
|
|
726
|
-
const page = row.keys[1];
|
|
733
|
+
if ((row.keys?.length ?? 0) < 2) continue;
|
|
734
|
+
const query = row.keys![0];
|
|
735
|
+
const page = row.keys![1];
|
|
727
736
|
if (!queryGroups.has(query)) {
|
|
728
737
|
queryGroups.set(query, []);
|
|
729
738
|
}
|
|
@@ -754,18 +763,30 @@ export function processCannibalization(
|
|
|
754
763
|
query,
|
|
755
764
|
total_clicks: totalClicks,
|
|
756
765
|
total_impressions: totalImpressions,
|
|
757
|
-
pages: competingPages.sort(
|
|
766
|
+
pages: competingPages.sort(
|
|
767
|
+
(a, b) => b.impressions - a.impressions || a.page.localeCompare(b.page),
|
|
768
|
+
),
|
|
758
769
|
});
|
|
759
770
|
}
|
|
760
771
|
}
|
|
761
772
|
|
|
762
773
|
return cannibalizationCandidates
|
|
763
|
-
.sort(
|
|
764
|
-
|
|
774
|
+
.sort(
|
|
775
|
+
(a, b) =>
|
|
776
|
+
b.total_impressions - a.total_impressions ||
|
|
777
|
+
a.query.localeCompare(b.query),
|
|
778
|
+
);
|
|
765
779
|
}
|
|
766
780
|
|
|
781
|
+
export type ContentDecayClassification =
|
|
782
|
+
| 'likely_decay'
|
|
783
|
+
| 'weak_insufficient_evidence'
|
|
784
|
+
| 'improving_visibility_with_click_volatility';
|
|
785
|
+
|
|
767
786
|
export interface DecayPageResult {
|
|
768
787
|
page: string;
|
|
788
|
+
classification: ContentDecayClassification;
|
|
789
|
+
evidence: string;
|
|
769
790
|
previous_clicks: number;
|
|
770
791
|
recent_clicks: number;
|
|
771
792
|
click_difference: number;
|
|
@@ -773,8 +794,83 @@ export interface DecayPageResult {
|
|
|
773
794
|
previous_impressions: number;
|
|
774
795
|
recent_impressions: number;
|
|
775
796
|
impression_difference: number;
|
|
797
|
+
impression_change_percentage: number | null;
|
|
776
798
|
previous_position: number;
|
|
777
799
|
recent_position: number;
|
|
800
|
+
position_change: number | null;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
function percentageChange(current: number, baseline: number): number | null {
|
|
804
|
+
if (baseline === 0) return current === 0 ? 0 : null;
|
|
805
|
+
return Math.round(((current - baseline) / baseline) * 1000) / 10;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
function classifyContentDecay(input: {
|
|
809
|
+
previousClicks: number;
|
|
810
|
+
recentClicks: number;
|
|
811
|
+
previousImpressions: number;
|
|
812
|
+
recentImpressions: number;
|
|
813
|
+
previousPosition: number;
|
|
814
|
+
recentPosition: number;
|
|
815
|
+
}): { classification: ContentDecayClassification; evidence: string } {
|
|
816
|
+
const {
|
|
817
|
+
previousClicks,
|
|
818
|
+
recentClicks,
|
|
819
|
+
previousImpressions,
|
|
820
|
+
recentImpressions,
|
|
821
|
+
previousPosition,
|
|
822
|
+
recentPosition,
|
|
823
|
+
} = input;
|
|
824
|
+
const clickDrop = previousClicks - recentClicks;
|
|
825
|
+
const clickDropPercentage =
|
|
826
|
+
previousClicks > 0 ? (clickDrop / previousClicks) * 100 : 0;
|
|
827
|
+
const impressionGrowth =
|
|
828
|
+
previousImpressions > 0
|
|
829
|
+
? ((recentImpressions - previousImpressions) / previousImpressions) * 100
|
|
830
|
+
: 0;
|
|
831
|
+
const impressionDrop =
|
|
832
|
+
previousImpressions > 0
|
|
833
|
+
? ((previousImpressions - recentImpressions) / previousImpressions) * 100
|
|
834
|
+
: 0;
|
|
835
|
+
const positionImprovement =
|
|
836
|
+
previousPosition > 0 && recentPosition > 0
|
|
837
|
+
? previousPosition - recentPosition
|
|
838
|
+
: 0;
|
|
839
|
+
const positionWorsening =
|
|
840
|
+
previousPosition > 0 && recentPosition > 0
|
|
841
|
+
? recentPosition - previousPosition
|
|
842
|
+
: 0;
|
|
843
|
+
|
|
844
|
+
const lowClickSample = previousClicks < 10 && clickDrop < 5;
|
|
845
|
+
const improvingVisibility =
|
|
846
|
+
impressionGrowth >= 10 && positionImprovement >= 2;
|
|
847
|
+
|
|
848
|
+
if (lowClickSample && improvingVisibility) {
|
|
849
|
+
return {
|
|
850
|
+
classification: 'improving_visibility_with_click_volatility',
|
|
851
|
+
evidence:
|
|
852
|
+
'The click decline is small in absolute terms while impressions grew at least 10% and average position improved by at least 2 positions.',
|
|
853
|
+
};
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
const meaningfulClickDecline =
|
|
857
|
+
previousClicks >= 10 && clickDrop >= 5 && clickDropPercentage >= 20;
|
|
858
|
+
const corroboratingVisibilityDecline =
|
|
859
|
+
impressionDrop >= 10 || positionWorsening >= 2;
|
|
860
|
+
|
|
861
|
+
if (meaningfulClickDecline && corroboratingVisibilityDecline) {
|
|
862
|
+
return {
|
|
863
|
+
classification: 'likely_decay',
|
|
864
|
+
evidence:
|
|
865
|
+
'Clicks fell by at least 5 from a baseline of at least 10, the decline was at least 20%, and impressions or average position also deteriorated materially.',
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
return {
|
|
870
|
+
classification: 'weak_insufficient_evidence',
|
|
871
|
+
evidence:
|
|
872
|
+
'Clicks declined, but the absolute sample or supporting impression/position signals are not strong enough to label the page as likely content decay.',
|
|
873
|
+
};
|
|
778
874
|
}
|
|
779
875
|
|
|
780
876
|
export function processContentDecay(
|
|
@@ -783,8 +879,8 @@ export function processContentDecay(
|
|
|
783
879
|
): DecayPageResult[] {
|
|
784
880
|
const recentMap = new Map<string, { clicks: number; impressions: number; ctr: number; position: number }>();
|
|
785
881
|
for (const row of recentRows) {
|
|
786
|
-
if (row.keys
|
|
787
|
-
recentMap.set(row.keys[0], {
|
|
882
|
+
if ((row.keys?.length ?? 0) < 1) continue;
|
|
883
|
+
recentMap.set(row.keys![0], {
|
|
788
884
|
clicks: row.clicks,
|
|
789
885
|
impressions: row.impressions,
|
|
790
886
|
ctr: row.ctr,
|
|
@@ -795,8 +891,8 @@ export function processContentDecay(
|
|
|
795
891
|
const decayCandidates: DecayPageResult[] = [];
|
|
796
892
|
|
|
797
893
|
for (const row of previousRows) {
|
|
798
|
-
if (row.keys
|
|
799
|
-
const page = row.keys[0];
|
|
894
|
+
if ((row.keys?.length ?? 0) < 1) continue;
|
|
895
|
+
const page = row.keys![0];
|
|
800
896
|
const prevClicks = row.clicks;
|
|
801
897
|
const prevImps = row.impressions;
|
|
802
898
|
const prevPos = row.position;
|
|
@@ -810,8 +906,18 @@ export function processContentDecay(
|
|
|
810
906
|
const impDiff = recImps - prevImps;
|
|
811
907
|
|
|
812
908
|
if (clickDiff < 0) {
|
|
909
|
+
const { classification, evidence } = classifyContentDecay({
|
|
910
|
+
previousClicks: prevClicks,
|
|
911
|
+
recentClicks: recClicks,
|
|
912
|
+
previousImpressions: prevImps,
|
|
913
|
+
recentImpressions: recImps,
|
|
914
|
+
previousPosition: prevPos,
|
|
915
|
+
recentPosition: recPos,
|
|
916
|
+
});
|
|
813
917
|
decayCandidates.push({
|
|
814
918
|
page,
|
|
919
|
+
classification,
|
|
920
|
+
evidence,
|
|
815
921
|
previous_clicks: prevClicks,
|
|
816
922
|
recent_clicks: recClicks,
|
|
817
923
|
click_difference: clickDiff,
|
|
@@ -819,15 +925,29 @@ export function processContentDecay(
|
|
|
819
925
|
previous_impressions: prevImps,
|
|
820
926
|
recent_impressions: recImps,
|
|
821
927
|
impression_difference: impDiff,
|
|
928
|
+
impression_change_percentage: percentageChange(recImps, prevImps),
|
|
822
929
|
previous_position: prevPos,
|
|
823
930
|
recent_position: recPos,
|
|
931
|
+
position_change:
|
|
932
|
+
prevPos > 0 && recPos > 0
|
|
933
|
+
? Math.round((recPos - prevPos) * 10) / 10
|
|
934
|
+
: null,
|
|
824
935
|
});
|
|
825
936
|
}
|
|
826
937
|
}
|
|
827
938
|
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
939
|
+
const classificationOrder: Record<ContentDecayClassification, number> = {
|
|
940
|
+
likely_decay: 0,
|
|
941
|
+
weak_insufficient_evidence: 1,
|
|
942
|
+
improving_visibility_with_click_volatility: 2,
|
|
943
|
+
};
|
|
944
|
+
return decayCandidates.sort(
|
|
945
|
+
(a, b) =>
|
|
946
|
+
classificationOrder[a.classification] -
|
|
947
|
+
classificationOrder[b.classification] ||
|
|
948
|
+
a.click_difference - b.click_difference ||
|
|
949
|
+
a.page.localeCompare(b.page),
|
|
950
|
+
);
|
|
831
951
|
}
|
|
832
952
|
|
|
833
953
|
// Google's Indexing API is not a general-purpose submission API. It is
|
|
@@ -1129,8 +1249,8 @@ export function processPerformanceComparison(
|
|
|
1129
1249
|
): PerformanceComparisonRow[] {
|
|
1130
1250
|
const mapB = new Map<string, { clicks: number; impressions: number; ctr: number; position: number }>();
|
|
1131
1251
|
for (const row of rowsB) {
|
|
1132
|
-
if (row.keys
|
|
1133
|
-
mapB.set(row.keys[0], {
|
|
1252
|
+
if ((row.keys?.length ?? 0) < 1) continue;
|
|
1253
|
+
mapB.set(row.keys![0], {
|
|
1134
1254
|
clicks: row.clicks,
|
|
1135
1255
|
impressions: row.impressions,
|
|
1136
1256
|
ctr: row.ctr,
|
|
@@ -1141,8 +1261,8 @@ export function processPerformanceComparison(
|
|
|
1141
1261
|
const comparison: PerformanceComparisonRow[] = [];
|
|
1142
1262
|
|
|
1143
1263
|
for (const row of rowsA) {
|
|
1144
|
-
if (row.keys
|
|
1145
|
-
const key = row.keys[0];
|
|
1264
|
+
if ((row.keys?.length ?? 0) < 1) continue;
|
|
1265
|
+
const key = row.keys![0];
|
|
1146
1266
|
const clicksA = row.clicks;
|
|
1147
1267
|
const impsA = row.impressions;
|
|
1148
1268
|
const ctrA = row.ctr;
|
|
@@ -1172,10 +1292,14 @@ export function processPerformanceComparison(
|
|
|
1172
1292
|
});
|
|
1173
1293
|
}
|
|
1174
1294
|
|
|
1175
|
-
const mapAKeys = new Set(
|
|
1295
|
+
const mapAKeys = new Set(
|
|
1296
|
+
rowsA
|
|
1297
|
+
.map((r) => r.keys?.[0])
|
|
1298
|
+
.filter((key): key is string => key !== undefined),
|
|
1299
|
+
);
|
|
1176
1300
|
for (const row of rowsB) {
|
|
1177
|
-
if (row.keys
|
|
1178
|
-
const key = row.keys[0];
|
|
1301
|
+
if ((row.keys?.length ?? 0) < 1) continue;
|
|
1302
|
+
const key = row.keys![0];
|
|
1179
1303
|
if (mapAKeys.has(key)) continue;
|
|
1180
1304
|
|
|
1181
1305
|
const clicksB = row.clicks;
|
|
@@ -1198,7 +1322,12 @@ export function processPerformanceComparison(
|
|
|
1198
1322
|
});
|
|
1199
1323
|
}
|
|
1200
1324
|
|
|
1201
|
-
return comparison.sort(
|
|
1325
|
+
return comparison.sort(
|
|
1326
|
+
(a, b) =>
|
|
1327
|
+
Math.abs(b.diff.clicks) - Math.abs(a.diff.clicks) ||
|
|
1328
|
+
Math.abs(b.diff.impressions) - Math.abs(a.diff.impressions) ||
|
|
1329
|
+
a.key.localeCompare(b.key),
|
|
1330
|
+
);
|
|
1202
1331
|
}
|
|
1203
1332
|
|
|
1204
1333
|
|