pagesight 0.10.0 → 0.12.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 CHANGED
@@ -8,26 +8,10 @@ See your site the way search engines and AI see it.
8
8
  npm install pagesight
9
9
  ```
10
10
 
11
- Google Search Console + PageSpeed Insights + CrUX + 139 AI crawlers. One package.
12
-
13
- ## Tools
14
-
15
- | Tool | What it does |
16
- |------|-------------|
17
- | `audit` | One-call site audit. Runs pagespeed + metatags + robots + sitemaps + inspect in parallel. Returns prioritized findings. |
18
- | `pagespeed` | Lighthouse scores, Core Web Vitals, opportunities with resource URLs, failing audits with selectors and fix links. |
19
- | `metatags` | OG, Twitter Card, canonical, JSON-LD with schema validation, redirect chain, image validation. |
20
- | `inspect` | Google index status, canonical choice, crawl state, rich results. |
21
- | `sample_inspect` | Sample URLs from a sitemap and batch-inspect via GSC. Diagnoses indexing patterns. |
22
- | `performance` | Search analytics — clicks, impressions, CTR, position. `compare: true` for period-over-period deltas. |
23
- | `crux` | Real-world Core Web Vitals from Chrome users (p75, histograms). |
24
- | `crux_history` | CWV trends over time — up to 40 weekly data points. |
25
- | `robots` | robots.txt validation (RFC 9309) + AI crawler audit (139+ bots). |
26
- | `sitemaps` | GSC properties and sitemaps with submitted/indexed counts. |
27
- | `setup` | Auth status and OAuth setup. |
11
+ Your AI assistant can write your code. Now it can see your site. Index status, performance, real-user metrics, search traffic, meta tags, structured data, AI crawler access — one package, one call.
28
12
 
29
13
  ```
30
- === Site Audit: https://fipe.chat ===
14
+ === Site Audit: https://example.com ===
31
15
 
32
16
  HIGH Missing canonical URL
33
17
  HIGH 7,772 sitemap URLs submitted, 0 indexed
@@ -37,22 +21,25 @@ LOW Missing Twitter Card tags
37
21
  LOW No structured data (JSON-LD) found
38
22
  ```
39
23
 
40
- ## Setup
41
-
42
- 1. [Google Cloud Console](https://console.cloud.google.com/) — enable Search Console API, PageSpeed Insights API, Chrome UX Report API
43
- 2. Create OAuth client ID (Desktop app) + API key
44
- 3. Configure:
24
+ ## Tools
45
25
 
46
- ```env
47
- GSC_CLIENT_ID=your-client-id.apps.googleusercontent.com
48
- GSC_CLIENT_SECRET=your-client-secret
49
- GSC_REFRESH_TOKEN=your-refresh-token
50
- GOOGLE_API_KEY=your-api-key
51
- ```
26
+ | Tool | What it does |
27
+ |------|-------------|
28
+ | `audit` | One-call site audit. Runs all checks in parallel. Returns prioritized findings. |
29
+ | `pagespeed` | Lighthouse scores, Core Web Vitals, opportunities, failing audits with fix links. |
30
+ | `metatags` | OG, Twitter Card, canonical, JSON-LD with schema validation, redirect chain, image validation. |
31
+ | `inspect` | Google index status, canonical choice, crawl state, rich results. |
32
+ | `sample_inspect` | Sample URLs from a sitemap and batch-inspect. Diagnoses indexing patterns. |
33
+ | `performance` | Search analytics — clicks, impressions, CTR, position. `compare: true` for period-over-period. |
34
+ | `crux` | Real-user Core Web Vitals (p75, histograms). |
35
+ | `crux_history` | CWV trends over time — up to 40 weekly data points. |
36
+ | `robots` | robots.txt validation (RFC 9309) + AI crawler audit (139+ bots). |
37
+ | `sitemaps` | Search Console properties and sitemaps with submitted/indexed counts. |
38
+ | `setup` | Auth status and OAuth setup. |
52
39
 
53
- `robots`, `metatags`, and `pagespeed` work without credentials.
40
+ ## Setup
54
41
 
55
- ### MCP config
42
+ Add to your MCP config:
56
43
 
57
44
  ```json
58
45
  {
@@ -71,15 +58,30 @@ GOOGLE_API_KEY=your-api-key
71
58
  }
