@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 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 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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@digestseo/mcp-gsc",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
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
@@ -302,7 +302,11 @@ export type PaginatedSearchAnalyticsQuery = Omit<
302
302
  >;
303
303
 
304
304
  export interface SearchAnalyticsRow {
305
- keys: string[];
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.length >= 2 &&
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((a, b) => b.impressions - a.impressions)
690
- .slice(0, 100)
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.length < 2) continue;
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((a, b) => b.impressions - a.impressions),
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((a, b) => b.total_impressions - a.total_impressions)
764
- .slice(0, 100);
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.length < 1) continue;
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.length < 1) continue;
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
- return decayCandidates
829
- .sort((a, b) => a.click_difference - b.click_difference)
830
- .slice(0, 100);
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.length < 1) continue;
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.length < 1) continue;
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(rowsA.map((r) => r.keys[0]));
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.length < 1) continue;
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((a, b) => b.period_a.clicks - a.period_a.clicks);
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