pagesight 0.11.0 → 0.12.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pagesight",
3
- "version": "0.11.0",
3
+ "version": "0.12.1",
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);
package/src/lib/psi.ts CHANGED
@@ -79,6 +79,10 @@ export interface PsiResult {
79
79
  export type PsiStrategy = "mobile" | "desktop";
80
80
  export type PsiCategoryType = "performance" | "accessibility" | "best-practices" | "seo";
81
81
 
82
+ export function hasApiKey(): boolean {
83
+ return !!process.env.GOOGLE_API_KEY;
84
+ }
85
+
82
86
  export async function runPagespeed(
83
87
  url: string,
84
88
  options?: {
@@ -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" });
@@ -219,26 +252,28 @@ function addInspectFindings(verdict: string, coverageState: string, findings: Fi
219
252
  function formatAudit(url: string, findings: Finding[], errors: string[]): string {
220
253
  const lines: string[] = [`=== Site Audit: ${url} ===`, ""];
221
254
 
255
+ // Lead with errors so partial results are obvious
256
+ if (errors.length > 0) {
257
+ lines.push(`${errors.length} check${errors.length > 1 ? "s" : ""} failed — results below are partial:`, "");
258
+ for (const e of errors) {
259
+ lines.push(` FAIL ${e}`);
260
+ }
261
+ lines.push("");
262
+ }
263
+
222
264
  // Sort: HIGH first, then MEDIUM, then LOW
223
265
  const order: Record<string, number> = { HIGH: 0, MEDIUM: 1, LOW: 2 };
224
266
  findings.sort((a, b) => order[a.severity] - order[b.severity]);
225
267
 
226
- if (findings.length === 0) {
268
+ if (findings.length === 0 && errors.length === 0) {
227
269
  lines.push("No issues found.");
228
- } else {
229
- lines.push(`${findings.length} findings:`, "");
270
+ } else if (findings.length > 0) {
271
+ lines.push(`${findings.length} finding${findings.length > 1 ? "s" : ""}:`, "");
230
272
  for (const f of findings) {
231
273
  lines.push(`${f.severity.padEnd(6)} ${f.message}`);
232
274
  }
233
275
  }
234
276
 
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
277
  return lines.join("\n");
243
278
  }
244
279
 
@@ -310,6 +345,33 @@ export function registerAuditTool(server: McpServer): void {
310
345
  }
311
346
  }
312
347
  addSitemapFindings(sitemaps.length, totalSubmitted, totalIndexed, findings);
348
+
349
+ // Auto-drill-down: when indexing is low, sample-inspect to explain why
350
+ const indexPct = totalSubmitted > 0 ? (totalIndexed / totalSubmitted) * 100 : 100;
351
+ if (site_url && indexPct < 50 && sitemaps.length > 0) {
352
+ try {
353
+ const sitemapPath = (sitemaps.find((s) => !s.isSitemapsIndex) ?? sitemaps[0]).path;
354
+ let parsed = await fetchSitemap(sitemapPath);
355
+ if (parsed.isSitemapIndex && parsed.childSitemaps.length > 0) {
356
+ parsed = await fetchSitemap(parsed.childSitemaps[0]);
357
+ }
358
+ if (parsed.urls.length > 0) {
359
+ const sampled = sampleUrls(parsed.urls, 5, "spread");
360
+ const inspections = [];
361
+ for (const u of sampled) {
362
+ inspections.push(await inspectSingle(u, site_url));
363
+ }
364
+ const drillDown = formatDrillDown(inspections);
365
+ // Append drill-down to the sitemap finding
366
+ const sitemapFinding = findings.find((f) => f.source === "sitemaps" && f.severity === "HIGH");
367
+ if (sitemapFinding) {
368
+ sitemapFinding.message += `\n${drillDown}`;
369
+ }
370
+ }
371
+ } catch {
372
+ // Drill-down is best-effort — don't fail the audit
373
+ }
374
+ }
313
375
  } else if (sitemapResult.status === "rejected") {
314
376
  errors.push(`Sitemaps: ${sitemapResult.reason}`);
315
377
  }
@@ -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) {
@@ -1,6 +1,7 @@
1
1
  import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { z } from "zod";
3
3
  import {
4
+ hasApiKey,
4
5
  type PsiAudit,
5
6
  type PsiAuditDetailItem,
6
7
  type PsiCategoryType,
@@ -8,6 +9,9 @@ import {
8
9
  runPagespeed,
9
10
  } from "../lib/psi.js";
10
11
 
12
+ const QUOTA_NOTE =
13
+ "\n\nNote: No GOOGLE_API_KEY configured — using shared quota (400 req/day). Set your own key to avoid rate limits.";
14
+
11
15
  function scoreLabel(score: number | null): string {
12
16
  if (score === null) return "N/A";
13
17
  const pct = Math.round(score * 100);
@@ -536,7 +540,8 @@ export function registerPagespeedTool(server: McpServer): void {
536
540
  if (url) {
537
541
  try {
538
542
  const result = await runPagespeed(url, opts);
539
- return { content: [{ type: "text", text: formatPagespeed(url, result) }] };
543
+ const text = formatPagespeed(url, result) + (hasApiKey() ? "" : QUOTA_NOTE);
544
+ return { content: [{ type: "text", text }] };
540
545
  } catch (err) {
541
546
  const msg = err instanceof Error ? err.message : String(err);
542
547
  return { content: [{ type: "text", text: `Error running PageSpeed analysis: ${msg}` }] };
@@ -569,6 +574,8 @@ export function registerPagespeedTool(server: McpServer): void {
569
574
  output += `\n\n--- Errors ---\n${errorLines.join("\n")}`;
570
575
  }
571
576
 
577
+ if (!hasApiKey()) output += QUOTA_NOTE;
578
+
572
579
  return { content: [{ type: "text", text: output }] };
573
580
  },
574
581
  );
@@ -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;