72
59
  ```
73
60
 
74
- ## Why not other SEO tools?
61
+ `robots`, `metatags`, and `pagespeed` work without credentials.
62
+
63
+ ### Full setup
64
+
65
+ 1. [Google Cloud Console](https://console.cloud.google.com/) — enable Search Console API, PageSpeed Insights API, Chrome UX Report API
66
+ 2. Create OAuth client ID (Desktop app) + API key
67
+ 3. Configure:
68
+
69
+ ```env
70
+ GSC_CLIENT_ID=your-client-id.apps.googleusercontent.com
71
+ GSC_CLIENT_SECRET=your-client-secret
72
+ GSC_REFRESH_TOKEN=your-refresh-token
73
+ GOOGLE_API_KEY=your-api-key
74
+ ```
75
+
76
+ ## Why Pagesight
75
77
 
76
- We checked every common SEO "rule" against official Google documentation:
78
+ Every data point comes from a verifiable source. Google's APIs, real Chrome users, RFC 9309, schema.org, a community-maintained bot registry. No invented scores. No rules we can't cite.
77
79
 
78
80
  - **"Title must be under 60 characters"** — Gary Illyes: "an externally made-up metric."
79
81
  - **"Only one H1 per page"** — John Mueller: "You can use H1 tags as often as you want."
80
82
  - **"Minimum 300 words per page"** — Mueller: "not a quality factor."
81
83
 
82
- Pagesight only reports what the sources actually return.
84
+ Pagesight reports what the sources report. Nothing more.
83
85
 
84
86
  ## License
85
87
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pagesight",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "See your site the way search engines and AI see it.",
5
5
  "keywords": [
6
6
  "seo",
package/src/index.ts CHANGED
@@ -4,6 +4,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
4
4
  import { registerAuditTool } from "./tools/audit.js";
5
5
  import { registerCruxTool } from "./tools/crux.js";
6
6
  import { registerInspectTool } from "./tools/inspect.js";
7
+ import { registerLinksTool } from "./tools/links.js";
7
8
  import { registerMetatagsTool } from "./tools/metatags.js";
8
9
  import { registerPagespeedTool } from "./tools/pagespeed.js";
9
10
  import { registerPerformanceTool } from "./tools/performance.js";
@@ -22,6 +23,7 @@ const server = new McpServer({
22
23
  registerAuditTool(server);
23
24
  registerCruxTool(server);
24
25
  registerInspectTool(server);
26
+ registerLinksTool(server);
25
27
  registerMetatagsTool(server);
26
28
  registerPagespeedTool(server);
27
29
  registerPerformanceTool(server);
@@ -3,6 +3,7 @@ import { z } from "zod";
3
3
  import { inspectUrl, listSitemaps } from "../lib/gsc.js";
4
4
  import { type PsiCategoryType, type PsiResult, runPagespeed } from "../lib/psi.js";
5
5
  import { auditAiCrawlers, fetchRobotsTxt, isAllowed } from "../lib/robots.js";
6
+ import { fetchSitemap, inspectSingle, sampleUrls } from "./sample-inspect.js";
6
7
 
7
8
  type Severity = "HIGH" | "MEDIUM" | "LOW";
8
9
 
@@ -128,9 +129,11 @@ function addPagespeedFindings(result: PsiResult, findings: Finding[]) {
128
129
  const fontItems = renderBlocking.details.items.filter((i) => i.url && String(i.url).includes("fonts"));
129
130
  if (fontItems.length > 0) {
130
131
  const wastedMs = fontItems.reduce((sum, i) => sum + (i.wastedMs ? Number(i.wastedMs) : 0), 0);
132
+ const isGoogleFonts = fontItems.some((i) => String(i.url).includes("fonts.googleapis.com"));
133
+ const label = isGoogleFonts ? "Render-blocking Google Fonts" : "Render-blocking font CSS";
131
134
  findings.push({
132
135
  severity: "MEDIUM",
133
- message: `Render-blocking Google Fonts (${Math.round(wastedMs)}ms wasted)`,
136
+ message: `${label} (${Math.round(wastedMs)}ms wasted)`,
134
137
  source: "pagespeed",
135
138
  });
136
139
  }
@@ -208,6 +211,36 @@ function addSitemapFindings(sitemapCount: number, totalSubmitted: number, totalI
208
211
  }
209
212
  }
210
213
 
214
+ function formatDrillDown(
215
+ inspections: Array<{ url: string; verdict: string; coverageState: string; error: string | null }>,
216
+ ): string {
217
+ const valid = inspections.filter((r) => !r.error);
218
+ if (valid.length === 0) return "";
219
+
220
+ const indexed = valid.filter((r) => r.verdict === "PASS").length;
221
+ const lines: string[] = [` Auto-inspected ${valid.length} URLs:`];
222
+ lines.push(` - ${indexed}/${valid.length} indexed`);
223
+
224
+ const stateCounts: Record<string, { count: number; urls: string[] }> = {};
225
+ for (const r of valid) {
226
+ if (r.verdict !== "PASS") {
227
+ const path = new URL(r.url).pathname;
228
+ const existing = stateCounts[r.coverageState];
229
+ if (existing) {
230
+ existing.count++;
231
+ existing.urls.push(path);
232
+ } else {
233
+ stateCounts[r.coverageState] = { count: 1, urls: [path] };
234
+ }
235
+ }
236
+ }
237
+ for (const [state, { count, urls }] of Object.entries(stateCounts)) {
238
+ lines.push(` - ${count}/${valid.length} ${state}: ${urls.join(", ")}`);
239
+ }
240
+
241
+ return lines.join("\n");
242
+ }
243
+
211
244
  function addInspectFindings(verdict: string, coverageState: string, findings: Finding[]) {
212
245
  if (verdict === "FAIL") {
213
246
  findings.push({ severity: "HIGH", message: `URL not indexed: ${coverageState}`, source: "inspect" });
@@ -310,6 +343,33 @@ export function registerAuditTool(server: McpServer): void {
310
343
  }
311
344
  }
312
345
  addSitemapFindings(sitemaps.length, totalSubmitted, totalIndexed, findings);
346
+
347
+ // Auto-drill-down: when indexing is low, sample-inspect to explain why
348
+ const indexPct = totalSubmitted > 0 ? (totalIndexed / totalSubmitted) * 100 : 100;
349
+ if (site_url && indexPct < 50 && sitemaps.length > 0) {
350
+ try {
351
+ const sitemapPath = (sitemaps.find((s) => !s.isSitemapsIndex) ?? sitemaps[0]).path;
352
+ let parsed = await fetchSitemap(sitemapPath);
353
+ if (parsed.isSitemapIndex && parsed.childSitemaps.length > 0) {
354
+ parsed = await fetchSitemap(parsed.childSitemaps[0]);
355
+ }
356
+ if (parsed.urls.length > 0) {
357
+ const sampled = sampleUrls(parsed.urls, 5, "spread");
358
+ const inspections = [];
359
+ for (const u of sampled) {
360
+ inspections.push(await inspectSingle(u, site_url));
361
+ }
362
+ const drillDown = formatDrillDown(inspections);
363
+ // Append drill-down to the sitemap finding
364
+ const sitemapFinding = findings.find((f) => f.source === "sitemaps" && f.severity === "HIGH");
365
+ if (sitemapFinding) {
366
+ sitemapFinding.message += `\n${drillDown}`;
367
+ }
368
+ }
369
+ } catch {
370
+ // Drill-down is best-effort — don't fail the audit
371
+ }
372
+ }
313
373
  } else if (sitemapResult.status === "rejected") {
314
374
  errors.push(`Sitemaps: ${sitemapResult.reason}`);
315
375
  }
@@ -34,6 +34,11 @@ function formatInspection(url: string, siteUrl: string, r: InspectionResult): st
34
34
  lines.push(`\nReferring URLs: ${idx.referringUrls.join(", ")}`);
35
35
  }
36
36
 
37
+ if (idx.verdict !== "PASS") {
38
+ const gscUrl = `https://search.google.com/search-console/inspect?resource_id=${encodeURIComponent(siteUrl)}&id=${encodeURIComponent(url)}`;
39
+ lines.push("", `→ This page is not indexed. Request indexing manually in Google Search Console:`, ` ${gscUrl}`);
40
+ }
41
+
37
42
  // Rich Results
