pagesight 0.17.0 → 0.19.0
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/README.md +20 -82
- package/docs/credentials.md +54 -0
- package/docs/diagnostics.md +85 -0
- package/docs/measurement.md +178 -0
- package/docs/opportunities.md +78 -0
- package/docs/snapshots.md +130 -0
- package/docs/usage.md +135 -0
- package/package.json +26 -29
- package/src/api/assessment.ts +315 -0
- package/src/api/bing.ts +83 -0
- package/src/api/compare-snapshots.ts +167 -0
- package/src/api/discover.ts +39 -0
- package/src/api/doctor.ts +35 -0
- package/src/api/evidence-schema.ts +78 -0
- package/src/api/evidence.ts +99 -0
- package/src/api/execute.ts +160 -0
- package/src/api/ga-freshness.ts +20 -0
- package/src/api/ga-realtime.ts +40 -0
- package/src/api/index.ts +5 -0
- package/src/api/opportunities.ts +317 -0
- package/src/api/report-table.ts +216 -0
- package/src/api/reports.ts +108 -0
- package/src/api/schema.ts +271 -0
- package/src/api/snapshot.ts +202 -0
- package/src/api/ui-findings.ts +68 -0
- package/src/assessment-text.ts +55 -0
- package/src/cli.ts +217 -0
- package/src/http.ts +39 -0
- package/src/index.ts +8 -25
- package/src/mcp-server.ts +27 -0
- package/src/mcp.ts +5 -0
- package/src/opportunities-text.ts +61 -0
- package/src/providers/bing.ts +48 -0
- package/src/{lib → providers}/crux.ts +4 -9
- package/src/providers/ga.ts +114 -0
- package/src/providers/google-tokens.ts +86 -0
- package/src/providers/gsc-auth.ts +93 -0
- package/src/{lib → providers}/gsc.ts +27 -24
- package/src/{lib/psi.ts → providers/pagespeed.ts} +3 -14
- package/src/shared/dates.ts +32 -0
- package/src/shared/http.ts +63 -0
- package/src/tools/ai.ts +19 -27
- package/src/tools/audit.ts +18 -98
- package/src/tools/observe.ts +28 -0
- package/src/tools/page/analyze.ts +194 -0
- package/src/tools/page/batch.ts +163 -0
- package/src/tools/page/contrast.ts +128 -0
- package/src/tools/page/links.ts +200 -0
- package/src/tools/page/metadata.ts +225 -0
- package/src/tools/page/structured-data.ts +288 -0
- package/src/tools/page/tool.ts +48 -0
- package/src/tools/search/actions.ts +56 -0
- package/src/tools/search/analytics.ts +266 -0
- package/src/tools/search/coverage.ts +247 -0
- package/src/tools/search/gaps.ts +129 -0
- package/src/tools/search/inspection.ts +160 -0
- package/src/tools/search/result.ts +3 -0
- package/src/tools/search/sample.ts +110 -0
- package/src/tools/search/schema.ts +62 -0
- package/src/{lib/sitemap.ts → tools/search/sitemap-sampling.ts} +1 -45
- package/src/tools/search/sites.ts +86 -0
- package/src/tools/search/tool.ts +11 -0
- package/src/tools/setup.ts +1 -1
- package/src/tools/speed/analyze.ts +191 -0
- package/src/tools/speed/batch.ts +265 -0
- package/src/tools/speed/crux.ts +176 -0
- package/src/tools/speed/pagespeed.ts +273 -0
- package/src/tools/speed/schema.ts +39 -0
- package/src/tools/speed/tool.ts +11 -0
- package/src/web/fetch.ts +31 -0
- package/src/web/images.ts +62 -0
- package/src/web/page-observation.ts +69 -0
- package/src/{lib → web}/robots.ts +20 -12
- package/src/web/sitemap-inventory.ts +64 -0
- package/src/web/sitemap-parser.ts +59 -0
- package/src/lib/auth.ts +0 -187
- package/src/tools/page.ts +0 -1241
- package/src/tools/search.ts +0 -1118
- package/src/tools/speed.ts +0 -956
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { type InspectionResult, inspectUrl } from "../../providers/gsc.js";
|
|
2
|
+
import { type SearchOptions } from "./schema.js";
|
|
3
|
+
import { textResult } from "./result.js";
|
|
4
|
+
function formatInspection(url: string, siteUrl: string, r: InspectionResult): string {
|
|
5
|
+
const idx = r.indexStatusResult;
|
|
6
|
+
const lines: string[] = [
|
|
7
|
+
`=== URL Inspection: ${url} ===`,
|
|
8
|
+
`Property: ${siteUrl}`,
|
|
9
|
+
"",
|
|
10
|
+
"--- Index Status ---",
|
|
11
|
+
"",
|
|
12
|
+
`Verdict: ${idx.verdict}`,
|
|
13
|
+
`Coverage: ${idx.coverageState}`,
|
|
14
|
+
`Page fetch: ${idx.pageFetchState}`,
|
|
15
|
+
`Robots.txt: ${idx.robotsTxtState}`,
|
|
16
|
+
`Indexing: ${idx.indexingState}`,
|
|
17
|
+
`Crawled as: ${idx.crawledAs ?? "unknown"}`,
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
if (idx.lastCrawlTime) lines.push(`Last crawled: ${idx.lastCrawlTime}`);
|
|
21
|
+
if (idx.userCanonical) lines.push(`Your canonical: ${idx.userCanonical}`);
|
|
22
|
+
if (idx.googleCanonical) lines.push(`Google's canonical: ${idx.googleCanonical}`);
|
|
23
|
+
|
|
24
|
+
if (idx.userCanonical && idx.googleCanonical && idx.userCanonical !== idx.googleCanonical) {
|
|
25
|
+
lines.push(`\n⚠ CANONICAL MISMATCH: You declared "${idx.userCanonical}" but Google chose "${idx.googleCanonical}"`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (idx.sitemap && idx.sitemap.length > 0) {
|
|
29
|
+
lines.push(`\nSitemaps: ${idx.sitemap.join(", ")}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (idx.referringUrls && idx.referringUrls.length > 0) {
|
|
33
|
+
lines.push(`\nReferring URLs: ${idx.referringUrls.join(", ")}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (idx.verdict !== "PASS") {
|
|
37
|
+
const gscUrl = `https://search.google.com/search-console/inspect?resource_id=${encodeURIComponent(siteUrl)}&id=${encodeURIComponent(url)}`;
|
|
38
|
+
lines.push("", "→ This page is not indexed. Request indexing manually in Google Search Console:", ` ${gscUrl}`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Rich Results
|
|
42
|
+
if (r.richResultsResult) {
|
|
43
|
+
lines.push("", "--- Rich Results ---", "");
|
|
44
|
+
lines.push(`Verdict: ${r.richResultsResult.verdict}`);
|
|
45
|
+
|
|
46
|
+
for (const item of r.richResultsResult.detectedItems ?? []) {
|
|
47
|
+
lines.push(`\nType: ${item.richResultType}`);
|
|
48
|
+
for (const instance of item.items ?? []) {
|
|
49
|
+
if (instance.name) lines.push(` Name: ${instance.name}`);
|
|
50
|
+
const issues = instance.issues ?? [];
|
|
51
|
+
if (issues.length === 0) {
|
|
52
|
+
lines.push(" Status: PASS");
|
|
53
|
+
} else {
|
|
54
|
+
for (const issue of issues) {
|
|
55
|
+
lines.push(` ${issue.severity}: ${issue.issueMessage}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Display deprecated mobile-usability evidence when supplied.
|
|
63
|
+
if (r.mobileUsabilityResult) {
|
|
64
|
+
lines.push("", "--- Mobile Usability (deprecated) ---", "");
|
|
65
|
+
lines.push(`Verdict: ${r.mobileUsabilityResult.verdict}`);
|
|
66
|
+
if (r.mobileUsabilityResult.issues) {
|
|
67
|
+
for (const issue of r.mobileUsabilityResult.issues) {
|
|
68
|
+
lines.push(` ${issue.issueType}${issue.message ? `: ${issue.message}` : ""}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Inspection link
|
|
74
|
+
if (r.inspectionResultLink) {
|
|
75
|
+
lines.push("", `Full report: ${r.inspectionResultLink}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return lines.join("\n");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function humanizeState(state: string): string {
|
|
82
|
+
const map: Record<string, string> = {
|
|
83
|
+
PAGE_FETCH_STATE_UNSPECIFIED: "not yet crawled",
|
|
84
|
+
ROBOTS_TXT_STATE_UNSPECIFIED: "not yet checked",
|
|
85
|
+
INDEXING_STATE_UNSPECIFIED: "not yet determined",
|
|
86
|
+
};
|
|
87
|
+
return map[state] ?? state;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface InspectionSummary {
|
|
91
|
+
url: string;
|
|
92
|
+
verdict: string;
|
|
93
|
+
coverageState: string;
|
|
94
|
+
pageFetchState: string;
|
|
95
|
+
robotsTxtState: string;
|
|
96
|
+
indexingState: string;
|
|
97
|
+
lastCrawlTime: string | null;
|
|
98
|
+
googleCanonical: string | null;
|
|
99
|
+
error: string | null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function inspectSingle(url: string, siteUrl: string): Promise<InspectionSummary> {
|
|
103
|
+
try {
|
|
104
|
+
const r = await inspectUrl(url, siteUrl);
|
|
105
|
+
const idx = r.indexStatusResult;
|
|
106
|
+
return {
|
|
107
|
+
url,
|
|
108
|
+
verdict: idx.verdict,
|
|
109
|
+
coverageState: idx.coverageState,
|
|
110
|
+
pageFetchState: idx.pageFetchState,
|
|
111
|
+
robotsTxtState: idx.robotsTxtState,
|
|
112
|
+
indexingState: idx.indexingState,
|
|
113
|
+
lastCrawlTime: idx.lastCrawlTime ?? null,
|
|
114
|
+
googleCanonical: idx.googleCanonical ?? null,
|
|
115
|
+
error: null,
|
|
116
|
+
};
|
|
117
|
+
} catch (err) {
|
|
118
|
+
return {
|
|
119
|
+
url,
|
|
120
|
+
verdict: "ERROR",
|
|
121
|
+
coverageState: "ERROR",
|
|
122
|
+
pageFetchState: "ERROR",
|
|
123
|
+
robotsTxtState: "ERROR",
|
|
124
|
+
indexingState: "ERROR",
|
|
125
|
+
lastCrawlTime: null,
|
|
126
|
+
googleCanonical: null,
|
|
127
|
+
error: err instanceof Error ? err.message : String(err),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export async function runInspect(options: SearchOptions) {
|
|
133
|
+
const { site_url, url } = options;
|
|
134
|
+
if (!url) return textResult("Error: url is required for inspect.");
|
|
135
|
+
if (!site_url) return textResult("Error: site_url is required for inspect.");
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
const result = await inspectUrl(url, site_url);
|
|
139
|
+
return textResult(formatInspection(url, site_url, result));
|
|
140
|
+
} catch (err) {
|
|
141
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
142
|
+
if (msg.includes("PERMISSION_DENIED")) {
|
|
143
|
+
const domain = new URL(url).hostname;
|
|
144
|
+
return textResult(
|
|
145
|
+
[
|
|
146
|
+
`Error: Cannot inspect "${url}" — permission denied for property "${site_url}".`,
|
|
147
|
+
"",
|
|
148
|
+
"Possible causes:",
|
|
149
|
+
` 1. ${domain} is not a verified property in Google Search Console`,
|
|
150
|
+
` 2. The authenticated account does not have access to "${site_url}"`,
|
|
151
|
+
` 3. The property format is wrong — try "sc-domain:${domain}" or "https://${domain}/"`,
|
|
152
|
+
"",
|
|
153
|
+
"To verify a property: https://search.google.com/search-console",
|
|
154
|
+
"To check your auth: use the setup tool to verify credentials",
|
|
155
|
+
].join("\n"),
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
return textResult(`Error inspecting URL: ${msg}`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { listSitemaps } from "../../providers/gsc.js";
|
|
2
|
+
import { type SearchOptions } from "./schema.js";
|
|
3
|
+
import { humanizeState, type InspectionSummary, inspectSingle } from "./inspection.js";
|
|
4
|
+
import { matchesCoverageFilter, formatSampleResults } from "./coverage.js";
|
|
5
|
+
import { fetchSitemap, sampleUrls } from "./sitemap-sampling.js";
|
|
6
|
+
import { textResult } from "./result.js";
|
|
7
|
+
|
|
8
|
+
export async function runSample(options: SearchOptions) {
|
|
9
|
+
const { site_url, sitemap_url, sample_size, sample_strategy, filter } = options;
|
|
10
|
+
if (!site_url) return textResult("Error: site_url is required for sample.");
|
|
11
|
+
|
|
12
|
+
const count = sample_size ?? 5;
|
|
13
|
+
const strategy = sample_strategy ?? "random";
|
|
14
|
+
|
|
15
|
+
// Discover sitemap URL if not provided
|
|
16
|
+
let resolvedSitemapUrl = sitemap_url;
|
|
17
|
+
if (!resolvedSitemapUrl) {
|
|
18
|
+
const sitemaps = await listSitemaps(site_url);
|
|
19
|
+
if (sitemaps.length === 0) {
|
|
20
|
+
return textResult(`No sitemaps found for ${site_url} in GSC. Provide a sitemap_url directly.`);
|
|
21
|
+
}
|
|
22
|
+
const nonIndex = sitemaps.find((s) => !s.isSitemapsIndex);
|
|
23
|
+
resolvedSitemapUrl = (nonIndex ?? sitemaps[0]).path;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Fetch and parse sitemap
|
|
27
|
+
let parsed = await fetchSitemap(resolvedSitemapUrl);
|
|
28
|
+
|
|
29
|
+
if (parsed.isSitemapIndex && parsed.childSitemaps.length > 0) {
|
|
30
|
+
const childUrl = parsed.childSitemaps[0];
|
|
31
|
+
parsed = await fetchSitemap(childUrl);
|
|
32
|
+
resolvedSitemapUrl = `${resolvedSitemapUrl} → ${childUrl}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (parsed.urls.length === 0) {
|
|
36
|
+
return textResult(`Sitemap ${resolvedSitemapUrl} contains no URLs.`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (filter) {
|
|
40
|
+
// Filtered sample: inspect more URLs, return only matches
|
|
41
|
+
const scanLimit = Math.min(count * 5, 50, parsed.urls.length);
|
|
42
|
+
const pool = sampleUrls(parsed.urls, scanLimit, strategy);
|
|
43
|
+
const matched: InspectionSummary[] = [];
|
|
44
|
+
let scanned = 0;
|
|
45
|
+
|
|
46
|
+
for (const u of pool) {
|
|
47
|
+
const result = await inspectSingle(u, site_url);
|
|
48
|
+
scanned++;
|
|
49
|
+
if (matchesCoverageFilter(result, filter)) {
|
|
50
|
+
matched.push(result);
|
|
51
|
+
if (matched.length >= count) break;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (matched.length === 0) {
|
|
56
|
+
return textResult(
|
|
57
|
+
[
|
|
58
|
+
`=== Filtered Sample: ${site_url} ===`,
|
|
59
|
+
`Sitemap: ${resolvedSitemapUrl} (${parsed.urls.length.toLocaleString()} URLs)`,
|
|
60
|
+
`Filter: ${filter}`,
|
|
61
|
+
`Scanned: ${scanned} | Matched: 0`,
|
|
62
|
+
"",
|
|
63
|
+
`No URLs matching "${filter}" found in ${scanned} inspected URLs.`,
|
|
64
|
+
"Try a larger sample_size or a different filter.",
|
|
65
|
+
].join("\n"),
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const lines: string[] = [
|
|
70
|
+
`=== Filtered Sample: ${site_url} ===`,
|
|
71
|
+
`Sitemap: ${resolvedSitemapUrl} (${parsed.urls.length.toLocaleString()} URLs)`,
|
|
72
|
+
`Filter: ${filter}`,
|
|
73
|
+
`Scanned: ${scanned} | Matched: ${matched.length}`,
|
|
74
|
+
"",
|
|
75
|
+
"--- Matching URLs ---",
|
|
76
|
+
"",
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
for (let i = 0; i < matched.length; i++) {
|
|
80
|
+
const r = matched[i];
|
|
81
|
+
lines.push(`${i + 1}. ${r.url}`);
|
|
82
|
+
lines.push(` Coverage: ${r.coverageState}`);
|
|
83
|
+
lines.push(` Page fetch: ${humanizeState(r.pageFetchState)}`);
|
|
84
|
+
if (r.robotsTxtState !== "ALLOWED") lines.push(` Robots.txt: ${humanizeState(r.robotsTxtState)}`);
|
|
85
|
+
if (r.indexingState !== "INDEXING_ALLOWED") lines.push(` Indexing: ${humanizeState(r.indexingState)}`);
|
|
86
|
+
if (r.lastCrawlTime) lines.push(` Last crawled: ${r.lastCrawlTime}`);
|
|
87
|
+
if (r.googleCanonical && r.googleCanonical !== r.url) {
|
|
88
|
+
lines.push(` Google canonical: ${r.googleCanonical}`);
|
|
89
|
+
}
|
|
90
|
+
lines.push("");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (matched.length < count && scanned >= scanLimit) {
|
|
94
|
+
lines.push(
|
|
95
|
+
`Found ${matched.length}/${count} requested — scanned ${scanned} URLs (limit ${scanLimit}). Increase sample_size for a wider scan.`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return textResult(lines.join("\n"));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const sampled = sampleUrls(parsed.urls, count, strategy);
|
|
103
|
+
|
|
104
|
+
const results: InspectionSummary[] = [];
|
|
105
|
+
for (const u of sampled) {
|
|
106
|
+
results.push(await inspectSingle(u, site_url));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return textResult(formatSampleResults(site_url, resolvedSitemapUrl, parsed.urls.length, results));
|
|
110
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const searchSchema = {
|
|
3
|
+
action: z
|
|
4
|
+
.enum(["inspect", "sample", "coverage", "sitemaps", "analytics", "gaps", "list_sites", "get_site", "get_sitemap"])
|
|
5
|
+
.optional()
|
|
6
|
+
.describe("Action to perform. Auto-detected from params when unambiguous."),
|
|
7
|
+
site_url: z.string().optional().describe("GSC property (e.g., 'sc-domain:example.com')."),
|
|
8
|
+
url: z.string().url().optional().describe("URL to inspect in Google's index."),
|
|
9
|
+
sitemap_url: z.string().url().optional().describe("Sitemap URL for sample/coverage inspection or get_sitemap."),
|
|
10
|
+
sample_size: z
|
|
11
|
+
.number()
|
|
12
|
+
.min(1)
|
|
13
|
+
.max(50)
|
|
14
|
+
.optional()
|
|
15
|
+
.describe("URLs to inspect (1-50). Default: 5 for sample, 20 for coverage."),
|
|
16
|
+
sample_strategy: z
|
|
17
|
+
.enum(["random", "first", "spread"])
|
|
18
|
+
.optional()
|
|
19
|
+
.describe("Sampling strategy. Default: 'spread' for coverage, 'random' for sample."),
|
|
20
|
+
filter: z
|
|
21
|
+
.enum([
|
|
22
|
+
"not_indexed",
|
|
23
|
+
"server_error",
|
|
24
|
+
"redirect",
|
|
25
|
+
"soft_404",
|
|
26
|
+
"blocked",
|
|
27
|
+
"duplicate",
|
|
28
|
+
"discovered",
|
|
29
|
+
"crawled_not_indexed",
|
|
30
|
+
])
|
|
31
|
+
.optional()
|
|
32
|
+
.describe("Filter sample results by coverage issue type. Inspects more URLs internally to find matches."),
|
|
33
|
+
start_date: z.string().optional().describe("Start date (YYYY-MM-DD) for analytics. Default: 28 days ago."),
|
|
34
|
+
end_date: z.string().optional().describe("End date (YYYY-MM-DD) for analytics. Default: 3 days ago."),
|
|
35
|
+
dimensions: z
|
|
36
|
+
.array(z.enum(["query", "page", "country", "device", "date", "searchAppearance", "hour"]))
|
|
37
|
+
.optional()
|
|
38
|
+
.describe("Analytics dimensions. Default: auto."),
|
|
39
|
+
search_type: z
|
|
40
|
+
.enum(["web", "image", "video", "news", "discover", "googleNews"])
|
|
41
|
+
.optional()
|
|
42
|
+
.describe("Search type for analytics."),
|
|
43
|
+
data_state: z.enum(["all", "final", "hourly_all"]).optional().describe("Data freshness for analytics."),
|
|
44
|
+
aggregation_type: z
|
|
45
|
+
.enum(["auto", "byPage", "byProperty", "byNewsShowcasePanel"])
|
|
46
|
+
.optional()
|
|
47
|
+
.describe("Aggregation mode for analytics."),
|
|
48
|
+
filters: z
|
|
49
|
+
.array(
|
|
50
|
+
z.object({
|
|
51
|
+
dimension: z.enum(["query", "page", "country", "device", "searchAppearance"]),
|
|
52
|
+
operator: z.enum(["equals", "contains", "notEquals", "notContains", "includingRegex", "excludingRegex"]),
|
|
53
|
+
expression: z.string(),
|
|
54
|
+
}),
|
|
55
|
+
)
|
|
56
|
+
.optional()
|
|
57
|
+
.describe("Dimension filters for analytics."),
|
|
58
|
+
row_limit: z.number().optional().describe("Max rows for analytics (1-25000). Default: 1000."),
|
|
59
|
+
start_row: z.number().optional().describe("Pagination offset for analytics."),
|
|
60
|
+
compare: z.boolean().optional().describe("Compare current vs previous period for analytics."),
|
|
61
|
+
};
|
|
62
|
+
export type SearchOptions = z.infer<z.ZodObject<typeof searchSchema>>;
|
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
export interface SitemapParseResult {
|
|
1
|
+
interface SitemapParseResult {
|
|
4
2
|
urls: string[];
|
|
5
3
|
isSitemapIndex: boolean;
|
|
6
4
|
childSitemaps: string[];
|
|
@@ -70,45 +68,3 @@ export function sampleUrls(urls: string[], count: number, strategy: string): str
|
|
|
70
68
|
}
|
|
71
69
|
return shuffled.slice(0, count);
|
|
72
70
|
}
|
|
73
|
-
|
|
74
|
-
export interface InspectionSummary {
|
|
75
|
-
url: string;
|
|
76
|
-
verdict: string;
|
|
77
|
-
coverageState: string;
|
|
78
|
-
pageFetchState: string;
|
|
79
|
-
robotsTxtState: string;
|
|
80
|
-
indexingState: string;
|
|
81
|
-
lastCrawlTime: string | null;
|
|
82
|
-
googleCanonical: string | null;
|
|
83
|
-
error: string | null;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
export async function inspectSingle(url: string, siteUrl: string): Promise<InspectionSummary> {
|
|
87
|
-
try {
|
|
88
|
-
const r = await inspectUrl(url, siteUrl);
|
|
89
|
-
const idx = r.indexStatusResult;
|
|
90
|
-
return {
|
|
91
|
-
url,
|
|
92
|
-
verdict: idx.verdict,
|
|
93
|
-
coverageState: idx.coverageState,
|
|
94
|
-
pageFetchState: idx.pageFetchState,
|
|
95
|
-
robotsTxtState: idx.robotsTxtState,
|
|
96
|
-
indexingState: idx.indexingState,
|
|
97
|
-
lastCrawlTime: idx.lastCrawlTime ?? null,
|
|
98
|
-
googleCanonical: idx.googleCanonical ?? null,
|
|
99
|
-
error: null,
|
|
100
|
-
};
|
|
101
|
-
} catch (err) {
|
|
102
|
-
return {
|
|
103
|
-
url,
|
|
104
|
-
verdict: "ERROR",
|
|
105
|
-
coverageState: "ERROR",
|
|
106
|
-
pageFetchState: "ERROR",
|
|
107
|
-
robotsTxtState: "ERROR",
|
|
108
|
-
indexingState: "ERROR",
|
|
109
|
-
lastCrawlTime: null,
|
|
110
|
-
googleCanonical: null,
|
|
111
|
-
error: err instanceof Error ? err.message : String(err),
|
|
112
|
-
};
|
|
113
|
-
}
|
|
114
|
-
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { type GscSite, type GscSitemap, getSite, getSitemap, listSitemaps, listSites } from "../../providers/gsc.js";
|
|
2
|
+
import { type SearchOptions } from "./schema.js";
|
|
3
|
+
import { textResult } from "./result.js";
|
|
4
|
+
function formatSites(sites: GscSite[]): string {
|
|
5
|
+
if (sites.length === 0) return "No Search Console properties found.";
|
|
6
|
+
|
|
7
|
+
const lines: string[] = [`=== GSC Properties (${sites.length}) ===`, ""];
|
|
8
|
+
for (const site of sites) {
|
|
9
|
+
lines.push(`${site.siteUrl} (${site.permissionLevel})`);
|
|
10
|
+
}
|
|
11
|
+
return lines.join("\n");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function formatSite(site: GscSite): string {
|
|
15
|
+
return [`=== Site: ${site.siteUrl} ===`, "", `Permission: ${site.permissionLevel}`].join("\n");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function formatSitemapDetail(sm: GscSitemap): string {
|
|
19
|
+
const lines: string[] = [`=== Sitemap: ${sm.path} ===`, ""];
|
|
20
|
+
if (sm.type) lines.push(`Type: ${sm.type}`);
|
|
21
|
+
if (sm.lastSubmitted) lines.push(`Submitted: ${sm.lastSubmitted}`);
|
|
22
|
+
if (sm.lastDownloaded) lines.push(`Downloaded: ${sm.lastDownloaded}`);
|
|
23
|
+
lines.push(`Pending: ${sm.isPending}`);
|
|
24
|
+
lines.push(`Index: ${sm.isSitemapsIndex}`);
|
|
25
|
+
if (sm.warnings) lines.push(`Warnings: ${sm.warnings}`);
|
|
26
|
+
if (sm.errors) lines.push(`Errors: ${sm.errors}`);
|
|
27
|
+
if (sm.contents && sm.contents.length > 0) {
|
|
28
|
+
lines.push("", "Contents:");
|
|
29
|
+
for (const c of sm.contents) {
|
|
30
|
+
lines.push(` ${c.type}: ${c.submitted ?? "?"} submitted; indexed count unavailable (deprecated API field)`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return lines.join("\n");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function formatSitemaps(siteUrl: string, sitemaps: GscSitemap[]): string {
|
|
37
|
+
if (sitemaps.length === 0) return `No sitemaps found for ${siteUrl}.`;
|
|
38
|
+
|
|
39
|
+
const lines: string[] = [`=== Sitemaps: ${siteUrl} (${sitemaps.length}) ===`, ""];
|
|
40
|
+
|
|
41
|
+
for (const sm of sitemaps) {
|
|
42
|
+
lines.push(`${sm.path}`);
|
|
43
|
+
if (sm.type) lines.push(` Type: ${sm.type}`);
|
|
44
|
+
if (sm.lastSubmitted) lines.push(` Submitted: ${sm.lastSubmitted}`);
|
|
45
|
+
if (sm.lastDownloaded) lines.push(` Downloaded: ${sm.lastDownloaded}`);
|
|
46
|
+
lines.push(` Pending: ${sm.isPending}`);
|
|
47
|
+
lines.push(` Index: ${sm.isSitemapsIndex}`);
|
|
48
|
+
if (sm.warnings) lines.push(` Warnings: ${sm.warnings}`);
|
|
49
|
+
if (sm.errors) lines.push(` Errors: ${sm.errors}`);
|
|
50
|
+
if (sm.contents) {
|
|
51
|
+
for (const c of sm.contents) {
|
|
52
|
+
lines.push(` ${c.type}: ${c.submitted ?? "?"} submitted; indexed count unavailable (deprecated API field)`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
lines.push("");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return lines.join("\n").trimEnd();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function runListSites() {
|
|
62
|
+
const sites = await listSites();
|
|
63
|
+
return textResult(formatSites(sites));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function runGetSite(options: SearchOptions) {
|
|
67
|
+
const { site_url } = options;
|
|
68
|
+
if (!site_url) return textResult("Error: site_url is required for get_site.");
|
|
69
|
+
const site = await getSite(site_url);
|
|
70
|
+
return textResult(formatSite(site));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function runGetSitemap(options: SearchOptions) {
|
|
74
|
+
const { site_url, sitemap_url } = options;
|
|
75
|
+
if (!site_url) return textResult("Error: site_url is required for get_sitemap.");
|
|
76
|
+
if (!sitemap_url) return textResult("Error: sitemap_url is required for get_sitemap.");
|
|
77
|
+
const sm = await getSitemap(site_url, sitemap_url);
|
|
78
|
+
return textResult(formatSitemapDetail(sm));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function runSitemaps(options: SearchOptions) {
|
|
82
|
+
const { site_url } = options;
|
|
83
|
+
if (!site_url) return textResult("Error: site_url is required for sitemaps.");
|
|
84
|
+
const sitemaps = await listSitemaps(site_url);
|
|
85
|
+
return textResult(formatSitemaps(site_url, sitemaps));
|
|
86
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { searchSchema } from "./schema.js";
|
|
3
|
+
import { runSearch } from "./actions.js";
|
|
4
|
+
export function registerSearchTool(server: McpServer): void {
|
|
5
|
+
server.tool(
|
|
6
|
+
"search",
|
|
7
|
+
"Query Google Search Console. Inspect URL indexing, get coverage breakdown by issue type, sample-inspect with filters, list properties and sitemaps, analyze search traffic, or find keyword gaps.",
|
|
8
|
+
searchSchema,
|
|
9
|
+
runSearch,
|
|
10
|
+
);
|
|
11
|
+
}
|
package/src/tools/setup.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import { exchangeCodeForToken, getAuthMethod, getOAuthSetupUrl } from "../
|
|
3
|
+
import { exchangeCodeForToken, getAuthMethod, getOAuthSetupUrl } from "../providers/gsc-auth.js";
|
|
4
4
|
|
|
5
5
|
export function registerSetupTool(server: McpServer): void {
|
|
6
6
|
server.tool(
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import type { SpeedOptions } from "./schema.js";
|
|
2
|
+
import { hasApiKey, type PsiCategoryType, type PsiResult, runPagespeed } from "../../providers/pagespeed.js";
|
|
3
|
+
import { type CruxFormFactor, queryCrux, queryCruxHistory } from "../../providers/crux.js";
|
|
4
|
+
import { QUOTA_NOTE, formatPagespeed } from "./pagespeed.js";
|
|
5
|
+
import { formatBatchCompare, formatBatchTable, runBatch } from "./batch.js";
|
|
6
|
+
import { formatCrux, formatCruxHistory } from "./crux.js";
|
|
7
|
+
|
|
8
|
+
export async function analyzeSpeed({
|
|
9
|
+
action,
|
|
10
|
+
url,
|
|
11
|
+
urls,
|
|
12
|
+
strategy,
|
|
13
|
+
categories,
|
|
14
|
+
locale,
|
|
15
|
+
origin,
|
|
16
|
+
form_factor,
|
|
17
|
+
metrics,
|
|
18
|
+
periods,
|
|
19
|
+
}: SpeedOptions) {
|
|
20
|
+
// Determine which action to run
|
|
21
|
+
let resolvedAction = action;
|
|
22
|
+
if (!resolvedAction) {
|
|
23
|
+
if (urls) {
|
|
24
|
+
resolvedAction = "pagespeed";
|
|
25
|
+
} else if (origin && !url) {
|
|
26
|
+
resolvedAction = "crux";
|
|
27
|
+
} else if (periods) {
|
|
28
|
+
resolvedAction = "crux_history";
|
|
29
|
+
} else {
|
|
30
|
+
resolvedAction = "pagespeed";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// --- PageSpeed action ---
|
|
35
|
+
if (resolvedAction === "pagespeed") {
|
|
36
|
+
const strat = (strategy as "mobile" | "desktop") ?? "mobile";
|
|
37
|
+
const cats = categories as PsiCategoryType[] | undefined;
|
|
38
|
+
const opts = { strategy: strat, categories: cats, locale };
|
|
39
|
+
|
|
40
|
+
if (url && urls) {
|
|
41
|
+
return {
|
|
42
|
+
content: [{ type: "text" as const, text: "Error: provide either 'url' (single) or 'urls' (batch), not both." }],
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
if (!url && !urls) {
|
|
46
|
+
return {
|
|
47
|
+
content: [
|
|
48
|
+
{ type: "text" as const, text: "Error: provide 'url' for single analysis or 'urls' for batch analysis." },
|
|
49
|
+
],
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Single URL
|
|
54
|
+
if (url) {
|
|
55
|
+
try {
|
|
56
|
+
const result = await runPagespeed(url, opts);
|
|
57
|
+
const text = formatPagespeed(url, result) + (hasApiKey() ? "" : QUOTA_NOTE);
|
|
58
|
+
return { content: [{ type: "text" as const, text }] };
|
|
59
|
+
} catch (err) {
|
|
60
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
61
|
+
return { content: [{ type: "text" as const, text: `Error running PageSpeed analysis: ${msg}` }] };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Batch mode
|
|
66
|
+
const batchUrls = urls as string[];
|
|
67
|
+
const results = await runBatch(batchUrls, opts);
|
|
68
|
+
|
|
69
|
+
const successes = results.filter((r): r is { url: string; result: PsiResult } => !!r.result);
|
|
70
|
+
const failures = results.filter((r): r is { url: string; error: string } => !!r.error);
|
|
71
|
+
|
|
72
|
+
if (successes.length === 0) {
|
|
73
|
+
const errorLines = failures.map((f) => `${f.url}: ${f.error}`);
|
|
74
|
+
return { content: [{ type: "text" as const, text: `All URLs failed:\n${errorLines.join("\n")}` }] };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let output: string;
|
|
78
|
+
if (successes.length === 2) {
|
|
79
|
+
output = formatBatchCompare(successes, strat);
|
|
80
|
+
} else {
|
|
81
|
+
output = formatBatchTable(successes, strat);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (failures.length > 0) {
|
|
85
|
+
const errorLines = failures.map((f) => `${f.url}: ${f.error}`);
|
|
86
|
+
output += `\n\n--- Errors ---\n${errorLines.join("\n")}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!hasApiKey()) output += QUOTA_NOTE;
|
|
90
|
+
|
|
91
|
+
return { content: [{ type: "text" as const, text: output }] };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// --- CrUX action ---
|
|
95
|
+
if (resolvedAction === "crux") {
|
|
96
|
+
const cruxUrl = url;
|
|
97
|
+
const cruxOrigin = origin;
|
|
98
|
+
|
|
99
|
+
if (!cruxUrl && !cruxOrigin) {
|
|
100
|
+
return { content: [{ type: "text" as const, text: "Error: provide url or origin for CrUX data." }] };
|
|
101
|
+
}
|
|
102
|
+
if (cruxUrl && cruxOrigin) {
|
|
103
|
+
return { content: [{ type: "text" as const, text: "Error: provide url or origin, not both." }] };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
const result = await queryCrux({
|
|
108
|
+
url: cruxUrl,
|
|
109
|
+
origin: cruxOrigin,
|
|
110
|
+
formFactor: form_factor as CruxFormFactor | undefined,
|
|
111
|
+
metrics,
|
|
112
|
+
});
|
|
113
|
+
return { content: [{ type: "text" as const, text: formatCrux(cruxUrl ?? cruxOrigin ?? "", result) }] };
|
|
114
|
+
} catch (err) {
|
|
115
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
116
|
+
if (msg.includes("404")) {
|
|
117
|
+
const target = cruxUrl ?? cruxOrigin ?? "";
|
|
118
|
+
const lines = [`No CrUX data for ${target}.`, ""];
|
|
119
|
+
lines.push("CrUX requires sufficient Chrome user traffic (roughly 1,000+ monthly visits).");
|
|
120
|
+
if (cruxUrl) {
|
|
121
|
+
const originUrl = new URL(cruxUrl).origin;
|
|
122
|
+
lines.push(`Try origin-level data instead: origin "${originUrl}"`);
|
|
123
|
+
}
|
|
124
|
+
lines.push("For lab metrics without traffic requirements, use speed with a url instead.");
|
|
125
|
+
return { content: [{ type: "text" as const, text: lines.join("\n") }] };
|
|
126
|
+
}
|
|
127
|
+
if (msg.includes("SERVICE_DISABLED") || msg.includes("API_KEY_SERVICE_BLOCKED")) {
|
|
128
|
+
return {
|
|
129
|
+
content: [
|
|
130
|
+
{
|
|
131
|
+
type: "text" as const,
|
|
132
|
+
text: "Chrome UX Report API is not enabled or the API key doesn't have access. Enable the API at: https://console.cloud.google.com/apis/library/chromeuxreport.googleapis.com — and ensure your API key allows it (Credentials > API key > API restrictions).",
|
|
133
|
+
},
|
|
134
|
+
],
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
return { content: [{ type: "text" as const, text: `Error querying CrUX: ${msg}` }] };
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// --- CrUX History action ---
|
|
142
|
+
if (resolvedAction === "crux_history") {
|
|
143
|
+
const histUrl = url;
|
|
144
|
+
const histOrigin = origin;
|
|
145
|
+
|
|
146
|
+
if (!histUrl && !histOrigin) {
|
|
147
|
+
return { content: [{ type: "text" as const, text: "Error: provide url or origin for CrUX history." }] };
|
|
148
|
+
}
|
|
149
|
+
if (histUrl && histOrigin) {
|
|
150
|
+
return { content: [{ type: "text" as const, text: "Error: provide url or origin, not both." }] };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
const result = await queryCruxHistory({
|
|
155
|
+
url: histUrl,
|
|
156
|
+
origin: histOrigin,
|
|
157
|
+
formFactor: form_factor as CruxFormFactor | undefined,
|
|
158
|
+
metrics,
|
|
159
|
+
collectionPeriodCount: periods,
|
|
160
|
+
});
|
|
161
|
+
return { content: [{ type: "text" as const, text: formatCruxHistory(histUrl ?? histOrigin ?? "", result) }] };
|
|
162
|
+
} catch (err) {
|
|
163
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
164
|
+
if (msg.includes("404")) {
|
|
165
|
+
const target = histUrl ?? histOrigin ?? "";
|
|
166
|
+
const lines = [`No CrUX history data for ${target}.`, ""];
|
|
167
|
+
lines.push("CrUX requires sufficient Chrome user traffic (roughly 1,000+ monthly visits).");
|
|
168
|
+
if (histUrl) {
|
|
169
|
+
const originUrl = new URL(histUrl).origin;
|
|
170
|
+
lines.push(`Try origin-level data instead: origin "${originUrl}"`);
|
|
171
|
+
}
|
|
172
|
+
lines.push("For lab metrics without traffic requirements, use speed with a url instead.");
|
|
173
|
+
return { content: [{ type: "text" as const, text: lines.join("\n") }] };
|
|
174
|
+
}
|
|
175
|
+
if (msg.includes("SERVICE_DISABLED")) {
|
|
176
|
+
return {
|
|
177
|
+
content: [
|
|
178
|
+
{
|
|
179
|
+
type: "text" as const,
|
|
180
|
+
text: "Chrome UX Report API is not enabled. Enable it at: https://console.cloud.google.com/apis/library/chromeuxreport.googleapis.com",
|
|
181
|
+
},
|
|
182
|
+
],
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
return { content: [{ type: "text" as const, text: `Error querying CrUX History: ${msg}` }] };
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const unhandled: never = resolvedAction;
|
|
190
|
+
return { content: [{ type: "text" as const, text: `Unknown action: ${String(unhandled)}` }] };
|
|
191
|
+
}
|