pagesight 0.2.3 → 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 +1 -1
- package/src/index.ts +2 -0
- package/src/lib/auth.ts +4 -0
- package/src/lib/gsc.ts +3 -1
- package/src/lib/psi.ts +25 -0
- package/src/tools/crux.ts +8 -2
- package/src/tools/inspect.ts +12 -1
- package/src/tools/metatags.ts +273 -0
- package/src/tools/pagespeed.ts +100 -10
- package/src/tools/performance.ts +1 -1
package/package.json
CHANGED
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/auth.ts
CHANGED
|
@@ -133,6 +133,10 @@ export async function getAccessToken(): Promise<string> {
|
|
|
133
133
|
);
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
export function clearTokenCache(): void {
|
|
137
|
+
cachedToken = null;
|
|
138
|
+
}
|
|
139
|
+
|
|
136
140
|
export function getAuthMethod(): string {
|
|
137
141
|
if (process.env.GSC_SERVICE_ACCOUNT_KEY) return "service_account";
|
|
138
142
|
if (process.env.GSC_REFRESH_TOKEN) return "oauth";
|
package/src/lib/gsc.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getAccessToken } from "./auth.js";
|
|
1
|
+
import { clearTokenCache, getAccessToken } from "./auth.js";
|
|
2
2
|
|
|
3
3
|
const GSC_API = "https://searchconsole.googleapis.com/v1";
|
|
4
4
|
const WEBMASTERS_API = "https://www.googleapis.com/webmasters/v3";
|
|
@@ -15,6 +15,8 @@ async function gscFetch(url: string, body?: unknown): Promise<Record<string, unk
|
|
|
15
15
|
});
|
|
16
16
|
|
|
17
17
|
if (!res.ok) {
|
|
18
|
+
// Clear cached token on 401 so next call gets a fresh one
|
|
19
|
+
if (res.status === 401) clearTokenCache();
|
|
18
20
|
const err = await res.text();
|
|
19
21
|
throw new Error(`GSC API error (${res.status}): ${err}`);
|
|
20
22
|
}
|
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 {
|
package/src/tools/crux.ts
CHANGED
|
@@ -177,7 +177,10 @@ export function registerCruxTool(server: McpServer): void {
|
|
|
177
177
|
},
|
|
178
178
|
async ({ url, origin, form_factor, metrics }) => {
|
|
179
179
|
if (!url && !origin) {
|
|
180
|
-
return { content: [{ type: "text", text: "Error: provide either url or origin." }] };
|
|
180
|
+
return { content: [{ type: "text", text: "Error: provide either url or origin, not both." }] };
|
|
181
|
+
}
|
|
182
|
+
if (url && origin) {
|
|
183
|
+
return { content: [{ type: "text", text: "Error: provide either url or origin, not both." }] };
|
|
181
184
|
}
|
|
182
185
|
try {
|
|
183
186
|
const result = await queryCrux({
|
|
@@ -248,7 +251,10 @@ export function registerCruxTool(server: McpServer): void {
|
|
|
248
251
|
},
|
|
249
252
|
async ({ url, origin, form_factor, metrics, periods }) => {
|
|
250
253
|
if (!url && !origin) {
|
|
251
|
-
return { content: [{ type: "text", text: "Error: provide either url or origin." }] };
|
|
254
|
+
return { content: [{ type: "text", text: "Error: provide either url or origin, not both." }] };
|
|
255
|
+
}
|
|
256
|
+
if (url && origin) {
|
|
257
|
+
return { content: [{ type: "text", text: "Error: provide either url or origin, not both." }] };
|
|
252
258
|
}
|
|
253
259
|
try {
|
|
254
260
|
const result = await queryCruxHistory({
|
package/src/tools/inspect.ts
CHANGED
|
@@ -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:
|
|
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
|
+
}
|
package/src/tools/pagespeed.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import {
|
|
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:
|
|
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
|
-
|
|
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
|
|
64
|
-
const severity =
|
|
65
|
-
|
|
66
|
-
|
|
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
|
|
package/src/tools/performance.ts
CHANGED
|
@@ -52,7 +52,7 @@ function formatPerformance(
|
|
|
52
52
|
|
|
53
53
|
const top = rows.slice(0, 25);
|
|
54
54
|
for (const row of top) {
|
|
55
|
-
const keys = row.keys.join(" | ");
|
|
55
|
+
const keys = row.keys.map((k, i) => `${dimensions[i] ?? "key"}=${k}`).join(" | ");
|
|
56
56
|
lines.push(`${keys}`);
|
|
57
57
|
lines.push(
|
|
58
58
|
` Clicks: ${row.clicks} | Impressions: ${row.impressions} | CTR: ${(row.ctr * 100).toFixed(1)}% | Position: ${row.position.toFixed(1)}`,
|