pagesight 0.2.4 → 0.3.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pagesight",
3
- "version": "0.2.4",
3
+ "version": "0.3.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
@@ -3,6 +3,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
4
  import { registerCruxTool } from "./tools/crux.js";
5
5
  import { registerInspectTool } from "./tools/inspect.js";
6
+ import { registerMetatagsTool } from "./tools/metatags.js";
6
7
  import { registerPagespeedTool } from "./tools/pagespeed.js";
7
8
  import { registerPerformanceTool } from "./tools/performance.js";
8
9
  import { registerRobotsTool } from "./tools/robots.js";
@@ -18,6 +19,7 @@ const server = new McpServer({
18
19
 
19
20
  registerCruxTool(server);
20
21
  registerInspectTool(server);
22
+ registerMetatagsTool(server);
21
23
  registerPagespeedTool(server);
22
24
  registerPerformanceTool(server);
23
25
  registerRobotsTool(server);
package/src/lib/psi.ts CHANGED
@@ -4,6 +4,30 @@ export interface PsiCategory {
4
4
  id: string;
5
5
  title: string;
6
6
  score: number | null;
7
+ auditRefs?: Array<{ id: string; weight: number }>;
8
+ }
9
+
10
+ export interface PsiAuditDetailItem {
11
+ node?: {
12
+ selector: string;
13
+ snippet?: string;
14
+ nodeLabel?: string;
15
+ explanation?: string;
16
+ };
17
+ // Table-style items (performance opportunities, SEO, etc.)
18
+ url?: string;
19
+ wastedBytes?: number;
20
+ wastedMs?: number;
21
+ totalBytes?: number;
22
+ // Generic key-value items
23
+ [key: string]: unknown;
24
+ }
25
+
26
+ export interface PsiAuditDetails {
27
+ type: string;
28
+ items?: PsiAuditDetailItem[];
29
+ overallSavingsMs?: number;
30
+ overallSavingsBytes?: number;
7
31
  }
8
32
 
9
33
  export interface PsiAudit {
@@ -15,6 +39,7 @@ export interface PsiAudit {
15
39
  displayValue?: string;
16
40
  numericValue?: number;
17
41
  numericUnit?: string;
42
+ details?: PsiAuditDetails;
18
43
  }
19
44
 
20
45
  export interface PsiMetric {
@@ -89,11 +89,22 @@ export function registerInspectTool(server: McpServer): void {
89
89
  } catch (err) {
90
90
  const msg = err instanceof Error ? err.message : String(err);
91
91
  if (msg.includes("PERMISSION_DENIED")) {
92
+ const domain = new URL(url).hostname;
92
93
  return {
93
94
  content: [
94
95
  {
95
96
  type: "text",
96
- text: `Error: URL "${url}" is not part of property "${site_url}", or you don't have access.`,
97
+ text: [
98
+ `Error: Cannot inspect "${url}" — permission denied for property "${site_url}".`,
99
+ "",
100
+ "Possible causes:",
101
+ ` 1. ${domain} is not a verified property in Google Search Console`,
102
+ ` 2. The authenticated account does not have access to "${site_url}"`,
103
+ ` 3. The property format is wrong — try "sc-domain:${domain}" or "https://${domain}/"`,
104
+ "",
105
+ "To verify a property: https://search.google.com/search-console",
106
+ "To check your auth: use the setup tool to verify credentials",
107
+ ].join("\n"),
97
108
  },
98
109
  ],
99
110
  };
@@ -0,0 +1,273 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { z } from "zod";
3
+
4
+ interface MetaTag {
5
+ name?: string;
6
+ property?: string;
7
+ content: string;
8
+ }
9
+
10
+ interface ParsedHead {
11
+ title: string | null;
12
+ charset: string | null;
13
+ canonical: string | null;
14
+ meta: MetaTag[];
15
+ hreflang: Array<{ lang: string; href: string }>;
16
+ jsonLd: unknown[];
17
+ }
18
+
19
+ function parseHead(html: string): ParsedHead {
20
+ // Extract <head> content (case-insensitive, handles attributes on <head>)
21
+ const headMatch = html.match(/<head[^>]*>([\s\S]*?)<\/head>/i);
22
+ const head = headMatch?.[1] ?? html;
23
+
24
+ // Title
25
+ const titleMatch = head.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
26
+ const title = titleMatch ? titleMatch[1].trim() : null;
27
+
28
+ // Charset
29
+ const charsetMatch = head.match(/<meta\s+charset=["']([^"']+)["']/i) ?? head.match(/charset=["']?([^"'\s;>]+)/i);
30
+ const charset = charsetMatch ? charsetMatch[1] : null;
31
+
32
+ // Canonical
33
+ const canonicalMatch = head.match(/<link[^>]*rel=["']canonical["'][^>]*href=["']([^"']+)["']/i);
34
+ const canonical = canonicalMatch ? canonicalMatch[1] : null;
35
+
36
+ // Meta tags (name=... content=... and property=... content=...)
37
+ const meta: MetaTag[] = [];
38
+ for (const m of head.matchAll(/<meta\s+([^>]*?)>/gi)) {
39
+ const attrs = m[1];
40
+ const nameMatch = attrs.match(/name=["']([^"']+)["']/i);
41
+ const propMatch = attrs.match(/property=["']([^"']+)["']/i);
42
+ const contentMatch = attrs.match(/content=["']([^"']*?)["']/i);
43
+ if (contentMatch && (nameMatch || propMatch)) {
44
+ meta.push({
45
+ name: nameMatch?.[1],
46
+ property: propMatch?.[1],
47
+ content: contentMatch[1],
48
+ });
49
+ }
50
+ }
51
+
52
+ // Hreflang
53
+ const hreflang: Array<{ lang: string; href: string }> = [];
54
+ const hreflangRegex =
55
+ /<link[^>]*rel=["']alternate["'][^>]*hreflang=["']([^"']+)["'][^>]*href=["']([^"']+)["'][^>]*>/gi;
56
+ const hreflangRegex2 =
57
+ /<link[^>]*hreflang=["']([^"']+)["'][^>]*href=["']([^"']+)["'][^>]*rel=["']alternate["'][^>]*>/gi;
58
+ const hreflangRegex3 =
59
+ /<link[^>]*href=["']([^"']+)["'][^>]*hreflang=["']([^"']+)["'][^>]*rel=["']alternate["'][^>]*>/gi;
60
+ for (const rx of [hreflangRegex, hreflangRegex2]) {
61
+ for (const hm of head.matchAll(rx)) {
62
+ hreflang.push({ lang: hm[1], href: hm[2] });
63
+ }
64
+ }
65
+ for (const hm of head.matchAll(hreflangRegex3)) {
66
+ hreflang.push({ lang: hm[2], href: hm[1] });
67
+ }
68
+
69
+ // JSON-LD
70
+ const jsonLd: unknown[] = [];
71
+ for (const ld of html.matchAll(/<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi)) {
72
+ try {
73
+ jsonLd.push(JSON.parse(ld[1]));
74
+ } catch {
75
+ // malformed JSON-LD, skip
76
+ }
77
+ }
78
+
79
+ return { title, charset, canonical, meta, hreflang, jsonLd };
80
+ }
81
+
82
+ function getMeta(meta: MetaTag[], key: string): string | null {
83
+ const tag = meta.find(
84
+ (m) => m.name?.toLowerCase() === key.toLowerCase() || m.property?.toLowerCase() === key.toLowerCase(),
85
+ );
86
+ return tag?.content ?? null;
87
+ }
88
+
89
+ function getAllMeta(meta: MetaTag[], prefix: string): Array<{ key: string; value: string }> {
90
+ const results: Array<{ key: string; value: string }> = [];
91
+ for (const tag of meta) {
92
+ const key = tag.property ?? tag.name ?? "";
93
+ if (key.toLowerCase().startsWith(prefix.toLowerCase())) {
94
+ results.push({ key, value: tag.content });
95
+ }
96
+ }
97
+ return results;
98
+ }
99
+
100
+ function formatJsonLd(data: unknown, indent = 0): string[] {
101
+ const lines: string[] = [];
102
+ const pad = " ".repeat(indent);
103
+
104
+ if (Array.isArray(data)) {
105
+ for (const item of data) {
106
+ lines.push(...formatJsonLd(item, indent));
107
+ }
108
+ return lines;
109
+ }
110
+
111
+ if (data && typeof data === "object") {
112
+ const obj = data as Record<string, unknown>;
113
+ const type = obj["@type"];
114
+ if (type) lines.push(`${pad}@type: ${Array.isArray(type) ? type.join(", ") : type}`);
115
+
116
+ for (const [key, val] of Object.entries(obj)) {
117
+ if (key.startsWith("@") && key !== "@type") continue;
118
+ if (key === "@type") continue;
119
+ if (typeof val === "string" || typeof val === "number" || typeof val === "boolean") {
120
+ const str = String(val);
121
+ lines.push(`${pad}${key}: ${str.length > 100 ? `${str.slice(0, 100)}...` : str}`);
122
+ } else if (Array.isArray(val)) {
123
+ lines.push(`${pad}${key}: [${val.length} items]`);
124
+ } else if (val && typeof val === "object") {
125
+ const nested = val as Record<string, unknown>;
126
+ if (nested["@type"]) {
127
+ lines.push(`${pad}${key}:`);
128
+ lines.push(...formatJsonLd(nested, indent + 1));
129
+ }
130
+ }
131
+ }
132
+ }
133
+
134
+ return lines;
135
+ }
136
+
137
+ function formatMetatags(url: string, parsed: ParsedHead): string {
138
+ const lines: string[] = [`=== Meta Tags: ${url} ===`, ""];
139
+
140
+ // Document
141
+ lines.push("--- Document ---", "");
142
+ lines.push(`Title: ${parsed.title ? `"${parsed.title}"` : "(missing)"}`);
143
+ lines.push(
144
+ `Description: ${getMeta(parsed.meta, "description") ? `"${getMeta(parsed.meta, "description")}"` : "(missing)"}`,
145
+ );
146
+ lines.push(`Charset: ${parsed.charset ?? "(missing)"}`);
147
+ lines.push(`Viewport: ${getMeta(parsed.meta, "viewport") ?? "(missing)"}`);
148
+ lines.push(`Canonical: ${parsed.canonical ?? "(not set)"}`);
149
+ const robots = getMeta(parsed.meta, "robots");
150
+ if (robots) lines.push(`Robots: ${robots}`);
151
+ const author = getMeta(parsed.meta, "author");
152
+ if (author) lines.push(`Author: ${author}`);
153
+ lines.push("");
154
+
155
+ // Open Graph
156
+ const ogTags = getAllMeta(parsed.meta, "og:");
157
+ lines.push("--- Open Graph ---", "");
158
+ if (ogTags.length > 0) {
159
+ for (const tag of ogTags) {
160
+ lines.push(`${tag.key}: ${tag.value}`);
161
+ }
162
+ } else {
163
+ lines.push("(none found)");
164
+ }
165
+ lines.push("");
166
+
167
+ // Twitter Card
168
+ const twitterTags = getAllMeta(parsed.meta, "twitter:");
169
+ lines.push("--- Twitter Card ---", "");
170
+ if (twitterTags.length > 0) {
171
+ for (const tag of twitterTags) {
172
+ lines.push(`${tag.key}: ${tag.value}`);
173
+ }
174
+ } else {
175
+ lines.push("(none found)");
176
+ }
177
+ lines.push("");
178
+
179
+ // Structured Data
180
+ lines.push("--- Structured Data (JSON-LD) ---", "");
181
+ if (parsed.jsonLd.length > 0) {
182
+ for (let i = 0; i < parsed.jsonLd.length; i++) {
183
+ if (i > 0) lines.push("");
184
+ lines.push(`Block ${i + 1}:`);
185
+ lines.push(...formatJsonLd(parsed.jsonLd[i], 1));
186
+ }
187
+ } else {
188
+ lines.push("(none found)");
189
+ }
190
+ lines.push("");
191
+
192
+ // Hreflang
193
+ if (parsed.hreflang.length > 0) {
194
+ lines.push("--- Alternate Languages ---", "");
195
+ for (const h of parsed.hreflang) {
196
+ lines.push(`${h.lang}: ${h.href}`);
197
+ }
198
+ lines.push("");
199
+ }
200
+
201
+ // Summary of missing tags relevant to search and social
202
+ const missing: string[] = [];
203
+ if (!parsed.title) missing.push("title");
204
+ if (!getMeta(parsed.meta, "description")) missing.push("meta description");
205
+ if (!parsed.canonical) missing.push("canonical URL");
206
+ if (ogTags.length === 0) missing.push("Open Graph tags");
207
+ else {
208
+ if (!getMeta(parsed.meta, "og:image")) missing.push("og:image");
209
+ }
210
+ if (twitterTags.length === 0) missing.push("Twitter Card tags");
211
+ if (parsed.jsonLd.length === 0) missing.push("structured data (JSON-LD)");
212
+
213
+ if (missing.length > 0) {
214
+ lines.push("--- Not Found ---", "");
215
+ for (const m of missing) {
216
+ lines.push(`- ${m}`);
217
+ }
218
+ }
219
+
220
+ return lines.join("\n");
221
+ }
222
+
223
+ export function registerMetatagsTool(server: McpServer): void {
224
+ server.tool(
225
+ "metatags",
226
+ "Fetch a page and report its meta tags, Open Graph, Twitter Card, canonical URL, structured data (JSON-LD), and hreflang. Shows what search engines and social platforms see.",
227
+ {
228
+ url: z.string().url().describe("The URL to fetch and analyze."),
229
+ user_agent: z.string().optional().describe("Custom User-Agent string. Default: Googlebot-compatible."),
230
+ },
231
+ async ({ url, user_agent }) => {
232
+ try {
233
+ const ua = user_agent ?? "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)";
234
+ const res = await fetch(url, {
235
+ headers: {
236
+ "User-Agent": ua,
237
+ Accept: "text/html",
238
+ },
239
+ redirect: "follow",
240
+ });
241
+
242
+ if (!res.ok) {
243
+ return {
244
+ content: [
245
+ {
246
+ type: "text",
247
+ text: `Error fetching ${url}: HTTP ${res.status} ${res.statusText}`,
248
+ },
249
+ ],
250
+ };
251
+ }
252
+
253
+ const html = await res.text();
254
+ const parsed = parseHead(html);
255
+ const finalUrl = res.url !== url ? `(redirected to ${res.url})\n\n` : "";
256
+
257
+ return {
258
+ content: [
259
+ {
260
+ type: "text",
261
+ text: `${finalUrl}${formatMetatags(res.url, parsed)}`,
262
+ },
263
+ ],
264
+ };
265
+ } catch (err) {
266
+ const msg = err instanceof Error ? err.message : String(err);
267
+ return {
268
+ content: [{ type: "text", text: `Error fetching meta tags: ${msg}` }],
269
+ };
270
+ }
271
+ },
272
+ );
273
+ }
@@ -1,6 +1,12 @@
1
1
  import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { z } from "zod";
3
- import { type PsiAudit, type PsiCategoryType, type PsiResult, runPagespeed } from "../lib/psi.js";
3
+ import {
4
+ type PsiAudit,
5
+ type PsiAuditDetailItem,
6
+ type PsiCategoryType,
7
+ type PsiResult,
8
+ runPagespeed,
9
+ } from "../lib/psi.js";
4
10
 
5
11
  function scoreLabel(score: number | null): string {
6
12
  if (score === null) return "N/A";
@@ -43,27 +49,38 @@ function formatLoadingExperience(label: string, exp: PsiResult["loadingExperienc
43
49
  }
44
50
 
45
51
  function formatOpportunities(audits: Record<string, PsiAudit>): string[] {
46
- const opportunities: Array<{ title: string; savings: string; score: number }> = [];
52
+ const opportunities: PsiAudit[] = [];
47
53
 
48
54
  for (const audit of Object.values(audits)) {
49
55
  if (audit.score !== null && audit.score < 1 && audit.numericValue && audit.numericValue > 0) {
50
56
  if (audit.scoreDisplayMode === "numeric" || audit.scoreDisplayMode === "binary") {
51
- const unit = audit.numericUnit === "millisecond" ? "ms" : audit.numericUnit === "byte" ? " bytes" : "";
52
- const savings = audit.displayValue ?? `${Math.round(audit.numericValue)}${unit}`;
53
- opportunities.push({ title: audit.title, savings, score: audit.score });
57
+ opportunities.push(audit);
54
58
  }
55
59
  }
56
60
  }
57
61
 
58
62
  if (opportunities.length === 0) return [];
59
63
 
60
- opportunities.sort((a, b) => a.score - b.score);
64
+ opportunities.sort((a, b) => (a.score ?? 0) - (b.score ?? 0));
61
65
 
62
66
  const lines: string[] = ["--- Opportunities ---", ""];
63
- for (const opp of opportunities.slice(0, 10)) {
64
- const severity = opp.score < 0.5 ? "HIGH" : opp.score < 0.9 ? "MEDIUM" : "LOW";
65
- lines.push(`${severity} ${opp.title}`);
66
- lines.push(` Potential savings: ${opp.savings}`);
67
+ for (const audit of opportunities.slice(0, 10)) {
68
+ const severity = (audit.score ?? 0) < 0.5 ? "HIGH" : (audit.score ?? 0) < 0.9 ? "MEDIUM" : "LOW";
69
+ const unit = audit.numericUnit === "millisecond" ? "ms" : audit.numericUnit === "byte" ? " bytes" : "";
70
+ const savings = audit.displayValue ?? `${Math.round(audit.numericValue ?? 0)}${unit}`;
71
+ lines.push(`${severity} ${audit.title}`);
72
+ lines.push(` Potential savings: ${savings}`);
73
+
74
+ const items = audit.details?.items;
75
+ if (items && items.length > 0) {
76
+ for (const item of items.slice(0, 3)) {
77
+ lines.push(...formatDetailItem(item));
78
+ }
79
+ if (items.length > 3) {
80
+ lines.push(` ... and ${items.length - 3} more resources`);
81
+ }
82
+ }
83
+ lines.push("");
67
84
  }
68
85
 
69
86
  return lines;
@@ -88,6 +105,66 @@ function formatDiagnostics(audits: Record<string, PsiAudit>): string[] {
88
105
  return lines;
89
106
  }
90
107
 
108
+ function formatDetailItem(item: PsiAuditDetailItem): string[] {
109
+ const lines: string[] = [];
110
+
111
+ if (item.node) {
112
+ const n = item.node;
113
+ if (n.nodeLabel) lines.push(` Element: "${n.nodeLabel}"`);
114
+ if (n.selector) lines.push(` Selector: ${n.selector}`);
115
+ if (n.snippet) lines.push(` HTML: ${n.snippet}`);
116
+ if (n.explanation) lines.push(` Issue: ${n.explanation}`);
117
+ } else if (item.url) {
118
+ const parts = [` ${item.url}`];
119
+ if (item.wastedMs) parts.push(`wastedMs=${Math.round(item.wastedMs)}`);
120
+ if (item.wastedBytes) parts.push(`wastedBytes=${Math.round(item.wastedBytes)}`);
121
+ if (item.totalBytes) parts.push(`totalBytes=${Math.round(item.totalBytes)}`);
122
+ lines.push(parts.join(" "));
123
+ }
124
+
125
+ return lines;
126
+ }
127
+
128
+ function formatFailingAudits(audits: Record<string, PsiAudit>, categoryRefs: string[]): string[] {
129
+ const failing: PsiAudit[] = [];
130
+
131
+ for (const ref of categoryRefs) {
132
+ const audit = audits[ref];
133
+ if (audit && audit.score !== null && audit.score < 1) {
134
+ failing.push(audit);
135
+ }
136
+ }
137
+
138
+ if (failing.length === 0) return [];
139
+
140
+ failing.sort((a, b) => (a.score ?? 0) - (b.score ?? 0));
141
+
142
+ const lines: string[] = [];
143
+ for (const audit of failing) {
144
+ const severity = audit.score === 0 ? "FAIL" : (audit.score ?? 0) < 0.5 ? "WARN" : "INFO";
145
+ lines.push(`[${severity}] ${audit.title}`);
146
+ if (audit.displayValue) lines.push(` Value: ${audit.displayValue}`);
147
+
148
+ // Extract learn-more URL from description markdown
149
+ const linkMatch = audit.description?.match(/\[.*?\]\((https?:\/\/[^)]+)\)/);
150
+ if (linkMatch) lines.push(` Learn more: ${linkMatch[1]}`);
151
+
152
+ const items = audit.details?.items;
153
+ if (items && items.length > 0) {
154
+ const maxItems = 5;
155
+ for (const item of items.slice(0, maxItems)) {
156
+ lines.push(...formatDetailItem(item));
157
+ }
158
+ if (items.length > maxItems) {
159
+ lines.push(` ... and ${items.length - maxItems} more`);
160
+ }
161
+ }
162
+ lines.push("");
163
+ }
164
+
165
+ return lines;
166
+ }
167
+
91
168
  function formatPagespeed(url: string, result: PsiResult): string {
92
169
  const lhr = result.lighthouseResult;
93
170
  const lines: string[] = [
@@ -153,6 +230,19 @@ function formatPagespeed(url: string, result: PsiResult): string {
153
230
  const diags = formatDiagnostics(lhr.audits);
154
231
  if (diags.length > 0) lines.push(...diags, "");
155
232
 
233
+ // Failing audits per non-performance category (a11y, SEO, best practices)
234
+ for (const cat of Object.values(lhr.categories)) {
235
+ if (cat.id === "performance" || !cat.auditRefs) continue;
236
+ const score = cat.score !== null ? Math.round(cat.score * 100) : null;
237
+ if (score === null || score >= 100) continue;
238
+
239
+ const refs = cat.auditRefs.map((r) => r.id);
240
+ const details = formatFailingAudits(lhr.audits, refs);
241
+ if (details.length > 0) {
242
+ lines.push(`--- ${cat.title} Issues ---`, "", ...details);
243
+ }
244
+ }
245
+
156
246
  // Timing
157
247
  lines.push(`Analysis took ${(lhr.timing.total / 1000).toFixed(1)}s`);
158
248