@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 +21 -0
- package/README.md +7 -5
- package/package.json +1 -1
- package/src/digest.ts +60 -24
- package/src/google.ts +166 -27
- package/src/index.ts +332 -86
- package/src/quick-wins-schema.ts +20 -3
- package/src/result-bounds.ts +91 -0
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
|
|
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
|
|
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
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
|
@@ -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
|
-
|
|
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
|
|
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(
|
|
690
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
764
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
829
|
-
|
|
830
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
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
|
|