nfunc-mcp 0.3.0 → 0.5.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 +121 -379
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/mappers/a11yDedupe.js +38 -7
- package/dist/mappers/a11yDedupe.js.map +1 -1
- package/dist/mappers/defectFormatter.d.ts +9 -1
- package/dist/mappers/defectFormatter.js +53 -13
- package/dist/mappers/defectFormatter.js.map +1 -1
- package/dist/mappers/labFieldComparator.d.ts +62 -0
- package/dist/mappers/labFieldComparator.js +134 -0
- package/dist/mappers/labFieldComparator.js.map +1 -0
- package/dist/mappers/priorityMapper.d.ts +42 -0
- package/dist/mappers/priorityMapper.js +58 -0
- package/dist/mappers/priorityMapper.js.map +1 -1
- package/dist/mappers/psiAggregator.d.ts +130 -0
- package/dist/mappers/psiAggregator.js +293 -0
- package/dist/mappers/psiAggregator.js.map +1 -0
- package/dist/mappers/runComparator.d.ts +85 -0
- package/dist/mappers/runComparator.js +165 -0
- package/dist/mappers/runComparator.js.map +1 -0
- package/dist/mappers/wcagLevels.d.ts +73 -0
- package/dist/mappers/wcagLevels.js +320 -0
- package/dist/mappers/wcagLevels.js.map +1 -0
- package/dist/mappers/webVitalsMapper.d.ts +52 -0
- package/dist/mappers/webVitalsMapper.js +131 -0
- package/dist/mappers/webVitalsMapper.js.map +1 -0
- package/dist/tools/accessibility.d.ts +1 -0
- package/dist/tools/accessibility.js +488 -63
- package/dist/tools/accessibility.js.map +1 -1
- package/dist/tools/lighthouse.js +370 -102
- package/dist/tools/lighthouse.js.map +1 -1
- package/dist/tools/performanceAudit.d.ts +2 -0
- package/dist/tools/performanceAudit.js +446 -0
- package/dist/tools/performanceAudit.js.map +1 -0
- package/dist/tools/performanceAuditPlan.d.ts +2 -0
- package/dist/tools/performanceAuditPlan.js +438 -0
- package/dist/tools/performanceAuditPlan.js.map +1 -0
- package/dist/utils/batchState.d.ts +75 -0
- package/dist/utils/batchState.js +128 -0
- package/dist/utils/batchState.js.map +1 -0
- package/dist/utils/csvReader.d.ts +20 -0
- package/dist/utils/csvReader.js +172 -0
- package/dist/utils/csvReader.js.map +1 -0
- package/dist/utils/httpClient.d.ts +84 -0
- package/dist/utils/httpClient.js +171 -0
- package/dist/utils/httpClient.js.map +1 -0
- package/dist/utils/outputParsers.js +26 -30
- package/dist/utils/outputParsers.js.map +1 -1
- package/dist/utils/psiAuth.d.ts +26 -0
- package/dist/utils/psiAuth.js +36 -0
- package/dist/utils/psiAuth.js.map +1 -0
- package/dist/utils/psiParser.d.ts +135 -0
- package/dist/utils/psiParser.js +200 -0
- package/dist/utils/psiParser.js.map +1 -0
- package/dist/utils/publicUrl.d.ts +17 -0
- package/dist/utils/publicUrl.js +115 -0
- package/dist/utils/publicUrl.js.map +1 -0
- package/dist/utils/sitemapReader.d.ts +27 -0
- package/dist/utils/sitemapReader.js +272 -0
- package/dist/utils/sitemapReader.js.map +1 -0
- package/dist/utils/urlClassifier.d.ts +45 -0
- package/dist/utils/urlClassifier.js +267 -0
- package/dist/utils/urlClassifier.js.map +1 -0
- package/dist/utils/urlInput.d.ts +30 -0
- package/dist/utils/urlInput.js +130 -0
- package/dist/utils/urlInput.js.map +1 -0
- package/docs/manual.md +769 -0
- package/docs/psi-report-spec.md +174 -0
- package/package.json +13 -3
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { httpGet } from "../utils/httpClient.js";
|
|
3
|
+
import { readSitemap } from "../utils/sitemapReader.js";
|
|
4
|
+
import { readUrlsFromCsv } from "../utils/csvReader.js";
|
|
5
|
+
import { classifyUrls } from "../utils/urlClassifier.js";
|
|
6
|
+
import { checkPublicReachability } from "../utils/publicUrl.js";
|
|
7
|
+
import { resolveApiKey, keylessWarning, KEY_ENV_VAR, KEYLESS_RUN_CAP, } from "../utils/psiAuth.js";
|
|
8
|
+
/**
|
|
9
|
+
* Wall-clock budget per PSI call.
|
|
10
|
+
*
|
|
11
|
+
* Measured, not guessed: fivebelow.com's homepage took 39-47 s per run against
|
|
12
|
+
* a live key, roughly three times the 10-30 s the documentation implies. An
|
|
13
|
+
* estimate exists to let someone decide whether to approve a batch, so it
|
|
14
|
+
* should err high — a 20-minute job quoted as 7 gets approved and then
|
|
15
|
+
* abandoned.
|
|
16
|
+
*/
|
|
17
|
+
const SECONDS_PER_RUN = 45;
|
|
18
|
+
const DAILY_QUOTA_WITH_KEY = 25_000;
|
|
19
|
+
/**
|
|
20
|
+
* How many templates get sampled by default.
|
|
21
|
+
*
|
|
22
|
+
* Classification is deliberately granular — merging "/en/blog/release/*" into
|
|
23
|
+
* "/en/*" would hide real differences — but granularity compounds: nodejs.org
|
|
24
|
+
* resolves to 100 templates, and sampling all of them is 364 PSI calls of
|
|
25
|
+
* mostly redundant blog posts. Templates are ranked by how much of the site
|
|
26
|
+
* they represent and the top slice is sampled; the rest are still reported, so
|
|
27
|
+
* the user can pull any of them in by name.
|
|
28
|
+
*/
|
|
29
|
+
const DEFAULT_MAX_TEMPLATES = 12;
|
|
30
|
+
const inputShape = {
|
|
31
|
+
origin: z
|
|
32
|
+
.string()
|
|
33
|
+
.url()
|
|
34
|
+
.describe("Site to plan an audit for, e.g. https://www.example.com"),
|
|
35
|
+
discovery: z
|
|
36
|
+
.enum(["sitemap", "list", "csv", "crawl"])
|
|
37
|
+
.optional()
|
|
38
|
+
.describe("How to find URLs. 'sitemap' (default) reads robots.txt and " +
|
|
39
|
+
"/sitemap.xml — fastest and authoritative. 'list' uses the urls " +
|
|
40
|
+
"input. 'csv' reads csv_path. 'crawl' is not implemented yet."),
|
|
41
|
+
urls: z.array(z.string().url()).optional(),
|
|
42
|
+
csv_path: z.string().optional(),
|
|
43
|
+
strategy: z.enum(["mobile", "desktop", "both"]).optional(),
|
|
44
|
+
runs_per_url: z.number().int().min(1).max(5).optional(),
|
|
45
|
+
max_templates: z
|
|
46
|
+
.number()
|
|
47
|
+
.int()
|
|
48
|
+
.min(1)
|
|
49
|
+
.max(100)
|
|
50
|
+
.optional()
|
|
51
|
+
.describe(`How many templates to sample, largest first (default ${DEFAULT_MAX_TEMPLATES}). ` +
|
|
52
|
+
"Smaller templates are still listed so they can be requested by name."),
|
|
53
|
+
api_key: z
|
|
54
|
+
.string()
|
|
55
|
+
.optional()
|
|
56
|
+
.describe(`Optional PSI key. Prefer setting ${KEY_ENV_VAR} in the MCP client ` +
|
|
57
|
+
"config — a key passed here is stored in the conversation transcript."),
|
|
58
|
+
};
|
|
59
|
+
/** Same-host filter. A sitemap may list other properties; auditing them silently would be wrong. */
|
|
60
|
+
function sameOrigin(url, originHost) {
|
|
61
|
+
try {
|
|
62
|
+
return new URL(url).hostname.toLowerCase() === originHost;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/** A leading /en/ or /en-us/ is a locale, not a section of the site. */
|
|
69
|
+
const LOCALE_SEGMENT = /^[a-z]{2}(-[a-z]{2})?$/i;
|
|
70
|
+
/**
|
|
71
|
+
* Which part of the site a template belongs to.
|
|
72
|
+
*
|
|
73
|
+
* The first path segment, except when that segment is a locale code — on
|
|
74
|
+
* nodejs.org every page lives under /en/, so keying on segment one put blog,
|
|
75
|
+
* docs and downloads in a single bucket and defeated the round-robin
|
|
76
|
+
* entirely. Locale-prefixed URLs are common enough (and this check narrow
|
|
77
|
+
* enough) to be worth special-casing.
|
|
78
|
+
*/
|
|
79
|
+
function sectionOf(pattern) {
|
|
80
|
+
const segments = pattern.replace(/^\//, "").split("?")[0].split("/").filter(Boolean);
|
|
81
|
+
if (segments.length > 1 && LOCALE_SEGMENT.test(segments[0]))
|
|
82
|
+
segments.shift();
|
|
83
|
+
return segments[0] ?? "/";
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Choose which templates to sample.
|
|
87
|
+
*
|
|
88
|
+
* Ranking purely by URL count fills the budget with whatever section is
|
|
89
|
+
* biggest: on nodejs.org that is eleven flavours of blog and nothing else,
|
|
90
|
+
* while a human would have picked a blog post, the docs, the download page and
|
|
91
|
+
* the homepage. Volume is a poor proxy for what a performance audit needs,
|
|
92
|
+
* because templates within one section usually share a codepath and therefore
|
|
93
|
+
* share their defects — the second blog variant tells you far less than the
|
|
94
|
+
* first download page.
|
|
95
|
+
*
|
|
96
|
+
* So selection round-robins across top-level sections, taking each section's
|
|
97
|
+
* largest remaining template in turn. Within a section, and between sections
|
|
98
|
+
* of equal standing, URL count still decides. The entry page — whatever URL was
|
|
99
|
+
* actually requested — is seeded first regardless: it is a single URL, so volume
|
|
100
|
+
* ranking always sorts it last, and it is the page the user named.
|
|
101
|
+
*/
|
|
102
|
+
function selectBudget(eligible, budget, entryPattern = "/") {
|
|
103
|
+
// The entry page is one URL, so volume ranking always sorts it last.
|
|
104
|
+
const homepage = eligible.find((t) => t.pattern === entryPattern);
|
|
105
|
+
const picked = homepage ? [homepage] : [];
|
|
106
|
+
const bySection = new Map();
|
|
107
|
+
for (const t of eligible) {
|
|
108
|
+
if (t === homepage)
|
|
109
|
+
continue;
|
|
110
|
+
const key = sectionOf(t.pattern);
|
|
111
|
+
const bucket = bySection.get(key);
|
|
112
|
+
if (bucket)
|
|
113
|
+
bucket.push(t);
|
|
114
|
+
else
|
|
115
|
+
bySection.set(key, [t]);
|
|
116
|
+
}
|
|
117
|
+
// Sections ordered by their largest template, so the dominant part of the
|
|
118
|
+
// site is still sampled first — just not exclusively.
|
|
119
|
+
const queues = [...bySection.values()]
|
|
120
|
+
.map((list) => [...list].sort((a, b) => b.urlCount - a.urlCount))
|
|
121
|
+
.sort((a, b) => b[0].urlCount - a[0].urlCount);
|
|
122
|
+
let progressed = true;
|
|
123
|
+
while (picked.length < budget && progressed) {
|
|
124
|
+
progressed = false;
|
|
125
|
+
for (const queue of queues) {
|
|
126
|
+
if (picked.length >= budget)
|
|
127
|
+
break;
|
|
128
|
+
const next = queue.shift();
|
|
129
|
+
if (!next)
|
|
130
|
+
continue;
|
|
131
|
+
picked.push(next);
|
|
132
|
+
progressed = true;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return picked;
|
|
136
|
+
}
|
|
137
|
+
function buildQuestions(templates, gated, deferred, runs, runsPerUrl, hasKey, truncated) {
|
|
138
|
+
const questions = [];
|
|
139
|
+
questions.push(`Sample sizes: the plan audits ${templates.reduce((n, t) => n + t.suggestedSample, 0)} ` +
|
|
140
|
+
`URLs across ${templates.length} templates. Accept these, or set your own per template?`);
|
|
141
|
+
if (runsPerUrl === 1) {
|
|
142
|
+
const extra = Math.round((runs * 2 * SECONDS_PER_RUN) / 60);
|
|
143
|
+
questions.push(`runs_per_url is 1, so a single slow run becomes an unchallenged data ` +
|
|
144
|
+
`point. Raising it to 3 takes the median and removes that risk, at ` +
|
|
145
|
+
`roughly ${extra} extra minutes. Raise it?`);
|
|
146
|
+
}
|
|
147
|
+
if (deferred.length > 0) {
|
|
148
|
+
questions.push(`${deferred.length} smaller template(s) are listed but not sampled, to keep the run ` +
|
|
149
|
+
`affordable. Pull any of them in by pattern, or raise max_templates?`);
|
|
150
|
+
}
|
|
151
|
+
if (gated.length > 0) {
|
|
152
|
+
questions.push(`${gated.map((t) => t.label).join(", ")} cannot be audited by PSI — it ` +
|
|
153
|
+
`fetches anonymously, so it would measure an empty cart or a login ` +
|
|
154
|
+
`redirect. Run these through run_lighthouse separately?`);
|
|
155
|
+
}
|
|
156
|
+
if (!hasKey) {
|
|
157
|
+
questions.push(`No ${KEY_ENV_VAR} is set. Provide a key, or cut the run to ` +
|
|
158
|
+
`${KEYLESS_RUN_CAP} runs or fewer?`);
|
|
159
|
+
}
|
|
160
|
+
if (truncated) {
|
|
161
|
+
questions.push("Sitemap discovery hit its cap, so the template breakdown covers a " +
|
|
162
|
+
"subset of the site. Proceed with that, or narrow the audit to " +
|
|
163
|
+
"specific sections?");
|
|
164
|
+
}
|
|
165
|
+
return questions;
|
|
166
|
+
}
|
|
167
|
+
export function registerPerformanceAuditPlanTool(server) {
|
|
168
|
+
server.registerTool("plan_performance_audit", {
|
|
169
|
+
description: "Plans a PageSpeed Insights audit without spending any PSI quota. " +
|
|
170
|
+
"Discovers a site's URLs (sitemap, an explicit list, or a CSV), " +
|
|
171
|
+
"clusters them into page templates, flags the ones PSI physically " +
|
|
172
|
+
"cannot audit, and returns a costed run plan plus the questions that " +
|
|
173
|
+
"need answering before it runs. " +
|
|
174
|
+
"\n\n" +
|
|
175
|
+
"**Always call this before run_performance_audit.** Present the " +
|
|
176
|
+
"returned templates and `questions` to the user, get their answers, " +
|
|
177
|
+
"then pass the confirmed page list to run_performance_audit. Nothing " +
|
|
178
|
+
"is audited until they approve — a misclassified template should " +
|
|
179
|
+
"cost a conversation turn, not 40 API calls." +
|
|
180
|
+
"\n\n" +
|
|
181
|
+
"PSI is for publicly hosted sites. Localhost and private hosts are " +
|
|
182
|
+
"rejected here; use run_lighthouse for those.",
|
|
183
|
+
inputSchema: inputShape,
|
|
184
|
+
}, async ({ origin, discovery, urls, csv_path, strategy, runs_per_url, max_templates, api_key }) => {
|
|
185
|
+
const warnings = [];
|
|
186
|
+
const mode = discovery ?? "sitemap";
|
|
187
|
+
const resolvedStrategy = strategy ?? "both";
|
|
188
|
+
const runsPerUrl = runs_per_url ?? 1;
|
|
189
|
+
const maxTemplates = max_templates ?? DEFAULT_MAX_TEMPLATES;
|
|
190
|
+
const strategyCount = resolvedStrategy === "both" ? 2 : 1;
|
|
191
|
+
// --- Preflight ------------------------------------------------------
|
|
192
|
+
const reach = checkPublicReachability(origin);
|
|
193
|
+
if (!reach.auditable) {
|
|
194
|
+
return {
|
|
195
|
+
content: [
|
|
196
|
+
{
|
|
197
|
+
type: "text",
|
|
198
|
+
text: JSON.stringify({ error: "origin_not_auditable", origin, reason: reach.reason }, null, 2),
|
|
199
|
+
},
|
|
200
|
+
],
|
|
201
|
+
isError: true,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
if (mode === "crawl") {
|
|
205
|
+
return {
|
|
206
|
+
content: [
|
|
207
|
+
{
|
|
208
|
+
type: "text",
|
|
209
|
+
text: JSON.stringify({
|
|
210
|
+
error: "crawl_not_implemented",
|
|
211
|
+
message: "Crawling is not built yet. Use discovery:'sitemap' " +
|
|
212
|
+
"(default), or supply URLs with discovery:'list' or " +
|
|
213
|
+
"discovery:'csv'.",
|
|
214
|
+
}, null, 2),
|
|
215
|
+
},
|
|
216
|
+
],
|
|
217
|
+
isError: true,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
const originHost = new URL(origin).hostname.toLowerCase();
|
|
221
|
+
const liveCheck = await httpGet(origin, { timeoutMs: 15_000, retries: 1 });
|
|
222
|
+
if (!liveCheck.ok) {
|
|
223
|
+
// A bot wall blocking *us* says nothing about PSI, which fetches from
|
|
224
|
+
// Google's own address space and is widely allowlisted — fivebelow.com
|
|
225
|
+
// returns a Cloudflare challenge here while having served PSI fine.
|
|
226
|
+
// Reporting that as "PSI will likely fail" would be a false alarm.
|
|
227
|
+
const botWalled = liveCheck.status === 403 || liveCheck.status === 503;
|
|
228
|
+
warnings.push(botWalled
|
|
229
|
+
? `${origin} returned HTTP ${liveCheck.status} to this preflight, which usually means ` +
|
|
230
|
+
"a bot-protection challenge rather than a broken site. PSI fetches from Google's " +
|
|
231
|
+
"infrastructure and is often allowlisted where we are not, so this is not a reason " +
|
|
232
|
+
"to stop — but if the audit comes back empty, that challenge page is the first suspect."
|
|
233
|
+
: `${origin} did not respond successfully (${liveCheck.error ?? `HTTP ${liveCheck.status}`}). ` +
|
|
234
|
+
"PSI will likely fail the same way; confirm the URL before running.");
|
|
235
|
+
}
|
|
236
|
+
// --- Discovery ------------------------------------------------------
|
|
237
|
+
let discovered = [];
|
|
238
|
+
let sitemapUrl = null;
|
|
239
|
+
let truncated = false;
|
|
240
|
+
if (mode === "list") {
|
|
241
|
+
if (!urls || urls.length === 0) {
|
|
242
|
+
return {
|
|
243
|
+
content: [
|
|
244
|
+
{
|
|
245
|
+
type: "text",
|
|
246
|
+
text: JSON.stringify({ error: "missing_urls", message: "discovery:'list' requires a non-empty urls array." }, null, 2),
|
|
247
|
+
},
|
|
248
|
+
],
|
|
249
|
+
isError: true,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
discovered = urls;
|
|
253
|
+
}
|
|
254
|
+
else if (mode === "csv") {
|
|
255
|
+
if (!csv_path) {
|
|
256
|
+
return {
|
|
257
|
+
content: [
|
|
258
|
+
{
|
|
259
|
+
type: "text",
|
|
260
|
+
text: JSON.stringify({ error: "missing_csv_path", message: "discovery:'csv' requires csv_path." }, null, 2),
|
|
261
|
+
},
|
|
262
|
+
],
|
|
263
|
+
isError: true,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
try {
|
|
267
|
+
const csv = readUrlsFromCsv(csv_path);
|
|
268
|
+
discovered = csv.urls;
|
|
269
|
+
warnings.push(...csv.warnings);
|
|
270
|
+
warnings.push(`Read ${csv.urls.length} URLs from column "${csv.column}" of ${csv_path}.`);
|
|
271
|
+
}
|
|
272
|
+
catch (err) {
|
|
273
|
+
return {
|
|
274
|
+
content: [
|
|
275
|
+
{
|
|
276
|
+
type: "text",
|
|
277
|
+
text: JSON.stringify({ error: "csv_unreadable", message: err.message }, null, 2),
|
|
278
|
+
},
|
|
279
|
+
],
|
|
280
|
+
isError: true,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
else {
|
|
285
|
+
const sitemap = await readSitemap(origin);
|
|
286
|
+
discovered = sitemap.urls;
|
|
287
|
+
sitemapUrl = sitemap.sitemapsRead[0] ?? null;
|
|
288
|
+
truncated = sitemap.truncated;
|
|
289
|
+
warnings.push(...sitemap.warnings);
|
|
290
|
+
}
|
|
291
|
+
// Seed the URL the user actually gave.
|
|
292
|
+
//
|
|
293
|
+
// Sitemaps frequently omit their entry page — nodejs.org lists /en/... and
|
|
294
|
+
// never "/" — so a plan built purely from discovery can skip the very page
|
|
295
|
+
// that was asked about. This originally seeded `new URL("/", origin)`,
|
|
296
|
+
// which silently retargeted the audit whenever the input carried a path:
|
|
297
|
+
// a request for /ecommerce/ was planned as the site's domain homepage, a
|
|
298
|
+
// completely different page.
|
|
299
|
+
const entry = new URL(origin);
|
|
300
|
+
const entryUrl = entry.toString();
|
|
301
|
+
const entryPath = entry.pathname.replace(/\/$/, "") || "/";
|
|
302
|
+
const sameUrl = (a, b) => a.replace(/\/$/, "") === b.replace(/\/$/, "");
|
|
303
|
+
if (!discovered.some((u) => sameUrl(u, entryUrl))) {
|
|
304
|
+
discovered = [entryUrl, ...discovered];
|
|
305
|
+
warnings.push(`Added ${entryUrl} — it was not listed by ${mode} discovery.`);
|
|
306
|
+
}
|
|
307
|
+
let onOrigin = discovered.filter((u) => sameOrigin(u, originHost));
|
|
308
|
+
// A path in the input scopes the audit to that subtree. Someone auditing
|
|
309
|
+
// /ecommerce/ is asking about the shop, not the blog that shares the host.
|
|
310
|
+
if (entryPath !== "/") {
|
|
311
|
+
const before = onOrigin.length;
|
|
312
|
+
onOrigin = onOrigin.filter((u) => {
|
|
313
|
+
try {
|
|
314
|
+
const path = new URL(u).pathname.replace(/\/$/, "") || "/";
|
|
315
|
+
return path === entryPath || path.startsWith(`${entryPath}/`);
|
|
316
|
+
}
|
|
317
|
+
catch {
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
if (before > onOrigin.length) {
|
|
322
|
+
warnings.push(`Scoped to ${entryPath}/ — dropped ${before - onOrigin.length} URL(s) outside that ` +
|
|
323
|
+
`path. Pass the bare origin instead to audit the whole site.`);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
if (onOrigin.length < discovered.length) {
|
|
327
|
+
warnings.push(`Dropped ${discovered.length - onOrigin.length} URL(s) pointing at a different host than ${originHost}.`);
|
|
328
|
+
}
|
|
329
|
+
if (onOrigin.length === 0) {
|
|
330
|
+
return {
|
|
331
|
+
content: [
|
|
332
|
+
{
|
|
333
|
+
type: "text",
|
|
334
|
+
text: JSON.stringify({
|
|
335
|
+
error: "no_urls_found",
|
|
336
|
+
origin,
|
|
337
|
+
discovery: mode,
|
|
338
|
+
warnings,
|
|
339
|
+
message: "No auditable URLs were discovered. Supply them directly " +
|
|
340
|
+
"with discovery:'list', or point at a CSV export.",
|
|
341
|
+
}, null, 2),
|
|
342
|
+
},
|
|
343
|
+
],
|
|
344
|
+
isError: true,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
// --- Classify -------------------------------------------------------
|
|
348
|
+
const { templates, skipped } = classifyUrls(onOrigin);
|
|
349
|
+
if (skipped > 0)
|
|
350
|
+
warnings.push(`Skipped ${skipped} unparseable URL(s).`);
|
|
351
|
+
// classifyUrls returns largest-first, so the head of the list is the
|
|
352
|
+
// slice of the site worth spending calls on — with one correction. The
|
|
353
|
+
// homepage is a single URL and therefore ranks last by volume, which on
|
|
354
|
+
// nodejs.org pushed it out of the sample entirely in favour of an
|
|
355
|
+
// eleventh blog variant. It is the most-visited page on essentially
|
|
356
|
+
// every site, so it is seeded into the budget before ranking applies.
|
|
357
|
+
const eligible = templates.filter((t) => !t.anySessionGated);
|
|
358
|
+
const inBudget = new Set(selectBudget(eligible, maxTemplates, entryPath).map((t) => t.id));
|
|
359
|
+
const planned = templates.map((t) => {
|
|
360
|
+
if (!inBudget.has(t.id) && !t.anySessionGated) {
|
|
361
|
+
return {
|
|
362
|
+
...t,
|
|
363
|
+
suggestedSample: 0,
|
|
364
|
+
reason: "below_sampling_budget",
|
|
365
|
+
recommendation: `Not sampled by default — it is outside the top ${maxTemplates} templates by ` +
|
|
366
|
+
"URL count. Ask for it by pattern to include it.",
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
if (t.anySessionGated) {
|
|
370
|
+
return {
|
|
371
|
+
...t,
|
|
372
|
+
auditable: false,
|
|
373
|
+
suggestedSample: 0,
|
|
374
|
+
reason: "session_gated",
|
|
375
|
+
recommendation: "PSI fetches anonymously and would measure a logged-out or " +
|
|
376
|
+
"empty version of this page. Audit it with run_lighthouse, " +
|
|
377
|
+
"which runs Chrome locally and can carry session cookies.",
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
return { ...t, sampled_urls: t.candidates.slice(0, t.suggestedSample) };
|
|
381
|
+
});
|
|
382
|
+
const sampled = planned.filter((t) => t.auditable && t.suggestedSample > 0);
|
|
383
|
+
const gated = planned.filter((t) => !t.auditable);
|
|
384
|
+
const deferred = planned.filter((t) => t.auditable && t.suggestedSample === 0);
|
|
385
|
+
// --- Cost -----------------------------------------------------------
|
|
386
|
+
const sampledUrls = sampled.reduce((n, t) => n + t.suggestedSample, 0);
|
|
387
|
+
const urlsCovered = sampled.reduce((n, t) => n + t.urlCount, 0);
|
|
388
|
+
const runs = sampledUrls * strategyCount * runsPerUrl;
|
|
389
|
+
const { key, source } = resolveApiKey(api_key);
|
|
390
|
+
if (!key && runs > KEYLESS_RUN_CAP)
|
|
391
|
+
warnings.push(keylessWarning(runs));
|
|
392
|
+
const payload = {
|
|
393
|
+
origin,
|
|
394
|
+
discovery: {
|
|
395
|
+
source: mode,
|
|
396
|
+
urls_found: onOrigin.length,
|
|
397
|
+
sitemap_url: sitemapUrl,
|
|
398
|
+
truncated,
|
|
399
|
+
},
|
|
400
|
+
api_key: { present: Boolean(key), source },
|
|
401
|
+
strategy: resolvedStrategy,
|
|
402
|
+
runs_per_url: runsPerUrl,
|
|
403
|
+
templates: planned.map((t) => ({
|
|
404
|
+
id: t.id,
|
|
405
|
+
label: t.label,
|
|
406
|
+
pattern: t.pattern,
|
|
407
|
+
url_count: t.urlCount,
|
|
408
|
+
suggested_sample: t.suggestedSample,
|
|
409
|
+
candidates: t.candidates,
|
|
410
|
+
...(t.sampled_urls ? { sampled_urls: t.sampled_urls } : {}),
|
|
411
|
+
auditable: t.auditable,
|
|
412
|
+
...(t.reason ? { reason: t.reason } : {}),
|
|
413
|
+
...(t.recommendation ? { recommendation: t.recommendation } : {}),
|
|
414
|
+
})),
|
|
415
|
+
coverage: {
|
|
416
|
+
templates_total: planned.length,
|
|
417
|
+
templates_sampled: sampled.length,
|
|
418
|
+
templates_deferred: deferred.length,
|
|
419
|
+
urls_represented: urlsCovered,
|
|
420
|
+
urls_total: onOrigin.length,
|
|
421
|
+
represented_pct: Math.round((urlsCovered / onOrigin.length) * 100),
|
|
422
|
+
},
|
|
423
|
+
estimate: {
|
|
424
|
+
urls_sampled: sampledUrls,
|
|
425
|
+
runs,
|
|
426
|
+
wall_clock_minutes: Math.max(1, Math.round((runs * SECONDS_PER_RUN) / 60)),
|
|
427
|
+
quota_used: runs,
|
|
428
|
+
quota_limit: key ? DAILY_QUOTA_WITH_KEY : KEYLESS_RUN_CAP,
|
|
429
|
+
},
|
|
430
|
+
questions: buildQuestions(sampled, gated, deferred, runs, runsPerUrl, Boolean(key), truncated),
|
|
431
|
+
next_step: "Show the templates and questions to the user. Once they confirm " +
|
|
432
|
+
"the sample, call run_performance_audit with the approved pages.",
|
|
433
|
+
warnings,
|
|
434
|
+
};
|
|
435
|
+
return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
//# sourceMappingURL=performanceAuditPlan.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"performanceAuditPlan.js","sourceRoot":"","sources":["../../src/tools/performanceAuditPlan.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared machinery for tools that process a list of URLs across several calls.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from the PSI runner, where every piece of it was earned the hard
|
|
5
|
+
* way: an MCP client backgrounds a tool call at 120 seconds, local Lighthouse
|
|
6
|
+
* takes 20-40 seconds per URL per form factor, and both PSI and pa11y fail
|
|
7
|
+
* intermittently on real sites. A batch therefore cannot be one long call, and
|
|
8
|
+
* a failure partway through must not cost the runs that already succeeded.
|
|
9
|
+
*
|
|
10
|
+
* The contract is: bound each call by a clock, write results to disk as they
|
|
11
|
+
* land, merge rather than overwrite an index, and hand back a cursor.
|
|
12
|
+
*/
|
|
13
|
+
/** Default per-call ceiling. Below the 120s at which Claude Code backgrounds a call. */
|
|
14
|
+
export declare const DEFAULT_MAX_SECONDS_PER_CALL = 100;
|
|
15
|
+
export interface BatchUnit {
|
|
16
|
+
url: string;
|
|
17
|
+
/** Device profile, engine, or whatever second dimension the tool runs over. */
|
|
18
|
+
variant: string;
|
|
19
|
+
}
|
|
20
|
+
/** Anything the index can hold. Tools define their own richer result types. */
|
|
21
|
+
export interface BatchRecord {
|
|
22
|
+
url: string;
|
|
23
|
+
variant: string;
|
|
24
|
+
[key: string]: unknown;
|
|
25
|
+
}
|
|
26
|
+
export declare function encodeCursor(index: number): string;
|
|
27
|
+
export declare function decodeCursor(cursor: string | undefined): number;
|
|
28
|
+
/** Cross-product of URLs and variants, in URL-major order so a partial run covers whole pages. */
|
|
29
|
+
export declare function buildUnits(urls: string[], variants: string[]): BatchUnit[];
|
|
30
|
+
/**
|
|
31
|
+
* A filesystem-safe stem for a URL.
|
|
32
|
+
*
|
|
33
|
+
* Derived from the path rather than hashed so the files are browsable: someone
|
|
34
|
+
* opening `output_dir` should be able to tell which page each report is for.
|
|
35
|
+
*/
|
|
36
|
+
export declare function slugForUrl(url: string): string;
|
|
37
|
+
export interface IndexMergeResult {
|
|
38
|
+
indexPath: string;
|
|
39
|
+
total: number;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Merge records into the running index, keyed on URL + variant.
|
|
43
|
+
*
|
|
44
|
+
* Merging rather than overwriting is what lets a second pass fill gaps without
|
|
45
|
+
* discarding the first pass, and what lets the final aggregate cover the whole
|
|
46
|
+
* batch instead of only the last chunk.
|
|
47
|
+
*/
|
|
48
|
+
export declare function mergeIndex<T extends BatchRecord>(dir: string, records: T[], fileName?: string): Promise<IndexMergeResult>;
|
|
49
|
+
export declare function readIndex<T extends BatchRecord>(dir: string, fileName?: string): Promise<T[]>;
|
|
50
|
+
/**
|
|
51
|
+
* Drop work already recorded in the index.
|
|
52
|
+
*
|
|
53
|
+
* Re-running to fill gaps is the normal workflow, not an edge case, because
|
|
54
|
+
* these tools fail intermittently on real sites. Without this, the advice
|
|
55
|
+
* "just run it again" silently re-does — and on metered APIs re-charges for —
|
|
56
|
+
* everything that already worked.
|
|
57
|
+
*
|
|
58
|
+
* Only applies when no cursor is in play: a cursor indexes into the unfiltered
|
|
59
|
+
* list, so filtering would misalign it.
|
|
60
|
+
*/
|
|
61
|
+
export declare function filterCompleted(dir: string, units: BatchUnit[], skipCompleted: boolean, cursor: string | undefined): Promise<{
|
|
62
|
+
units: BatchUnit[];
|
|
63
|
+
skipped: number;
|
|
64
|
+
}>;
|
|
65
|
+
/**
|
|
66
|
+
* Whether there is room to start another unit.
|
|
67
|
+
*
|
|
68
|
+
* Checked before a unit begins, never mid-unit, and it reserves the expected
|
|
69
|
+
* cost of the work about to start rather than only asking whether the budget is
|
|
70
|
+
* already spent — otherwise a 40-second run beginning at the 99-second mark
|
|
71
|
+
* overshoots by a full unit. The PSI runner overran a 150s budget by 38s
|
|
72
|
+
* before this reserve existed.
|
|
73
|
+
*/
|
|
74
|
+
export declare function hasBudget(startedAt: number, budgetMs: number, reserveMs: number, unitsDone: number): boolean;
|
|
75
|
+
export declare function elapsedSeconds(startedAt: number): number;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared machinery for tools that process a list of URLs across several calls.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from the PSI runner, where every piece of it was earned the hard
|
|
5
|
+
* way: an MCP client backgrounds a tool call at 120 seconds, local Lighthouse
|
|
6
|
+
* takes 20-40 seconds per URL per form factor, and both PSI and pa11y fail
|
|
7
|
+
* intermittently on real sites. A batch therefore cannot be one long call, and
|
|
8
|
+
* a failure partway through must not cost the runs that already succeeded.
|
|
9
|
+
*
|
|
10
|
+
* The contract is: bound each call by a clock, write results to disk as they
|
|
11
|
+
* land, merge rather than overwrite an index, and hand back a cursor.
|
|
12
|
+
*/
|
|
13
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
/** Default per-call ceiling. Below the 120s at which Claude Code backgrounds a call. */
|
|
16
|
+
export const DEFAULT_MAX_SECONDS_PER_CALL = 100;
|
|
17
|
+
export function encodeCursor(index) {
|
|
18
|
+
return Buffer.from(JSON.stringify({ i: index })).toString("base64url");
|
|
19
|
+
}
|
|
20
|
+
export function decodeCursor(cursor) {
|
|
21
|
+
if (!cursor)
|
|
22
|
+
return 0;
|
|
23
|
+
try {
|
|
24
|
+
const parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
|
|
25
|
+
return typeof parsed.i === "number" && parsed.i >= 0 ? parsed.i : 0;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return 0;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** Cross-product of URLs and variants, in URL-major order so a partial run covers whole pages. */
|
|
32
|
+
export function buildUnits(urls, variants) {
|
|
33
|
+
const units = [];
|
|
34
|
+
for (const url of urls)
|
|
35
|
+
for (const variant of variants)
|
|
36
|
+
units.push({ url, variant });
|
|
37
|
+
return units;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* A filesystem-safe stem for a URL.
|
|
41
|
+
*
|
|
42
|
+
* Derived from the path rather than hashed so the files are browsable: someone
|
|
43
|
+
* opening `output_dir` should be able to tell which page each report is for.
|
|
44
|
+
*/
|
|
45
|
+
export function slugForUrl(url) {
|
|
46
|
+
try {
|
|
47
|
+
const parsed = new URL(url);
|
|
48
|
+
const path = parsed.pathname.replace(/^\/|\/$/g, "").replace(/\//g, "-");
|
|
49
|
+
const query = parsed.search ? `-${parsed.search.slice(1).replace(/[^a-z0-9]+/gi, "-")}` : "";
|
|
50
|
+
const stem = `${path || "homepage"}${query}`.replace(/-+/g, "-").replace(/^-|-$/g, "");
|
|
51
|
+
// Long paths make unreadable filenames; the tail is the distinguishing part.
|
|
52
|
+
return stem.length > 80 ? stem.slice(-80).replace(/^-/, "") : stem;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return "url";
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Merge records into the running index, keyed on URL + variant.
|
|
60
|
+
*
|
|
61
|
+
* Merging rather than overwriting is what lets a second pass fill gaps without
|
|
62
|
+
* discarding the first pass, and what lets the final aggregate cover the whole
|
|
63
|
+
* batch instead of only the last chunk.
|
|
64
|
+
*/
|
|
65
|
+
export async function mergeIndex(dir, records, fileName = "_index.json") {
|
|
66
|
+
await mkdir(dir, { recursive: true });
|
|
67
|
+
const indexPath = join(dir, fileName);
|
|
68
|
+
let existing = [];
|
|
69
|
+
try {
|
|
70
|
+
existing = JSON.parse(await readFile(indexPath, "utf8"));
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
existing = [];
|
|
74
|
+
}
|
|
75
|
+
const byKey = new Map(existing.map((r) => [`${r.url}|${r.variant}`, r]));
|
|
76
|
+
for (const record of records)
|
|
77
|
+
byKey.set(`${record.url}|${record.variant}`, record);
|
|
78
|
+
const merged = [...byKey.values()];
|
|
79
|
+
await writeFile(indexPath, JSON.stringify(merged, null, 2), "utf8");
|
|
80
|
+
return { indexPath, total: merged.length };
|
|
81
|
+
}
|
|
82
|
+
export async function readIndex(dir, fileName = "_index.json") {
|
|
83
|
+
try {
|
|
84
|
+
return JSON.parse(await readFile(join(dir, fileName), "utf8"));
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return [];
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Drop work already recorded in the index.
|
|
92
|
+
*
|
|
93
|
+
* Re-running to fill gaps is the normal workflow, not an edge case, because
|
|
94
|
+
* these tools fail intermittently on real sites. Without this, the advice
|
|
95
|
+
* "just run it again" silently re-does — and on metered APIs re-charges for —
|
|
96
|
+
* everything that already worked.
|
|
97
|
+
*
|
|
98
|
+
* Only applies when no cursor is in play: a cursor indexes into the unfiltered
|
|
99
|
+
* list, so filtering would misalign it.
|
|
100
|
+
*/
|
|
101
|
+
export async function filterCompleted(dir, units, skipCompleted, cursor) {
|
|
102
|
+
if (!skipCompleted || cursor)
|
|
103
|
+
return { units, skipped: 0 };
|
|
104
|
+
const existing = await readIndex(dir);
|
|
105
|
+
if (existing.length === 0)
|
|
106
|
+
return { units, skipped: 0 };
|
|
107
|
+
const done = new Set(existing.map((r) => `${r.url}|${r.variant}`));
|
|
108
|
+
const remaining = units.filter((u) => !done.has(`${u.url}|${u.variant}`));
|
|
109
|
+
return { units: remaining, skipped: units.length - remaining.length };
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Whether there is room to start another unit.
|
|
113
|
+
*
|
|
114
|
+
* Checked before a unit begins, never mid-unit, and it reserves the expected
|
|
115
|
+
* cost of the work about to start rather than only asking whether the budget is
|
|
116
|
+
* already spent — otherwise a 40-second run beginning at the 99-second mark
|
|
117
|
+
* overshoots by a full unit. The PSI runner overran a 150s budget by 38s
|
|
118
|
+
* before this reserve existed.
|
|
119
|
+
*/
|
|
120
|
+
export function hasBudget(startedAt, budgetMs, reserveMs, unitsDone) {
|
|
121
|
+
if (unitsDone === 0)
|
|
122
|
+
return true; // always attempt at least one
|
|
123
|
+
return Date.now() - startedAt + reserveMs < budgetMs;
|
|
124
|
+
}
|
|
125
|
+
export function elapsedSeconds(startedAt) {
|
|
126
|
+
return Math.round((Date.now() - startedAt) / 1000);
|
|
127
|
+
}
|
|
128
|
+
//# sourceMappingURL=batchState.js.map
|