@digestseo/mcp-gsc 0.4.0 → 0.4.2

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 CHANGED
@@ -5,6 +5,27 @@ 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.2] - 2026-09-21
11
+
12
+ ### Fixed
13
+ - Cannibalization totals and impression shares now expose their query/page-row aggregation scope explicitly so clients do not mistake summed page impressions for a true query-level Search Console total.
14
+ - Discover Search Analytics calls now reject unsupported query grouping/filtering before reaching Google, and query-centric/comparison tools no longer advertise Discover where their contracts depend on query or average-position data that Discover does not provide.
15
+ - Quick-win guidance now treats Search Console position as an aggregate average-position metric rather than a literal current rank, and metadata no longer implies CTR is an eligibility filter when the implementation does not filter on CTR.
16
+
17
+ ## [0.4.1] - 2026-09-21
18
+
19
+ ### Changed
20
+ - 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.
21
+ - 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.
22
+ - Weekly-digest recommendations now keep measured Search Console observations separate from causal hypotheses and avoid unsupported publishing or indexing-speed prescriptions.
23
+
24
+ ### Fixed
25
+ - True site-total `analytics.query` calls with `dimensions: []` now accept Google's valid aggregate rows when the `keys` field is omitted.
26
+ - Query/page drilldowns now expose usable paging inputs instead of reporting pagination metadata that callers could not advance.
27
+ - `indexing.list_pages` now makes its impressions-based proxy semantics explicit and safely pages large result sets.
28
+
8
29
  ## [0.4.0] - 2026-09-21
9
30
 
10
31
  ### 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 optimization opportunities, competing pages, and declining content. |
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 average-position opportunity rows, query/page overlap, and evidence-ranked click declines. Quick-win candidates are observed query/page rows selected by aggregate Search Console average position, not proof of a stable current rank, and CTR is context rather than an eligibility filter. Cannibalization candidate totals/shares are scoped to observed query/page rows rather than true query-level property totals; 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
@@ -96,7 +98,7 @@ Once connected, ask your assistant things like:
96
98
  - *"What are my top 20 queries by clicks in the last 28 days?"*
97
99
  - *"Compare impressions for example.com this month vs last month — which pages dropped?"*
98
100
  - *"Is `https://example.com/pricing` indexed? When was it last crawled?"*
99
- - *"Which queries does my blog rank position 5–15 for? Those are my quick wins."*
101
+ - *"Which high-impression query/page rows have an average position between 5 and 15? Show me those optimization opportunities."*
100
102
  - *"Give me a weekly digest for `sc-domain:example.com` ending today."*
101
103
  - *"Do any of my sitemaps have errors or warnings?"*
102
104
  - *"Split my clicks into brand vs non-brand using a regex on the query."*
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@digestseo/mcp-gsc",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "private": false,
5
5
  "main": "scripts/glama-start.mjs",
