pagesight 0.16.0 → 0.18.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 +17 -83
- package/docs/credentials.md +54 -0
- package/docs/diagnostics.md +85 -0
- package/docs/snapshots.md +123 -0
- package/docs/usage.md +130 -0
- package/package.json +26 -29
- package/src/api/bing.ts +83 -0
- package/src/api/compare-snapshots.ts +377 -0
- package/src/api/discover.ts +39 -0
- package/src/api/doctor.ts +35 -0
- package/src/api/evidence-schema.ts +78 -0
- package/src/api/evidence.ts +99 -0
- package/src/api/execute.ts +147 -0
- package/src/api/index.ts +5 -0
- package/src/api/reports.ts +90 -0
- package/src/api/schema.ts +207 -0
- package/src/api/snapshot.ts +183 -0
- package/src/api/ui-findings.ts +68 -0
- package/src/cli.ts +182 -0
- package/src/http.ts +39 -0
- package/src/index.ts +8 -25
- package/src/mcp-server.ts +27 -0
- package/src/mcp.ts +5 -0
- package/src/providers/bing.ts +48 -0
- package/src/{lib → providers}/crux.ts +4 -9
- package/src/providers/ga.ts +114 -0
- package/src/providers/google-tokens.ts +86 -0
- package/src/providers/gsc-auth.ts +93 -0
- package/src/{lib → providers}/gsc.ts +27 -24
- package/src/{lib/psi.ts → providers/pagespeed.ts} +3 -14
- package/src/shared/dates.ts +32 -0
- package/src/shared/http.ts +63 -0
- package/src/tools/ai.ts +19 -27
- package/src/tools/audit.ts +26 -97
- package/src/tools/observe.ts +28 -0
- package/src/tools/page/analyze.ts +194 -0
- package/src/tools/page/batch.ts +163 -0
- package/src/tools/page/contrast.ts +128 -0
- package/src/tools/page/links.ts +200 -0
- package/src/tools/page/metadata.ts +225 -0
- package/src/tools/page/structured-data.ts +288 -0
- package/src/tools/page/tool.ts +48 -0
- package/src/tools/search/actions.ts +56 -0
- package/src/tools/search/analytics.ts +266 -0
- package/src/tools/search/coverage.ts +247 -0
- package/src/tools/search/gaps.ts +129 -0
- package/src/tools/search/inspection.ts +160 -0
- package/src/tools/search/result.ts +3 -0
- package/src/tools/search/sample.ts +110 -0
- package/src/tools/search/schema.ts +62 -0
- package/src/{lib/sitemap.ts → tools/search/sitemap-sampling.ts} +1 -45
- package/src/tools/search/sites.ts +86 -0
- package/src/tools/search/tool.ts +11 -0
- package/src/tools/setup.ts +59 -20
- package/src/tools/speed/analyze.ts +191 -0
- package/src/tools/speed/batch.ts +265 -0
- package/src/tools/speed/crux.ts +176 -0
- package/src/tools/speed/pagespeed.ts +273 -0
- package/src/tools/speed/schema.ts +39 -0
- package/src/tools/speed/tool.ts +11 -0
- package/src/web/fetch.ts +31 -0
- package/src/web/images.ts +62 -0
- package/src/web/page-observation.ts +69 -0
- package/src/{lib → web}/robots.ts +20 -12
- package/src/web/sitemap-inventory.ts +64 -0
- package/src/web/sitemap-parser.ts +59 -0
- package/src/lib/auth.ts +0 -187
- package/src/tools/page.ts +0 -1241
- package/src/tools/search.ts +0 -852
- package/src/tools/speed.ts +0 -956
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { type PsiAudit, type PsiCategoryType, type PsiResult, runPagespeed } from "../../providers/pagespeed.js";
|
|
2
|
+
import { scorePct } from "./pagespeed.js";
|
|
3
|
+
function shortUrl(url: string, allUrls: string[]): string {
|
|
4
|
+
try {
|
|
5
|
+
const u = new URL(url);
|
|
6
|
+
const path = u.pathname + u.search;
|
|
7
|
+
const hasDuplicate = allUrls.some(
|
|
8
|
+
(other) => other !== url && new URL(other).pathname + new URL(other).search === path,
|
|
9
|
+
);
|
|
10
|
+
return hasDuplicate ? u.hostname + path : path;
|
|
11
|
+
} catch {
|
|
12
|
+
return url;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function formatDelta(a: number | null, b: number | null): string {
|
|
17
|
+
if (a === null || b === null) return "";
|
|
18
|
+
const diff = b - a;
|
|
19
|
+
if (diff === 0) return " (=)";
|
|
20
|
+
return diff > 0 ? ` (+${diff})` : ` (${diff})`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function formatBatchCompare(results: Array<{ url: string; result: PsiResult }>, strategy: string): string {
|
|
24
|
+
if (results.length < 2) return "Error: compare requires at least 2 results.";
|
|
25
|
+
const [a, b] = results;
|
|
26
|
+
const lhrA = a.result.lighthouseResult;
|
|
27
|
+
const lhrB = b.result.lighthouseResult;
|
|
28
|
+
|
|
29
|
+
const lines: string[] = [
|
|
30
|
+
`=== PageSpeed Compare (${strategy}) ===`,
|
|
31
|
+
``,
|
|
32
|
+
`A: ${a.url}`,
|
|
33
|
+
`B: ${b.url}`,
|
|
34
|
+
`Lighthouse: ${lhrA.lighthouseVersion}`,
|
|
35
|
+
"",
|
|
36
|
+
"--- Scores ---",
|
|
37
|
+
"",
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
// Score comparison
|
|
41
|
+
const catIds = Object.keys(lhrA.categories);
|
|
42
|
+
for (const id of catIds) {
|
|
43
|
+
const catA = lhrA.categories[id];
|
|
44
|
+
const catB = lhrB.categories[id];
|
|
45
|
+
if (!catA || !catB) continue;
|
|
46
|
+
const pA = scorePct(catA.score);
|
|
47
|
+
const pB = scorePct(catB.score);
|
|
48
|
+
const delta = formatDelta(pA, pB);
|
|
49
|
+
lines.push(`${catA.title}: ${pA ?? "N/A"} → ${pB ?? "N/A"}${delta}`);
|
|
50
|
+
}
|
|
51
|
+
lines.push("");
|
|
52
|
+
|
|
53
|
+
// Lab performance comparison
|
|
54
|
+
const cwvIds = [
|
|
55
|
+
"first-contentful-paint",
|
|
56
|
+
"largest-contentful-paint",
|
|
57
|
+
"total-blocking-time",
|
|
58
|
+
"cumulative-layout-shift",
|
|
59
|
+
"speed-index",
|
|
60
|
+
"interactive",
|
|
61
|
+
];
|
|
62
|
+
const cwvLines: string[] = [];
|
|
63
|
+
for (const id of cwvIds) {
|
|
64
|
+
const auditA = lhrA.audits[id];
|
|
65
|
+
const auditB = lhrB.audits[id];
|
|
66
|
+
if (!auditA?.displayValue || !auditB?.displayValue) continue;
|
|
67
|
+
cwvLines.push(`${auditA.title}: ${auditA.displayValue} → ${auditB.displayValue}`);
|
|
68
|
+
}
|
|
69
|
+
if (cwvLines.length > 0) {
|
|
70
|
+
lines.push("--- Lab Performance Metrics ---", "", ...cwvLines, "");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Opportunities unique to each / shared
|
|
74
|
+
const oppsA = collectOpportunityIds(lhrA.audits);
|
|
75
|
+
const oppsB = collectOpportunityIds(lhrB.audits);
|
|
76
|
+
const onlyA = [...oppsA].filter((id) => !oppsB.has(id));
|
|
77
|
+
const onlyB = [...oppsB].filter((id) => !oppsA.has(id));
|
|
78
|
+
const shared = [...oppsA].filter((id) => oppsB.has(id));
|
|
79
|
+
|
|
80
|
+
if (onlyA.length > 0) {
|
|
81
|
+
lines.push(`--- Opportunities (A only) ---`, "");
|
|
82
|
+
for (const id of onlyA) lines.push(` ${lhrA.audits[id].title}: ${lhrA.audits[id].displayValue ?? ""}`);
|
|
83
|
+
lines.push("");
|
|
84
|
+
}
|
|
85
|
+
if (onlyB.length > 0) {
|
|
86
|
+
lines.push(`--- Opportunities (B only) ---`, "");
|
|
87
|
+
for (const id of onlyB) lines.push(` ${lhrB.audits[id].title}: ${lhrB.audits[id].displayValue ?? ""}`);
|
|
88
|
+
lines.push("");
|
|
89
|
+
}
|
|
90
|
+
if (shared.length > 0) {
|
|
91
|
+
lines.push(`--- Shared Opportunities ---`, "");
|
|
92
|
+
for (const id of shared) {
|
|
93
|
+
lines.push(
|
|
94
|
+
` ${lhrA.audits[id].title}: ${lhrA.audits[id].displayValue ?? ""} → ${lhrB.audits[id].displayValue ?? ""}`,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
lines.push("");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const timeA = (lhrA.timing.total / 1000).toFixed(1);
|
|
101
|
+
const timeB = (lhrB.timing.total / 1000).toFixed(1);
|
|
102
|
+
lines.push(`Analysis took ${timeA}s + ${timeB}s`);
|
|
103
|
+
|
|
104
|
+
return lines.join("\n");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function formatBatchTable(results: Array<{ url: string; result: PsiResult }>, strategy: string): string {
|
|
108
|
+
if (results.length === 0) return "Error: no results to display.";
|
|
109
|
+
const lines: string[] = [`=== Batch PageSpeed (${results.length} URLs, ${strategy}) ===`, ""];
|
|
110
|
+
|
|
111
|
+
// Collect all category IDs from first result
|
|
112
|
+
const catIds = Object.keys(results[0].result.lighthouseResult.categories);
|
|
113
|
+
const catNames = catIds.map((id) => results[0].result.lighthouseResult.categories[id].title);
|
|
114
|
+
|
|
115
|
+
// Score table
|
|
116
|
+
lines.push("--- Scores ---", "");
|
|
117
|
+
|
|
118
|
+
// Header
|
|
119
|
+
const urlCol = "URL";
|
|
120
|
+
const allUrls = results.map((r) => r.url);
|
|
121
|
+
const urlWidth = Math.max(urlCol.length, ...results.map((r) => shortUrl(r.url, allUrls).length));
|
|
122
|
+
const colWidth = Math.max(...catNames.map((n) => n.length), 4);
|
|
123
|
+
lines.push(`${urlCol.padEnd(urlWidth)} ${catNames.map((n) => n.padEnd(colWidth)).join(" ")}`);
|
|
124
|
+
|
|
125
|
+
// Rows
|
|
126
|
+
let bestPerf: { url: string; score: number } | null = null;
|
|
127
|
+
let worstPerf: { url: string; score: number } | null = null;
|
|
128
|
+
|
|
129
|
+
for (const { url, result } of results) {
|
|
130
|
+
const lhr = result.lighthouseResult;
|
|
131
|
+
const scores = catIds.map((id) => {
|
|
132
|
+
const s = scorePct(lhr.categories[id]?.score);
|
|
133
|
+
return s !== null ? String(s) : "N/A";
|
|
134
|
+
});
|
|
135
|
+
lines.push(`${shortUrl(url, allUrls).padEnd(urlWidth)} ${scores.map((s) => s.padEnd(colWidth)).join(" ")}`);
|
|
136
|
+
|
|
137
|
+
const perf = scorePct(lhr.categories.performance?.score);
|
|
138
|
+
if (perf !== null) {
|
|
139
|
+
if (!bestPerf || perf > bestPerf.score) bestPerf = { url: shortUrl(url, allUrls), score: perf };
|
|
140
|
+
if (!worstPerf || perf < worstPerf.score) worstPerf = { url: shortUrl(url, allUrls), score: perf };
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
lines.push("");
|
|
145
|
+
if (bestPerf) lines.push(`Best: ${bestPerf.url} (${bestPerf.score})`);
|
|
146
|
+
if (worstPerf && worstPerf.url !== bestPerf?.url) lines.push(`Worst: ${worstPerf.url} (${worstPerf.score})`);
|
|
147
|
+
lines.push("");
|
|
148
|
+
|
|
149
|
+
// Shared opportunities across pages
|
|
150
|
+
const oppCounts = new Map<string, { title: string; count: number; maxSavings: string }>();
|
|
151
|
+
for (const { result } of results) {
|
|
152
|
+
for (const id of collectOpportunityIds(result.lighthouseResult.audits)) {
|
|
153
|
+
const audit = result.lighthouseResult.audits[id];
|
|
154
|
+
const savings = audit.displayValue ?? "";
|
|
155
|
+
const existing = oppCounts.get(id);
|
|
156
|
+
if (existing) {
|
|
157
|
+
existing.count++;
|
|
158
|
+
if (savings && (!existing.maxSavings || savings > existing.maxSavings)) existing.maxSavings = savings;
|
|
159
|
+
} else {
|
|
160
|
+
oppCounts.set(id, { title: audit.title, count: 1, maxSavings: savings });
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const sharedOpps = [...oppCounts.entries()].filter(([, v]) => v.count >= 2).sort((a, b) => b[1].count - a[1].count);
|
|
166
|
+
if (sharedOpps.length > 0) {
|
|
167
|
+
lines.push("--- Shared Opportunities ---", "");
|
|
168
|
+
for (const [, { title, count, maxSavings }] of sharedOpps.slice(0, 10)) {
|
|
169
|
+
const cleaned = maxSavings.replace(/^Est savings of /i, "");
|
|
170
|
+
const savingsStr = cleaned ? `, up to ${cleaned}` : "";
|
|
171
|
+
lines.push(` ${title} (${count}/${results.length} pages${savingsStr})`);
|
|
172
|
+
}
|
|
173
|
+
lines.push("");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// A11y failures deduplicated across pages
|
|
177
|
+
const a11yFailures = new Map<string, { title: string; count: number; pages: string[]; selectors: string[] }>();
|
|
178
|
+
for (const { url, result } of results) {
|
|
179
|
+
const lhr = result.lighthouseResult;
|
|
180
|
+
const a11yCat = lhr.categories.accessibility;
|
|
181
|
+
if (!a11yCat?.auditRefs) continue;
|
|
182
|
+
|
|
183
|
+
for (const ref of a11yCat.auditRefs) {
|
|
184
|
+
const audit = lhr.audits[ref.id];
|
|
185
|
+
if (audit && audit.score !== null && audit.score < 1) {
|
|
186
|
+
const page = shortUrl(url, allUrls);
|
|
187
|
+
const selector = audit.details?.items?.[0]?.node?.selector ?? null;
|
|
188
|
+
const existing = a11yFailures.get(ref.id);
|
|
189
|
+
if (existing) {
|
|
190
|
+
existing.count++;
|
|
191
|
+
existing.pages.push(page);
|
|
192
|
+
if (selector && !existing.selectors.includes(selector)) existing.selectors.push(selector);
|
|
193
|
+
} else {
|
|
194
|
+
a11yFailures.set(ref.id, {
|
|
195
|
+
title: audit.title,
|
|
196
|
+
count: 1,
|
|
197
|
+
pages: [page],
|
|
198
|
+
selectors: selector ? [selector] : [],
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const sharedA11y = [...a11yFailures.entries()]
|
|
206
|
+
.filter(([, v]) => v.count >= 2)
|
|
207
|
+
.sort((a, b) => b[1].count - a[1].count);
|
|
208
|
+
if (sharedA11y.length > 0) {
|
|
209
|
+
lines.push("--- Accessibility Issues (shared) ---", "");
|
|
210
|
+
for (const [, { title, count, pages, selectors }] of sharedA11y.slice(0, 10)) {
|
|
211
|
+
lines.push(` ${title} (${count}/${results.length} pages: ${pages.join(", ")})`);
|
|
212
|
+
if (selectors.length > 0) {
|
|
213
|
+
lines.push(` Elements: ${selectors.slice(0, 3).join(", ")}`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
lines.push("");
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const totalTime = results.reduce((sum, r) => sum + r.result.lighthouseResult.timing.total, 0);
|
|
220
|
+
lines.push(`Total analysis time: ${(totalTime / 1000).toFixed(1)}s`);
|
|
221
|
+
|
|
222
|
+
return lines.join("\n");
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function collectOpportunityIds(audits: Record<string, PsiAudit>): Set<string> {
|
|
226
|
+
const ids = new Set<string>();
|
|
227
|
+
for (const [id, audit] of Object.entries(audits)) {
|
|
228
|
+
if (audit.score === null || audit.score >= 1) continue;
|
|
229
|
+
const mode = audit.scoreDisplayMode;
|
|
230
|
+
const hasNumeric = audit.numericValue && audit.numericValue > 0;
|
|
231
|
+
if (mode === "metricSavings" || ((mode === "numeric" || mode === "binary") && hasNumeric)) {
|
|
232
|
+
if (hasNumeric || (audit.details?.items?.length ?? 0) > 0) {
|
|
233
|
+
ids.add(id);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return ids;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export async function runBatch(
|
|
241
|
+
urls: string[],
|
|
242
|
+
options: { strategy?: "mobile" | "desktop"; categories?: PsiCategoryType[]; locale?: string },
|
|
243
|
+
): Promise<Array<{ url: string; result?: PsiResult; error?: string }>> {
|
|
244
|
+
const concurrency = 2;
|
|
245
|
+
const results: Array<{ url: string; result?: PsiResult; error?: string }> = [];
|
|
246
|
+
let i = 0;
|
|
247
|
+
|
|
248
|
+
while (i < urls.length) {
|
|
249
|
+
const batch = urls.slice(i, i + concurrency);
|
|
250
|
+
const settled = await Promise.all(
|
|
251
|
+
batch.map(async (url) => {
|
|
252
|
+
try {
|
|
253
|
+
const result = await runPagespeed(url, options);
|
|
254
|
+
return { url, result };
|
|
255
|
+
} catch (err) {
|
|
256
|
+
return { url, error: err instanceof Error ? err.message : String(err) };
|
|
257
|
+
}
|
|
258
|
+
}),
|
|
259
|
+
);
|
|
260
|
+
results.push(...settled);
|
|
261
|
+
i += concurrency;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return results;
|
|
265
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { type CruxHistoryResponse, type CruxResponse } from "../../providers/crux.js";
|
|
2
|
+
const METRIC_LABELS: Record<string, string> = {
|
|
3
|
+
cumulative_layout_shift: "CLS",
|
|
4
|
+
first_contentful_paint: "FCP",
|
|
5
|
+
interaction_to_next_paint: "INP",
|
|
6
|
+
largest_contentful_paint: "LCP",
|
|
7
|
+
experimental_time_to_first_byte: "TTFB",
|
|
8
|
+
round_trip_time: "RTT",
|
|
9
|
+
navigation_types: "Navigation Types",
|
|
10
|
+
form_factors: "Form Factors",
|
|
11
|
+
largest_contentful_paint_image_element_render_delay: "LCP Image Render Delay",
|
|
12
|
+
largest_contentful_paint_image_resource_load_delay: "LCP Image Load Delay",
|
|
13
|
+
largest_contentful_paint_image_resource_load_duration: "LCP Image Load Duration",
|
|
14
|
+
largest_contentful_paint_image_time_to_first_byte: "LCP Image TTFB",
|
|
15
|
+
largest_contentful_paint_resource_type: "LCP Resource Type",
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function formatDate(d: { year: number; month: number; day: number }): string {
|
|
19
|
+
return `${d.year}-${String(d.month).padStart(2, "0")}-${String(d.day).padStart(2, "0")}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function formatCrux(target: string, result: CruxResponse): string {
|
|
23
|
+
const r = result.record;
|
|
24
|
+
const period = r.collectionPeriod;
|
|
25
|
+
const lines: string[] = [
|
|
26
|
+
`=== CrUX: ${target} ===`,
|
|
27
|
+
`Form factor: ${r.key.formFactor ?? "all"}`,
|
|
28
|
+
`Period: ${formatDate(period.firstDate)} to ${formatDate(period.lastDate)}`,
|
|
29
|
+
"",
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
if (result.urlNormalizationDetails) {
|
|
33
|
+
const norm = result.urlNormalizationDetails;
|
|
34
|
+
if (norm.originalUrl !== norm.normalizedUrl) {
|
|
35
|
+
lines.push(`Normalized: ${norm.originalUrl} → ${norm.normalizedUrl}`, "");
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
lines.push("--- Metrics (p75) ---", "");
|
|
40
|
+
|
|
41
|
+
for (const [key, metric] of Object.entries(r.metrics)) {
|
|
42
|
+
const label = METRIC_LABELS[key] ?? key;
|
|
43
|
+
|
|
44
|
+
if (metric.percentiles) {
|
|
45
|
+
const val = metric.percentiles.p75;
|
|
46
|
+
const unit = key === "cumulative_layout_shift" ? "" : "ms";
|
|
47
|
+
lines.push(`${label}: ${val}${unit}`);
|
|
48
|
+
|
|
49
|
+
if (metric.histogram) {
|
|
50
|
+
const buckets = metric.histogram.map((b) => `${Math.round(b.density * 100)}%`).join(" / ");
|
|
51
|
+
lines.push(` Distribution (good/needs improvement/poor): ${buckets}`);
|
|
52
|
+
}
|
|
53
|
+
} else if (metric.fractions) {
|
|
54
|
+
lines.push(`${label}:`);
|
|
55
|
+
for (const [fKey, fVal] of Object.entries(metric.fractions)) {
|
|
56
|
+
lines.push(` ${fKey}: ${(fVal * 100).toFixed(1)}%`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Core Web Vitals assessment (Google ranking signal)
|
|
62
|
+
const cwvMetrics = r.metrics;
|
|
63
|
+
const lcp = cwvMetrics.largest_contentful_paint?.percentiles?.p75 as number | undefined;
|
|
64
|
+
const inp = cwvMetrics.interaction_to_next_paint?.percentiles?.p75 as number | undefined;
|
|
65
|
+
const cls = cwvMetrics.cumulative_layout_shift?.percentiles?.p75 as number | undefined;
|
|
66
|
+
|
|
67
|
+
if (lcp !== undefined || inp !== undefined || cls !== undefined) {
|
|
68
|
+
lines.push("", "--- Core Web Vitals Assessment ---", "");
|
|
69
|
+
const lcpPass = lcp !== undefined && lcp <= 2500;
|
|
70
|
+
const inpPass = inp !== undefined && inp <= 200;
|
|
71
|
+
const clsPass = cls !== undefined && cls <= 0.1;
|
|
72
|
+
|
|
73
|
+
if (lcp !== undefined)
|
|
74
|
+
lines.push(`LCP: ${lcp}ms ${lcpPass ? "GOOD" : lcp <= 4000 ? "NEEDS IMPROVEMENT" : "POOR"} (threshold: 2500ms)`);
|
|
75
|
+
if (inp !== undefined)
|
|
76
|
+
lines.push(`INP: ${inp}ms ${inpPass ? "GOOD" : inp <= 500 ? "NEEDS IMPROVEMENT" : "POOR"} (threshold: 200ms)`);
|
|
77
|
+
if (cls !== undefined)
|
|
78
|
+
lines.push(`CLS: ${cls} ${clsPass ? "GOOD" : cls <= 0.25 ? "NEEDS IMPROVEMENT" : "POOR"} (threshold: 0.1)`);
|
|
79
|
+
|
|
80
|
+
const allPresent = lcp !== undefined && inp !== undefined && cls !== undefined;
|
|
81
|
+
if (allPresent) {
|
|
82
|
+
const allPass = lcpPass && inpPass && clsPass;
|
|
83
|
+
lines.push(
|
|
84
|
+
"",
|
|
85
|
+
allPass
|
|
86
|
+
? "Overall: PASS — all Core Web Vitals are good (positive ranking signal)"
|
|
87
|
+
: "Overall: FAIL — not all Core Web Vitals pass (may affect rankings)",
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return lines.join("\n");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function formatCruxHistory(target: string, result: CruxHistoryResponse): string {
|
|
96
|
+
const r = result.record;
|
|
97
|
+
const periods = r.collectionPeriods;
|
|
98
|
+
const lines: string[] = [
|
|
99
|
+
`=== CrUX History: ${target} ===`,
|
|
100
|
+
`Form factor: ${r.key.formFactor ?? "all"}`,
|
|
101
|
+
`Periods: ${periods.length} (${formatDate(periods[0].firstDate)} to ${formatDate(periods[periods.length - 1].lastDate)})`,
|
|
102
|
+
"",
|
|
103
|
+
];
|
|
104
|
+
|
|
105
|
+
if (result.urlNormalizationDetails) {
|
|
106
|
+
const norm = result.urlNormalizationDetails;
|
|
107
|
+
if (norm.originalUrl !== norm.normalizedUrl) {
|
|
108
|
+
lines.push(`Normalized: ${norm.originalUrl} → ${norm.normalizedUrl}`, "");
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
lines.push("--- p75 Trend ---", "");
|
|
113
|
+
|
|
114
|
+
for (const [key, metric] of Object.entries(r.metrics)) {
|
|
115
|
+
const label = METRIC_LABELS[key] ?? key;
|
|
116
|
+
|
|
117
|
+
if (metric.percentilesTimeseries) {
|
|
118
|
+
const values = metric.percentilesTimeseries.p75s;
|
|
119
|
+
const first = values[0];
|
|
120
|
+
const last = values[values.length - 1];
|
|
121
|
+
const unit = key === "cumulative_layout_shift" ? "" : "ms";
|
|
122
|
+
|
|
123
|
+
if (first === null && last === null) {
|
|
124
|
+
lines.push(`${label}: insufficient data`);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
lines.push(
|
|
129
|
+
`${label}: ${first ?? "N/A"}${first !== null ? unit : ""} → ${last ?? "N/A"}${last !== null ? unit : ""} (${values.length} points)`,
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
// Show trend direction
|
|
133
|
+
if (first !== null && last !== null) {
|
|
134
|
+
const f = Number(first);
|
|
135
|
+
const l = Number(last);
|
|
136
|
+
if (!Number.isNaN(f) && !Number.isNaN(l)) {
|
|
137
|
+
const change = ((l - f) / f) * 100;
|
|
138
|
+
const dir = change > 5 ? "worse" : change < -5 ? "improved" : "stable";
|
|
139
|
+
lines.push(` Trend: ${change > 0 ? "+" : ""}${change.toFixed(1)}% (${dir})`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
} else if (metric.fractionTimeseries) {
|
|
143
|
+
lines.push(`${label}: (fraction timeseries, ${periods.length} points)`);
|
|
144
|
+
for (const [fKey, fData] of Object.entries(metric.fractionTimeseries)) {
|
|
145
|
+
const fracs = fData.fractions;
|
|
146
|
+
const first = fracs[0];
|
|
147
|
+
const last = fracs[fracs.length - 1];
|
|
148
|
+
if (first !== null && last !== null && !Number.isNaN(first) && !Number.isNaN(last)) {
|
|
149
|
+
lines.push(` ${fKey}: ${(first * 100).toFixed(1)}% → ${(last * 100).toFixed(1)}%`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Show last 5 data points as table for core metrics
|
|
156
|
+
const coreMetrics = ["largest_contentful_paint", "interaction_to_next_paint", "cumulative_layout_shift"];
|
|
157
|
+
const available = coreMetrics.filter((m) => r.metrics[m]?.percentilesTimeseries);
|
|
158
|
+
|
|
159
|
+
if (available.length > 0 && periods.length >= 5) {
|
|
160
|
+
lines.push("", "--- Recent Data Points ---", "");
|
|
161
|
+
const lastN = 5;
|
|
162
|
+
const startIdx = periods.length - lastN;
|
|
163
|
+
|
|
164
|
+
lines.push(`${"Date".padEnd(12)} ${available.map((m) => (METRIC_LABELS[m] ?? m).padEnd(10)).join(" ")}`);
|
|
165
|
+
for (let i = startIdx; i < periods.length; i++) {
|
|
166
|
+
const date = formatDate(periods[i].lastDate);
|
|
167
|
+
const vals = available.map((m) => {
|
|
168
|
+
const v = r.metrics[m].percentilesTimeseries?.p75s[i];
|
|
169
|
+
return String(v ?? "N/A").padEnd(10);
|
|
170
|
+
});
|
|
171
|
+
lines.push(`${date.padEnd(12)} ${vals.join(" ")}`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return lines.join("\n");
|
|
176
|
+
}
|