@digestseo/mcp-gsc 0.4.5 → 0.4.7
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 +13 -0
- package/README.md +4 -4
- package/SETUP.md +1 -1
- package/package.json +2 -2
- package/src/digest.ts +14 -13
- package/src/google.ts +155 -45
- package/src/index.ts +29 -5
- package/src/indexing-property-authorization.ts +27 -10
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,19 @@ 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
|
+
## [0.4.7] - 2026-09-21
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- Weekly-digest query movers no longer treat a query missing from one bounded top-query response as zero traffic; comparisons now require the query to be present in both weekly result sets and explain that Search Analytics does not guarantee every data row.
|
|
12
|
+
- Period comparison and content-decay analysis no longer fabricate zero metrics for dimension keys or pages missing from one non-exhaustive Search Analytics response; they now compare only rows returned in both periods and expose that scope explicitly.
|
|
13
|
+
|
|
14
|
+
## [0.4.6] - 2026-09-21
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
- Package metadata and install guidance now advertise the actual Node.js ranges accepted by bundled runtime dependencies (`^22.18.0 || >=24.11.0`) instead of claiming all Node.js 22+ releases are supported.
|
|
18
|
+
- Indexing API eligibility preflight now recognizes static JSON-LD, Microdata, and RDFa type markup, including schema.org URL type spellings, instead of rejecting otherwise eligible static pages solely because they do not use the narrow JSON-LD form previously detected.
|
|
19
|
+
- URL Inspection now rejects malformed/non-HTTP(S) URLs and URLs outside the supplied Search Console property locally before consuming the server's inspection safety budget or calling Google.
|
|
20
|
+
|
|
8
21
|
## [0.4.5] - 2026-09-21
|
|
9
22
|
|
|
10
23
|
### Fixed
|
package/README.md
CHANGED
|
@@ -78,9 +78,9 @@ By default (`GSC_ACCESS_MODE=readwrite`), this server exposes 21 tools. Read-onl
|
|
|
78
78
|
| **`urls.inspect`** | Read | Google's URL Inspection report for the version of one page currently known in Google's index. It is not a live URL test; mobile-usability output is deprecated. |
|
|
79
79
|
| **`urls.inspect_many`** | Read | Inspect Google's indexed versions of 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; this does not run live URL tests. |
|
|
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 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
|
|
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
|
-
| **`reports.weekly_digest`** | Read | Generate a plain-language seven-day performance report with movers, top pages, and one recommended action. |
|
|
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 compare only pages returned in both periods and do not turn one-sided row absence into zero traffic. |
|
|
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. `analytics.compare` compares only dimension keys returned in both period responses; a key absent from one non-exhaustive Search Analytics response is not assumed to have zero metrics. `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
|
+
| **`reports.weekly_digest`** | Read | Generate a plain-language seven-day performance report with movers, top pages, and one recommended action. Query movers compare only rows returned in both weekly Search Analytics result sets; a query missing from one bounded response is not treated as zero because Google does not guarantee every data row. |
|
|
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. |
|
|
86
86
|
| **`indexing.request`** | Write | Requests indexing through Google's Indexing API. Google currently restricts this API to pages containing `JobPosting` structured data or livestream pages containing `BroadcastEvent` inside `VideoObject`. It is not available for general webpage submission. |
|
|
@@ -89,7 +89,7 @@ Read-write mode requests the Google Search Console read-write and Indexing API s
|
|
|
89
89
|
|
|
90
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
91
|
|
|
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
|
|
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 static JSON-LD, Microdata, and RDFa markup 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. Structured data injected only after client-side JavaScript runs cannot be confirmed by this bounded preflight and therefore fails closed. A successful submission is only an acknowledgment that Google received the notification — it does not guarantee the URL will be indexed.
|
|
93
93
|
|
|
94
94
|
## What you can ask
|
|
95
95
|
|
package/SETUP.md
CHANGED
|
@@ -10,7 +10,7 @@ By the end you'll have a Worker at `https://<your-worker>.workers.dev/mcp` that
|
|
|
10
10
|
|
|
11
11
|
- A [Cloudflare account](https://dash.cloudflare.com/sign-up) (the free Workers plan is enough to start).
|
|
12
12
|
- A [Google account](https://accounts.google.com/) with access to the Search Console properties you want to query.
|
|
13
|
-
- Node.js 22+
|
|
13
|
+
- Node.js 22.18+ within the 22.x line, or Node.js 24.11+ with npm. The bundled runtime dependency graph includes Babel 8 packages with this stricter Node support range.
|
|
14
14
|
- The Wrangler CLI — installed for you by `npm install`; invoke it with `npx wrangler …`.
|
|
15
15
|
|
|
16
16
|
Clone and install:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@digestseo/mcp-gsc",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.7",
|
|
4
4
|
"private": false,
|
|
5
5
|
"main": "scripts/glama-start.mjs",
|
|
6
6
|
"bin": {
|
|
@@ -87,6 +87,6 @@
|
|
|
87
87
|
"vitest": "^4.1.11"
|
|
88
88
|
},
|
|
89
89
|
"engines": {
|
|
90
|
-
"node": "
|
|
90
|
+
"node": "^22.18.0 || >=24.11.0"
|
|
91
91
|
}
|
|
92
92
|
}
|
package/src/digest.ts
CHANGED
|
@@ -199,25 +199,22 @@ function rowsToPages(rows: SearchAnalyticsRow[]): PageRow[] {
|
|
|
199
199
|
|
|
200
200
|
function computeMovers(current: QueryRow[], prev: QueryRow[]): Movers {
|
|
201
201
|
const prevByQuery = new Map(prev.map((q) => [q.query, q]));
|
|
202
|
-
const currentByQuery = new Map(current.map((q) => [q.query, q]));
|
|
203
|
-
const allQueries = new Set<string>([
|
|
204
|
-
...current.map((q) => q.query),
|
|
205
|
-
...prev.map((q) => q.query),
|
|
206
|
-
]);
|
|
207
202
|
|
|
208
203
|
const rising: Mover[] = [];
|
|
209
204
|
const falling: Mover[] = [];
|
|
210
205
|
const newImpressions: Mover[] = [];
|
|
211
206
|
|
|
212
|
-
for (const
|
|
213
|
-
const
|
|
207
|
+
for (const c of current) {
|
|
208
|
+
const query = c.query;
|
|
214
209
|
const p = prevByQuery.get(query);
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
const
|
|
218
|
-
const
|
|
219
|
-
const
|
|
220
|
-
const
|
|
210
|
+
if (!p) continue;
|
|
211
|
+
|
|
212
|
+
const cClicks = c.clicks;
|
|
213
|
+
const pClicks = p.clicks;
|
|
214
|
+
const cImpr = c.impressions;
|
|
215
|
+
const pImpr = p.impressions;
|
|
216
|
+
const cPos = c.position;
|
|
217
|
+
const pPos = p.position;
|
|
221
218
|
|
|
222
219
|
if (cClicks - pClicks >= 5 && cClicks >= 2 * Math.max(pClicks, 1)) {
|
|
223
220
|
rising.push({
|
|
@@ -403,6 +400,10 @@ function renderMarkdown(data: {
|
|
|
403
400
|
|
|
404
401
|
lines.push('## What changed this week');
|
|
405
402
|
lines.push('');
|
|
403
|
+
lines.push(
|
|
404
|
+
'> Query movers compare only query rows returned in both weekly Search Console result sets. A query missing from one bounded response is not treated as zero because Search Analytics returns top rows and does not guarantee every data row.',
|
|
405
|
+
);
|
|
406
|
+
lines.push('');
|
|
406
407
|
|
|
407
408
|
const hasAnyMovers =
|
|
408
409
|
movers.rising.length > 0 ||
|
package/src/google.ts
CHANGED
|
@@ -925,9 +925,10 @@ export function processContentDecay(
|
|
|
925
925
|
const prevPos = row.position;
|
|
926
926
|
|
|
927
927
|
const recent = recentMap.get(page);
|
|
928
|
-
|
|
929
|
-
const
|
|
930
|
-
const
|
|
928
|
+
if (!recent) continue;
|
|
929
|
+
const recClicks = recent.clicks;
|
|
930
|
+
const recImps = recent.impressions;
|
|
931
|
+
const recPos = recent.position;
|
|
931
932
|
|
|
932
933
|
const clickDiff = recClicks - prevClicks;
|
|
933
934
|
const impDiff = recImps - prevImps;
|
|
@@ -985,6 +986,15 @@ const INDEXING_SCOPE_MESSAGE =
|
|
|
985
986
|
'and livestream pages (BroadcastEvent structured data inside VideoObject). ' +
|
|
986
987
|
'It is not available for general webpage submission.';
|
|
987
988
|
|
|
989
|
+
function normalizeSchemaType(value: string): string {
|
|
990
|
+
const trimmed = value.trim().replace(/[\/#]+$/, '');
|
|
991
|
+
const schemaUrl = trimmed.match(
|
|
992
|
+
/^https?:\/\/(?:www\.)?schema\.org\/([^/?#\s]+)$/i,
|
|
993
|
+
);
|
|
994
|
+
if (schemaUrl) return schemaUrl[1];
|
|
995
|
+
return trimmed;
|
|
996
|
+
}
|
|
997
|
+
|
|
988
998
|
function collectSchemaTypes(node: unknown, types: Set<string>): void {
|
|
989
999
|
if (Array.isArray(node)) {
|
|
990
1000
|
for (const item of node) collectSchemaTypes(item, types);
|
|
@@ -993,9 +1003,11 @@ function collectSchemaTypes(node: unknown, types: Set<string>): void {
|
|
|
993
1003
|
if (!node || typeof node !== 'object') return;
|
|
994
1004
|
const obj = node as Record<string, unknown>;
|
|
995
1005
|
const type = obj['@type'];
|
|
996
|
-
if (typeof type === 'string') types.add(type);
|
|
1006
|
+
if (typeof type === 'string') types.add(normalizeSchemaType(type));
|
|
997
1007
|
else if (Array.isArray(type)) {
|
|
998
|
-
for (const t of type)
|
|
1008
|
+
for (const t of type) {
|
|
1009
|
+
if (typeof t === 'string') types.add(normalizeSchemaType(t));
|
|
1010
|
+
}
|
|
999
1011
|
}
|
|
1000
1012
|
for (const [key, value] of Object.entries(obj)) {
|
|
1001
1013
|
if (key === '@type') continue;
|
|
@@ -1010,9 +1022,11 @@ function containsBroadcastEvent(node: unknown): boolean {
|
|
|
1010
1022
|
const obj = node as Record<string, unknown>;
|
|
1011
1023
|
const types = new Set<string>();
|
|
1012
1024
|
const type = obj['@type'];
|
|
1013
|
-
if (typeof type === 'string') types.add(type);
|
|
1025
|
+
if (typeof type === 'string') types.add(normalizeSchemaType(type));
|
|
1014
1026
|
else if (Array.isArray(type)) {
|
|
1015
|
-
for (const t of type)
|
|
1027
|
+
for (const t of type) {
|
|
1028
|
+
if (typeof t === 'string') types.add(normalizeSchemaType(t));
|
|
1029
|
+
}
|
|
1016
1030
|
}
|
|
1017
1031
|
if (types.has('BroadcastEvent')) return true;
|
|
1018
1032
|
return Object.values(obj).some(containsBroadcastEvent);
|
|
@@ -1025,14 +1039,134 @@ function hasEligibleVideoBroadcast(node: unknown): boolean {
|
|
|
1025
1039
|
const obj = node as Record<string, unknown>;
|
|
1026
1040
|
const types = new Set<string>();
|
|
1027
1041
|
const type = obj['@type'];
|
|
1028
|
-
if (typeof type === 'string') types.add(type);
|
|
1042
|
+
if (typeof type === 'string') types.add(normalizeSchemaType(type));
|
|
1029
1043
|
else if (Array.isArray(type)) {
|
|
1030
|
-
for (const t of type)
|
|
1044
|
+
for (const t of type) {
|
|
1045
|
+
if (typeof t === 'string') types.add(normalizeSchemaType(t));
|
|
1046
|
+
}
|
|
1031
1047
|
}
|
|
1032
1048
|
if (types.has('VideoObject') && containsBroadcastEvent(obj)) return true;
|
|
1033
1049
|
return Object.values(obj).some(hasEligibleVideoBroadcast);
|
|
1034
1050
|
}
|
|
1035
1051
|
|
|
1052
|
+
const VOID_HTML_TAGS = new Set([
|
|
1053
|
+
'area',
|
|
1054
|
+
'base',
|
|
1055
|
+
'br',
|
|
1056
|
+
'col',
|
|
1057
|
+
'embed',
|
|
1058
|
+
'hr',
|
|
1059
|
+
'img',
|
|
1060
|
+
'input',
|
|
1061
|
+
'link',
|
|
1062
|
+
'meta',
|
|
1063
|
+
'param',
|
|
1064
|
+
'source',
|
|
1065
|
+
'track',
|
|
1066
|
+
'wbr',
|
|
1067
|
+
]);
|
|
1068
|
+
|
|
1069
|
+
function htmlAttribute(attributes: string, name: string): string | undefined {
|
|
1070
|
+
const pattern = new RegExp(
|
|
1071
|
+
`\\b${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s"'=<>]+))`,
|
|
1072
|
+
'i',
|
|
1073
|
+
);
|
|
1074
|
+
const match = pattern.exec(attributes);
|
|
1075
|
+
return match?.[1] ?? match?.[2] ?? match?.[3];
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
function isSchemaVocabulary(value: string | undefined): boolean {
|
|
1079
|
+
return Boolean(value && /^https?:\/\/(?:www\.)?schema\.org\/?$/i.test(value.trim()));
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
function explicitSchemaType(value: string): string | undefined {
|
|
1083
|
+
const trimmed = value.trim();
|
|
1084
|
+
if (/^https?:\/\/(?:www\.)?schema\.org\//i.test(trimmed)) {
|
|
1085
|
+
return normalizeSchemaType(trimmed);
|
|
1086
|
+
}
|
|
1087
|
+
return undefined;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
function htmlStructuredTypes(
|
|
1091
|
+
attributes: string,
|
|
1092
|
+
schemaVocabularyInScope: boolean,
|
|
1093
|
+
): Set<string> {
|
|
1094
|
+
const types = new Set<string>();
|
|
1095
|
+
|
|
1096
|
+
const itemType = htmlAttribute(attributes, 'itemtype');
|
|
1097
|
+
if (itemType) {
|
|
1098
|
+
for (const token of itemType.split(/\s+/)) {
|
|
1099
|
+
const normalized = explicitSchemaType(token);
|
|
1100
|
+
if (normalized) types.add(normalized);
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
const rdfaType = htmlAttribute(attributes, 'typeof');
|
|
1105
|
+
if (rdfaType) {
|
|
1106
|
+
for (const token of rdfaType.split(/\s+/)) {
|
|
1107
|
+
const explicit = explicitSchemaType(token);
|
|
1108
|
+
if (explicit) types.add(explicit);
|
|
1109
|
+
else if (schemaVocabularyInScope && token.trim()) {
|
|
1110
|
+
types.add(normalizeSchemaType(token));
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
return types;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
/**
|
|
1119
|
+
* Recognize static Microdata/RDFa eligibility without turning this preflight
|
|
1120
|
+
* into a general-purpose HTML validator. The stack is only used to enforce
|
|
1121
|
+
* Google's VideoObject -> BroadcastEvent nesting requirement.
|
|
1122
|
+
*/
|
|
1123
|
+
function hasEligibleHtmlStructuredData(html: string): boolean {
|
|
1124
|
+
const staticMarkup = html
|
|
1125
|
+
.replace(/<!--(?:[\s\S]*?)-->/g, '')
|
|
1126
|
+
.replace(/<(script|style|template)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, '');
|
|
1127
|
+
const stack: Array<{
|
|
1128
|
+
tag: string;
|
|
1129
|
+
schemaVocabulary: boolean;
|
|
1130
|
+
videoObjectInScope: boolean;
|
|
1131
|
+
}> = [];
|
|
1132
|
+
const tagPattern = /<\s*(\/?)\s*([a-zA-Z][\w:-]*)([^>]*)>/g;
|
|
1133
|
+
let match: RegExpExecArray | null;
|
|
1134
|
+
|
|
1135
|
+
while ((match = tagPattern.exec(staticMarkup)) !== null) {
|
|
1136
|
+
const closing = match[1] === '/';
|
|
1137
|
+
const tag = match[2].toLowerCase();
|
|
1138
|
+
const attributes = match[3];
|
|
1139
|
+
|
|
1140
|
+
if (closing) {
|
|
1141
|
+
for (let index = stack.length - 1; index >= 0; index -= 1) {
|
|
1142
|
+
if (stack[index].tag === tag) {
|
|
1143
|
+
stack.length = index;
|
|
1144
|
+
break;
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
continue;
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
const parent = stack.at(-1);
|
|
1151
|
+
const schemaVocabulary =
|
|
1152
|
+
isSchemaVocabulary(htmlAttribute(attributes, 'vocab')) ||
|
|
1153
|
+
parent?.schemaVocabulary === true;
|
|
1154
|
+
const types = htmlStructuredTypes(attributes, schemaVocabulary);
|
|
1155
|
+
if (types.has('JobPosting')) return true;
|
|
1156
|
+
|
|
1157
|
+
const videoObjectInScope =
|
|
1158
|
+
types.has('VideoObject') || parent?.videoObjectInScope === true;
|
|
1159
|
+
if (types.has('BroadcastEvent') && videoObjectInScope) return true;
|
|
1160
|
+
|
|
1161
|
+
const selfClosing = /\/\s*$/.test(attributes);
|
|
1162
|
+
if (!selfClosing && !VOID_HTML_TAGS.has(tag)) {
|
|
1163
|
+
stack.push({ tag, schemaVocabulary, videoObjectInScope });
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
return false;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1036
1170
|
export interface IndexingEligibility {
|
|
1037
1171
|
eligible: boolean;
|
|
1038
1172
|
reason?: string;
|
|
@@ -1112,9 +1246,9 @@ async function readBoundedResponseText(response: Response): Promise<string> {
|
|
|
1112
1246
|
/**
|
|
1113
1247
|
* Checks whether `url` carries the structured data Google's Indexing API
|
|
1114
1248
|
* currently supports (JobPosting, or BroadcastEvent nested in VideoObject),
|
|
1115
|
-
* by inspecting
|
|
1116
|
-
* parsed, eligibility can't be confirmed and the
|
|
1117
|
-
* ineligible rather than silently submitted.
|
|
1249
|
+
* by inspecting static JSON-LD, Microdata, and RDFa markup. Best-effort: if
|
|
1250
|
+
* the page can't be fetched or parsed, eligibility can't be confirmed and the
|
|
1251
|
+
* URL is treated as ineligible rather than silently submitted.
|
|
1118
1252
|
*/
|
|
1119
1253
|
export async function checkIndexingEligibility(
|
|
1120
1254
|
url: string,
|
|
@@ -1193,7 +1327,8 @@ export async function checkIndexingEligibility(
|
|
|
1193
1327
|
|
|
1194
1328
|
const types = new Set<string>();
|
|
1195
1329
|
let hasVideoBroadcast = false;
|
|
1196
|
-
const scriptPattern =
|
|
1330
|
+
const scriptPattern =
|
|
1331
|
+
/<script\b[^>]*\btype\s*=\s*(?:"application\/ld\+json"|'application\/ld\+json'|application\/ld\+json)[^>]*>([\s\S]*?)<\/script>/gi;
|
|
1197
1332
|
let match: RegExpExecArray | null;
|
|
1198
1333
|
while ((match = scriptPattern.exec(html)) !== null) {
|
|
1199
1334
|
try {
|
|
@@ -1205,7 +1340,11 @@ export async function checkIndexingEligibility(
|
|
|
1205
1340
|
}
|
|
1206
1341
|
}
|
|
1207
1342
|
|
|
1208
|
-
if (
|
|
1343
|
+
if (
|
|
1344
|
+
types.has('JobPosting') ||
|
|
1345
|
+
hasVideoBroadcast ||
|
|
1346
|
+
hasEligibleHtmlStructuredData(html)
|
|
1347
|
+
) {
|
|
1209
1348
|
return { eligible: true };
|
|
1210
1349
|
}
|
|
1211
1350
|
|
|
@@ -1295,7 +1434,8 @@ export function processPerformanceComparison(
|
|
|
1295
1434
|
const ctrA = row.ctr;
|
|
1296
1435
|
const posA = row.position;
|
|
1297
1436
|
|
|
1298
|
-
const b = mapB.get(key)
|
|
1437
|
+
const b = mapB.get(key);
|
|
1438
|
+
if (!b) continue;
|
|
1299
1439
|
const clicksB = b.clicks;
|
|
1300
1440
|
const impsB = b.impressions;
|
|
1301
1441
|
const ctrB = b.ctr;
|
|
@@ -1319,36 +1459,6 @@ export function processPerformanceComparison(
|
|
|
1319
1459
|
});
|
|
1320
1460
|
}
|
|
1321
1461
|
|
|
1322
|
-
const mapAKeys = new Set(
|
|
1323
|
-
rowsA
|
|
1324
|
-
.map((r) => r.keys?.[0])
|
|
1325
|
-
.filter((key): key is string => key !== undefined),
|
|
1326
|
-
);
|
|
1327
|
-
for (const row of rowsB) {
|
|
1328
|
-
if ((row.keys?.length ?? 0) < 1) continue;
|
|
1329
|
-
const key = row.keys![0];
|
|
1330
|
-
if (mapAKeys.has(key)) continue;
|
|
1331
|
-
|
|
1332
|
-
const clicksB = row.clicks;
|
|
1333
|
-
const impsB = row.impressions;
|
|
1334
|
-
const ctrB = row.ctr;
|
|
1335
|
-
const posB = row.position;
|
|
1336
|
-
|
|
1337
|
-
comparison.push({
|
|
1338
|
-
key,
|
|
1339
|
-
period_a: { clicks: 0, impressions: 0, ctr: 0, position: 0 },
|
|
1340
|
-
period_b: { clicks: clicksB, impressions: impsB, ctr: ctrB, position: posB },
|
|
1341
|
-
diff: {
|
|
1342
|
-
clicks: -clicksB,
|
|
1343
|
-
clicks_percentage: calculatePercentageChange(0, clicksB),
|
|
1344
|
-
impressions: -impsB,
|
|
1345
|
-
impressions_percentage: calculatePercentageChange(0, impsB),
|
|
1346
|
-
ctr: -ctrB,
|
|
1347
|
-
position: -posB,
|
|
1348
|
-
},
|
|
1349
|
-
});
|
|
1350
|
-
}
|
|
1351
|
-
|
|
1352
1462
|
return comparison.sort(
|
|
1353
1463
|
(a, b) =>
|
|
1354
1464
|
Math.abs(b.diff.clicks) - Math.abs(a.diff.clicks) ||
|
package/src/index.ts
CHANGED
|
@@ -70,6 +70,7 @@ import {
|
|
|
70
70
|
import {
|
|
71
71
|
assertIndexingRequestUrl,
|
|
72
72
|
assertIndexingUrlAuthorized,
|
|
73
|
+
assertUrlWithinSearchConsoleProperty,
|
|
73
74
|
} from './indexing-property-authorization';
|
|
74
75
|
import {
|
|
75
76
|
DEFAULT_ANALYSIS_RESULT_LIMIT,
|
|
@@ -344,6 +345,8 @@ const CONTENT_DECAY_OUTPUT_SCHEMA = {
|
|
|
344
345
|
recent: z.object({ start: z.string(), end: z.string() }),
|
|
345
346
|
previous: z.object({ start: z.string(), end: z.string() }),
|
|
346
347
|
}),
|
|
348
|
+
comparison_scope: z.literal('common_returned_rows_only'),
|
|
349
|
+
comparison_note: z.string(),
|
|
347
350
|
decay_count: z.number().int().nonnegative(),
|
|
348
351
|
assessment_count: z.number().int().nonnegative(),
|
|
349
352
|
decay_results: z.array(
|
|
@@ -392,7 +395,12 @@ const SEARCH_VISIBLE_PAGES_DESCRIPTION =
|
|
|
392
395
|
const SEARCH_VISIBLE_PAGES_NOTE =
|
|
393
396
|
'These are Search Analytics performance rows for pages with recorded impressions in the requested period, not a complete list of indexed URLs. Absence does not mean a URL is unindexed. Use urls.inspect or urls.inspect_many for URL-level index status.';
|
|
394
397
|
|
|
398
|
+
const COMMON_RETURNED_ROWS_COMPARISON_NOTE =
|
|
399
|
+
'Comparisons use only dimension keys returned in both period responses. A key missing from one Search Analytics response is not treated as zero because Google does not guarantee every data row.';
|
|
400
|
+
|
|
395
401
|
const PERFORMANCE_COMPARISON_OUTPUT_SCHEMA = {
|
|
402
|
+
comparison_scope: z.literal('common_returned_rows_only'),
|
|
403
|
+
comparison_note: z.string(),
|
|
396
404
|
comparisons: z.array(
|
|
397
405
|
z.object({
|
|
398
406
|
key: z.string(),
|
|
@@ -583,12 +591,12 @@ const TOOL_CATALOG = [
|
|
|
583
591
|
{
|
|
584
592
|
name: 'insights.content_decay',
|
|
585
593
|
description:
|
|
586
|
-
'Assess page-level click declines across two contiguous periods
|
|
594
|
+
'Assess page-level click declines across two contiguous periods for pages returned in both Search Analytics responses, without treating one-sided row absence as zero traffic.',
|
|
587
595
|
},
|
|
588
596
|
{
|
|
589
597
|
name: 'indexing.request',
|
|
590
598
|
description:
|
|
591
|
-
'Request
|
|
599
|
+
'Request an eligible JobPosting or livestream URL update through Google\'s restricted Indexing API; this is not a general webpage submission tool.',
|
|
592
600
|
},
|
|
593
601
|
{
|
|
594
602
|
name: 'indexing.list_pages',
|
|
@@ -597,7 +605,7 @@ const TOOL_CATALOG = [
|
|
|
597
605
|
{
|
|
598
606
|
name: 'analytics.compare',
|
|
599
607
|
description:
|
|
600
|
-
'Compare Search Console performance metrics
|
|
608
|
+
'Compare Search Console performance metrics between two date ranges for dimension keys returned in both Search Analytics responses; one-sided row absence is not treated as zero.',
|
|
601
609
|
},
|
|
602
610
|
{
|
|
603
611
|
name: 'reports.weekly_digest',
|
|
@@ -759,6 +767,11 @@ class GscMcpRuntime {
|
|
|
759
767
|
},
|
|
760
768
|
async ({ site_url, inspection_url, language_code }) => {
|
|
761
769
|
const googleId = this.requireGoogleId();
|
|
770
|
+
assertUrlWithinSearchConsoleProperty(
|
|
771
|
+
inspection_url,
|
|
772
|
+
site_url,
|
|
773
|
+
'inspection_url',
|
|
774
|
+
);
|
|
762
775
|
const rateLimitError = await this.rateLimitError(googleId, 'urls.inspect');
|
|
763
776
|
if (rateLimitError) return rateLimitError;
|
|
764
777
|
const accessToken = await this.getAccessToken(googleId);
|
|
@@ -801,6 +814,13 @@ class GscMcpRuntime {
|
|
|
801
814
|
},
|
|
802
815
|
async ({ site_url, inspection_urls, language_code }) => {
|
|
803
816
|
const googleId = this.requireGoogleId();
|
|
817
|
+
for (const inspectionUrl of inspection_urls) {
|
|
818
|
+
assertUrlWithinSearchConsoleProperty(
|
|
819
|
+
inspectionUrl,
|
|
820
|
+
site_url,
|
|
821
|
+
'inspection_url',
|
|
822
|
+
);
|
|
823
|
+
}
|
|
804
824
|
const rateLimitError = await this.rateLimitError(
|
|
805
825
|
googleId,
|
|
806
826
|
'urls.inspect_many',
|
|
@@ -1443,7 +1463,7 @@ class GscMcpRuntime {
|
|
|
1443
1463
|
'insights.content_decay',
|
|
1444
1464
|
{
|
|
1445
1465
|
title: 'Detect Content Decay',
|
|
1446
|
-
description: 'Assess page-level click declines across two contiguous periods without treating every small click change as content decay. Each result is classified as likely_decay, weak_insufficient_evidence, or improving_visibility_with_click_volatility using deterministic click-volume, impression, and average-position signals. This is a heuristic assessment, not statistical proof. Results are bounded with limit/start_row; source pagination is reported separately.',
|
|
1466
|
+
description: 'Assess page-level click declines across two contiguous periods without treating every small click change as content decay. Only pages returned in both Search Analytics period responses are compared; a page missing from one response is not treated as zero because Google does not guarantee every data row. Each result is classified as likely_decay, weak_insufficient_evidence, or improving_visibility_with_click_volatility using deterministic click-volume, impression, and average-position signals. This is a heuristic assessment, not statistical proof. Results are bounded with limit/start_row; source pagination is reported separately.',
|
|
1447
1467
|
inputSchema: {
|
|
1448
1468
|
site_url: SEARCH_CONSOLE_PROPERTY_SCHEMA,
|
|
1449
1469
|
compare_days: CONTENT_DECAY_COMPARE_DAYS_SCHEMA,
|
|
@@ -1497,6 +1517,8 @@ class GscMcpRuntime {
|
|
|
1497
1517
|
recent: { start: recentStart, end: recentEnd },
|
|
1498
1518
|
previous: { start: previousStart, end: previousEnd },
|
|
1499
1519
|
},
|
|
1520
|
+
comparison_scope: 'common_returned_rows_only' as const,
|
|
1521
|
+
comparison_note: COMMON_RETURNED_ROWS_COMPARISON_NOTE,
|
|
1500
1522
|
decay_count: decayResults.filter(
|
|
1501
1523
|
(result) => result.classification === 'likely_decay',
|
|
1502
1524
|
).length,
|
|
@@ -1615,7 +1637,7 @@ class GscMcpRuntime {
|
|
|
1615
1637
|
'analytics.compare',
|
|
1616
1638
|
{
|
|
1617
1639
|
title: 'Compare Performance Between Periods',
|
|
1618
|
-
description: 'Compare Search Console performance metrics (clicks, impressions, CTR, average position) between two distinct date ranges (Period A vs Period B) for a selected dimension (query, page, country, device). Apply the same search type and optional dimension filters to both periods. Results are ranked by largest absolute click change, then impression change, and safely paged with limit/start_row. Percentage change is null when
|
|
1640
|
+
description: 'Compare Search Console performance metrics (clicks, impressions, CTR, average position) between two distinct date ranges (Period A vs Period B) for a selected dimension (query, page, country, device). Only dimension keys returned in both Search Analytics period responses are compared; a key missing from one response is not treated as zero because Google does not guarantee every data row. Apply the same search type and optional dimension filters to both periods. Results are ranked by largest absolute click change, then impression change, and safely paged with limit/start_row. Percentage change is null when an explicitly returned baseline row has zero and the comparison value differs. Discover is intentionally unavailable because this comparison contract includes average position, which the Discover report does not support.',
|
|
1619
1641
|
inputSchema: {
|
|
1620
1642
|
site_url: SEARCH_CONSOLE_PROPERTY_SCHEMA,
|
|
1621
1643
|
start_date_a: SEARCH_CONSOLE_DATE_SCHEMA.describe('Start date of Period A (recent, YYYY-MM-DD)'),
|
|
@@ -1705,6 +1727,8 @@ class GscMcpRuntime {
|
|
|
1705
1727
|
const comparison = processPerformanceComparison(sourceA.rows, sourceB.rows);
|
|
1706
1728
|
const bounded = windowKnownResults(comparison, start_row, limit);
|
|
1707
1729
|
const payload = {
|
|
1730
|
+
comparison_scope: 'common_returned_rows_only' as const,
|
|
1731
|
+
comparison_note: COMMON_RETURNED_ROWS_COMPARISON_NOTE,
|
|
1708
1732
|
comparisons: bounded.items,
|
|
1709
1733
|
pagination: {
|
|
1710
1734
|
period_a: paginationMetadata(sourceA),
|
|
@@ -76,6 +76,32 @@ function isWithinDomainProperty(target: URL, domain: string): boolean {
|
|
|
76
76
|
return target.hostname === domain || target.hostname.endsWith(`.${domain}`);
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Confirms that a fully-qualified HTTP(S) URL belongs to the supplied Search
|
|
81
|
+
* Console property. This is shared by URL Inspection and Indexing API guards
|
|
82
|
+
* so both paths use identical URL-prefix and Domain-property semantics.
|
|
83
|
+
*/
|
|
84
|
+
export function assertUrlWithinSearchConsoleProperty(
|
|
85
|
+
url: string,
|
|
86
|
+
siteUrl: string,
|
|
87
|
+
fieldName = 'url',
|
|
88
|
+
): void {
|
|
89
|
+
const target = parseHttpUrl(url, fieldName);
|
|
90
|
+
const normalizedSiteUrl = normalizeSiteUrl(siteUrl);
|
|
91
|
+
|
|
92
|
+
if (normalizedSiteUrl.startsWith(DOMAIN_PROPERTY_PREFIX)) {
|
|
93
|
+
if (isWithinDomainProperty(target, parseDomainProperty(normalizedSiteUrl))) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
} else if (isWithinUrlPrefix(target, new URL(normalizedSiteUrl))) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
throw new Error(
|
|
101
|
+
`${fieldName} must belong to the site_url Search Console property.`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
79
105
|
/**
|
|
80
106
|
* Confirms that an Indexing API URL is contained by an owner-level Search
|
|
81
107
|
* Console property available to the authenticated account. URL parsing avoids
|
|
@@ -86,7 +112,6 @@ export function assertIndexingUrlAuthorized(
|
|
|
86
112
|
siteUrl: string,
|
|
87
113
|
sites: SearchConsoleSite[],
|
|
88
114
|
): void {
|
|
89
|
-
const target = parseHttpUrl(url, 'url');
|
|
90
115
|
const normalizedSiteUrl = normalizeSiteUrl(siteUrl);
|
|
91
116
|
const authorizedSite = sites.find((site) => {
|
|
92
117
|
if (site.permissionLevel !== SITE_OWNER_PERMISSION) return false;
|
|
@@ -103,15 +128,7 @@ export function assertIndexingUrlAuthorized(
|
|
|
103
128
|
);
|
|
104
129
|
}
|
|
105
130
|
|
|
106
|
-
|
|
107
|
-
if (isWithinDomainProperty(target, parseDomainProperty(normalizedSiteUrl))) {
|
|
108
|
-
return;
|
|
109
|
-
}
|
|
110
|
-
} else if (isWithinUrlPrefix(target, new URL(normalizedSiteUrl))) {
|
|
111
|
-
return;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
throw new Error('url must belong to the authorized site_url Search Console property.');
|
|
131
|
+
assertUrlWithinSearchConsoleProperty(url, normalizedSiteUrl);
|
|
115
132
|
}
|
|
116
133
|
|
|
117
134
|
export function assertIndexingRequestUrl(url: string): void {
|