38
43
  if (r.richResultsResult) {
39
44
  lines.push("", "--- Rich Results ---", "");
@@ -0,0 +1,183 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { z } from "zod";
3
+
4
+ interface LinkResult {
5
+ href: string;
6
+ status: number | null;
7
+ redirectChain: Array<{ url: string; status: number }>;
8
+ finalUrl: string | null;
9
+ error: string | null;
10
+ }
11
+
12
+ async function checkLink(href: string): Promise<LinkResult> {
13
+ const chain: Array<{ url: string; status: number }> = [];
14
+ let current = href;
15
+
16
+ try {
17
+ for (let i = 0; i < 10; i++) {
18
+ const res = await fetch(current, {
19
+ method: "GET",
20
+ headers: { "User-Agent": "Mozilla/5.0 (compatible; Googlebot/2.1)", Accept: "text/html" },
21
+ redirect: "manual",
22
+ });
23
+
24
+ chain.push({ url: current, status: res.status });
25
+
26
+ if (res.status >= 300 && res.status < 400) {
27
+ const location = res.headers.get("location");
28
+ if (!location) break;
29
+ current = new URL(location, current).href;
30
+ continue;
31
+ }
32
+
33
+ return {
34
+ href,
35
+ status: res.status,
36
+ redirectChain: chain,
37
+ finalUrl: chain.length > 1 ? current : null,
38
+ error: null,
39
+ };
40
+ }
41
+
42
+ return {
43
+ href,
44
+ status: chain[chain.length - 1]?.status ?? null,
45
+ redirectChain: chain,
46
+ finalUrl: current,
47
+ error: "Too many redirects",
48
+ };
49
+ } catch (err) {
50
+ return {
51
+ href,
52
+ status: null,
53
+ redirectChain: chain,
54
+ finalUrl: null,
55
+ error: err instanceof Error ? err.message : String(err),
56
+ };
57
+ }
58
+ }
59
+
60
+ function extractInternalLinks(html: string, origin: string): string[] {
61
+ const links = new Set<string>();
62
+
63
+ for (const match of html.matchAll(/<a\s[^>]*href=["']([^"'#]+)/gi)) {
64
+ const raw = match[1].trim();
65
+ if (!raw || raw.startsWith("javascript:") || raw.startsWith("mailto:") || raw.startsWith("tel:")) continue;
66
+
67
+ try {
68
+ const resolved = new URL(raw, origin).href;
69
+ if (resolved.startsWith(origin) && !resolved.includes("/cdn-cgi/")) {
70
+ links.add(resolved);
71
+ }
72
+ } catch {
73
+ // Invalid URL, skip
74
+ }
75
+ }
76
+
77
+ return [...links];
78
+ }
79
+
80
+ function formatResults(url: string, results: LinkResult[]): string {
81
+ const lines: string[] = [`=== Internal Links: ${url} ===`, `Found ${results.length} internal links`, ""];
82
+
83
+ const broken = results.filter((r) => r.error || (r.status && r.status >= 400));
84
+ const redirected = results.filter((r) => !r.error && r.redirectChain.length > 1 && r.status && r.status < 400);
85
+ const ok = results.filter((r) => !r.error && r.redirectChain.length === 1 && r.status && r.status < 400);
86
+
87
+ // Summary
88
+ lines.push("--- Summary ---", "");
89
+ lines.push(`OK: ${ok.length}`);
90
+ if (redirected.length > 0) lines.push(`Redirected: ${redirected.length}`);
91
+ if (broken.length > 0) lines.push(`Broken: ${broken.length}`);
92
+ lines.push("");
93
+
94
+ // Broken links
95
+ if (broken.length > 0) {
96
+ lines.push("--- Broken Links ---", "");
97
+ for (const r of broken) {
98
+ if (r.error) {
99
+ lines.push(`FAIL ${r.href}`);
100
+ lines.push(` Error: ${r.error}`);
101
+ } else {
102
+ lines.push(`FAIL ${r.href} → ${r.status}`);
103
+ }
104
+ }
105
+ lines.push("");
106
+ }
107
+
108
+ // Redirect chains
109
+ if (redirected.length > 0) {
110
+ lines.push("--- Redirect Chains ---", "");
111
+ for (const r of redirected) {
112
+ const hops = r.redirectChain.length - 1;
113
+ const chainStr = r.redirectChain.map((h) => `${h.status}`).join(" → ");
114
+ lines.push(`REDIRECT ${r.href}`);
115
+ lines.push(` ${chainStr} → ${r.finalUrl} (${hops} hop${hops > 1 ? "s" : ""})`);
116
+ }
117
+ lines.push("");
118
+ }
119
+
120
+ return lines.join("\n");
121
+ }
122
+
123
+ export function registerLinksTool(server: McpServer): void {
124
+ server.tool(
125
+ "links",
126
+ "Check internal links on a page. Fetches the page, extracts all internal <a href> links, and checks each for broken links (404/5xx), redirect chains, and errors.",
127
+ {
128
+ url: z.string().url().describe("The page URL to check internal links for."),
129
+ },
130
+ async ({ url }) => {
131
+ try {
132
+ const origin = new URL(url).origin;
133
+
134
+ // Fetch the page
135
+ const res = await fetch(url, {
136
+ headers: { "User-Agent": "Mozilla/5.0 (compatible; Googlebot/2.1)", Accept: "text/html" },
137
+ redirect: "follow",
138
+ });
139
+
140
+ if (!res.ok) {
141
+ return {
142
+ content: [{ type: "text", text: `Error fetching ${url}: HTTP ${res.status}` }],
143
+ };
144
+ }
145
+
146
+ const html = await res.text();
147
+ const links = extractInternalLinks(html, origin);
148
+
149
+ if (links.length === 0) {
150
+ return {
151
+ content: [
152
+ {
153
+ type: "text",
154
+ text: `No internal links found on ${url}\nNote: this page may use client-side rendering (SPA). Only static <a href> links in the HTML are detected.`,
155
+ },
156
+ ],
157
+ };
158
+ }
159
+
160
+ // Check links with concurrency 5
161
+ const concurrency = 5;
162
+ const results: LinkResult[] = [];
163
+ let i = 0;
164
+
165
+ while (i < links.length) {
166
+ const batch = links.slice(i, i + concurrency);
167
+ const settled = await Promise.all(batch.map((href) => checkLink(href)));
168
+ results.push(...settled);
169
+ i += concurrency;
170
+ }
171
+
172
+ return {
173
+ content: [{ type: "text", text: formatResults(url, results) }],
174
+ };
175
+ } catch (err) {
176
+ const msg = err instanceof Error ? err.message : String(err);
177
+ return {
178
+ content: [{ type: "text", text: `Error checking links: ${msg}` }],
179
+ };
180
+ }
181
+ },
182
+ );
183
+ }
@@ -140,6 +140,7 @@ interface SchemaRule {
140
140
  required: string[];
141
141
  recommended: string[];
142
142
  imageFields: string[];
143
+ nestedRequired?: Record<string, string[]>;
143
144
  }
144
145
 
145
146
  // Required/recommended fields sourced from Google's Rich Results documentation
@@ -169,6 +170,7 @@ const SCHEMA_RULES: Record<string, SchemaRule> = {
169
170
  required: ["name", "image"],
170
171
  recommended: ["description", "offers", "brand", "review", "aggregateRating"],
171
172
  imageFields: ["image"],
173
+ nestedRequired: { offers: ["price", "priceCurrency"] },
172
174
  },
173
175
  LocalBusiness: {
174
176
  required: ["name", "address"],
@@ -200,6 +202,43 @@ const SCHEMA_RULES: Record<string, SchemaRule> = {
200
202
  recommended: ["duration", "contentUrl", "embedUrl"],
201
203
  imageFields: ["thumbnailUrl"],
202
204
  },
205
+ SoftwareApplication: {
206
+ required: ["name", "offers"],
207
+ recommended: ["applicationCategory", "operatingSystem", "review", "aggregateRating"],
208
+ imageFields: ["image"],
209
+ nestedRequired: { offers: ["price", "priceCurrency"] },
210
+ },
211
+ Dataset: {
212
+ required: ["name", "description"],
213
+ recommended: ["distribution", "creator", "license"],
214
+ imageFields: [],
215
+ nestedRequired: { distribution: ["contentUrl", "encodingFormat"] },
216
+ },
217
+ TechArticle: {
218
+ required: ["headline", "image", "datePublished", "author"],
219
+ recommended: ["dateModified", "publisher"],
220
+ imageFields: ["image"],
221
+ },
222
+ BlogPosting: {
223
+ required: ["headline", "image", "datePublished", "author"],
224
+ recommended: ["dateModified", "publisher"],
225
+ imageFields: ["image"],
226
+ },
227
+ HowTo: {
228
+ required: ["name", "step"],
229
+ recommended: ["image", "totalTime", "estimatedCost"],
230
+ imageFields: ["image"],
231
+ },
232
+ Course: {
233
+ required: ["name", "description"],
234
+ recommended: ["provider", "offers"],
235
+ imageFields: [],
236
+ },
237
+ JobPosting: {
238
+ required: ["title", "description", "datePosted", "hiringOrganization"],
239
+ recommended: ["employmentType", "jobLocation", "baseSalary", "validThrough"],
240
+ imageFields: [],
241
+ },
203
242
  };
204
243
 
205
244
  interface ValidationIssue {
@@ -216,9 +255,14 @@ function getNestedValue(obj: Record<string, unknown>, field: string): unknown {
216
255
  return undefined;
217
256
  }
218
257
 
219
- function validateJsonLd(blocks: unknown[]): { issues: ValidationIssue[]; imageUrls: string[] } {
258
+ function validateJsonLd(blocks: unknown[]): {
259
+ issues: ValidationIssue[];
260
+ imageUrls: string[];
261
+ validatedTypes: Set<string>;
262
+ } {
220
263
  const issues: ValidationIssue[] = [];
221
264
  const imageUrls: string[] = [];
265
+ const validatedTypes = new Set<string>();
222
266
 
223
267
  function validateBlock(data: unknown) {
224
268
  if (Array.isArray(data)) {
@@ -234,6 +278,7 @@ function validateJsonLd(blocks: unknown[]): { issues: ValidationIssue[]; imageUr
234
278
  for (const type of types) {
235
279
  const rule = SCHEMA_RULES[String(type)];
236
280
  if (!rule) continue;
281
+ validatedTypes.add(String(type));
237
282
 
238
283
  for (const field of rule.required) {
239
284
  if (getNestedValue(obj, field) === undefined) {
@@ -247,6 +292,27 @@ function validateJsonLd(blocks: unknown[]): { issues: ValidationIssue[]; imageUr
247
292
  }
248
293
  }
249
294
 
295
+ // Check nested required fields (e.g., offers.price inside Product)
296
+ if (rule.nestedRequired) {
297
+ for (const [parent, fields] of Object.entries(rule.nestedRequired)) {
298
+ const parentVal = obj[parent];
299
+ if (parentVal && typeof parentVal === "object") {
300
+ const targets = Array.isArray(parentVal) ? parentVal : [parentVal];
301
+ for (const target of targets) {
302
+ if (target && typeof target === "object") {
303
+ const nested = target as Record<string, unknown>;
304
+ for (const field of fields) {
305
+ if (nested[field] === undefined || nested[field] === null || nested[field] === "") {
306
+ issues.push({ type: String(type), level: "required", field: `${parent}.${field}` });
307
+ }
308
+ }
309
+ break; // Only check the first item in arrays
310
+ }
311
+ }
312
+ }
313
+ }
314
+ }
315
+
250
316
  // Collect image URLs for validation
251
317
  for (const field of rule.imageFields) {
252
318
  const val = obj[field];
@@ -262,17 +328,18 @@ function validateJsonLd(blocks: unknown[]): { issues: ValidationIssue[]; imageUr
262
328
  }
263
329
  }
264
330
 
265
- // Recurse into nested objects
331
+ // Recurse into nested objects and arrays (e.g., @graph)
266
332
  for (const val of Object.values(obj)) {
267
- if (val && typeof val === "object" && !Array.isArray(val)) {
268
- const nested = val as Record<string, unknown>;
269
- if (nested["@type"]) validateBlock(nested);
333
+ if (Array.isArray(val)) {
334
+ for (const item of val) validateBlock(item);
335
+ } else if (val && typeof val === "object") {
336
+ validateBlock(val);
270
337
  }
271
338
  }
272
339
  }
273
340
 
274
341
  for (const block of blocks) validateBlock(block);
275
- return { issues, imageUrls };
342
+ return { issues, imageUrls, validatedTypes };
276
343
  }
277
344
 
278
345
  // What each recommended field enables (sourced from Google Rich Results docs)
@@ -293,12 +360,31 @@ const FIELD_HINTS: Record<string, string> = {
293
360
  "Event.offers": "shows ticket prices in search",
294
361
  "Recipe.author": "shown in recipe rich results",
295
362
  "VideoObject.duration": "shown in video rich results",
363
+ "SoftwareApplication.applicationCategory": "shown in software rich results",
364
+ "SoftwareApplication.operatingSystem": "shown in software rich results",
365
+ "SoftwareApplication.review": "enables star ratings",
366
+ "SoftwareApplication.aggregateRating": "enables aggregate star ratings",
367
+ "Dataset.distribution": "enables dataset download in search",
368
+ "Dataset.creator": "shown in dataset rich results",
369
+ "Dataset.license": "shown in dataset rich results",
370
+ "Course.provider": "shown in course rich results",
371
+ "Course.offers": "enables price display for courses",
372
+ "JobPosting.employmentType": "shown in job search results",
373
+ "JobPosting.jobLocation": "shown in job search results",
374
+ "JobPosting.baseSalary": "enables salary display in job search",
296
375
  };
297
376
 
298
- function formatValidation(issues: ValidationIssue[]): string[] {
299
- if (issues.length === 0) return ["All validated types have their required fields."];
300
-
377
+ function formatValidation(issues: ValidationIssue[], validatedTypes: Set<string>): string[] {
301
378
  const lines: string[] = [];
379
+
380
+ // Show PASS for types with no required issues
381
+ const typesWithRequiredIssues = new Set(issues.filter((i) => i.level === "required").map((i) => i.type));
382
+ for (const type of validatedTypes) {
383
+ if (!typesWithRequiredIssues.has(type)) {
384
+ lines.push(`PASS ${type} — all required fields present`);
385
+ }
386
+ }
387
+
302
388
  const required = issues.filter((i) => i.level === "required");
303
389
  const recommended = issues.filter((i) => i.level === "recommended");
304
390
 
@@ -404,6 +490,20 @@ function formatMetatags(url: string, parsed: ParsedHead): string {
404
490
  if (robots) lines.push(`Robots: ${robots}`);
405
491
  const author = getMeta(parsed.meta, "author");
406
492
  if (author) lines.push(`Author: ${author}`);
493
+
494
+ // HTML entity warnings
495
+ const entityCheck = [
496
+ { label: "title", value: parsed.title },
497
+ { label: "description", value: getMeta(parsed.meta, "description") },
498
+ { label: "og:title", value: getMeta(parsed.meta, "og:title") },
499
+ { label: "og:description", value: getMeta(parsed.meta, "og:description") },
500
+ ];
501
+ for (const { label, value } of entityCheck) {
502
+ if (value && /&(?:amp|lt|gt|quot|apos|#\d+|#x[\da-f]+);/i.test(value)) {
503
+ lines.push(`WARN: ${label} contains HTML entities — may render incorrectly in social previews`);
504
+ }
505
+ }
506
+
407
507
  lines.push("");
408
508
 
409
509
  // Open Graph
@@ -566,10 +666,10 @@ export function registerMetatagsTool(server: McpServer): void {
566
666
 
567
667
  // Structured data validation
568
668
  if (parsed.jsonLd.length > 0) {
569
- const { issues, imageUrls } = validateJsonLd(parsed.jsonLd);
570
- if (issues.length > 0 || imageUrls.length > 0) {
669
+ const { issues, imageUrls, validatedTypes } = validateJsonLd(parsed.jsonLd);
670
+ if (validatedTypes.size > 0 || imageUrls.length > 0) {
571
671
  output.push("", "--- Structured Data Validation ---", "");
572
- output.push(...formatValidation(issues));
672
+ output.push(...formatValidation(issues, validatedTypes));
573
673
 
574
674
  // HEAD-check image URLs from structured data
575
675
  if (imageUrls.length > 0) {
@@ -16,6 +16,10 @@ function scoreLabel(score: number | null): string {
16
16
  return `${pct} (poor)`;
17
17
  }
18
18
 
19
+ function scorePct(score: number | null): number | null {
20
+ return score === null ? null : Math.round(score * 100);
21
+ }
22
+
19
23
  function cwvRating(category: string): string {
20
24
  if (category === "FAST") return "good";
21
25
  if (category === "AVERAGE") return "needs improvement";
@@ -190,6 +194,8 @@ function formatFailingAudits(audits: Record<string, PsiAudit>, categoryRefs: str
190
194
  return lines;
191
195
  }
192
196
 
197
+ // --- Single URL formatting (existing) ---
198
+
193
199
  function formatPagespeed(url: string, result: PsiResult): string {
194
200
  const lhr = result.lighthouseResult;
195
201
  const lines: string[] = [
@@ -274,12 +280,234 @@ function formatPagespeed(url: string, result: PsiResult): string {
274
280
  return lines.join("\n");
275
281
  }
276
282
 
283
+ // --- Batch formatting ---
284
+
285
+ function shortUrl(url: string, allUrls: string[]): string {
286
+ try {
287
+ const u = new URL(url);
288
+ const path = u.pathname + u.search;
289
+ const hasDuplicate = allUrls.some(
290
+ (other) => other !== url && new URL(other).pathname + new URL(other).search === path,
291
+ );
292
+ return hasDuplicate ? u.hostname + path : path;
293
+ } catch {
294
+ return url;
295
+ }
296
+ }
297
+
298
+ function formatDelta(a: number | null, b: number | null): string {
299
+ if (a === null || b === null) return "";
300
+ const diff = b - a;
301
+ if (diff === 0) return " (=)";
302
+ return diff > 0 ? ` (+${diff})` : ` (${diff})`;
303
+ }
304
+
305
+ function formatBatchCompare(results: Array<{ url: string; result: PsiResult }>, strategy: string): string {
306
+ const [a, b] = results;
307
+ const lhrA = a.result.lighthouseResult;
308
+ const lhrB = b.result.lighthouseResult;
309
+
310
+ const lines: string[] = [
311
+ `=== PageSpeed Compare (${strategy}) ===`,
312
+ ``,
313
+ `A: ${a.url}`,
314
+ `B: ${b.url}`,
315
+ `Lighthouse: ${lhrA.lighthouseVersion}`,
316
+ "",
317
+ "--- Scores ---",
318
+ "",
319
+ ];
320
+
321
+ // Score comparison
322
+ const catIds = Object.keys(lhrA.categories);
323
+ for (const id of catIds) {
324
+ const catA = lhrA.categories[id];
325
+ const catB = lhrB.categories[id];
326
+ if (!catA || !catB) continue;
327
+ const pA = scorePct(catA.score);
328
+ const pB = scorePct(catB.score);
329
+ const delta = formatDelta(pA, pB);
330
+ lines.push(`${catA.title}: ${pA ?? "N/A"} → ${pB ?? "N/A"}${delta}`);
331
+ }
332
+ lines.push("");
333
+
334
+ // CWV comparison
335
+ const cwvIds = [
336
+ "first-contentful-paint",
337
+ "largest-contentful-paint",
338
+ "total-blocking-time",
339
+ "cumulative-layout-shift",
340
+ "speed-index",
341
+ "interactive",
342
+ ];
343
+ const cwvLines: string[] = [];
344
+ for (const id of cwvIds) {
345
+ const auditA = lhrA.audits[id];
346
+ const auditB = lhrB.audits[id];
347
+ if (!auditA?.displayValue || !auditB?.displayValue) continue;
348
+ cwvLines.push(`${auditA.title}: ${auditA.displayValue} → ${auditB.displayValue}`);
349
+ }
350
+ if (cwvLines.length > 0) {
351
+ lines.push("--- Core Web Vitals (Lab) ---", "", ...cwvLines, "");
352
+ }
353
+
354
+ // Opportunities unique to each / shared
355
+ const oppsA = collectOpportunityIds(lhrA.audits);
356
+ const oppsB = collectOpportunityIds(lhrB.audits);
357
+ const onlyA = [...oppsA].filter((id) => !oppsB.has(id));
358
+ const onlyB = [...oppsB].filter((id) => !oppsA.has(id));
359
+ const shared = [...oppsA].filter((id) => oppsB.has(id));
360
+
361
+ if (onlyA.length > 0) {
362
+ lines.push(`--- Opportunities (A only) ---`, "");
363
+ for (const id of onlyA) lines.push(` ${lhrA.audits[id].title}: ${lhrA.audits[id].displayValue ?? ""}`);
364
+ lines.push("");
365
+ }
366
+ if (onlyB.length > 0) {
367
+ lines.push(`--- Opportunities (B only) ---`, "");
368
+ for (const id of onlyB) lines.push(` ${lhrB.audits[id].title}: ${lhrB.audits[id].displayValue ?? ""}`);
369
+ lines.push("");
370
+ }
371
+ if (shared.length > 0) {
372
+ lines.push(`--- Shared Opportunities ---`, "");
373
+ for (const id of shared) {
374
+ lines.push(
375
+ ` ${lhrA.audits[id].title}: ${lhrA.audits[id].displayValue ?? ""} → ${lhrB.audits[id].displayValue ?? ""}`,
376
+ );
377
+ }
378
+ lines.push("");
379
+ }
380
+
381
+ const timeA = (lhrA.timing.total / 1000).toFixed(1);
382
+ const timeB = (lhrB.timing.total / 1000).toFixed(1);
383
+ lines.push(`Analysis took ${timeA}s + ${timeB}s`);
384
+
385
+ return lines.join("\n");
386
+ }
387
+
388
+ function formatBatchTable(results: Array<{ url: string; result: PsiResult }>, strategy: string): string {
389
+ const lines: string[] = [`=== Batch PageSpeed (${results.length} URLs, ${strategy}) ===`, ""];
390
+
391
+ // Collect all category IDs from first result
392
+ const catIds = Object.keys(results[0].result.lighthouseResult.categories);
393
+ const catNames = catIds.map((id) => results[0].result.lighthouseResult.categories[id].title);
394
+
395
+ // Score table
396
+ lines.push("--- Scores ---", "");
397
+
398
+ // Header
399
+ const urlCol = "URL";
400
+ const allUrls = results.map((r) => r.url);
401
+ const urlWidth = Math.max(urlCol.length, ...results.map((r) => shortUrl(r.url, allUrls).length));
402
+ const colWidth = Math.max(...catNames.map((n) => n.length), 4);
403
+ lines.push(`${urlCol.padEnd(urlWidth)} ${catNames.map((n) => n.padEnd(colWidth)).join(" ")}`);
404
+
405
+ // Rows
406
+ let bestPerf: { url: string; score: number } | null = null;
407
+ let worstPerf: { url: string; score: number } | null = null;
408
+
409
+ for (const { url, result } of results) {
410
+ const lhr = result.lighthouseResult;
411
+ const scores = catIds.map((id) => {
412
+ const s = scorePct(lhr.categories[id]?.score);
413
+ return s !== null ? String(s) : "N/A";
414
+ });
415
+ lines.push(`${shortUrl(url, allUrls).padEnd(urlWidth)} ${scores.map((s) => s.padEnd(colWidth)).join(" ")}`);
416
+
417
+ const perf = scorePct(lhr.categories.performance?.score);
418
+ if (perf !== null) {
419
+ if (!bestPerf || perf > bestPerf.score) bestPerf = { url: shortUrl(url, allUrls), score: perf };
420
+ if (!worstPerf || perf < worstPerf.score) worstPerf = { url: shortUrl(url, allUrls), score: perf };
421
+ }
422
+ }
423
+
424
+ lines.push("");
425
+ if (bestPerf) lines.push(`Best: ${bestPerf.url} (${bestPerf.score})`);
426
+ if (worstPerf && worstPerf.url !== bestPerf?.url) lines.push(`Worst: ${worstPerf.url} (${worstPerf.score})`);
427
+ lines.push("");
428
+
429
+ // Shared opportunities across pages
430
+ const oppCounts = new Map<string, { title: string; count: number }>();
431
+ for (const { result } of results) {
432
+ for (const id of collectOpportunityIds(result.lighthouseResult.audits)) {
433
+ const existing = oppCounts.get(id);
434
+ if (existing) {
435
+ existing.count++;
436
+ } else {
437
+ oppCounts.set(id, { title: result.lighthouseResult.audits[id].title, count: 1 });
438
+ }
439
+ }
440
+ }
441
+
442
+ const sharedOpps = [...oppCounts.entries()].filter(([, v]) => v.count >= 2).sort((a, b) => b[1].count - a[1].count);
443
+ if (sharedOpps.length > 0) {
444
+ lines.push("--- Shared Opportunities ---", "");
445
+ for (const [, { title, count }] of sharedOpps.slice(0, 10)) {
446
+ lines.push(` ${title} (${count}/${results.length} pages)`);
447
+ }
448
+ lines.push("");
449
+ }
450
+
451
+ const totalTime = results.reduce((sum, r) => sum + r.result.lighthouseResult.timing.total, 0);
452
+ lines.push(`Total analysis time: ${(totalTime / 1000).toFixed(1)}s`);
453
+
454
+ return lines.join("\n");
455
+ }
456
+
457
+ function collectOpportunityIds(audits: Record<string, PsiAudit>): Set<string> {
458
+ const ids = new Set<string>();
459
+ for (const [id, audit] of Object.entries(audits)) {
460
+ if (audit.score === null || audit.score >= 1) continue;
461
+ const mode = audit.scoreDisplayMode;
462
+ const hasNumeric = audit.numericValue && audit.numericValue > 0;
463
+ if (mode === "metricSavings" || ((mode === "numeric" || mode === "binary") && hasNumeric)) {
464
+ if (hasNumeric || (audit.details?.items?.length ?? 0) > 0) {
465
+ ids.add(id);
466
+ }
467
+ }
468
+ }
469
+ return ids;
470
+ }
471
+
472
+ async function runBatch(
473
+ urls: string[],
474
+ options: { strategy?: "mobile" | "desktop"; categories?: PsiCategoryType[]; locale?: string },
475
+ ): Promise<Array<{ url: string; result?: PsiResult; error?: string }>> {
476
+ const concurrency = 2;
477
+ const results: Array<{ url: string; result?: PsiResult; error?: string }> = [];
478
+ let i = 0;
479
+
480
+ while (i < urls.length) {
481
+ const batch = urls.slice(i, i + concurrency);
482
+ const settled = await Promise.all(
483
+ batch.map(async (url) => {
484
+ try {
485
+ const result = await runPagespeed(url, options);
486
+ return { url, result };
487
+ } catch (err) {
488
+ return { url, error: err instanceof Error ? err.message : String(err) };
489
+ }
490
+ }),
491
+ );
492
+ results.push(...settled);
493
+ i += concurrency;
494
+ }
495
+
496
+ return results;
497
+ }
498
+
277
499
  export function registerPagespeedTool(server: McpServer): void {
278
500
  server.tool(
279
501
  "pagespeed",
280
- "Analyze a page's performance using Google PageSpeed Insights API. Returns Lighthouse scores, Core Web Vitals (lab + field), opportunities, and diagnostics.",
502
+ "Analyze page performance using Google PageSpeed Insights. Accepts a single URL or multiple URLs (batch mode). With 2 URLs, returns a side-by-side comparison with deltas. With 3-10 URLs, returns a summary table with shared opportunities.",
281
503
  {
282
- url: z.string().url().describe("The URL to analyze."),
504
+ url: z.string().url().optional().describe("Single URL to analyze. Use this OR urls, not both."),
505
+ urls: z
506
+ .array(z.string().url())
507
+ .min(2)
508
+ .max(10)
509
+ .optional()
510
+ .describe("Multiple URLs (2-10) for batch analysis. 2 URLs = compare mode, 3+ = summary table."),
283
511
  strategy: z.enum(["mobile", "desktop"]).optional().describe("Device strategy. Default: 'mobile'."),
284
512
  categories: z
285
513
  .array(z.enum(["performance", "accessibility", "best-practices", "seo"]))
@@ -287,18 +515,61 @@ export function registerPagespeedTool(server: McpServer): void {
287
515
  .describe("Lighthouse categories to run. Default: all four."),
288
516
  locale: z.string().optional().describe("Locale for localized results (e.g., 'pt-BR', 'en')."),
289
517
  },
290
- async ({ url, strategy, categories, locale }) => {
291
- try {
292
- const result = await runPagespeed(url, {
293
- strategy: strategy as "mobile" | "desktop" | undefined,
294
- categories: categories as PsiCategoryType[] | undefined,
295
- locale,
296
- });
297
- return { content: [{ type: "text", text: formatPagespeed(url, result) }] };
298
- } catch (err) {
299
- const msg = err instanceof Error ? err.message : String(err);
300
- return { content: [{ type: "text", text: `Error running PageSpeed analysis: ${msg}` }] };
518
+ async ({ url, urls, strategy, categories, locale }) => {
519
+ const strat = (strategy as "mobile" | "desktop") ?? "mobile";
520
+ const cats = categories as PsiCategoryType[] | undefined;
521
+ const opts = { strategy: strat, categories: cats, locale };
522
+
523
+ // Validate: must provide url or urls, not both
524
+ if (url && urls) {
525
+ return {
526
+ content: [{ type: "text", text: "Error: provide either 'url' (single) or 'urls' (batch), not both." }],
527
+ };
528
+ }
529
+ if (!url && !urls) {
530
+ return {
531
+ content: [{ type: "text", text: "Error: provide 'url' for single analysis or 'urls' for batch analysis." }],
532
+ };
301
533
  }
534
+
535
+ // Single URL — existing behavior
536
+ if (url) {
537
+ try {
538
+ const result = await runPagespeed(url, opts);
539
+ return { content: [{ type: "text", text: formatPagespeed(url, result) }] };
540
+ } catch (err) {
541
+ const msg = err instanceof Error ? err.message : String(err);
542
+ return { content: [{ type: "text", text: `Error running PageSpeed analysis: ${msg}` }] };
543
+ }
544
+ }
545
+
546
+ // Batch mode
547
+ const batchUrls = urls as string[];
548
+ const results = await runBatch(batchUrls, opts);
549
+
550
+ // Separate successes and failures
551
+ const successes = results.filter((r): r is { url: string; result: PsiResult } => !!r.result);
552
+ const failures = results.filter((r): r is { url: string; error: string } => !!r.error);
553
+
554
+ if (successes.length === 0) {
555
+ const errorLines = failures.map((f) => `${f.url}: ${f.error}`);
556
+ return { content: [{ type: "text", text: `All URLs failed:\n${errorLines.join("\n")}` }] };
557
+ }
558
+
559
+ let output: string;
560
+ if (successes.length === 2) {
561
+ output = formatBatchCompare(successes, strat);
562
+ } else {
563
+ output = formatBatchTable(successes, strat);
564
+ }
565
+
566
+ // Append any failures
567
+ if (failures.length > 0) {
568
+ const errorLines = failures.map((f) => `${f.url}: ${f.error}`);
569
+ output += `\n\n--- Errors ---\n${errorLines.join("\n")}`;
570
+ }
571
+
572
+ return { content: [{ type: "text", text: output }] };
302
573
  },
303
574
  );
304
575
  }
@@ -2,13 +2,13 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { z } from "zod";
3
3
  import { inspectUrl, listSitemaps } from "../lib/gsc.js";
4
4
 
5
- interface SitemapParseResult {
5
+ export interface SitemapParseResult {
6
6
  urls: string[];
7
7
  isSitemapIndex: boolean;
8
8
  childSitemaps: string[];
9
9
  }
10
10
 
11
- function parseSitemapXml(xml: string): SitemapParseResult {
11
+ export function parseSitemapXml(xml: string): SitemapParseResult {
12
12
  const urls: string[] = [];
13
13
  const childSitemaps: string[] = [];
14
14
 
@@ -28,7 +28,7 @@ function parseSitemapXml(xml: string): SitemapParseResult {
28
28
  return { urls, isSitemapIndex, childSitemaps };
29
29
  }
30
30
 
31
- async function fetchSitemap(sitemapUrl: string): Promise<SitemapParseResult> {
31
+ export async function fetchSitemap(sitemapUrl: string): Promise<SitemapParseResult> {
32
32
  const res = await fetch(sitemapUrl, {
33
33
  headers: { "User-Agent": "Pagesight/1.0" },
34
34
  });
@@ -41,7 +41,7 @@ async function fetchSitemap(sitemapUrl: string): Promise<SitemapParseResult> {
41
41
  return parseSitemapXml(xml);
42
42
  }
43
43
 
44
- function sampleUrls(urls: string[], count: number, strategy: string): string[] {
44
+ export function sampleUrls(urls: string[], count: number, strategy: string): string[] {
45
45
  if (urls.length <= count) return [...urls];
46
46
 
47
47
  if (strategy === "first") {
@@ -66,7 +66,7 @@ function sampleUrls(urls: string[], count: number, strategy: string): string[] {
66
66
  return shuffled.slice(0, count);
67
67
  }
68
68
 
69
- interface InspectionSummary {
69
+ export interface InspectionSummary {
70
70
  url: string;
71
71
  verdict: string;
72
72
  coverageState: string;
@@ -78,7 +78,7 @@ interface InspectionSummary {
78
78
  error: string | null;
79
79
  }
80
80
 
81
- async function inspectSingle(url: string, siteUrl: string): Promise<InspectionSummary> {
81
+ export async function inspectSingle(url: string, siteUrl: string): Promise<InspectionSummary> {
82
82
  try {
83
83
  const r = await inspectUrl(url, siteUrl);
84
84
  const idx = r.indexStatusResult;