pagesight 0.5.2 → 0.7.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/package.json +1 -1
- package/src/index.ts +2 -0
- package/src/tools/audit.ts +330 -0
- package/src/tools/performance.ts +177 -20
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
3
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { registerAuditTool } from "./tools/audit.js";
|
|
4
5
|
import { registerCruxTool } from "./tools/crux.js";
|
|
5
6
|
import { registerInspectTool } from "./tools/inspect.js";
|
|
6
7
|
import { registerMetatagsTool } from "./tools/metatags.js";
|
|
@@ -18,6 +19,7 @@ const server = new McpServer({
|
|
|
18
19
|
version: pkg.version,
|
|
19
20
|
});
|
|
20
21
|
|
|
22
|
+
registerAuditTool(server);
|
|
21
23
|
registerCruxTool(server);
|
|
22
24
|
registerInspectTool(server);
|
|
23
25
|
registerMetatagsTool(server);
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { inspectUrl, listSitemaps } from "../lib/gsc.js";
|
|
4
|
+
import { type PsiCategoryType, type PsiResult, runPagespeed } from "../lib/psi.js";
|
|
5
|
+
import { auditAiCrawlers, fetchRobotsTxt, isAllowed } from "../lib/robots.js";
|
|
6
|
+
|
|
7
|
+
type Severity = "HIGH" | "MEDIUM" | "LOW";
|
|
8
|
+
|
|
9
|
+
interface Finding {
|
|
10
|
+
severity: Severity;
|
|
11
|
+
message: string;
|
|
12
|
+
source: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// --- Metatags (inline, no external dep needed) ---
|
|
16
|
+
|
|
17
|
+
interface MetaCheckResult {
|
|
18
|
+
title: string | null;
|
|
19
|
+
description: string | null;
|
|
20
|
+
canonical: string | null;
|
|
21
|
+
ogImage: string | null;
|
|
22
|
+
ogTitle: string | null;
|
|
23
|
+
twitterCard: string | null;
|
|
24
|
+
hasJsonLd: boolean;
|
|
25
|
+
redirectCount: number;
|
|
26
|
+
finalUrl: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function checkMeta(url: string): Promise<MetaCheckResult> {
|
|
30
|
+
let current = url;
|
|
31
|
+
let redirectCount = 0;
|
|
32
|
+
|
|
33
|
+
for (let i = 0; i < 10; i++) {
|
|
34
|
+
const res = await fetch(current, {
|
|
35
|
+
headers: { "User-Agent": "Mozilla/5.0 (compatible; Googlebot/2.1)", Accept: "text/html" },
|
|
36
|
+
redirect: "manual",
|
|
37
|
+
});
|
|
38
|
+
if (res.status >= 300 && res.status < 400) {
|
|
39
|
+
const location = res.headers.get("location");
|
|
40
|
+
if (!location) break;
|
|
41
|
+
current = new URL(location, current).href;
|
|
42
|
+
redirectCount++;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const html = await res.text();
|
|
47
|
+
const head = html.match(/<head[^>]*>([\s\S]*?)<\/head>/i)?.[1] ?? html;
|
|
48
|
+
|
|
49
|
+
const getMeta = (key: string) => {
|
|
50
|
+
const nameMatch = head.match(new RegExp(`<meta[^>]*name=["']${key}["'][^>]*content=["']([^"']*?)["']`, "i"));
|
|
51
|
+
const propMatch = head.match(new RegExp(`<meta[^>]*property=["']${key}["'][^>]*content=["']([^"']*?)["']`, "i"));
|
|
52
|
+
return nameMatch?.[1] ?? propMatch?.[1] ?? null;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
title: head.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1]?.trim() ?? null,
|
|
57
|
+
description: getMeta("description"),
|
|
58
|
+
canonical: head.match(/<link[^>]*rel=["']canonical["'][^>]*href=["']([^"']+)["']/i)?.[1] ?? null,
|
|
59
|
+
ogImage: getMeta("og:image"),
|
|
60
|
+
ogTitle: getMeta("og:title"),
|
|
61
|
+
twitterCard: getMeta("twitter:card"),
|
|
62
|
+
hasJsonLd: /<script[^>]*type=["']application\/ld\+json["']/i.test(html),
|
|
63
|
+
redirectCount,
|
|
64
|
+
finalUrl: current,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Exhausted redirects
|
|
69
|
+
return {
|
|
70
|
+
title: null,
|
|
71
|
+
description: null,
|
|
72
|
+
canonical: null,
|
|
73
|
+
ogImage: null,
|
|
74
|
+
ogTitle: null,
|
|
75
|
+
twitterCard: null,
|
|
76
|
+
hasJsonLd: false,
|
|
77
|
+
redirectCount,
|
|
78
|
+
finalUrl: current,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// --- Audit logic ---
|
|
83
|
+
|
|
84
|
+
function addMetaFindings(meta: MetaCheckResult, findings: Finding[]) {
|
|
85
|
+
if (!meta.title) findings.push({ severity: "HIGH", message: "Missing <title> tag", source: "metatags" });
|
|
86
|
+
if (!meta.description) findings.push({ severity: "HIGH", message: "Missing meta description", source: "metatags" });
|
|
87
|
+
if (!meta.canonical) findings.push({ severity: "HIGH", message: "Missing canonical URL", source: "metatags" });
|
|
88
|
+
if (!meta.ogImage)
|
|
89
|
+
findings.push({ severity: "MEDIUM", message: "Missing og:image — no social preview image", source: "metatags" });
|
|
90
|
+
if (!meta.ogTitle) findings.push({ severity: "MEDIUM", message: "Missing Open Graph tags", source: "metatags" });
|
|
91
|
+
if (!meta.twitterCard) findings.push({ severity: "LOW", message: "Missing Twitter Card tags", source: "metatags" });
|
|
92
|
+
if (!meta.hasJsonLd)
|
|
93
|
+
findings.push({ severity: "LOW", message: "No structured data (JSON-LD) found", source: "metatags" });
|
|
94
|
+
if (meta.redirectCount > 2)
|
|
95
|
+
findings.push({
|
|
96
|
+
severity: "MEDIUM",
|
|
97
|
+
message: `Redirect chain has ${meta.redirectCount} hops — consider reducing`,
|
|
98
|
+
source: "metatags",
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function addPagespeedFindings(result: PsiResult, findings: Finding[]) {
|
|
103
|
+
const lhr = result.lighthouseResult;
|
|
104
|
+
|
|
105
|
+
for (const cat of Object.values(lhr.categories)) {
|
|
106
|
+
const score = cat.score !== null ? Math.round(cat.score * 100) : null;
|
|
107
|
+
if (score === null) continue;
|
|
108
|
+
|
|
109
|
+
if (cat.id === "performance" && score < 50) {
|
|
110
|
+
findings.push({ severity: "HIGH", message: `Performance score: ${score}/100`, source: "pagespeed" });
|
|
111
|
+
} else if (cat.id === "performance" && score < 90) {
|
|
112
|
+
findings.push({ severity: "MEDIUM", message: `Performance score: ${score}/100`, source: "pagespeed" });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (cat.id === "accessibility" && score < 90) {
|
|
116
|
+
const sev = score < 70 ? "HIGH" : "MEDIUM";
|
|
117
|
+
findings.push({ severity: sev, message: `Accessibility score: ${score}/100`, source: "pagespeed" });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (cat.id === "seo" && score < 90) {
|
|
121
|
+
findings.push({ severity: "MEDIUM", message: `SEO score: ${score}/100`, source: "pagespeed" });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Flag specific high-impact audits
|
|
126
|
+
const renderBlocking = lhr.audits["render-blocking-insight"] ?? lhr.audits["render-blocking-resources"];
|
|
127
|
+
if (renderBlocking?.score === 0 && renderBlocking.details?.items) {
|
|
128
|
+
const fontItems = renderBlocking.details.items.filter((i) => i.url && String(i.url).includes("fonts"));
|
|
129
|
+
if (fontItems.length > 0) {
|
|
130
|
+
const wastedMs = fontItems.reduce((sum, i) => sum + (i.wastedMs ? Number(i.wastedMs) : 0), 0);
|
|
131
|
+
findings.push({
|
|
132
|
+
severity: "MEDIUM",
|
|
133
|
+
message: `Render-blocking Google Fonts (${Math.round(wastedMs)}ms wasted)`,
|
|
134
|
+
source: "pagespeed",
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// FCP / LCP
|
|
140
|
+
const fcp = lhr.audits["first-contentful-paint"];
|
|
141
|
+
if (fcp?.numericValue && fcp.numericValue > 2500) {
|
|
142
|
+
findings.push({
|
|
143
|
+
severity: "MEDIUM",
|
|
144
|
+
message: `FCP ${(fcp.numericValue / 1000).toFixed(1)}s — above 2.5s threshold`,
|
|
145
|
+
source: "pagespeed",
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const lcp = lhr.audits["largest-contentful-paint"];
|
|
150
|
+
if (lcp?.numericValue && lcp.numericValue > 2500) {
|
|
151
|
+
findings.push({
|
|
152
|
+
severity: lcp.numericValue > 4000 ? "HIGH" : "MEDIUM",
|
|
153
|
+
message: `LCP ${(lcp.numericValue / 1000).toFixed(1)}s — above 2.5s threshold`,
|
|
154
|
+
source: "pagespeed",
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function addRobotsFindings(
|
|
160
|
+
robotsTxt: string | null,
|
|
161
|
+
blockedAiCrawlers: number,
|
|
162
|
+
totalAiCrawlers: number,
|
|
163
|
+
googlebotAllowed: boolean,
|
|
164
|
+
findings: Finding[],
|
|
165
|
+
) {
|
|
166
|
+
if (robotsTxt === null) {
|
|
167
|
+
findings.push({ severity: "LOW", message: "No robots.txt found", source: "robots" });
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (!googlebotAllowed) {
|
|
171
|
+
findings.push({ severity: "HIGH", message: "Googlebot is blocked by robots.txt", source: "robots" });
|
|
172
|
+
}
|
|
173
|
+
if (blockedAiCrawlers > 0) {
|
|
174
|
+
findings.push({
|
|
175
|
+
severity: "LOW",
|
|
176
|
+
message: `${blockedAiCrawlers}/${totalAiCrawlers} AI crawlers blocked`,
|
|
177
|
+
source: "robots",
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function addSitemapFindings(sitemapCount: number, totalSubmitted: number, totalIndexed: number, findings: Finding[]) {
|
|
183
|
+
if (sitemapCount === 0) {
|
|
184
|
+
findings.push({ severity: "MEDIUM", message: "No sitemaps submitted to GSC", source: "sitemaps" });
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (totalSubmitted > 0 && totalIndexed === 0) {
|
|
188
|
+
findings.push({
|
|
189
|
+
severity: "HIGH",
|
|
190
|
+
message: `${totalSubmitted.toLocaleString()} sitemap URLs submitted, 0 indexed`,
|
|
191
|
+
source: "sitemaps",
|
|
192
|
+
});
|
|
193
|
+
} else if (totalSubmitted > 0) {
|
|
194
|
+
const pct = Math.round((totalIndexed / totalSubmitted) * 100);
|
|
195
|
+
if (pct < 50) {
|
|
196
|
+
findings.push({
|
|
197
|
+
severity: "HIGH",
|
|
198
|
+
message: `${totalSubmitted.toLocaleString()} sitemap URLs, only ${pct}% indexed (${totalIndexed.toLocaleString()})`,
|
|
199
|
+
source: "sitemaps",
|
|
200
|
+
});
|
|
201
|
+
} else if (pct < 80) {
|
|
202
|
+
findings.push({
|
|
203
|
+
severity: "MEDIUM",
|
|
204
|
+
message: `${totalSubmitted.toLocaleString()} sitemap URLs, ${pct}% indexed (${totalIndexed.toLocaleString()})`,
|
|
205
|
+
source: "sitemaps",
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function addInspectFindings(verdict: string, coverageState: string, findings: Finding[]) {
|
|
212
|
+
if (verdict === "FAIL") {
|
|
213
|
+
findings.push({ severity: "HIGH", message: `URL not indexed: ${coverageState}`, source: "inspect" });
|
|
214
|
+
} else if (verdict !== "PASS") {
|
|
215
|
+
findings.push({ severity: "MEDIUM", message: `Index status: ${coverageState}`, source: "inspect" });
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function formatAudit(url: string, findings: Finding[], errors: string[]): string {
|
|
220
|
+
const lines: string[] = [`=== Site Audit: ${url} ===`, ""];
|
|
221
|
+
|
|
222
|
+
// Sort: HIGH first, then MEDIUM, then LOW
|
|
223
|
+
const order: Record<string, number> = { HIGH: 0, MEDIUM: 1, LOW: 2 };
|
|
224
|
+
findings.sort((a, b) => order[a.severity] - order[b.severity]);
|
|
225
|
+
|
|
226
|
+
if (findings.length === 0) {
|
|
227
|
+
lines.push("No issues found.");
|
|
228
|
+
} else {
|
|
229
|
+
lines.push(`${findings.length} findings:`, "");
|
|
230
|
+
for (const f of findings) {
|
|
231
|
+
lines.push(`${f.severity.padEnd(6)} ${f.message}`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (errors.length > 0) {
|
|
236
|
+
lines.push("", "--- Errors (partial audit) ---", "");
|
|
237
|
+
for (const e of errors) {
|
|
238
|
+
lines.push(`- ${e}`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return lines.join("\n");
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function registerAuditTool(server: McpServer): void {
|
|
246
|
+
server.tool(
|
|
247
|
+
"audit",
|
|
248
|
+
"Run a comprehensive site audit. Checks PageSpeed scores, meta tags, robots.txt, sitemaps, and index status in one call. Returns a prioritized list of findings.",
|
|
249
|
+
{
|
|
250
|
+
url: z.string().url().describe("The URL to audit (e.g., 'https://example.com')."),
|
|
251
|
+
site_url: z
|
|
252
|
+
.string()
|
|
253
|
+
.optional()
|
|
254
|
+
.describe("GSC property for index/sitemap checks (e.g., 'sc-domain:example.com'). Omit to skip GSC checks."),
|
|
255
|
+
strategy: z.enum(["mobile", "desktop"]).optional().describe("PageSpeed device strategy. Default: 'mobile'."),
|
|
256
|
+
},
|
|
257
|
+
async ({ url, site_url, strategy }) => {
|
|
258
|
+
const findings: Finding[] = [];
|
|
259
|
+
const errors: string[] = [];
|
|
260
|
+
const origin = new URL(url).origin;
|
|
261
|
+
|
|
262
|
+
// Run independent checks in parallel
|
|
263
|
+
const [metaResult, pagespeedResult, robotsResult, sitemapResult, inspectResult] = await Promise.allSettled([
|
|
264
|
+
checkMeta(url),
|
|
265
|
+
runPagespeed(url, {
|
|
266
|
+
strategy: (strategy as "mobile" | "desktop") ?? "mobile",
|
|
267
|
+
categories: ["performance", "accessibility", "seo", "best-practices"] as PsiCategoryType[],
|
|
268
|
+
}),
|
|
269
|
+
fetchRobotsTxt(origin).then(async ({ robotsTxt }) => {
|
|
270
|
+
const crawlers = await auditAiCrawlers(robotsTxt);
|
|
271
|
+
const { allowed: googlebotAllowed } = isAllowed(robotsTxt, "Googlebot", "/");
|
|
272
|
+
return { robotsTxt, crawlers, googlebotAllowed };
|
|
273
|
+
}),
|
|
274
|
+
site_url ? listSitemaps(site_url) : Promise.resolve(null),
|
|
275
|
+
site_url ? inspectUrl(url, site_url).catch(() => null) : Promise.resolve(null),
|
|
276
|
+
]);
|
|
277
|
+
|
|
278
|
+
// Process meta
|
|
279
|
+
if (metaResult.status === "fulfilled") {
|
|
280
|
+
addMetaFindings(metaResult.value, findings);
|
|
281
|
+
} else {
|
|
282
|
+
errors.push(`Meta tags: ${metaResult.reason}`);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Process pagespeed
|
|
286
|
+
if (pagespeedResult.status === "fulfilled") {
|
|
287
|
+
addPagespeedFindings(pagespeedResult.value, findings);
|
|
288
|
+
} else {
|
|
289
|
+
errors.push(`PageSpeed: ${pagespeedResult.reason}`);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Process robots
|
|
293
|
+
if (robotsResult.status === "fulfilled") {
|
|
294
|
+
const { robotsTxt, crawlers, googlebotAllowed } = robotsResult.value;
|
|
295
|
+
const blocked = crawlers.filter((c) => !c.allowed).length;
|
|
296
|
+
addRobotsFindings(robotsTxt.raw || null, blocked, crawlers.length, googlebotAllowed, findings);
|
|
297
|
+
} else {
|
|
298
|
+
errors.push(`Robots.txt: ${robotsResult.reason}`);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Process sitemaps
|
|
302
|
+
if (sitemapResult.status === "fulfilled" && sitemapResult.value) {
|
|
303
|
+
const sitemaps = sitemapResult.value;
|
|
304
|
+
let totalSubmitted = 0;
|
|
305
|
+
let totalIndexed = 0;
|
|
306
|
+
for (const sm of sitemaps) {
|
|
307
|
+
for (const c of sm.contents ?? []) {
|
|
308
|
+
totalSubmitted += Number(c.submitted ?? 0);
|
|
309
|
+
totalIndexed += Number(c.indexed ?? 0);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
addSitemapFindings(sitemaps.length, totalSubmitted, totalIndexed, findings);
|
|
313
|
+
} else if (sitemapResult.status === "rejected") {
|
|
314
|
+
errors.push(`Sitemaps: ${sitemapResult.reason}`);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Process inspect
|
|
318
|
+
if (inspectResult.status === "fulfilled" && inspectResult.value) {
|
|
319
|
+
const idx = inspectResult.value.indexStatusResult;
|
|
320
|
+
addInspectFindings(idx.verdict, idx.coverageState, findings);
|
|
321
|
+
} else if (inspectResult.status === "rejected") {
|
|
322
|
+
errors.push(`Inspect: ${inspectResult.reason}`);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return {
|
|
326
|
+
content: [{ type: "text", text: formatAudit(url, findings, errors) }],
|
|
327
|
+
};
|
|
328
|
+
},
|
|
329
|
+
);
|
|
330
|
+
}
|
package/src/tools/performance.ts
CHANGED
|
@@ -2,6 +2,28 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { querySearchAnalytics, type SearchAnalyticsFilter, type SearchAnalyticsResponse } from "../lib/gsc.js";
|
|
4
4
|
|
|
5
|
+
interface Totals {
|
|
6
|
+
clicks: number;
|
|
7
|
+
impressions: number;
|
|
8
|
+
ctr: number;
|
|
9
|
+
position: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function computeTotals(rows: SearchAnalyticsResponse["rows"]): Totals {
|
|
13
|
+
const r = rows ?? [];
|
|
14
|
+
const clicks = r.reduce((sum, row) => sum + row.clicks, 0);
|
|
15
|
+
const impressions = r.reduce((sum, row) => sum + row.impressions, 0);
|
|
16
|
+
const ctr = impressions > 0 ? clicks / impressions : 0;
|
|
17
|
+
const position = impressions > 0 ? r.reduce((sum, row) => sum + row.position * row.impressions, 0) / impressions : 0;
|
|
18
|
+
return { clicks, impressions, ctr, position };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function pctChange(current: number, previous: number): string {
|
|
22
|
+
if (previous === 0) return current > 0 ? "+∞" : "0%";
|
|
23
|
+
const change = ((current - previous) / previous) * 100;
|
|
24
|
+
return `${change > 0 ? "+" : ""}${change.toFixed(1)}%`;
|
|
25
|
+
}
|
|
26
|
+
|
|
5
27
|
function formatPerformance(
|
|
6
28
|
siteUrl: string,
|
|
7
29
|
result: SearchAnalyticsResponse,
|
|
@@ -32,19 +54,15 @@ function formatPerformance(
|
|
|
32
54
|
return lines.join("\n");
|
|
33
55
|
}
|
|
34
56
|
|
|
35
|
-
const
|
|
36
|
-
const totalImpressions = rows.reduce((sum, r) => sum + r.impressions, 0);
|
|
37
|
-
const avgCtr = totalImpressions > 0 ? totalClicks / totalImpressions : 0;
|
|
38
|
-
const avgPosition =
|
|
39
|
-
totalImpressions > 0 ? rows.reduce((sum, r) => sum + r.position * r.impressions, 0) / totalImpressions : 0;
|
|
57
|
+
const totals = computeTotals(rows);
|
|
40
58
|
|
|
41
59
|
lines.push(
|
|
42
60
|
`--- Summary (${rows.length} rows returned) ---`,
|
|
43
61
|
"",
|
|
44
|
-
`Clicks: ${
|
|
45
|
-
`Impressions: ${
|
|
46
|
-
`Avg CTR: ${(
|
|
47
|
-
`Avg Position: ${
|
|
62
|
+
`Clicks: ${totals.clicks.toLocaleString()}`,
|
|
63
|
+
`Impressions: ${totals.impressions.toLocaleString()}`,
|
|
64
|
+
`Avg CTR: ${(totals.ctr * 100).toFixed(1)}%`,
|
|
65
|
+
`Avg Position: ${totals.position.toFixed(1)}`,
|
|
48
66
|
"",
|
|
49
67
|
"--- Top Results ---",
|
|
50
68
|
"",
|
|
@@ -66,6 +84,103 @@ function formatPerformance(
|
|
|
66
84
|
return lines.join("\n");
|
|
67
85
|
}
|
|
68
86
|
|
|
87
|
+
function formatComparison(
|
|
88
|
+
siteUrl: string,
|
|
89
|
+
current: SearchAnalyticsResponse,
|
|
90
|
+
previous: SearchAnalyticsResponse,
|
|
91
|
+
dimensions: string[],
|
|
92
|
+
currentStart: string,
|
|
93
|
+
currentEnd: string,
|
|
94
|
+
previousStart: string,
|
|
95
|
+
previousEnd: string,
|
|
96
|
+
): string {
|
|
97
|
+
const curRows = current.rows ?? [];
|
|
98
|
+
const prevRows = previous.rows ?? [];
|
|
99
|
+
const cur = computeTotals(curRows);
|
|
100
|
+
const prev = computeTotals(prevRows);
|
|
101
|
+
|
|
102
|
+
const lines: string[] = [
|
|
103
|
+
`=== Search Performance: ${siteUrl} ===`,
|
|
104
|
+
`Current: ${currentStart} to ${currentEnd}`,
|
|
105
|
+
`Previous: ${previousStart} to ${previousEnd}`,
|
|
106
|
+
`Dimensions: ${dimensions.join(", ")}`,
|
|
107
|
+
"",
|
|
108
|
+
"--- Summary ---",
|
|
109
|
+
"",
|
|
110
|
+
` Current Previous Change`,
|
|
111
|
+
`Clicks: ${String(cur.clicks.toLocaleString()).padEnd(12)} ${String(prev.clicks.toLocaleString()).padEnd(12)} ${pctChange(cur.clicks, prev.clicks)}`,
|
|
112
|
+
`Impressions: ${String(cur.impressions.toLocaleString()).padEnd(12)} ${String(prev.impressions.toLocaleString()).padEnd(12)} ${pctChange(cur.impressions, prev.impressions)}`,
|
|
113
|
+
`Avg CTR: ${`${(cur.ctr * 100).toFixed(1)}%`.padEnd(12)} ${`${(prev.ctr * 100).toFixed(1)}%`.padEnd(12)} ${((cur.ctr - prev.ctr) * 100).toFixed(1)}pp`,
|
|
114
|
+
`Avg Position:${String(cur.position.toFixed(1)).padEnd(13)} ${String(prev.position.toFixed(1)).padEnd(12)} ${prev.position === 0 && cur.position > 0 ? "new" : cur.position < prev.position ? "improved" : cur.position > prev.position ? "regressed" : "stable"} (${(cur.position - prev.position).toFixed(1)})`,
|
|
115
|
+
"",
|
|
116
|
+
];
|
|
117
|
+
|
|
118
|
+
// Build lookup for previous period rows
|
|
119
|
+
const prevMap = new Map<string, (typeof prevRows)[0]>();
|
|
120
|
+
for (const row of prevRows) {
|
|
121
|
+
prevMap.set(row.keys.join("|"), row);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Find biggest movers (by click change)
|
|
125
|
+
const movers: Array<{ keys: string[]; curClicks: number; prevClicks: number; curPos: number; prevPos: number }> = [];
|
|
126
|
+
for (const row of curRows) {
|
|
127
|
+
const key = row.keys.join("|");
|
|
128
|
+
const prevRow = prevMap.get(key);
|
|
129
|
+
movers.push({
|
|
130
|
+
keys: row.keys,
|
|
131
|
+
curClicks: row.clicks,
|
|
132
|
+
prevClicks: prevRow?.clicks ?? 0,
|
|
133
|
+
curPos: row.position,
|
|
134
|
+
prevPos: prevRow?.position ?? 0,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Also include rows that disappeared (were in previous but not in current)
|
|
139
|
+
const curKeys = new Set(curRows.map((r) => r.keys.join("|")));
|
|
140
|
+
for (const row of prevRows) {
|
|
141
|
+
const key = row.keys.join("|");
|
|
142
|
+
if (!curKeys.has(key)) {
|
|
143
|
+
movers.push({
|
|
144
|
+
keys: row.keys,
|
|
145
|
+
curClicks: 0,
|
|
146
|
+
prevClicks: row.clicks,
|
|
147
|
+
curPos: 0,
|
|
148
|
+
prevPos: row.position,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Sort by absolute click change descending
|
|
154
|
+
movers.sort((a, b) => Math.abs(b.curClicks - b.prevClicks) - Math.abs(a.curClicks - a.prevClicks));
|
|
155
|
+
|
|
156
|
+
const improved = movers.filter((m) => m.curClicks > m.prevClicks).slice(0, 10);
|
|
157
|
+
const regressed = movers.filter((m) => m.curClicks < m.prevClicks).slice(0, 10);
|
|
158
|
+
|
|
159
|
+
if (improved.length > 0) {
|
|
160
|
+
lines.push("--- Improved ---", "");
|
|
161
|
+
for (const m of improved) {
|
|
162
|
+
const keys = m.keys.map((k, i) => `${dimensions[i] ?? "key"}=${k}`).join(" | ");
|
|
163
|
+
const posChange = m.prevPos > 0 ? ` | Position: ${m.prevPos.toFixed(1)} → ${m.curPos.toFixed(1)}` : "";
|
|
164
|
+
lines.push(`${keys}`);
|
|
165
|
+
lines.push(` Clicks: ${m.prevClicks} → ${m.curClicks} (${pctChange(m.curClicks, m.prevClicks)})${posChange}`);
|
|
166
|
+
}
|
|
167
|
+
lines.push("");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (regressed.length > 0) {
|
|
171
|
+
lines.push("--- Regressed ---", "");
|
|
172
|
+
for (const m of regressed) {
|
|
173
|
+
const keys = m.keys.map((k, i) => `${dimensions[i] ?? "key"}=${k}`).join(" | ");
|
|
174
|
+
const posChange = m.prevPos > 0 ? ` | Position: ${m.prevPos.toFixed(1)} → ${m.curPos.toFixed(1)}` : "";
|
|
175
|
+
lines.push(`${keys}`);
|
|
176
|
+
lines.push(` Clicks: ${m.prevClicks} → ${m.curClicks} (${pctChange(m.curClicks, m.prevClicks)})${posChange}`);
|
|
177
|
+
}
|
|
178
|
+
lines.push("");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return lines.join("\n");
|
|
182
|
+
}
|
|
183
|
+
|
|
69
184
|
function daysAgo(n: number): string {
|
|
70
185
|
// GSC dates are in PT (Pacific Time). Use UTC-8 as a stable approximation.
|
|
71
186
|
const now = new Date(Date.now() - 8 * 60 * 60 * 1000);
|
|
@@ -121,6 +236,12 @@ export function registerPerformanceTool(server: McpServer): void {
|
|
|
121
236
|
.describe("Dimension filters. Combined with AND logic."),
|
|
122
237
|
row_limit: z.number().optional().describe("Max rows (1-25000). Default: 1000."),
|
|
123
238
|
start_row: z.number().optional().describe("Zero-based offset for pagination. Default: 0."),
|
|
239
|
+
compare: z
|
|
240
|
+
.boolean()
|
|
241
|
+
.optional()
|
|
242
|
+
.describe(
|
|
243
|
+
"Compare against the previous period of equal length. Shows click/impression/CTR/position deltas and top movers.",
|
|
244
|
+
),
|
|
124
245
|
},
|
|
125
246
|
async ({
|
|
126
247
|
site_url,
|
|
@@ -133,6 +254,7 @@ export function registerPerformanceTool(server: McpServer): void {
|
|
|
133
254
|
filters,
|
|
134
255
|
row_limit,
|
|
135
256
|
start_row,
|
|
257
|
+
compare,
|
|
136
258
|
}) => {
|
|
137
259
|
const startDate = start_date ?? daysAgo(28);
|
|
138
260
|
const endDate = end_date ?? daysAgo(3);
|
|
@@ -143,19 +265,54 @@ export function registerPerformanceTool(server: McpServer): void {
|
|
|
143
265
|
? [{ groupType: "and" as const, filters: filters as SearchAnalyticsFilter[] }]
|
|
144
266
|
: undefined;
|
|
145
267
|
|
|
268
|
+
const queryOpts = {
|
|
269
|
+
startDate,
|
|
270
|
+
endDate,
|
|
271
|
+
dimensions: dims,
|
|
272
|
+
type: search_type,
|
|
273
|
+
dataState: data_state,
|
|
274
|
+
aggregationType: aggregation_type,
|
|
275
|
+
rowLimit: row_limit ?? 1000,
|
|
276
|
+
startRow: start_row,
|
|
277
|
+
dimensionFilterGroups: filterGroups,
|
|
278
|
+
};
|
|
279
|
+
|
|
146
280
|
try {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
281
|
+
if (compare) {
|
|
282
|
+
// Calculate previous period of equal length
|
|
283
|
+
const start = new Date(startDate);
|
|
284
|
+
const end = new Date(endDate);
|
|
285
|
+
const durationMs = end.getTime() - start.getTime();
|
|
286
|
+
const prevEnd = new Date(start.getTime() - 1 * 24 * 60 * 60 * 1000); // day before current start
|
|
287
|
+
const prevStart = new Date(prevEnd.getTime() - durationMs);
|
|
288
|
+
const prevStartDate = prevStart.toISOString().split("T")[0];
|
|
289
|
+
const prevEndDate = prevEnd.toISOString().split("T")[0];
|
|
290
|
+
|
|
291
|
+
const [current, previous] = await Promise.all([
|
|
292
|
+
querySearchAnalytics(site_url, queryOpts),
|
|
293
|
+
querySearchAnalytics(site_url, { ...queryOpts, startDate: prevStartDate, endDate: prevEndDate }),
|
|
294
|
+
]);
|
|
295
|
+
|
|
296
|
+
return {
|
|
297
|
+
content: [
|
|
298
|
+
{
|
|
299
|
+
type: "text",
|
|
300
|
+
text: formatComparison(
|
|
301
|
+
site_url,
|
|
302
|
+
current,
|
|
303
|
+
previous,
|
|
304
|
+
dims,
|
|
305
|
+
startDate,
|
|
306
|
+
endDate,
|
|
307
|
+
prevStartDate,
|
|
308
|
+
prevEndDate,
|
|
309
|
+
),
|
|
310
|
+
},
|
|
311
|
+
],
|
|
312
|
+
};
|
|
313
|
+
}
|
|
158
314
|
|
|
315
|
+
const result = await querySearchAnalytics(site_url, queryOpts);
|
|
159
316
|
return {
|
|
160
317
|
content: [{ type: "text", text: formatPerformance(site_url, result, dims, startDate, endDate) }],
|
|
161
318
|
};
|