6
6
  "bin": {
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: `Last week, '${bigDrop.query}' brought you ${bigDrop.prevClicks} clicks. This week, only ${bigDrop.currentClicks}. A drop this size in one week usually means either: a competitor outranked you, the page itself changed, or Google changed how it shows your page.`,
327
- how: `1. Search Google for '${bigDrop.query}' and find your page.\n2. Compare your result to the top 3 results above and below you. What do they have that you don't?\n3. Either update your page to be more useful, or accept the drop if the query isn't strategic.`,
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: 'Publish one new piece of content this week',
362
- why: 'Your numbers are steady — nothing broken, nothing breakthrough. The single biggest predictor of organic search growth is consistent publishing of content people are searching for.',
363
- how: "1. Open your GSC and look at the 'Queries' tab — find a query with at least 5 impressions but where you don't have a dedicated page.\n2. Write a 500-1000 word page directly answering that query.\n3. Publish it. Submit the URL via 'URL inspection' for faster indexing.",
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
- prevTotals.impressions > 0
448
- ? ((currentTotals.impressions - prevTotals.impressions) /
449
- prevTotals.impressions) *
450
- 100
451
- : 0;
452
- const siteClicksChangePct =
453
- prevTotals.clicks > 0
454
- ? ((currentTotals.clicks - prevTotals.clicks) / prevTotals.clicks) *
455
- 100
456
- : 0;
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 (Math.abs(impressionsChangePct) >= 25) {
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 (Math.abs(clicksChangePct) >= 25 && Math.abs(impressionsChangePct) < 25) {
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 = impressionsChangePct >= 0 ? '+' : '';
631
- return `Your click rate changed sharply this week (clicks ${clicksSign}${clicksChangePct.toFixed(0)}%, but impressions only moved ${impressionsSign}${impressionsChangePct.toFixed(0)}%). Worth looking at whether something changed about how your titles or descriptions appear in search results.`;
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
@@ -247,6 +247,8 @@ export function assertSearchAnalyticsQueryCompatible(body: SearchAnalyticsQuery)
247
247
  const filters = body.dimensionFilterGroups?.flatMap((group) => group.filters) ?? [];
248
248
  const hasPageGroupingOrFilter =
249
249
  body.dimensions.includes('page') || filters.some((filter) => filter.dimension === 'page');
250
+ const hasQueryGroupingOrFilter =
251
+ body.dimensions.includes('query') || filters.some((filter) => filter.dimension === 'query');
250
252
 
251
253
  for (const filter of filters) {
252
254
  if (filter.expression.length > 4096) {
@@ -267,6 +269,12 @@ export function assertSearchAnalyticsQueryCompatible(body: SearchAnalyticsQuery)
267
269
  }
268
270
  }
269
271
 
272
+ if (body.type === 'discover' && hasQueryGroupingOrFilter) {
273
+ throw new Error(
274
+ 'Google Discover does not support the query dimension or query filters in Search Analytics.',
275
+ );
276
+ }
277
+
270
278
  if (body.aggregationType === 'byNewsShowcasePanel') {
271
279
  if (body.type !== 'discover' && body.type !== 'googleNews') {
272
280
  throw new Error(
@@ -302,7 +310,11 @@ export type PaginatedSearchAnalyticsQuery = Omit<
302
310
  >;
303
311
 
304
312
  export interface SearchAnalyticsRow {
305
- keys: string[];
313
+ /**
314
+ * Dimension values returned by Google. Aggregate queries with
315
+ * dimensions: [] legitimately omit this field.
316
+ */
317
+ keys?: string[];
306
318
  clicks: number;
307
319
  impressions: number;
308
320
  ctr: number;
@@ -681,16 +693,21 @@ export function processQuickWins(
681
693
  return rows
682
694
  .filter(
683
695
  (row) =>
684
- row.keys.length >= 2 &&
696
+ (row.keys?.length ?? 0) >= 2 &&
685
697
  row.position >= minPosition &&
686
698
  row.position <= maxPosition &&
687
699
  row.impressions >= minImpressions
688
700
  )
689
- .sort((a, b) => b.impressions - a.impressions)
690
- .slice(0, 100)
701
+ .sort(
702
+ (a, b) =>
703
+ b.impressions - a.impressions ||
704
+ a.position - b.position ||
705
+ (a.keys?.[0] ?? '').localeCompare(b.keys?.[0] ?? '') ||
706
+ (a.keys?.[1] ?? '').localeCompare(b.keys?.[1] ?? ''),
707
+ )
691
708
  .map((row) => ({
692
- query: row.keys[0],
693
- page: row.keys[1],
709
+ query: row.keys![0],
710
+ page: row.keys![1],
694
711
  clicks: row.clicks,
695
712
  impressions: row.impressions,
696
713
  ctr: row.ctr,
@@ -711,6 +728,7 @@ export interface CannibalizationResult {
711
728
  query: string;
712
729
  total_clicks: number;
713
730
  total_impressions: number;
731
+ aggregation_scope: 'observed_query_page_rows';
714
732
  pages: CannibalizationPage[];
715
733
  }
716
734
 
@@ -721,9 +739,9 @@ export function processCannibalization(
721
739
  ): CannibalizationResult[] {
722
740
  const queryGroups = new Map<string, Array<{ page: string; clicks: number; impressions: number; ctr: number; position: number }>>();
723
741
  for (const row of rows) {
724
- if (row.keys.length < 2) continue;
725
- const query = row.keys[0];
726
- const page = row.keys[1];
742
+ if ((row.keys?.length ?? 0) < 2) continue;
743
+ const query = row.keys![0];
744
+ const page = row.keys![1];
727
745
  if (!queryGroups.has(query)) {
728
746
  queryGroups.set(query, []);
729
747
  }
@@ -754,18 +772,31 @@ export function processCannibalization(
754
772
  query,
755
773
  total_clicks: totalClicks,
756
774
  total_impressions: totalImpressions,
757
- pages: competingPages.sort((a, b) => b.impressions - a.impressions),
775
+ aggregation_scope: 'observed_query_page_rows',
776
+ pages: competingPages.sort(
777
+ (a, b) => b.impressions - a.impressions || a.page.localeCompare(b.page),
778
+ ),
758
779
  });
759
780
  }
760
781
  }
761
782
 
762
783
  return cannibalizationCandidates
763
- .sort((a, b) => b.total_impressions - a.total_impressions)
764
- .slice(0, 100);
784
+ .sort(
785
+ (a, b) =>
786
+ b.total_impressions - a.total_impressions ||
787
+ a.query.localeCompare(b.query),
788
+ );
765
789
  }
766
790
 
791
+ export type ContentDecayClassification =
792
+ | 'likely_decay'
793
+ | 'weak_insufficient_evidence'
794
+ | 'improving_visibility_with_click_volatility';
795
+
767
796
  export interface DecayPageResult {
768
797
  page: string;
798
+ classification: ContentDecayClassification;
799
+ evidence: string;
769
800
  previous_clicks: number;
770
801
  recent_clicks: number;
771
802
  click_difference: number;
@@ -773,8 +804,83 @@ export interface DecayPageResult {
773
804
  previous_impressions: number;
774
805
  recent_impressions: number;
775
806
  impression_difference: number;
807
+ impression_change_percentage: number | null;
776
808
  previous_position: number;
777
809
  recent_position: number;
810
+ position_change: number | null;
811
+ }
812
+
813
+ function percentageChange(current: number, baseline: number): number | null {
814
+ if (baseline === 0) return current === 0 ? 0 : null;
815
+ return Math.round(((current - baseline) / baseline) * 1000) / 10;
816
+ }
817
+
818
+ function classifyContentDecay(input: {
819
+ previousClicks: number;
820
+ recentClicks: number;
821
+ previousImpressions: number;
822
+ recentImpressions: number;
823
+ previousPosition: number;
824
+ recentPosition: number;
825
+ }): { classification: ContentDecayClassification; evidence: string } {
826
+ const {
827
+ previousClicks,
828
+ recentClicks,
829
+ previousImpressions,
830
+ recentImpressions,
831
+ previousPosition,
832
+ recentPosition,
833
+ } = input;
834
+ const clickDrop = previousClicks - recentClicks;
835
+ const clickDropPercentage =
836
+ previousClicks > 0 ? (clickDrop / previousClicks) * 100 : 0;
837
+ const impressionGrowth =
838
+ previousImpressions > 0
839
+ ? ((recentImpressions - previousImpressions) / previousImpressions) * 100
840
+ : 0;
841
+ const impressionDrop =
842
+ previousImpressions > 0
843
+ ? ((previousImpressions - recentImpressions) / previousImpressions) * 100
844
+ : 0;
845
+ const positionImprovement =
846
+ previousPosition > 0 && recentPosition > 0
847
+ ? previousPosition - recentPosition
848
+ : 0;
849
+ const positionWorsening =
850
+ previousPosition > 0 && recentPosition > 0
851
+ ? recentPosition - previousPosition
852
+ : 0;
853
+
854
+ const lowClickSample = previousClicks < 10 && clickDrop < 5;
855
+ const improvingVisibility =
856
+ impressionGrowth >= 10 && positionImprovement >= 2;
857
+
858
+ if (lowClickSample && improvingVisibility) {
859
+ return {
860
+ classification: 'improving_visibility_with_click_volatility',
861
+ evidence:
862
+ 'The click decline is small in absolute terms while impressions grew at least 10% and average position improved by at least 2 positions.',
863
+ };
864
+ }
865
+
866
+ const meaningfulClickDecline =
867
+ previousClicks >= 10 && clickDrop >= 5 && clickDropPercentage >= 20;
868
+ const corroboratingVisibilityDecline =
869
+ impressionDrop >= 10 || positionWorsening >= 2;
870
+
871
+ if (meaningfulClickDecline && corroboratingVisibilityDecline) {
872
+ return {
873
+ classification: 'likely_decay',
874
+ evidence:
875
+ '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.',
876
+ };
877
+ }
878
+
879
+ return {
880
+ classification: 'weak_insufficient_evidence',
881
+ evidence:
882
+ 'Clicks declined, but the absolute sample or supporting impression/position signals are not strong enough to label the page as likely content decay.',
883
+ };
778
884
  }
779
885
 
780
886
  export function processContentDecay(
@@ -783,8 +889,8 @@ export function processContentDecay(
783
889
  ): DecayPageResult[] {
784
890
  const recentMap = new Map<string, { clicks: number; impressions: number; ctr: number; position: number }>();
785
891
  for (const row of recentRows) {
786
- if (row.keys.length < 1) continue;
787
- recentMap.set(row.keys[0], {
892
+ if ((row.keys?.length ?? 0) < 1) continue;
893
+ recentMap.set(row.keys![0], {
788
894
  clicks: row.clicks,
789
895
  impressions: row.impressions,
790
896
  ctr: row.ctr,
@@ -795,8 +901,8 @@ export function processContentDecay(
795
901
  const decayCandidates: DecayPageResult[] = [];
796
902
 
797
903
  for (const row of previousRows) {
798
- if (row.keys.length < 1) continue;
799
- const page = row.keys[0];
904
+ if ((row.keys?.length ?? 0) < 1) continue;
905
+ const page = row.keys![0];
800
906
  const prevClicks = row.clicks;
801
907
  const prevImps = row.impressions;
802
908
  const prevPos = row.position;
@@ -810,8 +916,18 @@ export function processContentDecay(
810
916
  const impDiff = recImps - prevImps;
811
917
 
812
918
  if (clickDiff < 0) {
919
+ const { classification, evidence } = classifyContentDecay({
920
+ previousClicks: prevClicks,
921
+ recentClicks: recClicks,
922
+ previousImpressions: prevImps,
923
+ recentImpressions: recImps,
924
+ previousPosition: prevPos,
925
+ recentPosition: recPos,
926
+ });
813
927
  decayCandidates.push({
814
928
  page,
929
+ classification,
930
+ evidence,
815
931
  previous_clicks: prevClicks,
816
932
  recent_clicks: recClicks,
817
933
  click_difference: clickDiff,
@@ -819,15 +935,29 @@ export function processContentDecay(
819
935
  previous_impressions: prevImps,
820
936
  recent_impressions: recImps,
821
937
  impression_difference: impDiff,
938
+ impression_change_percentage: percentageChange(recImps, prevImps),
822
939
  previous_position: prevPos,
823
940
  recent_position: recPos,
941
+ position_change:
942
+ prevPos > 0 && recPos > 0
943
+ ? Math.round((recPos - prevPos) * 10) / 10
944
+ : null,
824
945
  });
825
946
  }
826
947
  }
827
948
 
828
- return decayCandidates
829
- .sort((a, b) => a.click_difference - b.click_difference)
830
- .slice(0, 100);
949
+ const classificationOrder: Record<ContentDecayClassification, number> = {
950
+ likely_decay: 0,
951
+ weak_insufficient_evidence: 1,
952
+ improving_visibility_with_click_volatility: 2,
953
+ };
954
+ return decayCandidates.sort(
955
+ (a, b) =>
956
+ classificationOrder[a.classification] -
957
+ classificationOrder[b.classification] ||
958
+ a.click_difference - b.click_difference ||
959
+ a.page.localeCompare(b.page),
960
+ );
831
961
  }
832
962
 
833
963
  // Google's Indexing API is not a general-purpose submission API. It is
@@ -1129,8 +1259,8 @@ export function processPerformanceComparison(
1129
1259
  ): PerformanceComparisonRow[] {
1130
1260
  const mapB = new Map<string, { clicks: number; impressions: number; ctr: number; position: number }>();
1131
1261
  for (const row of rowsB) {
1132
- if (row.keys.length < 1) continue;
1133
- mapB.set(row.keys[0], {
1262
+ if ((row.keys?.length ?? 0) < 1) continue;
1263
+ mapB.set(row.keys![0], {
1134
1264
  clicks: row.clicks,
1135
1265
  impressions: row.impressions,
1136
1266
  ctr: row.ctr,
@@ -1141,8 +1271,8 @@ export function processPerformanceComparison(
1141
1271
  const comparison: PerformanceComparisonRow[] = [];
1142
1272
 
1143
1273
  for (const row of rowsA) {
1144
- if (row.keys.length < 1) continue;
1145
- const key = row.keys[0];
1274
+ if ((row.keys?.length ?? 0) < 1) continue;
1275
+ const key = row.keys![0];
1146
1276
  const clicksA = row.clicks;
1147
1277
  const impsA = row.impressions;
1148
1278
  const ctrA = row.ctr;
@@ -1172,10 +1302,14 @@ export function processPerformanceComparison(
1172
1302
  });
1173
1303
  }
1174
1304
 
1175
- const mapAKeys = new Set(rowsA.map((r) => r.keys[0]));
1305
+ const mapAKeys = new Set(
1306
+ rowsA
1307
+ .map((r) => r.keys?.[0])
1308
+ .filter((key): key is string => key !== undefined),
1309
+ );
1176
1310
  for (const row of rowsB) {
1177
- if (row.keys.length < 1) continue;
1178
- const key = row.keys[0];
1311
+ if ((row.keys?.length ?? 0) < 1) continue;
1312
+ const key = row.keys![0];
1179
1313
  if (mapAKeys.has(key)) continue;
1180
1314
 
1181
1315
  const clicksB = row.clicks;
@@ -1198,7 +1332,12 @@ export function processPerformanceComparison(
1198
1332
  });
1199
1333
  }
1200
1334
 
1201
- return comparison.sort((a, b) => b.period_a.clicks - a.period_a.clicks);
1335
+ return comparison.sort(
1336
+ (a, b) =>
1337
+ Math.abs(b.diff.clicks) - Math.abs(a.diff.clicks) ||
1338
+ Math.abs(b.diff.impressions) - Math.abs(a.diff.impressions) ||
1339
+ a.key.localeCompare(b.key),
1340
+ );
1202
1341
  }
1203
1342
 
1204
1343