@digestseo/mcp-gsc 0.4.4 → 0.4.6
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 +1 -1
- package/SETUP.md +1 -1
- package/package.json +2 -2
- package/src/google.ts +149 -11
- package/src/index.ts +42 -29
- package/src/indexing-property-authorization.ts +27 -10
- package/src/quick-wins-schema.ts +3 -2
- package/src/search-console-property-schema.ts +45 -0
- package/src/sitemap-url-schema.ts +13 -0
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.6] - 2026-09-21
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- 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.
|
|
12
|
+
- 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.
|
|
13
|
+
- 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.
|
|
14
|
+
|
|
15
|
+
## [0.4.5] - 2026-09-21
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
- Search Console property inputs now reject malformed, relative, unsupported-scheme, credential-bearing, and malformed `sc-domain:` identifiers locally across all tools instead of deferring obvious property-identifier errors to Google.
|
|
19
|
+
- Sitemap get/list-filter/submit/delete inputs now reject malformed, relative, and non-HTTP(S) sitemap URLs locally instead of deferring obvious contract errors to Google.
|
|
20
|
+
|
|
8
21
|
## [0.4.4] - 2026-09-21
|
|
9
22
|
|
|
10
23
|
### Fixed
|
package/README.md
CHANGED
|
@@ -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.6",
|
|
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/google.ts
CHANGED
|
@@ -985,6 +985,15 @@ const INDEXING_SCOPE_MESSAGE =
|
|
|
985
985
|
'and livestream pages (BroadcastEvent structured data inside VideoObject). ' +
|
|
986
986
|
'It is not available for general webpage submission.';
|
|
987
987
|
|
|
988
|
+
function normalizeSchemaType(value: string): string {
|
|
989
|
+
const trimmed = value.trim().replace(/[\/#]+$/, '');
|
|
990
|
+
const schemaUrl = trimmed.match(
|
|
991
|
+
/^https?:\/\/(?:www\.)?schema\.org\/([^/?#\s]+)$/i,
|
|
992
|
+
);
|
|
993
|
+
if (schemaUrl) return schemaUrl[1];
|
|
994
|
+
return trimmed;
|
|
995
|
+
}
|
|
996
|
+
|
|
988
997
|
function collectSchemaTypes(node: unknown, types: Set<string>): void {
|
|
989
998
|
if (Array.isArray(node)) {
|
|
990
999
|
for (const item of node) collectSchemaTypes(item, types);
|
|
@@ -993,9 +1002,11 @@ function collectSchemaTypes(node: unknown, types: Set<string>): void {
|
|
|
993
1002
|
if (!node || typeof node !== 'object') return;
|
|
994
1003
|
const obj = node as Record<string, unknown>;
|
|
995
1004
|
const type = obj['@type'];
|
|
996
|
-
if (typeof type === 'string') types.add(type);
|
|
1005
|
+
if (typeof type === 'string') types.add(normalizeSchemaType(type));
|
|
997
1006
|
else if (Array.isArray(type)) {
|
|
998
|
-
for (const t of type)
|
|
1007
|
+
for (const t of type) {
|
|
1008
|
+
if (typeof t === 'string') types.add(normalizeSchemaType(t));
|
|
1009
|
+
}
|
|
999
1010
|
}
|
|
1000
1011
|
for (const [key, value] of Object.entries(obj)) {
|
|
1001
1012
|
if (key === '@type') continue;
|
|
@@ -1010,9 +1021,11 @@ function containsBroadcastEvent(node: unknown): boolean {
|
|
|
1010
1021
|
const obj = node as Record<string, unknown>;
|
|
1011
1022
|
const types = new Set<string>();
|
|
1012
1023
|
const type = obj['@type'];
|
|
1013
|
-
if (typeof type === 'string') types.add(type);
|
|
1024
|
+
if (typeof type === 'string') types.add(normalizeSchemaType(type));
|
|
1014
1025
|
else if (Array.isArray(type)) {
|
|
1015
|
-
for (const t of type)
|
|
1026
|
+
for (const t of type) {
|
|
1027
|
+
if (typeof t === 'string') types.add(normalizeSchemaType(t));
|
|
1028
|
+
}
|
|
1016
1029
|
}
|
|
1017
1030
|
if (types.has('BroadcastEvent')) return true;
|
|
1018
1031
|
return Object.values(obj).some(containsBroadcastEvent);
|
|
@@ -1025,14 +1038,134 @@ function hasEligibleVideoBroadcast(node: unknown): boolean {
|
|
|
1025
1038
|
const obj = node as Record<string, unknown>;
|
|
1026
1039
|
const types = new Set<string>();
|
|
1027
1040
|
const type = obj['@type'];
|
|
1028
|
-
if (typeof type === 'string') types.add(type);
|
|
1041
|
+
if (typeof type === 'string') types.add(normalizeSchemaType(type));
|
|
1029
1042
|
else if (Array.isArray(type)) {
|
|
1030
|
-
for (const t of type)
|
|
1043
|
+
for (const t of type) {
|
|
1044
|
+
if (typeof t === 'string') types.add(normalizeSchemaType(t));
|
|
1045
|
+
}
|
|
1031
1046
|
}
|
|
1032
1047
|
if (types.has('VideoObject') && containsBroadcastEvent(obj)) return true;
|
|
1033
1048
|
return Object.values(obj).some(hasEligibleVideoBroadcast);
|
|
1034
1049
|
}
|
|
1035
1050
|
|
|
1051
|
+
const VOID_HTML_TAGS = new Set([
|
|
1052
|
+
'area',
|
|
1053
|
+
'base',
|
|
1054
|
+
'br',
|
|
1055
|
+
'col',
|
|
1056
|
+
'embed',
|
|
1057
|
+
'hr',
|
|
1058
|
+
'img',
|
|
1059
|
+
'input',
|
|
1060
|
+
'link',
|
|
1061
|
+
'meta',
|
|
1062
|
+
'param',
|
|
1063
|
+
'source',
|
|
1064
|
+
'track',
|
|
1065
|
+
'wbr',
|
|
1066
|
+
]);
|
|
1067
|
+
|
|
1068
|
+
function htmlAttribute(attributes: string, name: string): string | undefined {
|
|
1069
|
+
const pattern = new RegExp(
|
|
1070
|
+
`\\b${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s"'=<>]+))`,
|
|
1071
|
+
'i',
|
|
1072
|
+
);
|
|
1073
|
+
const match = pattern.exec(attributes);
|
|
1074
|
+
return match?.[1] ?? match?.[2] ?? match?.[3];
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
function isSchemaVocabulary(value: string | undefined): boolean {
|
|
1078
|
+
return Boolean(value && /^https?:\/\/(?:www\.)?schema\.org\/?$/i.test(value.trim()));
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
function explicitSchemaType(value: string): string | undefined {
|
|
1082
|
+
const trimmed = value.trim();
|
|
1083
|
+
if (/^https?:\/\/(?:www\.)?schema\.org\//i.test(trimmed)) {
|
|
1084
|
+
return normalizeSchemaType(trimmed);
|
|
1085
|
+
}
|
|
1086
|
+
return undefined;
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
function htmlStructuredTypes(
|
|
1090
|
+
attributes: string,
|
|
1091
|
+
schemaVocabularyInScope: boolean,
|
|
1092
|
+
): Set<string> {
|
|
1093
|
+
const types = new Set<string>();
|
|
1094
|
+
|
|
1095
|
+
const itemType = htmlAttribute(attributes, 'itemtype');
|
|
1096
|
+
if (itemType) {
|
|
1097
|
+
for (const token of itemType.split(/\s+/)) {
|
|
1098
|
+
const normalized = explicitSchemaType(token);
|
|
1099
|
+
if (normalized) types.add(normalized);
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
const rdfaType = htmlAttribute(attributes, 'typeof');
|
|
1104
|
+
if (rdfaType) {
|
|
1105
|
+
for (const token of rdfaType.split(/\s+/)) {
|
|
1106
|
+
const explicit = explicitSchemaType(token);
|
|
1107
|
+
if (explicit) types.add(explicit);
|
|
1108
|
+
else if (schemaVocabularyInScope && token.trim()) {
|
|
1109
|
+
types.add(normalizeSchemaType(token));
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
return types;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
/**
|
|
1118
|
+
* Recognize static Microdata/RDFa eligibility without turning this preflight
|
|
1119
|
+
* into a general-purpose HTML validator. The stack is only used to enforce
|
|
1120
|
+
* Google's VideoObject -> BroadcastEvent nesting requirement.
|
|
1121
|
+
*/
|
|
1122
|
+
function hasEligibleHtmlStructuredData(html: string): boolean {
|
|
1123
|
+
const staticMarkup = html
|
|
1124
|
+
.replace(/<!--(?:[\s\S]*?)-->/g, '')
|
|
1125
|
+
.replace(/<(script|style|template)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, '');
|
|
1126
|
+
const stack: Array<{
|
|
1127
|
+
tag: string;
|
|
1128
|
+
schemaVocabulary: boolean;
|
|
1129
|
+
videoObjectInScope: boolean;
|
|
1130
|
+
}> = [];
|
|
1131
|
+
const tagPattern = /<\s*(\/?)\s*([a-zA-Z][\w:-]*)([^>]*)>/g;
|
|
1132
|
+
let match: RegExpExecArray | null;
|
|
1133
|
+
|
|
1134
|
+
while ((match = tagPattern.exec(staticMarkup)) !== null) {
|
|
1135
|
+
const closing = match[1] === '/';
|
|
1136
|
+
const tag = match[2].toLowerCase();
|
|
1137
|
+
const attributes = match[3];
|
|
1138
|
+
|
|
1139
|
+
if (closing) {
|
|
1140
|
+
for (let index = stack.length - 1; index >= 0; index -= 1) {
|
|
1141
|
+
if (stack[index].tag === tag) {
|
|
1142
|
+
stack.length = index;
|
|
1143
|
+
break;
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
continue;
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
const parent = stack.at(-1);
|
|
1150
|
+
const schemaVocabulary =
|
|
1151
|
+
isSchemaVocabulary(htmlAttribute(attributes, 'vocab')) ||
|
|
1152
|
+
parent?.schemaVocabulary === true;
|
|
1153
|
+
const types = htmlStructuredTypes(attributes, schemaVocabulary);
|
|
1154
|
+
if (types.has('JobPosting')) return true;
|
|
1155
|
+
|
|
1156
|
+
const videoObjectInScope =
|
|
1157
|
+
types.has('VideoObject') || parent?.videoObjectInScope === true;
|
|
1158
|
+
if (types.has('BroadcastEvent') && videoObjectInScope) return true;
|
|
1159
|
+
|
|
1160
|
+
const selfClosing = /\/\s*$/.test(attributes);
|
|
1161
|
+
if (!selfClosing && !VOID_HTML_TAGS.has(tag)) {
|
|
1162
|
+
stack.push({ tag, schemaVocabulary, videoObjectInScope });
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
return false;
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1036
1169
|
export interface IndexingEligibility {
|
|
1037
1170
|
eligible: boolean;
|
|
1038
1171
|
reason?: string;
|
|
@@ -1112,9 +1245,9 @@ async function readBoundedResponseText(response: Response): Promise<string> {
|
|
|
1112
1245
|
/**
|
|
1113
1246
|
* Checks whether `url` carries the structured data Google's Indexing API
|
|
1114
1247
|
* 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.
|
|
1248
|
+
* by inspecting static JSON-LD, Microdata, and RDFa markup. Best-effort: if
|
|
1249
|
+
* the page can't be fetched or parsed, eligibility can't be confirmed and the
|
|
1250
|
+
* URL is treated as ineligible rather than silently submitted.
|
|
1118
1251
|
*/
|
|
1119
1252
|
export async function checkIndexingEligibility(
|
|
1120
1253
|
url: string,
|
|
@@ -1193,7 +1326,8 @@ export async function checkIndexingEligibility(
|
|
|
1193
1326
|
|
|
1194
1327
|
const types = new Set<string>();
|
|
1195
1328
|
let hasVideoBroadcast = false;
|
|
1196
|
-
const scriptPattern =
|
|
1329
|
+
const scriptPattern =
|
|
1330
|
+
/<script\b[^>]*\btype\s*=\s*(?:"application\/ld\+json"|'application\/ld\+json'|application\/ld\+json)[^>]*>([\s\S]*?)<\/script>/gi;
|
|
1197
1331
|
let match: RegExpExecArray | null;
|
|
1198
1332
|
while ((match = scriptPattern.exec(html)) !== null) {
|
|
1199
1333
|
try {
|
|
@@ -1205,7 +1339,11 @@ export async function checkIndexingEligibility(
|
|
|
1205
1339
|
}
|
|
1206
1340
|
}
|
|
1207
1341
|
|
|
1208
|
-
if (
|
|
1342
|
+
if (
|
|
1343
|
+
types.has('JobPosting') ||
|
|
1344
|
+
hasVideoBroadcast ||
|
|
1345
|
+
hasEligibleHtmlStructuredData(html)
|
|
1346
|
+
) {
|
|
1209
1347
|
return { eligible: true };
|
|
1210
1348
|
}
|
|
1211
1349
|
|
package/src/index.ts
CHANGED
|
@@ -56,6 +56,11 @@ import {
|
|
|
56
56
|
import { CONTENT_DECAY_COMPARE_DAYS_SCHEMA } from './content-decay-schema';
|
|
57
57
|
import { resolveIndexedPagesDateRange } from './indexed-pages-range';
|
|
58
58
|
import { createQuickWinsInputSchema } from './quick-wins-schema';
|
|
59
|
+
import { SITEMAP_URL_SCHEMA } from './sitemap-url-schema';
|
|
60
|
+
import {
|
|
61
|
+
SEARCH_CONSOLE_PROPERTY_DESCRIPTION,
|
|
62
|
+
SEARCH_CONSOLE_PROPERTY_SCHEMA,
|
|
63
|
+
} from './search-console-property-schema';
|
|
59
64
|
import { WRITE_TOOL_ANNOTATIONS } from './write-tool-annotations';
|
|
60
65
|
import {
|
|
61
66
|
getToolCatalogForAccessMode,
|
|
@@ -65,6 +70,7 @@ import {
|
|
|
65
70
|
import {
|
|
66
71
|
assertIndexingRequestUrl,
|
|
67
72
|
assertIndexingUrlAuthorized,
|
|
73
|
+
assertUrlWithinSearchConsoleProperty,
|
|
68
74
|
} from './indexing-property-authorization';
|
|
69
75
|
import {
|
|
70
76
|
DEFAULT_ANALYSIS_RESULT_LIMIT,
|
|
@@ -104,9 +110,6 @@ const READ_ONLY_ANNOTATIONS = {
|
|
|
104
110
|
openWorldHint: true,
|
|
105
111
|
} as const;
|
|
106
112
|
|
|
107
|
-
const SITE_URL_DESCRIPTION =
|
|
108
|
-
"The Search Console property identifier, exactly as returned by sites.list. Two formats exist: domain properties use 'sc-domain:example.com'; URL-prefix properties use the full URL including protocol and trailing slash, e.g. 'https://www.example.com/'. Passing the wrong format returns a permission error even when the user owns the site — call sites.list first if unsure.";
|
|
109
|
-
|
|
110
113
|
const METRIC_OUTPUT_SCHEMA = {
|
|
111
114
|
clicks: z.number(),
|
|
112
115
|
impressions: z.number(),
|
|
@@ -586,7 +589,7 @@ const TOOL_CATALOG = [
|
|
|
586
589
|
{
|
|
587
590
|
name: 'indexing.request',
|
|
588
591
|
description:
|
|
589
|
-
'Request
|
|
592
|
+
'Request an eligible JobPosting or livestream URL update through Google\'s restricted Indexing API; this is not a general webpage submission tool.',
|
|
590
593
|
},
|
|
591
594
|
{
|
|
592
595
|
name: 'indexing.list_pages',
|
|
@@ -720,7 +723,7 @@ class GscMcpRuntime {
|
|
|
720
723
|
description:
|
|
721
724
|
"Retrieve one exact Google Search Console property and the connected account's permission level for it. Use this when the user has already named a property and you need to confirm that exact property or its access level without listing every property first.",
|
|
722
725
|
inputSchema: {
|
|
723
|
-
site_url:
|
|
726
|
+
site_url: SEARCH_CONSOLE_PROPERTY_SCHEMA,
|
|
724
727
|
},
|
|
725
728
|
outputSchema: SITE_DETAIL_OUTPUT_SCHEMA,
|
|
726
729
|
annotations: READ_ONLY_ANNOTATIONS,
|
|
@@ -739,7 +742,7 @@ class GscMcpRuntime {
|
|
|
739
742
|
title: 'Inspect URL index status',
|
|
740
743
|
description: `Inspect the version of a single URL currently known in Google's index. Returns Google's index-status analysis, last crawl, page-fetch/indexing state, canonicals, and rich-results/AMP analysis where available. This API does not test the live URL or prove current live-page indexability, and its mobile-usability result is deprecated. Use this when the user asks 'is X indexed?' or wants Google's indexed-state evidence for one page. For a bounded group of 2-10 URLs, prefer urls.inspect_many; Google still processes one URL Inspection request per URL and applies the same quota semantics.`,
|
|
741
744
|
inputSchema: {
|
|
742
|
-
site_url:
|
|
745
|
+
site_url: SEARCH_CONSOLE_PROPERTY_SCHEMA,
|
|
743
746
|
inspection_url: z
|
|
744
747
|
.string()
|
|
745
748
|
.describe(
|
|
@@ -757,6 +760,11 @@ class GscMcpRuntime {
|
|
|
757
760
|
},
|
|
758
761
|
async ({ site_url, inspection_url, language_code }) => {
|
|
759
762
|
const googleId = this.requireGoogleId();
|
|
763
|
+
assertUrlWithinSearchConsoleProperty(
|
|
764
|
+
inspection_url,
|
|
765
|
+
site_url,
|
|
766
|
+
'inspection_url',
|
|
767
|
+
);
|
|
760
768
|
const rateLimitError = await this.rateLimitError(googleId, 'urls.inspect');
|
|
761
769
|
if (rateLimitError) return rateLimitError;
|
|
762
770
|
const accessToken = await this.getAccessToken(googleId);
|
|
@@ -779,7 +787,7 @@ class GscMcpRuntime {
|
|
|
779
787
|
title: 'Inspect multiple URLs',
|
|
780
788
|
description: `Inspect the versions of up to 10 URLs currently known in Google's index from one Search Console property. This API does not run live URL tests. Google still processes one URL Inspection request per URL, so every requested URL consumes one quota unit and one unit of this server's shared URL-inspection safety budget. Requests are sent sequentially to avoid unnecessary bursts. Use this for a small group of important or debugging-target URLs; do not use it to crawl an entire site.`,
|
|
781
789
|
inputSchema: {
|
|
782
|
-
site_url:
|
|
790
|
+
site_url: SEARCH_CONSOLE_PROPERTY_SCHEMA,
|
|
783
791
|
inspection_urls: z
|
|
784
792
|
.array(z.string().url())
|
|
785
793
|
.min(1)
|
|
@@ -799,6 +807,13 @@ class GscMcpRuntime {
|
|
|
799
807
|
},
|
|
800
808
|
async ({ site_url, inspection_urls, language_code }) => {
|
|
801
809
|
const googleId = this.requireGoogleId();
|
|
810
|
+
for (const inspectionUrl of inspection_urls) {
|
|
811
|
+
assertUrlWithinSearchConsoleProperty(
|
|
812
|
+
inspectionUrl,
|
|
813
|
+
site_url,
|
|
814
|
+
'inspection_url',
|
|
815
|
+
);
|
|
816
|
+
}
|
|
802
817
|
const rateLimitError = await this.rateLimitError(
|
|
803
818
|
googleId,
|
|
804
819
|
'urls.inspect_many',
|
|
@@ -842,10 +857,8 @@ class GscMcpRuntime {
|
|
|
842
857
|
description:
|
|
843
858
|
'List sitemaps submitted for a Search Console property. Optionally filter to entries included by one sitemap index. Returns sitemap URLs, last submitted/downloaded dates, submitted URL counts, warning and error counts, and sitemap status. Google\'s deprecated sitemap indexed count is intentionally omitted. Use this when the user asks about sitemap health, submission status, wants to audit which sitemaps are working, or needs the child sitemaps belonging to a specific sitemap index.',
|
|
844
859
|
inputSchema: {
|
|
845
|
-
site_url:
|
|
846
|
-
sitemap_index:
|
|
847
|
-
.string()
|
|
848
|
-
.url()
|
|
860
|
+
site_url: SEARCH_CONSOLE_PROPERTY_SCHEMA,
|
|
861
|
+
sitemap_index: SITEMAP_URL_SCHEMA
|
|
849
862
|
.optional()
|
|
850
863
|
.describe(
|
|
851
864
|
'Optional sitemap index URL. When supplied, Google returns sitemap entries included in that index.',
|
|
@@ -928,7 +941,7 @@ class GscMcpRuntime {
|
|
|
928
941
|
' non-preliminary data.',
|
|
929
942
|
].join('\n'),
|
|
930
943
|
inputSchema: {
|
|
931
|
-
site_url:
|
|
944
|
+
site_url: SEARCH_CONSOLE_PROPERTY_SCHEMA,
|
|
932
945
|
start_date: SEARCH_CONSOLE_DATE_SCHEMA.describe('Start date (inclusive) in YYYY-MM-DD format.'),
|
|
933
946
|
end_date: SEARCH_CONSOLE_DATE_SCHEMA.describe(
|
|
934
947
|
'End date (inclusive) in YYYY-MM-DD format. Note the 2-3 day data lag: the most recent complete date is usually 3 days ago.',
|
|
@@ -1086,7 +1099,7 @@ class GscMcpRuntime {
|
|
|
1086
1099
|
title: 'Find queries for a page',
|
|
1087
1100
|
description: 'For one exact page URL, return the Search Console queries that produced impressions for it over a date range. This wraps an exact page dimension filter so agents do not need to construct analytics.query filter groups manually. Exact page matching is case-sensitive in Search Console. Use row_limit and start_row to page through bounded results; Search Console can still omit anonymized queries. Google Discover is intentionally unavailable because Discover does not expose query data.',
|
|
1088
1101
|
inputSchema: {
|
|
1089
|
-
site_url:
|
|
1102
|
+
site_url: SEARCH_CONSOLE_PROPERTY_SCHEMA,
|
|
1090
1103
|
page_url: z.string().url().describe('Exact fully-qualified page URL to filter on, e.g. https://example.com/guides/seo/. Search Console exact page filters are case-sensitive.'),
|
|
1091
1104
|
start_date: SEARCH_CONSOLE_DATE_SCHEMA.describe('Start date (inclusive) in YYYY-MM-DD format.'),
|
|
1092
1105
|
end_date: SEARCH_CONSOLE_DATE_SCHEMA.describe('End date (inclusive) in YYYY-MM-DD format. Note the 2-3 day GSC data lag.'),
|
|
@@ -1164,7 +1177,7 @@ class GscMcpRuntime {
|
|
|
1164
1177
|
title: 'Find pages for a query',
|
|
1165
1178
|
description: 'For one exact search query, return the site pages that received impressions for it over a date range. This wraps an exact query dimension filter so agents do not need to construct analytics.query filter groups manually. Exact query matching is case-sensitive in Search Console. Use row_limit and start_row to page through bounded results and verify which URL Google is surfacing before diagnosing cannibalization or content targeting. Google Discover is intentionally unavailable because Discover does not expose query data.',
|
|
1166
1179
|
inputSchema: {
|
|
1167
|
-
site_url:
|
|
1180
|
+
site_url: SEARCH_CONSOLE_PROPERTY_SCHEMA,
|
|
1168
1181
|
query: z.string().min(1).describe('Exact Search Console query text to filter on. Exact query filters are case-sensitive.'),
|
|
1169
1182
|
start_date: SEARCH_CONSOLE_DATE_SCHEMA.describe('Start date (inclusive) in YYYY-MM-DD format.'),
|
|
1170
1183
|
end_date: SEARCH_CONSOLE_DATE_SCHEMA.describe('End date (inclusive) in YYYY-MM-DD format. Note the 2-3 day GSC data lag.'),
|
|
@@ -1243,7 +1256,7 @@ class GscMcpRuntime {
|
|
|
1243
1256
|
title: 'Add Search Console property',
|
|
1244
1257
|
description: 'Add a new website property to your Google Search Console account. Note: Domain properties require sc-domain prefix (e.g., sc-domain:example.com), URL-prefix properties require full URL (e.g., https://example.com/).',
|
|
1245
1258
|
inputSchema: {
|
|
1246
|
-
site_url:
|
|
1259
|
+
site_url: SEARCH_CONSOLE_PROPERTY_SCHEMA,
|
|
1247
1260
|
},
|
|
1248
1261
|
outputSchema: MESSAGE_OUTPUT_SCHEMA,
|
|
1249
1262
|
annotations: WRITE_TOOL_ANNOTATIONS['sites.add'],
|
|
@@ -1265,7 +1278,7 @@ class GscMcpRuntime {
|
|
|
1265
1278
|
title: 'Delete Search Console property',
|
|
1266
1279
|
description: 'Remove an existing website property from your Google Search Console account.',
|
|
1267
1280
|
inputSchema: {
|
|
1268
|
-
site_url:
|
|
1281
|
+
site_url: SEARCH_CONSOLE_PROPERTY_SCHEMA,
|
|
1269
1282
|
},
|
|
1270
1283
|
outputSchema: MESSAGE_OUTPUT_SCHEMA,
|
|
1271
1284
|
annotations: WRITE_TOOL_ANNOTATIONS['sites.delete'],
|
|
@@ -1287,8 +1300,8 @@ class GscMcpRuntime {
|
|
|
1287
1300
|
title: 'Submit sitemap',
|
|
1288
1301
|
description: 'Submit a new sitemap to your Google Search Console account.',
|
|
1289
1302
|
inputSchema: {
|
|
1290
|
-
site_url:
|
|
1291
|
-
feedpath:
|
|
1303
|
+
site_url: SEARCH_CONSOLE_PROPERTY_SCHEMA,
|
|
1304
|
+
feedpath: SITEMAP_URL_SCHEMA.describe('The full HTTP/HTTPS URL of the sitemap file to submit, e.g. https://example.com/sitemap.xml'),
|
|
1292
1305
|
},
|
|
1293
1306
|
outputSchema: MESSAGE_OUTPUT_SCHEMA,
|
|
1294
1307
|
annotations: WRITE_TOOL_ANNOTATIONS['sitemaps.submit'],
|
|
@@ -1310,8 +1323,8 @@ class GscMcpRuntime {
|
|
|
1310
1323
|
title: 'Delete sitemap',
|
|
1311
1324
|
description: 'Remove/delete a submitted sitemap from your Google Search Console account.',
|
|
1312
1325
|
inputSchema: {
|
|
1313
|
-
site_url:
|
|
1314
|
-
feedpath:
|
|
1326
|
+
site_url: SEARCH_CONSOLE_PROPERTY_SCHEMA,
|
|
1327
|
+
feedpath: SITEMAP_URL_SCHEMA.describe('The full HTTP/HTTPS URL of the sitemap file to delete, e.g. https://example.com/sitemap.xml'),
|
|
1315
1328
|
},
|
|
1316
1329
|
outputSchema: MESSAGE_OUTPUT_SCHEMA,
|
|
1317
1330
|
annotations: WRITE_TOOL_ANNOTATIONS['sitemaps.delete'],
|
|
@@ -1334,8 +1347,8 @@ class GscMcpRuntime {
|
|
|
1334
1347
|
title: 'Get sitemap details',
|
|
1335
1348
|
description: 'Get status and details of a single sitemap submitted to Google Search Console.',
|
|
1336
1349
|
inputSchema: {
|
|
1337
|
-
site_url:
|
|
1338
|
-
feedpath:
|
|
1350
|
+
site_url: SEARCH_CONSOLE_PROPERTY_SCHEMA,
|
|
1351
|
+
feedpath: SITEMAP_URL_SCHEMA.describe('The full HTTP/HTTPS URL of the sitemap file, e.g. https://example.com/sitemap.xml'),
|
|
1339
1352
|
},
|
|
1340
1353
|
outputSchema: { sitemap: SITEMAP_OUTPUT_SCHEMA },
|
|
1341
1354
|
annotations: READ_ONLY_ANNOTATIONS,
|
|
@@ -1353,7 +1366,7 @@ class GscMcpRuntime {
|
|
|
1353
1366
|
{
|
|
1354
1367
|
title: 'Identify SEO Quick Wins',
|
|
1355
1368
|
description: 'Find observed query/page Search Analytics rows with at least the requested impressions whose average position falls in a configurable opportunity range (8-20 by default). Average position is an aggregate Search Console metric, not a literal current rank; CTR is returned for context and is not an eligibility filter. Results are ordered deterministically by impressions, then bounded with limit/start_row and explicit result_page metadata. Source pagination separately flags the local 100,000-row safety ceiling.',
|
|
1356
|
-
inputSchema: createQuickWinsInputSchema(
|
|
1369
|
+
inputSchema: createQuickWinsInputSchema(),
|
|
1357
1370
|
outputSchema: z.object(QUICK_WIN_OUTPUT_SCHEMA),
|
|
1358
1371
|
annotations: READ_ONLY_ANNOTATIONS,
|
|
1359
1372
|
},
|
|
@@ -1387,7 +1400,7 @@ class GscMcpRuntime {
|
|
|
1387
1400
|
title: 'Detect Keyword Cannibalization',
|
|
1388
1401
|
description: 'Analyze query/page Search Analytics to find queries split across multiple pages. Candidates are ranked deterministically by the observed query/page impression sum, each candidate bounds its page list, and limit/start_row plus result_page provide safe pagination. total_clicks, total_impressions, and impression_share are calculated from observed query/page rows and are not true query-level property aggregates; multiple pages can make that row sum exceed the query-level Search Console total. Multiple ranking URLs can also reflect legitimate locale or intent differences, so treat candidates as evidence to investigate rather than proof of harmful cannibalization.',
|
|
1389
1402
|
inputSchema: {
|
|
1390
|
-
site_url:
|
|
1403
|
+
site_url: SEARCH_CONSOLE_PROPERTY_SCHEMA,
|
|
1391
1404
|
start_date: SEARCH_CONSOLE_DATE_SCHEMA.describe('Start date (inclusive) in YYYY-MM-DD format.'),
|
|
1392
1405
|
end_date: SEARCH_CONSOLE_DATE_SCHEMA.describe('End date (inclusive) in YYYY-MM-DD format. Note the 2-3 day GSC data lag.'),
|
|
1393
1406
|
min_impressions: CANNIBALIZATION_MIN_IMPRESSIONS_SCHEMA,
|
|
@@ -1445,7 +1458,7 @@ class GscMcpRuntime {
|
|
|
1445
1458
|
title: 'Detect Content Decay',
|
|
1446
1459
|
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.',
|
|
1447
1460
|
inputSchema: {
|
|
1448
|
-
site_url:
|
|
1461
|
+
site_url: SEARCH_CONSOLE_PROPERTY_SCHEMA,
|
|
1449
1462
|
compare_days: CONTENT_DECAY_COMPARE_DAYS_SCHEMA,
|
|
1450
1463
|
limit: ANALYSIS_RESULT_LIMIT_SCHEMA,
|
|
1451
1464
|
start_row: RESULT_START_ROW_SCHEMA,
|
|
@@ -1519,7 +1532,7 @@ class GscMcpRuntime {
|
|
|
1519
1532
|
title: 'Request Indexing',
|
|
1520
1533
|
description: "Requests indexing through Google's Indexing API for a URL within an owner-level Search Console property accessible to the connected Google account. 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.",
|
|
1521
1534
|
inputSchema: {
|
|
1522
|
-
site_url:
|
|
1535
|
+
site_url: SEARCH_CONSOLE_PROPERTY_SCHEMA.describe(`${SEARCH_CONSOLE_PROPERTY_DESCRIPTION} The connected Google account must be an owner of this property.`),
|
|
1523
1536
|
url: z.string().superRefine((value, ctx) => {
|
|
1524
1537
|
try {
|
|
1525
1538
|
assertIndexingRequestUrl(value);
|
|
@@ -1557,7 +1570,7 @@ class GscMcpRuntime {
|
|
|
1557
1570
|
title: 'List Search-Visible Pages',
|
|
1558
1571
|
description: `${SEARCH_VISIBLE_PAGES_DESCRIPTION} When one date boundary is omitted, the server derives the other to target an inclusive 30-day range; generated end dates are capped at the latest complete date. Responses are bounded and pageable with row_limit/start_row.`,
|
|
1559
1572
|
inputSchema: {
|
|
1560
|
-
site_url:
|
|
1573
|
+
site_url: SEARCH_CONSOLE_PROPERTY_SCHEMA,
|
|
1561
1574
|
start_date: SEARCH_CONSOLE_DATE_SCHEMA.optional().describe('Start date (inclusive) in YYYY-MM-DD format. If end_date is omitted, the generated end date is 29 days later, capped at the latest complete date.'),
|
|
1562
1575
|
end_date: SEARCH_CONSOLE_DATE_SCHEMA.optional().describe('End date (inclusive) in YYYY-MM-DD format. If start_date is omitted, the generated start date is 29 days earlier. Defaults to 3 days ago. Note the 2-3 day data lag.'),
|
|
1563
1576
|
row_limit: z.number().int().min(1).max(25000).default(1000).describe(`Maximum pages requested for this logical response (1-25000). Output is safely bounded; continue with result_page.next_start_row while result_page.has_more is true.`),
|
|
@@ -1617,7 +1630,7 @@ class GscMcpRuntime {
|
|
|
1617
1630
|
title: 'Compare Performance Between Periods',
|
|
1618
1631
|
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 the baseline is 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
1632
|
inputSchema: {
|
|
1620
|
-
site_url:
|
|
1633
|
+
site_url: SEARCH_CONSOLE_PROPERTY_SCHEMA,
|
|
1621
1634
|
start_date_a: SEARCH_CONSOLE_DATE_SCHEMA.describe('Start date of Period A (recent, YYYY-MM-DD)'),
|
|
1622
1635
|
end_date_a: SEARCH_CONSOLE_DATE_SCHEMA.describe('End date of Period A (recent, YYYY-MM-DD)'),
|
|
1623
1636
|
start_date_b: SEARCH_CONSOLE_DATE_SCHEMA.describe('Start date of Period B (previous, YYYY-MM-DD)'),
|
|
@@ -1723,7 +1736,7 @@ class GscMcpRuntime {
|
|
|
1723
1736
|
description:
|
|
1724
1737
|
'Generate a plain-language weekly SEO report for one Google Search Console property. Returns a markdown digest covering the 7 days ending on end_date, with week-over-week comparison, top pages, queries gaining or losing traction, and one specific action item. Defaults end_date to 3 days ago so the report uses the latest usually-complete Search Console data; pass end_date explicitly to include fresher preliminary data.',
|
|
1725
1738
|
inputSchema: {
|
|
1726
|
-
site_url:
|
|
1739
|
+
site_url: SEARCH_CONSOLE_PROPERTY_SCHEMA,
|
|
1727
1740
|
end_date: SEARCH_CONSOLE_DATE_SCHEMA
|
|
1728
1741
|
.optional()
|
|
1729
1742
|
.describe('End date (inclusive) in YYYY-MM-DD format. Defaults to 3 days ago, which is usually the latest complete Search Console date. Pass a more recent date explicitly to include preliminary data.'),
|
|
@@ -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 {
|
package/src/quick-wins-schema.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { SEARCH_CONSOLE_DATE_SCHEMA } from './date-validation';
|
|
3
|
+
import { SEARCH_CONSOLE_PROPERTY_SCHEMA } from './search-console-property-schema';
|
|
3
4
|
import {
|
|
4
5
|
DEFAULT_ANALYSIS_RESULT_LIMIT,
|
|
5
6
|
MAX_ANALYSIS_RESULT_LIMIT,
|
|
@@ -16,10 +17,10 @@ const QUICK_WINS_THRESHOLDS_SHAPE = {
|
|
|
16
17
|
max_position: z.number().positive().default(20).describe('Maximum Search Console average position for the opportunity range (inclusive). This is an aggregate metric, not a literal current rank. Must be greater than 0. Default is 20.'),
|
|
17
18
|
};
|
|
18
19
|
|
|
19
|
-
export function createQuickWinsInputSchema(
|
|
20
|
+
export function createQuickWinsInputSchema() {
|
|
20
21
|
return z
|
|
21
22
|
.object({
|
|
22
|
-
site_url:
|
|
23
|
+
site_url: SEARCH_CONSOLE_PROPERTY_SCHEMA,
|
|
23
24
|
start_date: SEARCH_CONSOLE_DATE_SCHEMA.describe('Start date (inclusive) in YYYY-MM-DD format.'),
|
|
24
25
|
end_date: SEARCH_CONSOLE_DATE_SCHEMA.describe('End date (inclusive) in YYYY-MM-DD format. Note the 2-3 day GSC data lag.'),
|
|
25
26
|
...QUICK_WINS_THRESHOLDS_SHAPE,
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
export const SEARCH_CONSOLE_PROPERTY_DESCRIPTION =
|
|
4
|
+
"The Search Console property identifier, exactly as returned by sites.list. Domain properties use 'sc-domain:example.com'; URL-prefix properties use an HTTP or HTTPS URL, typically including the trailing slash, e.g. 'https://www.example.com/'. Passing the wrong property form can return a permission error even when the user owns the site - call sites.list first if unsure.";
|
|
5
|
+
|
|
6
|
+
function isDomainPropertyIdentifier(value: string): boolean {
|
|
7
|
+
if (!value.startsWith('sc-domain:')) return false;
|
|
8
|
+
const domain = value.slice('sc-domain:'.length);
|
|
9
|
+
return (
|
|
10
|
+
domain.length > 0 &&
|
|
11
|
+
!/\s/.test(domain) &&
|
|
12
|
+
!domain.includes('/') &&
|
|
13
|
+
!domain.includes('?') &&
|
|
14
|
+
!domain.includes('#') &&
|
|
15
|
+
!domain.includes('@') &&
|
|
16
|
+
!domain.includes(':')
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isUrlPrefixPropertyIdentifier(value: string): boolean {
|
|
21
|
+
if (value !== value.trim() || /\s/.test(value)) return false;
|
|
22
|
+
try {
|
|
23
|
+
const parsed = new URL(value);
|
|
24
|
+
return (
|
|
25
|
+
(parsed.protocol === 'http:' || parsed.protocol === 'https:') &&
|
|
26
|
+
parsed.hostname.length > 0 &&
|
|
27
|
+
parsed.username === '' &&
|
|
28
|
+
parsed.password === ''
|
|
29
|
+
);
|
|
30
|
+
} catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function isSearchConsolePropertyIdentifier(value: string): boolean {
|
|
36
|
+
return isDomainPropertyIdentifier(value) || isUrlPrefixPropertyIdentifier(value);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const SEARCH_CONSOLE_PROPERTY_SCHEMA = z
|
|
40
|
+
.string()
|
|
41
|
+
.refine(isSearchConsolePropertyIdentifier, {
|
|
42
|
+
message:
|
|
43
|
+
"Expected a Search Console property identifier such as 'sc-domain:example.com' or an HTTP/HTTPS URL-prefix property such as 'https://www.example.com/'.",
|
|
44
|
+
})
|
|
45
|
+
.describe(SEARCH_CONSOLE_PROPERTY_DESCRIPTION);
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
export const SITEMAP_URL_SCHEMA = z
|
|
4
|
+
.string()
|
|
5
|
+
.url()
|
|
6
|
+
.refine((value) => {
|
|
7
|
+
try {
|
|
8
|
+
const protocol = new URL(value).protocol;
|
|
9
|
+
return protocol === 'http:' || protocol === 'https:';
|
|
10
|
+
} catch {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
}, 'Sitemap URL must use http:// or https://.');
|