@fulldotdev/scan 0.1.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 +79 -0
- package/bin/fullscan.js +2 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +239 -0
- package/dist/engine/analysis.d.ts +2 -0
- package/dist/engine/analysis.js +747 -0
- package/dist/engine/browser-inspection.d.ts +143 -0
- package/dist/engine/browser-inspection.js +567 -0
- package/dist/engine/browser.d.ts +22 -0
- package/dist/engine/browser.js +629 -0
- package/dist/engine/collect.d.ts +6 -0
- package/dist/engine/collect.js +359 -0
- package/dist/engine/crawl-scope.d.ts +22 -0
- package/dist/engine/crawl-scope.js +145 -0
- package/dist/engine/env.d.ts +1 -0
- package/dist/engine/env.js +3 -0
- package/dist/engine/html.d.ts +307 -0
- package/dist/engine/html.js +645 -0
- package/dist/engine/language.d.ts +13 -0
- package/dist/engine/language.js +75 -0
- package/dist/engine/lighthouse-evidence.d.ts +36 -0
- package/dist/engine/lighthouse-evidence.js +69 -0
- package/dist/engine/lighthouse.d.ts +4 -0
- package/dist/engine/lighthouse.js +284 -0
- package/dist/engine/log.d.ts +1 -0
- package/dist/engine/log.js +4 -0
- package/dist/engine/network.d.ts +53 -0
- package/dist/engine/network.js +296 -0
- package/dist/engine/proxy.d.ts +8 -0
- package/dist/engine/proxy.js +95 -0
- package/dist/engine/run.d.ts +38 -0
- package/dist/engine/run.js +202 -0
- package/dist/engine/select.d.ts +6 -0
- package/dist/engine/select.js +38 -0
- package/dist/engine/site.d.ts +186 -0
- package/dist/engine/site.js +758 -0
- package/dist/engine/srcset.d.ts +1 -0
- package/dist/engine/srcset.js +31 -0
- package/dist/engine/state.d.ts +30 -0
- package/dist/engine/state.js +198 -0
- package/dist/engine/structured-data.d.ts +83 -0
- package/dist/engine/structured-data.js +331 -0
- package/dist/engine/types.d.ts +128 -0
- package/dist/engine/types.js +63 -0
- package/dist/engine.d.ts +1 -0
- package/dist/engine.js +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +7 -0
- package/dist/report/build.d.ts +377 -0
- package/dist/report/build.js +2263 -0
- package/dist/report/evidence.d.ts +58 -0
- package/dist/report/evidence.js +192 -0
- package/dist/report/rules.d.ts +41 -0
- package/dist/report/rules.js +301 -0
- package/package.json +52 -0
|
@@ -0,0 +1,2263 @@
|
|
|
1
|
+
import { areaTopics, auditCondition, accessibilityCondition, publicReport, unverifiedChanges, rules, topicLabels, topicOrder, } from "./rules.js";
|
|
2
|
+
import { pageType, socialPlatform } from "../engine/crawl-scope.js";
|
|
3
|
+
import { detectLanguage, languageOfTag } from "../engine/language.js";
|
|
4
|
+
import jpeg from "jpeg-js";
|
|
5
|
+
import { sameSite, slashTwin } from "../engine/network.js";
|
|
6
|
+
const ITEM_LIMIT = 200;
|
|
7
|
+
// Hosts that only exist to track visitors. Seen on first load, before any
|
|
8
|
+
// interaction, they ran without consent.
|
|
9
|
+
const trackerHosts = [
|
|
10
|
+
[/(^|\.)google-analytics\.com$/, "Google Analytics"],
|
|
11
|
+
[/(^|\.)analytics\.google\.com$/, "Google Analytics"],
|
|
12
|
+
[/(^|\.)doubleclick\.net$/, "Google Ads (DoubleClick)"],
|
|
13
|
+
[/(^|\.)googleadservices\.com$/, "Google Ads"],
|
|
14
|
+
[/(^|\.)googlesyndication\.com$/, "Google AdSense"],
|
|
15
|
+
[/(^|\.)connect\.facebook\.net$/, "Meta Pixel"],
|
|
16
|
+
[/(^|\.)facebook\.com$/, "Meta Pixel"],
|
|
17
|
+
[/(^|\.)hotjar\.com$/, "Hotjar"],
|
|
18
|
+
[/(^|\.)clarity\.ms$/, "Microsoft Clarity"],
|
|
19
|
+
[/(^|\.)ads\.linkedin\.com$/, "LinkedIn Insight"],
|
|
20
|
+
[/(^|\.)analytics\.tiktok\.com$/, "TikTok Pixel"],
|
|
21
|
+
[/(^|\.)ct\.pinterest\.com$/, "Pinterest Tag"],
|
|
22
|
+
[/(^|\.)sc-static\.net$/, "Snap Pixel"],
|
|
23
|
+
[/(^|\.)bat\.bing\.com$/, "Microsoft Ads"],
|
|
24
|
+
[/(^|\.)hubspot\.com$/, "HubSpot tracking"],
|
|
25
|
+
[/(^|\.)mixpanel\.com$/, "Mixpanel"],
|
|
26
|
+
[/(^|\.)segment\.io$/, "Segment"],
|
|
27
|
+
];
|
|
28
|
+
function trackerName(url) {
|
|
29
|
+
try {
|
|
30
|
+
const host = new URL(url).hostname;
|
|
31
|
+
return trackerHosts.find(([pattern]) => pattern.test(host))?.[1] ?? null;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const businessTypes = /(Organization|LocalBusiness|Business|Store|Restaurant|Dentist|Physician|MedicalBusiness|ProfessionalService|Person|Corporation|Hotel|Attorney|RealEstateAgent|AutoRepair|Gym|HealthClub|SportsActivityLocation)$/;
|
|
38
|
+
export function fieldVerdict(d) {
|
|
39
|
+
const grades = [
|
|
40
|
+
d.lcp == null ? null : d.lcp <= 2500 ? 0 : d.lcp <= 4000 ? 1 : 2,
|
|
41
|
+
d.inp == null ? null : d.inp <= 200 ? 0 : d.inp <= 500 ? 1 : 2,
|
|
42
|
+
d.cls == null ? null : d.cls <= 0.1 ? 0 : d.cls <= 0.25 ? 1 : 2,
|
|
43
|
+
].filter((g) => g !== null);
|
|
44
|
+
if (!grades.length)
|
|
45
|
+
return null;
|
|
46
|
+
const worst = Math.max(...grades);
|
|
47
|
+
return worst === 0 ? "good" : worst === 1 ? "needs-improvement" : "poor";
|
|
48
|
+
}
|
|
49
|
+
const fieldFrom = (data) => data
|
|
50
|
+
? data.available
|
|
51
|
+
? {
|
|
52
|
+
available: true,
|
|
53
|
+
level: data.level ?? "origin",
|
|
54
|
+
lcp: data.lcp ?? null,
|
|
55
|
+
inp: data.inp ?? null,
|
|
56
|
+
cls: data.cls ?? null,
|
|
57
|
+
verdict: fieldVerdict(data),
|
|
58
|
+
period: data.period ?? null,
|
|
59
|
+
note: data.level === "url"
|
|
60
|
+
? "Chrome UX Report for this page, real Chrome users over 28 days, 75th percentile"
|
|
61
|
+
: "Chrome UX Report for the whole origin, real Chrome users over 28 days, 75th percentile",
|
|
62
|
+
}
|
|
63
|
+
: {
|
|
64
|
+
available: false,
|
|
65
|
+
level: data.level ?? "origin",
|
|
66
|
+
lcp: null,
|
|
67
|
+
inp: null,
|
|
68
|
+
cls: null,
|
|
69
|
+
verdict: null,
|
|
70
|
+
period: null,
|
|
71
|
+
note: data.note ?? "Not enough Chrome traffic for field data",
|
|
72
|
+
}
|
|
73
|
+
: null;
|
|
74
|
+
// Findings whose measured values wobble between runs are compared by
|
|
75
|
+
// presence only, never by their items.
|
|
76
|
+
const volatile = new Set([
|
|
77
|
+
"performance-opportunities",
|
|
78
|
+
"small-performance-opportunities",
|
|
79
|
+
"slow-responses",
|
|
80
|
+
"heavy-pages",
|
|
81
|
+
"slow-mobile",
|
|
82
|
+
"slow-lcp-own-browser",
|
|
83
|
+
"field-vitals-poor",
|
|
84
|
+
"field-page-vitals-poor",
|
|
85
|
+
"javascript-errors",
|
|
86
|
+
"external-errors",
|
|
87
|
+
]);
|
|
88
|
+
const itemKey = (item) => typeof item === "string" ? item : (item.url ?? item.text);
|
|
89
|
+
// Items are identified by their URL (or text for site-level items). A
|
|
90
|
+
// missing item counts as resolved only when `verified` confirms that the
|
|
91
|
+
// evidence its rule depends on was collected again for that URL (or, for
|
|
92
|
+
// site-level items, that the check ran); otherwise it stays unverified.
|
|
93
|
+
export function diffFindings(before, after, verified = () => true) {
|
|
94
|
+
const id = (f) => f.id ?? f.code;
|
|
95
|
+
const beforeById = new Map(before.map((f) => [id(f), f]));
|
|
96
|
+
const afterById = new Map(after.map((f) => [id(f), f]));
|
|
97
|
+
const change = (f, count, extra = {}) => ({
|
|
98
|
+
id: id(f),
|
|
99
|
+
code: f.code,
|
|
100
|
+
title: f.title,
|
|
101
|
+
severity: f.severity,
|
|
102
|
+
count,
|
|
103
|
+
...extra,
|
|
104
|
+
});
|
|
105
|
+
const newFindings = after.flatMap((f) => {
|
|
106
|
+
const old = beforeById.get(f.id);
|
|
107
|
+
if (!old)
|
|
108
|
+
return [f];
|
|
109
|
+
if (volatile.has(f.code))
|
|
110
|
+
return [];
|
|
111
|
+
const oldKeys = new Set(old.items.map(itemKey));
|
|
112
|
+
const items = f.items.filter((i) => !oldKeys.has(itemKey(i)));
|
|
113
|
+
return items.length ? [{ ...f, count: items.length, items }] : [];
|
|
114
|
+
});
|
|
115
|
+
const resolvedFindings = [], unverifiedFindings = [], recurringFindings = [], worsenedFindings = [];
|
|
116
|
+
for (const old of before) {
|
|
117
|
+
const now = afterById.get(id(old));
|
|
118
|
+
if (now) {
|
|
119
|
+
recurringFindings.push(change(old, now.count));
|
|
120
|
+
if (now.count > old.count && !volatile.has(old.code))
|
|
121
|
+
worsenedFindings.push(change(old, now.count, { from: old.count, to: now.count }));
|
|
122
|
+
}
|
|
123
|
+
// Volatile findings are compared by presence only; when one disappears,
|
|
124
|
+
// every retained item is judged on its own page. Items the old report
|
|
125
|
+
// did not retain cannot be verified and stay unverified.
|
|
126
|
+
const gone = volatile.has(old.code)
|
|
127
|
+
? now
|
|
128
|
+
? []
|
|
129
|
+
: old.items
|
|
130
|
+
: old.items.filter((i) => !new Set((now?.items ?? []).map(itemKey)).has(itemKey(i)));
|
|
131
|
+
const resolved = gone.filter((i) => verified(i, old)).length;
|
|
132
|
+
const unverified = gone.length - resolved + Math.max(0, old.count - old.items.length);
|
|
133
|
+
if (resolved)
|
|
134
|
+
resolvedFindings.push(change(old, resolved));
|
|
135
|
+
if (unverified && (gone.length || !now))
|
|
136
|
+
unverifiedFindings.push(change(old, unverified));
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
newFindings,
|
|
140
|
+
resolvedFindings,
|
|
141
|
+
unverifiedFindings,
|
|
142
|
+
recurringFindings,
|
|
143
|
+
worsenedFindings,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
// Share of a coarse luminance grid that differs between two screenshots.
|
|
147
|
+
export function screenshotDifference(a, b) {
|
|
148
|
+
try {
|
|
149
|
+
const grid = (buffer) => {
|
|
150
|
+
const image = jpeg.decode(buffer, {
|
|
151
|
+
useTArray: true,
|
|
152
|
+
maxMemoryUsageInMB: 64,
|
|
153
|
+
});
|
|
154
|
+
const cols = 24, rows = 48;
|
|
155
|
+
const cells = [];
|
|
156
|
+
for (let r = 0; r < rows; r++)
|
|
157
|
+
for (let c = 0; c < cols; c++) {
|
|
158
|
+
let sum = 0, n = 0;
|
|
159
|
+
const x0 = Math.floor((c * image.width) / cols), x1 = Math.floor(((c + 1) * image.width) / cols), y0 = Math.floor((r * image.height) / rows), y1 = Math.floor(((r + 1) * image.height) / rows);
|
|
160
|
+
for (let y = y0; y < y1; y += 2)
|
|
161
|
+
for (let x = x0; x < x1; x += 2) {
|
|
162
|
+
const i = (y * image.width + x) * 4;
|
|
163
|
+
sum +=
|
|
164
|
+
0.299 * image.data[i] +
|
|
165
|
+
0.587 * image.data[i + 1] +
|
|
166
|
+
0.114 * image.data[i + 2];
|
|
167
|
+
n++;
|
|
168
|
+
}
|
|
169
|
+
cells.push(n ? sum / n : 0);
|
|
170
|
+
}
|
|
171
|
+
return cells;
|
|
172
|
+
};
|
|
173
|
+
const left = grid(a), right = grid(b);
|
|
174
|
+
const changed = left.filter((v, i) => Math.abs(v - right[i]) > 24).length;
|
|
175
|
+
return Math.round((changed / left.length) * 100);
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
const slug = (value) => value
|
|
182
|
+
.toLowerCase()
|
|
183
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
184
|
+
.replace(/^-|-$/g, "")
|
|
185
|
+
.slice(0, 60);
|
|
186
|
+
const severityRank = {
|
|
187
|
+
critical: 0,
|
|
188
|
+
warning: 1,
|
|
189
|
+
hint: 2,
|
|
190
|
+
};
|
|
191
|
+
const confidenceRank = {
|
|
192
|
+
confirmed: 0,
|
|
193
|
+
probable: 1,
|
|
194
|
+
review: 2,
|
|
195
|
+
};
|
|
196
|
+
const platformBlocked = (url) => !!socialPlatform(url)?.blocksBots;
|
|
197
|
+
export async function buildReport(scan, input) {
|
|
198
|
+
const rows = input.records;
|
|
199
|
+
const urlRows = input.urls;
|
|
200
|
+
const sources = new Map(urlRows.map((r) => [r.url, r.sources]));
|
|
201
|
+
const omitted = Number(scan.coverage?.omittedCandidates ?? 0);
|
|
202
|
+
const of = (kind) => rows.filter((r) => r.kind === kind);
|
|
203
|
+
const pages = of("page");
|
|
204
|
+
const byKey = new Map(pages.map((p) => [p.key, p]));
|
|
205
|
+
// A trailing-slash twin recorded by a page job is a page when the twin
|
|
206
|
+
// itself was crawled; it must not double as a broken or checked file.
|
|
207
|
+
const resources = of("resource").filter((r) => !byKey.has(r.key));
|
|
208
|
+
const resourceByKey = new Map(resources.map((r) => [r.key, r]));
|
|
209
|
+
const renderedByKey = new Map(of("rendered").map((r) => [r.key, r.data]));
|
|
210
|
+
const home = pages.find((p) => p.key === scan.url) ??
|
|
211
|
+
pages.find((p) => new URL(p.key).pathname === "/");
|
|
212
|
+
const status = (o) => o.data.http?.status ?? null;
|
|
213
|
+
const failure = (o) => String(status(o) ?? o.data.http?.error ?? "no response");
|
|
214
|
+
// Where a URL was found: the pages that link to it.
|
|
215
|
+
const linkingPages = (url) => (sources.get(url) ?? [])
|
|
216
|
+
.filter((s) => s.startsWith("link:") || s.startsWith("browser:"))
|
|
217
|
+
.map((s) => s.replace(/^(link|browser):/, ""));
|
|
218
|
+
const linkedFrom = (url) => {
|
|
219
|
+
const from = linkingPages(url);
|
|
220
|
+
return from.length
|
|
221
|
+
? ` (linked from ${from[0]}${from.length > 1 ? ` and ${from.length - 1} more` : ""})`
|
|
222
|
+
: "";
|
|
223
|
+
};
|
|
224
|
+
const okPages = pages.filter((p) => p.data.http?.outcome === "ok");
|
|
225
|
+
const htmlPages = okPages.filter((p) => p.data.html !== false);
|
|
226
|
+
// 401, 403 and 429 are "inconclusive" (coverage), not broken.
|
|
227
|
+
const brokenPages = pages.filter((p) => p.data.http?.outcome !== "inconclusive" &&
|
|
228
|
+
p.data.http?.outcome !== "blocked" &&
|
|
229
|
+
((status(p) ?? 0) >= 400 || p.data.http?.outcome === "error"));
|
|
230
|
+
const isErrorPage = (url) => /\/404(\.html)?\/?$/.test(url);
|
|
231
|
+
const content = htmlPages.filter((p) => !isErrorPage(p.key));
|
|
232
|
+
const own = (url) => {
|
|
233
|
+
try {
|
|
234
|
+
return sameSite(url, scan.url);
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
const policyFor = (p) => {
|
|
241
|
+
const directives = [
|
|
242
|
+
p.data.xRobotsTag,
|
|
243
|
+
...(p.data.robots ?? [])
|
|
244
|
+
.filter((r) => /^(robots|googlebot)$/i.test(r.name ?? ""))
|
|
245
|
+
.map((r) => r.content),
|
|
246
|
+
].join(",");
|
|
247
|
+
const noindex = /\b(noindex|none)\b/i.test(directives);
|
|
248
|
+
return { noindex, status: noindex ? "noindex" : "indexable" };
|
|
249
|
+
};
|
|
250
|
+
const lighthouseOn = scan.options.lighthouse !== "off";
|
|
251
|
+
const noindex = (p) => policyFor(p).noindex;
|
|
252
|
+
const canonicalElsewhere = (p) => (p.data.canonicals ?? []).some((c) => c.href &&
|
|
253
|
+
c.href !== p.data.http?.finalUrl &&
|
|
254
|
+
c.href !== p.key);
|
|
255
|
+
// Findings: one entry per distinct problem or shared cause, with the
|
|
256
|
+
// affected pages and representative evidence.
|
|
257
|
+
const findings = [];
|
|
258
|
+
const skips = new Map();
|
|
259
|
+
const skip = (code, outcome, detail) => {
|
|
260
|
+
if (!findings.some((f) => f.code === code))
|
|
261
|
+
skips.set(code, { outcome, detail });
|
|
262
|
+
};
|
|
263
|
+
const add = (code, items, overrides = {}) => {
|
|
264
|
+
if (!items.length)
|
|
265
|
+
return;
|
|
266
|
+
const rule = rules[code];
|
|
267
|
+
if (!rule)
|
|
268
|
+
throw new Error(`Unknown finding code ${code}`);
|
|
269
|
+
const normalized = items.map((i) => typeof i === "string" ? { url: null, text: i } : i);
|
|
270
|
+
const urls = [
|
|
271
|
+
...new Set(normalized.map((i) => i.url).filter((u) => !!u)),
|
|
272
|
+
];
|
|
273
|
+
findings.push({
|
|
274
|
+
id: overrides.group ? `${code}:${slug(overrides.group)}` : code,
|
|
275
|
+
code,
|
|
276
|
+
group: overrides.group,
|
|
277
|
+
topic: rule.topic,
|
|
278
|
+
severity: overrides.severity ?? rule.severity,
|
|
279
|
+
confidence: overrides.confidence ?? rule.confidence,
|
|
280
|
+
title: overrides.title ?? rule.title,
|
|
281
|
+
problem: rule.problem,
|
|
282
|
+
impact: rule.impact,
|
|
283
|
+
count: normalized.length,
|
|
284
|
+
pages: urls.length,
|
|
285
|
+
items: normalized,
|
|
286
|
+
urls,
|
|
287
|
+
});
|
|
288
|
+
};
|
|
289
|
+
const withLink = (url) => ({ url, text: url });
|
|
290
|
+
// ---- Availability and security ----
|
|
291
|
+
const tls = of("tls")[0]?.data;
|
|
292
|
+
if (!home ||
|
|
293
|
+
home.data.http?.outcome === "error" ||
|
|
294
|
+
(status(home) ?? 500) >= 500)
|
|
295
|
+
add("homepage-unreachable", [
|
|
296
|
+
home
|
|
297
|
+
? { url: home.key, text: `${failure(home)} ${home.key}` }
|
|
298
|
+
: withLink(scan.url),
|
|
299
|
+
]);
|
|
300
|
+
if (tls?.status === "observed" && (!tls.authorized || !tls.hostnameValid))
|
|
301
|
+
add("tls-invalid", [
|
|
302
|
+
tls.authorizationError ?? tls.hostnameError ?? "certificate rejected",
|
|
303
|
+
]);
|
|
304
|
+
else if (tls?.status !== "observed")
|
|
305
|
+
skip("tls-invalid", "unavailable", tls?.error);
|
|
306
|
+
if (tls?.status === "observed" &&
|
|
307
|
+
typeof tls.daysRemaining === "number" &&
|
|
308
|
+
tls.daysRemaining >= 0 &&
|
|
309
|
+
tls.daysRemaining <= 14)
|
|
310
|
+
add("tls-expiring", [
|
|
311
|
+
`expires ${String(tls.certificate?.validTo ?? "")} (${tls.daysRemaining} days)`,
|
|
312
|
+
], {
|
|
313
|
+
title: `TLS certificate expires in ${tls.daysRemaining} days`,
|
|
314
|
+
});
|
|
315
|
+
const domainData = of("domain")[0]?.data;
|
|
316
|
+
const domain = domainData?.status === "observed"
|
|
317
|
+
? {
|
|
318
|
+
name: domainData.domain,
|
|
319
|
+
expiresAt: domainData.expiresAt ?? null,
|
|
320
|
+
daysRemaining: domainData.daysRemaining ?? null,
|
|
321
|
+
registrar: domainData.registrar ?? null,
|
|
322
|
+
}
|
|
323
|
+
: null;
|
|
324
|
+
if (!domain)
|
|
325
|
+
skip("domain-expiring", "unavailable", domainData?.error ?? "registry data not published");
|
|
326
|
+
else if (domain.daysRemaining === null)
|
|
327
|
+
skip("domain-expiring", "unavailable", "registry does not publish an expiry date");
|
|
328
|
+
else if (domain.daysRemaining <= 60)
|
|
329
|
+
add("domain-expiring", [`${domain.name} on ${String(domain.expiresAt).slice(0, 10)}`], {
|
|
330
|
+
severity: domain.daysRemaining <= 30 ? "critical" : "warning",
|
|
331
|
+
title: `Domain registration expires in ${domain.daysRemaining} days`,
|
|
332
|
+
});
|
|
333
|
+
add("insecure-forms", htmlPages.flatMap((p) => (p.data.forms ?? [])
|
|
334
|
+
.filter((f) => f.insecureDestination)
|
|
335
|
+
.map((f) => ({ url: p.key, text: `${p.key} (${f.action})` }))));
|
|
336
|
+
const variants = of("url-variant");
|
|
337
|
+
add("http-not-redirected", variants
|
|
338
|
+
.filter((v) => v.key.startsWith("http://") &&
|
|
339
|
+
v.data.status === 200 &&
|
|
340
|
+
!String(v.data.finalUrl ?? "").startsWith("https://"))
|
|
341
|
+
.map((v) => withLink(v.key)));
|
|
342
|
+
if (home?.data.http?.headers) {
|
|
343
|
+
const headers = home.data.http.headers;
|
|
344
|
+
const missing = [];
|
|
345
|
+
if (home.key.startsWith("https://") &&
|
|
346
|
+
!headers["strict-transport-security"])
|
|
347
|
+
missing.push("Strict-Transport-Security");
|
|
348
|
+
if (!headers["content-security-policy"])
|
|
349
|
+
missing.push("Content-Security-Policy");
|
|
350
|
+
if (!headers["x-content-type-options"])
|
|
351
|
+
missing.push("X-Content-Type-Options");
|
|
352
|
+
if (!headers["x-frame-options"] &&
|
|
353
|
+
!/frame-ancestors/i.test(headers["content-security-policy"] ?? ""))
|
|
354
|
+
missing.push("X-Frame-Options or frame-ancestors");
|
|
355
|
+
if (!headers["referrer-policy"])
|
|
356
|
+
missing.push("Referrer-Policy");
|
|
357
|
+
add("security-headers", missing.map((m) => ({
|
|
358
|
+
url: home.key,
|
|
359
|
+
text: `${m} missing on ${home.key}`,
|
|
360
|
+
})));
|
|
361
|
+
}
|
|
362
|
+
else
|
|
363
|
+
skip("security-headers", "unavailable", "homepage response headers not available");
|
|
364
|
+
const dns = of("dns")[0]?.data;
|
|
365
|
+
if (dns?.email && dns.records?.mx?.status === "observed") {
|
|
366
|
+
const missing = [];
|
|
367
|
+
if (!dns.email.spf?.length)
|
|
368
|
+
missing.push("SPF record missing");
|
|
369
|
+
if (!dns.email.dmarc?.length)
|
|
370
|
+
missing.push("DMARC record missing");
|
|
371
|
+
else if (/p=none/i.test(dns.email.dmarc[0]))
|
|
372
|
+
missing.push("DMARC policy is p=none, which does not protect the domain");
|
|
373
|
+
add("email-authentication", missing);
|
|
374
|
+
if ((dns.records.mx.values ?? []).length)
|
|
375
|
+
add("email-transport-security", dns.email.mtaSts?.length
|
|
376
|
+
? []
|
|
377
|
+
: [`_mta-sts.${dns.domain} record missing`]);
|
|
378
|
+
else
|
|
379
|
+
skip("email-transport-security", "not-applicable", "no mail servers published");
|
|
380
|
+
}
|
|
381
|
+
else {
|
|
382
|
+
skip("email-authentication", "unavailable", "MX lookup unavailable");
|
|
383
|
+
skip("email-transport-security", "unavailable", "MX lookup unavailable");
|
|
384
|
+
}
|
|
385
|
+
const details = of("browser-detail");
|
|
386
|
+
add("mixed-content", details.flatMap((d) => d.key.startsWith("https://")
|
|
387
|
+
? (d.data.network ?? [])
|
|
388
|
+
.filter((n) => String(n.url).startsWith("http://"))
|
|
389
|
+
.map((n) => ({ url: d.key, text: `${n.url} on ${d.key}` }))
|
|
390
|
+
: []));
|
|
391
|
+
const trackers = new Map();
|
|
392
|
+
for (const d of details)
|
|
393
|
+
for (const n of d.data.network ?? []) {
|
|
394
|
+
const name = trackerName(String(n.url));
|
|
395
|
+
if (name)
|
|
396
|
+
trackers.set(`${name}|${d.key}`, {
|
|
397
|
+
url: d.key,
|
|
398
|
+
text: `${name} on ${d.key}`,
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
add("trackers-before-consent", [...trackers.values()]);
|
|
402
|
+
const browserMetrics = of("browser-metrics");
|
|
403
|
+
add("cookies-before-consent", browserMetrics.flatMap((m) => (m.data.cookies?.names ?? [])
|
|
404
|
+
.filter((c) => c.thirdParty)
|
|
405
|
+
.map((c) => ({
|
|
406
|
+
url: m.key,
|
|
407
|
+
text: `${c.name} from ${c.domain} on ${m.key}`,
|
|
408
|
+
}))));
|
|
409
|
+
const safe = of("safe-browsing")[0]?.data;
|
|
410
|
+
if (safe?.status === "observed" && safe.flagged)
|
|
411
|
+
add("safe-browsing-flagged", safe.threats.map((t) => ({
|
|
412
|
+
url: scan.url,
|
|
413
|
+
text: `${t.replace(/_/g, " ").toLowerCase()} on ${scan.url}`,
|
|
414
|
+
})));
|
|
415
|
+
else if (!safe)
|
|
416
|
+
skip("safe-browsing-flagged", "not-checked", "no Google API key configured");
|
|
417
|
+
else if (safe.status !== "observed")
|
|
418
|
+
skip("safe-browsing-flagged", "unavailable", safe.error);
|
|
419
|
+
const lists = of("blocklists")[0]?.data;
|
|
420
|
+
const listResults = (lists?.results ?? []);
|
|
421
|
+
add("blocklisted", listResults.filter((r) => r.status === "listed").map((r) => r.list));
|
|
422
|
+
if (listResults.length &&
|
|
423
|
+
listResults.every((r) => r.status === "unavailable"))
|
|
424
|
+
skip("blocklisted", "unavailable", "blocklist query refused");
|
|
425
|
+
const labs = of("ssl-labs")[0]?.data;
|
|
426
|
+
if (labs?.status === "observed" && labs.grade && /^[CDEFTM]/.test(labs.grade))
|
|
427
|
+
add("tls-grade-low", [`${labs.grade} for ${new URL(scan.url).hostname}`], {
|
|
428
|
+
title: `SSL Labs grades the TLS setup ${labs.grade}`,
|
|
429
|
+
});
|
|
430
|
+
else if (labs?.status !== "observed")
|
|
431
|
+
skip("tls-grade-low", "unavailable", labs?.note ?? labs?.error);
|
|
432
|
+
const certificates = of("certificates")[0]?.data;
|
|
433
|
+
// Only labels below the registered domain count: a .dev or .test
|
|
434
|
+
// domain is not a staging host.
|
|
435
|
+
const registeredDomain = dns?.domain ?? null;
|
|
436
|
+
const subdomainLabels = (h) => registeredDomain &&
|
|
437
|
+
(h === registeredDomain || h.endsWith(`.${registeredDomain}`))
|
|
438
|
+
? h.slice(0, Math.max(0, h.length - registeredDomain.length - 1))
|
|
439
|
+
: h;
|
|
440
|
+
if (certificates?.status === "observed")
|
|
441
|
+
add("staging-hostnames", (certificates.hostnames ?? []).filter((h) => /(^|\.)(staging|stage|test|testing|dev|develop|acc|acceptance|beta|old|demo|preview|uat)(\.|$)/.test(subdomainLabels(h))));
|
|
442
|
+
else
|
|
443
|
+
skip("staging-hostnames", "unavailable", certificates?.error);
|
|
444
|
+
// ---- Crawling and indexability ----
|
|
445
|
+
add("broken-pages", brokenPages
|
|
446
|
+
.filter((p) => !isErrorPage(p.key))
|
|
447
|
+
.map((p) => ({
|
|
448
|
+
url: p.key,
|
|
449
|
+
text: `${failure(p)} ${p.key}${linkedFrom(p.key)}`,
|
|
450
|
+
})));
|
|
451
|
+
if (home &&
|
|
452
|
+
home.data.html !== false &&
|
|
453
|
+
noindex(home))
|
|
454
|
+
add("homepage-noindex", [withLink(home.key)]);
|
|
455
|
+
add("unexpected-noindex", htmlPages
|
|
456
|
+
.filter((p) => p.key !== home?.key && noindex(p))
|
|
457
|
+
.map((p) => withLink(p.key)));
|
|
458
|
+
const robots = of("robots")[0]?.data;
|
|
459
|
+
const homePolicy = of("crawler-policy").find((o) => o.key === home?.key)?.data ??
|
|
460
|
+
robots?.policy;
|
|
461
|
+
if (robots?.http?.outcome === "error" || (robots?.http?.status ?? 0) >= 500)
|
|
462
|
+
add("robots-unavailable", [
|
|
463
|
+
`${robots.http.status ?? robots.http.error} ${robots.http.finalUrl ?? robots.http.url}`,
|
|
464
|
+
]);
|
|
465
|
+
if (homePolicy?.bots?.some((b) => b.bot === "Googlebot" && b.allowed === false))
|
|
466
|
+
add("search-blocked", [withLink(homePolicy.robotsUrl)]);
|
|
467
|
+
else if (!homePolicy)
|
|
468
|
+
skip("search-blocked", "unavailable", "robots policy not evaluated");
|
|
469
|
+
add("canonical-broken", (of("canonical-targets")[0]?.data ?? [])
|
|
470
|
+
.filter((c) => c.targetHttp && (c.targetHttp.status ?? 0) >= 400)
|
|
471
|
+
.map((c) => ({
|
|
472
|
+
url: c.source,
|
|
473
|
+
text: `${c.targetHttp.status} ${c.target} (canonical of ${c.source})`,
|
|
474
|
+
})));
|
|
475
|
+
const renderedPages = [...renderedByKey.entries()];
|
|
476
|
+
add("canonical-rendered-differs", renderedPages
|
|
477
|
+
.filter(([, r]) => r.comparison && !r.comparison.canonical.same)
|
|
478
|
+
.map(([url, r]) => ({
|
|
479
|
+
url,
|
|
480
|
+
text: `${url}: HTML ${r.comparison.canonical.original.join(", ") || "none"}, rendered ${r.comparison.canonical.rendered.join(", ") || "none"}`,
|
|
481
|
+
})));
|
|
482
|
+
add("robots-rendered-differs", renderedPages
|
|
483
|
+
.filter(([, r]) => r.comparison && !r.comparison.robots.same)
|
|
484
|
+
.map(([url, r]) => ({
|
|
485
|
+
url,
|
|
486
|
+
text: `${url}: HTML "${r.comparison.robots.original || "none"}", rendered "${r.comparison.robots.rendered || "none"}"`,
|
|
487
|
+
})));
|
|
488
|
+
const sitemaps = of("sitemap");
|
|
489
|
+
const validSitemaps = sitemaps.filter((s) => s.data.valid);
|
|
490
|
+
if (!validSitemaps.length)
|
|
491
|
+
add("sitemap-missing", [
|
|
492
|
+
withLink(`${new URL(scan.url).origin}/sitemap.xml`),
|
|
493
|
+
]);
|
|
494
|
+
add("sitemap-invalid", sitemaps
|
|
495
|
+
.filter((s) => !s.data.valid &&
|
|
496
|
+
(sources.get(s.key) ?? []).some((src) => src !== "default-location"))
|
|
497
|
+
.map((s) => ({
|
|
498
|
+
url: s.key,
|
|
499
|
+
text: `${s.data.http?.status ?? s.data.http?.error ?? "no response"} ${s.key}${s.data.error ? `: ${s.data.error}` : ""}`,
|
|
500
|
+
})));
|
|
501
|
+
const sitemapEntries = new Map();
|
|
502
|
+
for (const s of validSitemaps)
|
|
503
|
+
for (const entry of s.data.urls ?? []) {
|
|
504
|
+
const url = entry.scanUrl ?? entry.url;
|
|
505
|
+
if (!sitemapEntries.has(url))
|
|
506
|
+
sitemapEntries.set(url, s.key);
|
|
507
|
+
}
|
|
508
|
+
add("sitemap-problems", [
|
|
509
|
+
...new Map([...sitemapEntries].flatMap(([url, sitemap]) => {
|
|
510
|
+
const page = byKey.get(url) ?? resourceByKey.get(url);
|
|
511
|
+
if (!page)
|
|
512
|
+
return [];
|
|
513
|
+
if (page.data.http?.outcome === "inconclusive" ||
|
|
514
|
+
page.data.http?.outcome === "blocked")
|
|
515
|
+
return [];
|
|
516
|
+
if ((status(page) ?? 0) >= 400 ||
|
|
517
|
+
page.data.http?.outcome === "error" ||
|
|
518
|
+
isErrorPage(url))
|
|
519
|
+
return [
|
|
520
|
+
[url, { url, text: `${failure(page)} ${url} (in ${sitemap})` }],
|
|
521
|
+
];
|
|
522
|
+
if ((page.data.http?.redirects ?? []).length)
|
|
523
|
+
return [
|
|
524
|
+
[
|
|
525
|
+
url,
|
|
526
|
+
{
|
|
527
|
+
url,
|
|
528
|
+
text: `redirects to ${page.data.http.finalUrl}: ${url} (in ${sitemap})`,
|
|
529
|
+
},
|
|
530
|
+
],
|
|
531
|
+
];
|
|
532
|
+
if (page.data.html !== false && byKey.has(url) && noindex(page))
|
|
533
|
+
return [[url, { url, text: `noindex ${url} (in ${sitemap})` }]];
|
|
534
|
+
return [];
|
|
535
|
+
})).values(),
|
|
536
|
+
]);
|
|
537
|
+
const now = Date.now();
|
|
538
|
+
add("sitemap-dates", [
|
|
539
|
+
...new Map(validSitemaps.flatMap((s) => (s.data.urls ?? []).flatMap((entry) => {
|
|
540
|
+
if (!entry.lastmod)
|
|
541
|
+
return [];
|
|
542
|
+
const url = entry.scanUrl ?? entry.url;
|
|
543
|
+
const t = Date.parse(entry.lastmod);
|
|
544
|
+
if (Number.isNaN(t))
|
|
545
|
+
return [
|
|
546
|
+
[
|
|
547
|
+
url,
|
|
548
|
+
{ url, text: `unreadable "${entry.lastmod}" for ${url}` },
|
|
549
|
+
],
|
|
550
|
+
];
|
|
551
|
+
if (t > now + 86400000)
|
|
552
|
+
return [
|
|
553
|
+
[
|
|
554
|
+
url,
|
|
555
|
+
{
|
|
556
|
+
url,
|
|
557
|
+
text: `in the future (${String(entry.lastmod).slice(0, 10)}) ${url}`,
|
|
558
|
+
},
|
|
559
|
+
],
|
|
560
|
+
];
|
|
561
|
+
if (t < now - 3 * 365 * 86400000)
|
|
562
|
+
return [
|
|
563
|
+
[
|
|
564
|
+
url,
|
|
565
|
+
{
|
|
566
|
+
url,
|
|
567
|
+
text: `over three years old (${String(entry.lastmod).slice(0, 10)}) ${url}`,
|
|
568
|
+
},
|
|
569
|
+
],
|
|
570
|
+
];
|
|
571
|
+
return [];
|
|
572
|
+
}))).values(),
|
|
573
|
+
]);
|
|
574
|
+
if (validSitemaps.length)
|
|
575
|
+
add("sitemap-not-listed", content
|
|
576
|
+
.filter((p) => status(p) === 200 &&
|
|
577
|
+
!noindex(p) &&
|
|
578
|
+
!canonicalElsewhere(p) &&
|
|
579
|
+
!sitemapEntries.has(p.key) &&
|
|
580
|
+
!sitemapEntries.has(p.data.http.finalUrl) &&
|
|
581
|
+
!new URL(p.key).search)
|
|
582
|
+
.map((p) => withLink(p.key)));
|
|
583
|
+
else
|
|
584
|
+
skip("sitemap-not-listed", "not-applicable", "no valid sitemap");
|
|
585
|
+
const linkGraph = of("link-graph")[0]?.data;
|
|
586
|
+
const graphPages = linkGraph?.pages ?? [];
|
|
587
|
+
const pageRows = urlRows.filter((u) => u.kind === "page");
|
|
588
|
+
const followedPages = pageRows.filter((u) => u.scheduled);
|
|
589
|
+
const crawlComplete = pages.length >= followedPages.length &&
|
|
590
|
+
!pageRows.some((u) => u.skipReason === "explicit-limit") &&
|
|
591
|
+
omitted === 0;
|
|
592
|
+
// Orphans are only meaningful when every followed page was crawled.
|
|
593
|
+
if (crawlComplete && linkGraph)
|
|
594
|
+
add("orphan-pages", graphPages
|
|
595
|
+
.filter((p) => p.orphanCandidate && byKey.has(p.url))
|
|
596
|
+
.map((p) => withLink(p.url)));
|
|
597
|
+
else
|
|
598
|
+
skip("orphan-pages", linkGraph ? "not-checked" : "unavailable", "crawl did not cover every followed page");
|
|
599
|
+
const slashVariants = of("slash-variant");
|
|
600
|
+
add("trailing-slash-variants", slashVariants
|
|
601
|
+
.filter((r) => ["duplicate", "different-redirect-target"].includes(r.data.result))
|
|
602
|
+
.map((r) => ({
|
|
603
|
+
url: r.data.url,
|
|
604
|
+
text: `${r.data.result}: ${r.data.url} and ${r.data.alternateUrl}`,
|
|
605
|
+
})));
|
|
606
|
+
add("canonicalized-slash-variants", slashVariants
|
|
607
|
+
.filter((r) => r.data.result === "canonicalized")
|
|
608
|
+
.map((r) => ({
|
|
609
|
+
url: r.data.url,
|
|
610
|
+
text: `${r.data.url} and ${r.data.alternateUrl}, canonical ${r.data.original.canonicals[0]}`,
|
|
611
|
+
})));
|
|
612
|
+
add("different-slash-content", slashVariants
|
|
613
|
+
.filter((r) => r.data.result === "different-content")
|
|
614
|
+
.map((r) => ({
|
|
615
|
+
url: r.data.url,
|
|
616
|
+
text: `${r.data.url} and ${r.data.alternateUrl}`,
|
|
617
|
+
})));
|
|
618
|
+
const redirectChains = [...pages, ...resources].filter((r) => (r.data.http?.redirects ?? []).length >= 2);
|
|
619
|
+
const successfulExternal = (r) => !own(r.key) &&
|
|
620
|
+
r.data.http?.outcome === "ok" &&
|
|
621
|
+
r.data.http?.status >= 200 &&
|
|
622
|
+
r.data.http?.status < 300;
|
|
623
|
+
add("redirect-chains", redirectChains
|
|
624
|
+
.filter((r) => !successfulExternal(r))
|
|
625
|
+
.map((r) => ({
|
|
626
|
+
url: r.key,
|
|
627
|
+
text: `${r.data.http.redirects.length} hops ${r.key} to ${r.data.http.finalUrl}`,
|
|
628
|
+
})));
|
|
629
|
+
const hreflangs = of("hreflang-links")[0]?.data ?? [];
|
|
630
|
+
if (hreflangs.length) {
|
|
631
|
+
add("hreflang-not-reciprocal", hreflangs
|
|
632
|
+
.filter((h) => h.reciprocal === false)
|
|
633
|
+
.map((h) => ({
|
|
634
|
+
url: h.source,
|
|
635
|
+
text: `${h.source} to ${h.href} (${h.hreflang})`,
|
|
636
|
+
})));
|
|
637
|
+
add("hreflang-unreachable", hreflangs
|
|
638
|
+
.filter((h) => (h.targetStatus ?? 0) >= 400 || h.targetOutcome === "error")
|
|
639
|
+
.map((h) => ({
|
|
640
|
+
url: h.source,
|
|
641
|
+
text: `${h.targetStatus ?? h.targetOutcome} ${h.href} (${h.hreflang} alternate of ${h.source})`,
|
|
642
|
+
})));
|
|
643
|
+
add("hreflang-invalid", hreflangs
|
|
644
|
+
.filter((h) => !h.languageValid)
|
|
645
|
+
.map((h) => ({
|
|
646
|
+
url: h.source,
|
|
647
|
+
text: `hreflang="${h.hreflang}" on ${h.source}`,
|
|
648
|
+
})));
|
|
649
|
+
}
|
|
650
|
+
else
|
|
651
|
+
for (const code of [
|
|
652
|
+
"hreflang-not-reciprocal",
|
|
653
|
+
"hreflang-unreachable",
|
|
654
|
+
"hreflang-invalid",
|
|
655
|
+
])
|
|
656
|
+
skip(code, "not-applicable", "no hreflang declarations");
|
|
657
|
+
const paginated = of("pagination")[0]?.data ?? [];
|
|
658
|
+
if (paginated.length) {
|
|
659
|
+
add("pagination-broken", paginated.flatMap((p) => [
|
|
660
|
+
...((p.nextStatus ?? 0) >= 400
|
|
661
|
+
? [
|
|
662
|
+
{
|
|
663
|
+
url: p.url,
|
|
664
|
+
text: `${p.nextStatus} next page ${p.next} from ${p.url}`,
|
|
665
|
+
},
|
|
666
|
+
]
|
|
667
|
+
: []),
|
|
668
|
+
...p.numberedBroken.map((u) => ({
|
|
669
|
+
url: p.url,
|
|
670
|
+
text: `broken page link ${u} from ${p.url}`,
|
|
671
|
+
})),
|
|
672
|
+
]));
|
|
673
|
+
add("pagination-canonical-first", paginated
|
|
674
|
+
.filter((p) => p.canonicalRelation === "first-page")
|
|
675
|
+
.map((p) => ({
|
|
676
|
+
url: p.url,
|
|
677
|
+
text: `${p.url} canonical ${p.canonical}`,
|
|
678
|
+
})));
|
|
679
|
+
add("pagination-noindex", paginated
|
|
680
|
+
.filter((p) => !p.isFirst && p.noindex)
|
|
681
|
+
.map((p) => withLink(p.url)));
|
|
682
|
+
add("pagination-rendered-only", paginated
|
|
683
|
+
.filter((p) => p.renderedOnly)
|
|
684
|
+
.map((p) => ({
|
|
685
|
+
url: p.url,
|
|
686
|
+
text: `${p.url}: ${p.next ?? p.numbered[0]}`,
|
|
687
|
+
})));
|
|
688
|
+
}
|
|
689
|
+
else
|
|
690
|
+
for (const code of [
|
|
691
|
+
"pagination-broken",
|
|
692
|
+
"pagination-canonical-first",
|
|
693
|
+
"pagination-noindex",
|
|
694
|
+
"pagination-rendered-only",
|
|
695
|
+
])
|
|
696
|
+
skip(code, linkGraph ? "not-applicable" : "unavailable", "no pagination detected");
|
|
697
|
+
if (renderedByKey.size)
|
|
698
|
+
add("rendered-only-pages", graphPages
|
|
699
|
+
.filter((p) => p.renderedOnly && byKey.has(p.url))
|
|
700
|
+
.map((p) => withLink(p.url)));
|
|
701
|
+
// ---- Content and structure ----
|
|
702
|
+
add("missing-title", content.filter((p) => !p.data.title).map((p) => withLink(p.key)));
|
|
703
|
+
add("title-length", content
|
|
704
|
+
.filter((p) => p.data.title &&
|
|
705
|
+
(p.data.title.length < 30 || p.data.title.length > 60))
|
|
706
|
+
.map((p) => ({
|
|
707
|
+
url: p.key,
|
|
708
|
+
text: `${p.data.title.length} characters "${p.data.title}" on ${p.key}`,
|
|
709
|
+
})));
|
|
710
|
+
add("missing-description", content
|
|
711
|
+
.filter((p) => !(p.data.descriptions ?? []).some(Boolean))
|
|
712
|
+
.map((p) => withLink(p.key)));
|
|
713
|
+
add("description-length", content
|
|
714
|
+
.map((p) => ({
|
|
715
|
+
key: p.key,
|
|
716
|
+
d: (p.data.descriptions ?? []).find(Boolean),
|
|
717
|
+
}))
|
|
718
|
+
.filter((p) => p.d && (p.d.length < 70 || p.d.length > 160))
|
|
719
|
+
.map((p) => ({
|
|
720
|
+
url: p.key,
|
|
721
|
+
text: `${p.d.length} characters on ${p.key}`,
|
|
722
|
+
})));
|
|
723
|
+
const duplicateData = of("duplicates")[0]?.data;
|
|
724
|
+
for (const [field, code] of [
|
|
725
|
+
["title", "duplicate-titles"],
|
|
726
|
+
["description", "duplicate-descriptions"],
|
|
727
|
+
["textHash", "duplicate-content"],
|
|
728
|
+
])
|
|
729
|
+
add(code, (duplicateData?.exact ?? [])
|
|
730
|
+
.filter((d) => d.field === field)
|
|
731
|
+
.flatMap((d, index) => d.urls.map((url) => ({
|
|
732
|
+
url,
|
|
733
|
+
text: `${url} (group ${index + 1}, ${d.urls.length} pages${field === "textHash" ? "" : `, "${d.value}"`})`,
|
|
734
|
+
}))));
|
|
735
|
+
const near = duplicateData?.near ?? [];
|
|
736
|
+
add("similar-content", near
|
|
737
|
+
.filter((d) => !d.substitution)
|
|
738
|
+
.flatMap((d, index) => d.urls.map((url) => ({
|
|
739
|
+
url,
|
|
740
|
+
text: `${url} (pair ${index + 1}, ${Math.round(d.similarity * 100)}% word-set overlap with ${d.urls.find((u) => u !== url)})`,
|
|
741
|
+
}))));
|
|
742
|
+
add("substituted-content", near
|
|
743
|
+
.filter((d) => d.substitution)
|
|
744
|
+
.flatMap((d) => d.urls.map((url, i) => ({
|
|
745
|
+
url,
|
|
746
|
+
text: `${url} differs from ${d.urls[1 - i]} only in: ${[...d.differences.onlyLeft, ...d.differences.onlyRight].join(", ")}`,
|
|
747
|
+
}))));
|
|
748
|
+
add("overlapping-collections", (duplicateData?.collections ?? []).flatMap((c) => c.urls.map((url, i) => ({
|
|
749
|
+
url,
|
|
750
|
+
text: `${url} shares ${c.shared} of ${c.items[i]} listed products with ${c.urls[1 - i]}`,
|
|
751
|
+
}))));
|
|
752
|
+
if (!duplicateData) {
|
|
753
|
+
for (const code of [
|
|
754
|
+
"duplicate-titles",
|
|
755
|
+
"duplicate-descriptions",
|
|
756
|
+
"duplicate-content",
|
|
757
|
+
"similar-content",
|
|
758
|
+
"substituted-content",
|
|
759
|
+
"overlapping-collections",
|
|
760
|
+
])
|
|
761
|
+
skip(code, "unavailable", "site analysis did not run");
|
|
762
|
+
}
|
|
763
|
+
add("thin-pages", content
|
|
764
|
+
.filter((p) => (p.data.wordCount ?? 0) < 100)
|
|
765
|
+
.map((p) => ({
|
|
766
|
+
url: p.key,
|
|
767
|
+
text: `${p.data.wordCount ?? 0} words on ${p.key}`,
|
|
768
|
+
})));
|
|
769
|
+
const h1s = (p) => (p.data.headings ?? []).filter((h) => h.level === 1).length;
|
|
770
|
+
add("h1-missing", content.filter((p) => h1s(p) === 0).map((p) => withLink(p.key)));
|
|
771
|
+
add("h1-multiple", content
|
|
772
|
+
.filter((p) => h1s(p) > 1)
|
|
773
|
+
.map((p) => ({ url: p.key, text: `${h1s(p)} on ${p.key}` })));
|
|
774
|
+
add("heading-skips", content
|
|
775
|
+
.filter((p) => {
|
|
776
|
+
const levels = (p.data.headings ?? []).map((h) => h.level);
|
|
777
|
+
return levels.some((l, i) => i > 0 && l > levels[i - 1] + 1);
|
|
778
|
+
})
|
|
779
|
+
.map((p) => withLink(p.key)));
|
|
780
|
+
add("long-paragraphs", content
|
|
781
|
+
.filter((p) => (p.data.readability?.paragraphsOver150 ?? 0) > 0)
|
|
782
|
+
.map((p) => ({
|
|
783
|
+
url: p.key,
|
|
784
|
+
text: `${p.data.readability.paragraphsOver150} on ${p.key}`,
|
|
785
|
+
})));
|
|
786
|
+
add("long-sentences", content
|
|
787
|
+
.filter((p) => (p.data.readability?.sentences ?? 0) >= 10 &&
|
|
788
|
+
(p.data.readability?.longSentenceShare ?? 0) > 0.25)
|
|
789
|
+
.map((p) => ({
|
|
790
|
+
url: p.key,
|
|
791
|
+
text: `${Math.round(p.data.readability.longSentenceShare * 100)}% on ${p.key}`,
|
|
792
|
+
})));
|
|
793
|
+
add("language-mismatch", content.flatMap((p) => {
|
|
794
|
+
const declared = languageOfTag(p.data.language);
|
|
795
|
+
const detected = detectLanguage(p.data.text ?? "");
|
|
796
|
+
if (!detected)
|
|
797
|
+
return [];
|
|
798
|
+
if (!declared)
|
|
799
|
+
return [
|
|
800
|
+
{
|
|
801
|
+
url: p.key,
|
|
802
|
+
text: `no lang attribute, content looks ${detected}: ${p.key}`,
|
|
803
|
+
},
|
|
804
|
+
];
|
|
805
|
+
return declared !== detected
|
|
806
|
+
? [
|
|
807
|
+
{
|
|
808
|
+
url: p.key,
|
|
809
|
+
text: `lang="${p.data.language}" but content looks ${detected}: ${p.key}`,
|
|
810
|
+
},
|
|
811
|
+
]
|
|
812
|
+
: [];
|
|
813
|
+
}));
|
|
814
|
+
const languageSwitches = of("language-links")[0]?.data ?? [];
|
|
815
|
+
if (languageSwitches.length) {
|
|
816
|
+
add("language-switch-broken", languageSwitches
|
|
817
|
+
.filter((s) => (s.status ?? 0) >= 400 || s.outcome === "error")
|
|
818
|
+
.map((s) => ({
|
|
819
|
+
url: s.source,
|
|
820
|
+
text: `${s.status ?? s.outcome} ${s.url} ("${s.text}" on ${s.source})`,
|
|
821
|
+
})));
|
|
822
|
+
add("language-switch-mismatch", languageSwitches
|
|
823
|
+
.filter((s) => s.status === 200 &&
|
|
824
|
+
((s.declaredMatches === false && s.targetLanguage) ||
|
|
825
|
+
(s.detectedLanguage && s.detectedLanguage !== s.expected)))
|
|
826
|
+
.map((s) => ({
|
|
827
|
+
url: s.source,
|
|
828
|
+
text: `"${s.text}" on ${s.source} leads to ${s.url}: declared ${s.targetLanguage ?? "none"}, text looks ${s.detectedLanguage ?? "unclear"}, expected ${s.expected}`,
|
|
829
|
+
})));
|
|
830
|
+
}
|
|
831
|
+
else {
|
|
832
|
+
skip("language-switch-broken", linkGraph ? "not-applicable" : "unavailable", "no language-switch links detected");
|
|
833
|
+
skip("language-switch-mismatch", linkGraph ? "not-applicable" : "unavailable", "no language-switch links detected");
|
|
834
|
+
}
|
|
835
|
+
add("content-requires-javascript", renderedPages
|
|
836
|
+
.filter(([, r]) => r.comparison?.text?.ratio !== null &&
|
|
837
|
+
r.comparison?.text?.ratio < 0.5 &&
|
|
838
|
+
r.comparison.text.renderedWords >= 50)
|
|
839
|
+
.map(([url, r]) => ({
|
|
840
|
+
url,
|
|
841
|
+
text: `${r.comparison.text.originalWords} of ${r.comparison.text.renderedWords} words without JavaScript on ${url}`,
|
|
842
|
+
})));
|
|
843
|
+
add("title-rendered-differs", renderedPages
|
|
844
|
+
.filter(([, r]) => r.comparison &&
|
|
845
|
+
(!r.comparison.title.same ||
|
|
846
|
+
r.comparison.headings.added.length ||
|
|
847
|
+
r.comparison.headings.removed.length))
|
|
848
|
+
.map(([url, r]) => ({
|
|
849
|
+
url,
|
|
850
|
+
text: `${url}: ${!r.comparison.title.same ? `title "${r.comparison.title.original}" became "${r.comparison.title.rendered}"; ` : ""}${r.comparison.headings.added.length} headings added, ${r.comparison.headings.removed.length} removed`,
|
|
851
|
+
})));
|
|
852
|
+
if (!renderedByKey.size)
|
|
853
|
+
for (const code of [
|
|
854
|
+
"canonical-rendered-differs",
|
|
855
|
+
"robots-rendered-differs",
|
|
856
|
+
"rendered-only-pages",
|
|
857
|
+
"content-requires-javascript",
|
|
858
|
+
"title-rendered-differs",
|
|
859
|
+
"structured-data-rendered-only",
|
|
860
|
+
"pagination-rendered-only",
|
|
861
|
+
])
|
|
862
|
+
skip(code, scan.options.browser ? "unavailable" : "not-checked", scan.options.browser
|
|
863
|
+
? "no page completed the browser pass"
|
|
864
|
+
: "browser pass disabled");
|
|
865
|
+
// ---- Links and navigation ----
|
|
866
|
+
// The guessed /favicon.ico probe is not a link; it only feeds the favicon check.
|
|
867
|
+
const guessed = (url) => (sources.get(url) ?? []).every((s) => s.startsWith("favicon-fallback:"));
|
|
868
|
+
const brokenResources = resources.filter((r) => ((status(r) ?? 0) >= 400 || r.data.http?.outcome === "error") &&
|
|
869
|
+
!guessed(r.key));
|
|
870
|
+
const resourceLine = (r) => ({
|
|
871
|
+
url: linkingPages(r.key)[0] ?? null,
|
|
872
|
+
text: `${failure(r)} ${r.key}${linkedFrom(r.key)}`,
|
|
873
|
+
});
|
|
874
|
+
add("broken-resources", brokenResources.filter((r) => own(r.key)).map(resourceLine));
|
|
875
|
+
add("broken-external-resources", brokenResources
|
|
876
|
+
.filter((r) => !own(r.key) && !platformBlocked(r.key))
|
|
877
|
+
.map(resourceLine));
|
|
878
|
+
add("unverifiable-external", resources
|
|
879
|
+
.filter((r) => !own(r.key) &&
|
|
880
|
+
(r.data.http?.outcome === "inconclusive" ||
|
|
881
|
+
r.data.http?.outcome === "blocked" ||
|
|
882
|
+
(platformBlocked(r.key) && (status(r) ?? 0) >= 400)))
|
|
883
|
+
.map(resourceLine));
|
|
884
|
+
add("broken-anchors", (of("anchors")[0]?.data ?? [])
|
|
885
|
+
.filter((a) => a.present === false)
|
|
886
|
+
.map((a) => ({
|
|
887
|
+
url: a.source,
|
|
888
|
+
text: `${a.target}#${a.fragment} from ${a.source}`,
|
|
889
|
+
})));
|
|
890
|
+
add("external-redirect-chains", redirectChains.filter(successfulExternal).map((r) => ({
|
|
891
|
+
url: linkingPages(r.key)[0] ?? null,
|
|
892
|
+
text: `${r.data.http.redirects.length} hops ${r.key} to ${r.data.http.finalUrl}`,
|
|
893
|
+
})));
|
|
894
|
+
const vagueByPage = new Map();
|
|
895
|
+
for (const v of linkGraph?.vagueAnchors ?? [])
|
|
896
|
+
vagueByPage.set(v.source, (vagueByPage.get(v.source) ?? 0) + 1);
|
|
897
|
+
add("vague-anchors", [...vagueByPage].map(([url, n]) => ({
|
|
898
|
+
url,
|
|
899
|
+
text: `${n} on ${url}, for example "${linkGraph.vagueAnchors.find((v) => v.source === url)?.text || "(empty)"}"`,
|
|
900
|
+
})));
|
|
901
|
+
add("few-contextual-links", (linkGraph?.fewContextual ?? []).map((f) => ({
|
|
902
|
+
url: f.url,
|
|
903
|
+
text: `${f.url}: 0 contextual links, peers in ${f.group} have a median of ${f.peerMedian} (${f.peers} pages compared)`,
|
|
904
|
+
})));
|
|
905
|
+
add("deep-pages", graphPages
|
|
906
|
+
.filter((p) => byKey.has(p.url) && p.clickDepth !== null && p.clickDepth >= 4)
|
|
907
|
+
.map((p) => ({ url: p.url, text: `${p.clickDepth} clicks: ${p.url}` })));
|
|
908
|
+
if (!linkGraph)
|
|
909
|
+
for (const code of [
|
|
910
|
+
"broken-anchors",
|
|
911
|
+
"vague-anchors",
|
|
912
|
+
"few-contextual-links",
|
|
913
|
+
"deep-pages",
|
|
914
|
+
"canonical-broken",
|
|
915
|
+
])
|
|
916
|
+
skip(code, "unavailable", "site analysis did not run");
|
|
917
|
+
const business = of("business-identity")[0]?.data;
|
|
918
|
+
add("map-link-broken", (business?.maps ?? [])
|
|
919
|
+
.filter((m) => (m.status ?? 0) >= 400)
|
|
920
|
+
.map((m) => ({ url: null, text: `${m.status} ${m.url}` })));
|
|
921
|
+
// ---- Structured data ----
|
|
922
|
+
add("structured-data-invalid", content
|
|
923
|
+
.filter((p) => (p.data.structuredData ?? []).some((s) => s.parseValid === false))
|
|
924
|
+
.map((p) => withLink(p.key)));
|
|
925
|
+
add("structured-data-structural", content
|
|
926
|
+
.filter((p) => (p.data.structuredData ?? []).some((s) => (s.structuralErrors ?? []).length))
|
|
927
|
+
.map((p) => ({
|
|
928
|
+
url: p.key,
|
|
929
|
+
text: `${p.key}: ${p.data.structuredData
|
|
930
|
+
.flatMap((s) => s.structuralErrors ?? [])
|
|
931
|
+
.slice(0, 3)
|
|
932
|
+
.join("; ")}`,
|
|
933
|
+
})));
|
|
934
|
+
const types = (p) => (p.data.structuredData ?? []).flatMap((s) => [s.type, ...(s.graph ?? [])].flat().filter(Boolean).map(String));
|
|
935
|
+
if (home && home.data.html !== false) {
|
|
936
|
+
if (!content.some((p) => (p.data.structuredData ?? []).length))
|
|
937
|
+
add("structured-data-missing", [withLink(home.key)]);
|
|
938
|
+
else if (!types(home).some((t) => businessTypes.test(t)))
|
|
939
|
+
add("homepage-business-schema", [withLink(home.key)]);
|
|
940
|
+
}
|
|
941
|
+
const structured = of("structured-data")[0]?.data ?? [];
|
|
942
|
+
const missingByType = new Map();
|
|
943
|
+
for (const page of structured)
|
|
944
|
+
for (const check of page.richResults ?? [])
|
|
945
|
+
if (check.missingRequired.length || check.problems.length)
|
|
946
|
+
missingByType.set(check.type, [
|
|
947
|
+
...(missingByType.get(check.type) ?? []),
|
|
948
|
+
{
|
|
949
|
+
url: page.url,
|
|
950
|
+
text: `${page.url}: ${[...check.missingRequired.map((p) => `${p} missing`), ...check.problems].join(", ")}`,
|
|
951
|
+
},
|
|
952
|
+
]);
|
|
953
|
+
for (const [type, items] of missingByType)
|
|
954
|
+
add("rich-result-required-missing", items, {
|
|
955
|
+
group: type,
|
|
956
|
+
title: `${type} structured data lacks documented required properties`,
|
|
957
|
+
});
|
|
958
|
+
const offerPages = structured.filter((s) => s.offers?.products?.length);
|
|
959
|
+
if (offerPages.length) {
|
|
960
|
+
const outcome = (key) => offerPages.flatMap((s) => s.offers.products
|
|
961
|
+
.filter((p) => p.outcomes[key] === "mismatch")
|
|
962
|
+
.map((p) => ({
|
|
963
|
+
url: s.url,
|
|
964
|
+
text: key === "price"
|
|
965
|
+
? `${s.url}: declared ${p.declared.price} ${p.declared.currency ?? ""}, visible ${p.visible.prices.join(", ")}`
|
|
966
|
+
: key === "currency"
|
|
967
|
+
? `${s.url}: declared ${p.declared.currency}, visible ${p.visible.currencies.join(", ")}`
|
|
968
|
+
: `${s.url}: declared ${p.declared.availability}, page says "${p.visible.availability}"`,
|
|
969
|
+
})));
|
|
970
|
+
add("offer-price-mismatch", outcome("price"));
|
|
971
|
+
add("offer-currency-mismatch", outcome("currency"));
|
|
972
|
+
add("offer-availability-mismatch", outcome("availability"));
|
|
973
|
+
add("offer-comparison-review", offerPages.flatMap((s) => s.offers.products
|
|
974
|
+
.filter((p) => Object.values(p.outcomes).includes("review"))
|
|
975
|
+
.map((p) => ({
|
|
976
|
+
url: s.url,
|
|
977
|
+
text: `${s.url}: ${Object.entries(p.outcomes)
|
|
978
|
+
.filter(([, v]) => v === "review")
|
|
979
|
+
.map(([k]) => k)
|
|
980
|
+
.join(", ")} ambiguous${p.ambiguous ? " (several products, a price range or many visible prices)" : ""}`,
|
|
981
|
+
}))));
|
|
982
|
+
}
|
|
983
|
+
else
|
|
984
|
+
for (const code of [
|
|
985
|
+
"offer-price-mismatch",
|
|
986
|
+
"offer-currency-mismatch",
|
|
987
|
+
"offer-availability-mismatch",
|
|
988
|
+
"offer-comparison-review",
|
|
989
|
+
])
|
|
990
|
+
skip(code, structured.length || linkGraph ? "not-applicable" : "unavailable", "no product offers in structured data");
|
|
991
|
+
if (!structured.some((s) => s.richResults?.length))
|
|
992
|
+
skip("rich-result-required-missing", linkGraph ? "not-applicable" : "unavailable", "no recognised rich-result types");
|
|
993
|
+
add("structured-data-rendered-only", structured
|
|
994
|
+
.filter((s) => s.source === "rendered-only")
|
|
995
|
+
.map((s) => ({ url: s.url, text: `${s.url}: ${s.types.join(", ")}` })));
|
|
996
|
+
// ---- Performance ----
|
|
997
|
+
const lighthouse = of("lighthouse");
|
|
998
|
+
const mobileRuns = lighthouse.filter((l) => l.data.device === "mobile");
|
|
999
|
+
if (!lighthouse.length)
|
|
1000
|
+
for (const code of [
|
|
1001
|
+
"slow-mobile",
|
|
1002
|
+
"performance-opportunities",
|
|
1003
|
+
"small-performance-opportunities",
|
|
1004
|
+
"heavy-pages",
|
|
1005
|
+
"unsized-images",
|
|
1006
|
+
])
|
|
1007
|
+
skip(code, lighthouseOn && scan.options.browser
|
|
1008
|
+
? "unavailable"
|
|
1009
|
+
: "not-checked", lighthouseOn
|
|
1010
|
+
? "no Lighthouse run completed"
|
|
1011
|
+
: "Lighthouse not run in this scan");
|
|
1012
|
+
add("slow-mobile", mobileRuns
|
|
1013
|
+
.filter((l) => typeof l.data.scores?.performance === "number" &&
|
|
1014
|
+
l.data.scores.performance < 0.5)
|
|
1015
|
+
.map((l) => ({
|
|
1016
|
+
url: l.data.url,
|
|
1017
|
+
text: `${Math.round(l.data.scores.performance * 100)} ${l.data.url}`,
|
|
1018
|
+
})));
|
|
1019
|
+
const opportunities = mobileRuns.flatMap((l) => (l.data.opportunities ?? []).map((o) => ({ ...o, url: l.data.url })));
|
|
1020
|
+
const substantial = (o) => (o.savingsMs ?? 0) >= 500 || (o.savingsBytes ?? 0) >= 100_000;
|
|
1021
|
+
const byAudit = new Map();
|
|
1022
|
+
for (const o of opportunities)
|
|
1023
|
+
byAudit.set(o.id, [...(byAudit.get(o.id) ?? []), o]);
|
|
1024
|
+
for (const [id, items] of byAudit) {
|
|
1025
|
+
const big = items.filter(substantial), small = items.filter((o) => !substantial(o));
|
|
1026
|
+
const line = (o) => ({
|
|
1027
|
+
url: o.url,
|
|
1028
|
+
text: `${o.savingsMs ?? 0} ms, ${Math.round((o.savingsBytes ?? 0) / 1024)} KiB estimated on ${o.url}${o.items?.length
|
|
1029
|
+
? ` (${o.items
|
|
1030
|
+
.map((i) => i.url ?? i.node)
|
|
1031
|
+
.filter(Boolean)
|
|
1032
|
+
.slice(0, 3)
|
|
1033
|
+
.join(", ")})`
|
|
1034
|
+
: ""}`,
|
|
1035
|
+
});
|
|
1036
|
+
if (big.length)
|
|
1037
|
+
add("performance-opportunities", big.sort((a, b) => (b.savingsMs ?? 0) - (a.savingsMs ?? 0)).map(line), {
|
|
1038
|
+
group: id,
|
|
1039
|
+
title: auditCondition(id),
|
|
1040
|
+
});
|
|
1041
|
+
if (small.length)
|
|
1042
|
+
add("small-performance-opportunities", small.map(line), {
|
|
1043
|
+
group: id,
|
|
1044
|
+
title: auditCondition(id),
|
|
1045
|
+
});
|
|
1046
|
+
}
|
|
1047
|
+
// Page weight from Lighthouse when it ran, otherwise from our own pass.
|
|
1048
|
+
const weights = (mobileRuns.length
|
|
1049
|
+
? mobileRuns.map((l) => ({
|
|
1050
|
+
url: l.data.url,
|
|
1051
|
+
bytes: l.data.weight?.total?.bytes ?? null,
|
|
1052
|
+
}))
|
|
1053
|
+
: browserMetrics.map((m) => ({ url: m.key, bytes: m.data.bytes ?? null }))).filter((w) => typeof w.bytes === "number");
|
|
1054
|
+
const median = (values) => {
|
|
1055
|
+
const sorted = values
|
|
1056
|
+
.filter((v) => typeof v === "number")
|
|
1057
|
+
.sort((a, b) => a - b);
|
|
1058
|
+
return sorted.length ? sorted[Math.floor(sorted.length / 2)] : null;
|
|
1059
|
+
};
|
|
1060
|
+
const metrics = browserMetrics.length
|
|
1061
|
+
? {
|
|
1062
|
+
pages: browserMetrics.length,
|
|
1063
|
+
ttfb: median(browserMetrics.map((m) => m.data.ttfb)),
|
|
1064
|
+
fcp: median(browserMetrics.map((m) => m.data.fcp)),
|
|
1065
|
+
lcp: median(browserMetrics.map((m) => m.data.lcp)),
|
|
1066
|
+
cls: median(browserMetrics.map((m) => m.data.cls)),
|
|
1067
|
+
bytes: median(browserMetrics.map((m) => m.data.bytes)),
|
|
1068
|
+
thirdPartyShare: (() => {
|
|
1069
|
+
const total = browserMetrics.reduce((a, m) => a + (m.data.bytes ?? 0), 0);
|
|
1070
|
+
const third = browserMetrics.reduce((a, m) => a + (m.data.thirdPartyBytes ?? 0), 0);
|
|
1071
|
+
return total ? Math.round((third / total) * 100) : null;
|
|
1072
|
+
})(),
|
|
1073
|
+
}
|
|
1074
|
+
: null;
|
|
1075
|
+
add("slow-lcp-own-browser", browserMetrics
|
|
1076
|
+
.filter((m) => (m.data.lcp ?? 0) > 4000)
|
|
1077
|
+
.map((m) => ({
|
|
1078
|
+
url: m.key,
|
|
1079
|
+
text: `${((m.data.lcp ?? 0) / 1000).toFixed(1)} s ${m.key}`,
|
|
1080
|
+
})));
|
|
1081
|
+
add("heavy-pages", weights
|
|
1082
|
+
.filter((w) => w.bytes > 3_000_000)
|
|
1083
|
+
.sort((a, b) => b.bytes - a.bytes)
|
|
1084
|
+
.map((w) => ({
|
|
1085
|
+
url: w.url,
|
|
1086
|
+
text: `${(w.bytes / 1_000_000).toFixed(1)} MB ${w.url}`,
|
|
1087
|
+
})));
|
|
1088
|
+
add("slow-responses", okPages
|
|
1089
|
+
.filter((p) => (p.data.http?.durationMs ?? 0) > 1500)
|
|
1090
|
+
.sort((a, b) => b.data.http.durationMs - a.data.http.durationMs)
|
|
1091
|
+
.map((p) => ({
|
|
1092
|
+
url: p.key,
|
|
1093
|
+
text: `${(p.data.http.durationMs / 1000).toFixed(1)} s ${p.key}`,
|
|
1094
|
+
})));
|
|
1095
|
+
const fieldRows = of("field-data");
|
|
1096
|
+
const field = fieldFrom(fieldRows.find((f) => f.key === new URL(scan.url).origin)?.data ??
|
|
1097
|
+
fieldRows.find((f) => f.data.level !== "url")?.data);
|
|
1098
|
+
// Page-level data is kept only where Google has it; pages without data
|
|
1099
|
+
// fall back to the origin record and are counted, not listed.
|
|
1100
|
+
const fieldPageRows = fieldRows.filter((f) => f.data.level === "url");
|
|
1101
|
+
const fieldPages = fieldPageRows
|
|
1102
|
+
.filter((f) => f.data.available)
|
|
1103
|
+
.map((f) => ({ url: f.key, ...fieldFrom(f.data) }));
|
|
1104
|
+
const poorLines = (d) => [
|
|
1105
|
+
...(d.lcp !== null && d.lcp > 4000
|
|
1106
|
+
? [`Largest Contentful Paint ${(d.lcp / 1000).toFixed(1)} s`]
|
|
1107
|
+
: []),
|
|
1108
|
+
...(d.inp !== null && d.inp > 500
|
|
1109
|
+
? [`Interaction to Next Paint ${d.inp} ms`]
|
|
1110
|
+
: []),
|
|
1111
|
+
...(d.cls !== null && d.cls > 0.25
|
|
1112
|
+
? [`Cumulative Layout Shift ${d.cls}`]
|
|
1113
|
+
: []),
|
|
1114
|
+
];
|
|
1115
|
+
if (field?.verdict === "poor")
|
|
1116
|
+
add("field-vitals-poor", poorLines(field));
|
|
1117
|
+
else if (!fieldRows.length) {
|
|
1118
|
+
skip("field-vitals-poor", "not-checked", "no Google API key configured");
|
|
1119
|
+
skip("field-page-vitals-poor", "not-checked", "no Google API key configured");
|
|
1120
|
+
}
|
|
1121
|
+
else if (!field?.available)
|
|
1122
|
+
skip("field-vitals-poor", "unavailable", field?.note);
|
|
1123
|
+
add("field-page-vitals-poor", fieldPages
|
|
1124
|
+
.filter((f) => f.verdict === "poor")
|
|
1125
|
+
.flatMap((f) => poorLines(f).map((l) => ({ url: f.url, text: `${l} on ${f.url}` }))));
|
|
1126
|
+
if (fieldRows.length && !fieldPages.length)
|
|
1127
|
+
skip("field-page-vitals-poor", "unavailable", "no page-level Chrome UX Report data for any crawled page");
|
|
1128
|
+
add("unsized-images", mobileRuns
|
|
1129
|
+
.filter((l) => (l.data.unsizedImages ?? 0) > 0)
|
|
1130
|
+
.map((l) => ({
|
|
1131
|
+
url: l.data.url,
|
|
1132
|
+
text: `${l.data.unsizedImages} on ${l.data.url}`,
|
|
1133
|
+
})));
|
|
1134
|
+
// ---- Accessibility and usability ----
|
|
1135
|
+
const byRule = new Map();
|
|
1136
|
+
const byReview = new Map();
|
|
1137
|
+
for (const d of details) {
|
|
1138
|
+
for (const v of d.data.accessibility?.violations ?? []) {
|
|
1139
|
+
const entry = byRule.get(v.id) ?? {
|
|
1140
|
+
help: v.help,
|
|
1141
|
+
items: [],
|
|
1142
|
+
impact: v.impact ?? null,
|
|
1143
|
+
};
|
|
1144
|
+
const sample = (v.nodes ?? [])[0];
|
|
1145
|
+
entry.items.push({
|
|
1146
|
+
url: d.key,
|
|
1147
|
+
text: `${v.nodeCount ?? v.nodes?.length ?? 0} elements on ${d.key}${sample?.target ? `, for example ${sample.target}` : ""}`,
|
|
1148
|
+
});
|
|
1149
|
+
byRule.set(v.id, entry);
|
|
1150
|
+
}
|
|
1151
|
+
for (const v of d.data.accessibility?.incomplete ?? []) {
|
|
1152
|
+
const entry = byReview.get(v.id) ?? {
|
|
1153
|
+
help: v.help,
|
|
1154
|
+
items: [],
|
|
1155
|
+
};
|
|
1156
|
+
entry.items.push({
|
|
1157
|
+
url: d.key,
|
|
1158
|
+
text: `${v.nodeCount ?? v.nodes?.length ?? 0} elements on ${d.key}`,
|
|
1159
|
+
});
|
|
1160
|
+
byReview.set(v.id, entry);
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
for (const [id, entry] of byRule)
|
|
1164
|
+
add("accessibility-violations", entry.items, {
|
|
1165
|
+
group: id,
|
|
1166
|
+
title: accessibilityCondition(id),
|
|
1167
|
+
severity: ["critical", "serious"].includes(entry.impact ?? "")
|
|
1168
|
+
? "warning"
|
|
1169
|
+
: "hint",
|
|
1170
|
+
});
|
|
1171
|
+
for (const [id, entry] of byReview)
|
|
1172
|
+
add("accessibility-review", entry.items, {
|
|
1173
|
+
group: id,
|
|
1174
|
+
title: accessibilityCondition(id, true),
|
|
1175
|
+
});
|
|
1176
|
+
if (!details.length)
|
|
1177
|
+
for (const code of [
|
|
1178
|
+
"accessibility-violations",
|
|
1179
|
+
"accessibility-review",
|
|
1180
|
+
"mobile-overflow",
|
|
1181
|
+
"focus-indicator",
|
|
1182
|
+
"javascript-errors",
|
|
1183
|
+
"external-errors",
|
|
1184
|
+
"mixed-content",
|
|
1185
|
+
"trackers-before-consent",
|
|
1186
|
+
"cookies-before-consent",
|
|
1187
|
+
"slow-lcp-own-browser",
|
|
1188
|
+
])
|
|
1189
|
+
skip(code, scan.options.browser ? "unavailable" : "not-checked", scan.options.browser
|
|
1190
|
+
? "no page completed the browser pass"
|
|
1191
|
+
: "browser pass disabled");
|
|
1192
|
+
add("missing-alt", content
|
|
1193
|
+
.map((p) => ({
|
|
1194
|
+
key: p.key,
|
|
1195
|
+
missing: (p.data.images ?? []).filter((i) => i.alt === null)
|
|
1196
|
+
.length,
|
|
1197
|
+
}))
|
|
1198
|
+
.filter((p) => p.missing)
|
|
1199
|
+
.map((p) => ({ url: p.key, text: `${p.missing} on ${p.key}` })));
|
|
1200
|
+
add("forms-without-labels", content.flatMap((p) => (p.data.forms ?? []).flatMap((f) => (f.controls ?? [])
|
|
1201
|
+
.filter((c) => !c.hasAccessibleName &&
|
|
1202
|
+
!["hidden", "submit", "button", "reset"].includes(c.type))
|
|
1203
|
+
.map((c) => ({
|
|
1204
|
+
url: p.key,
|
|
1205
|
+
text: `${c.name ?? c.id ?? c.tag} on ${p.key}`,
|
|
1206
|
+
})))));
|
|
1207
|
+
add("mobile-overflow", details.flatMap((d) => (d.data.overflow ?? [])
|
|
1208
|
+
.filter((o) => o.width <= 375 &&
|
|
1209
|
+
o.conclusive === true &&
|
|
1210
|
+
o.documentOverflow === true)
|
|
1211
|
+
.map((o) => ({
|
|
1212
|
+
url: d.key,
|
|
1213
|
+
text: `${o.documentWidth}px document at ${o.width}px viewport on ${d.key}`,
|
|
1214
|
+
}))));
|
|
1215
|
+
add("focus-indicator", details
|
|
1216
|
+
.filter((d) => (d.data.keyboard?.withoutVisibleIndicator ?? 0) > 0)
|
|
1217
|
+
.map((d) => ({
|
|
1218
|
+
url: d.key,
|
|
1219
|
+
text: `${d.data.keyboard.withoutVisibleIndicator} of ${d.data.keyboard.stops} tab stops on ${d.key}`,
|
|
1220
|
+
})));
|
|
1221
|
+
const consoleLines = details.flatMap((d) => (d.data.console ?? [])
|
|
1222
|
+
.filter((c) => c.type === "pageerror" || c.type === "error")
|
|
1223
|
+
.map((c) => ({
|
|
1224
|
+
text: String(c.text).replace(/\s+/g, " ").slice(0, 160),
|
|
1225
|
+
page: d.key,
|
|
1226
|
+
})));
|
|
1227
|
+
const isExternalError = (text) => /Content Security Policy|Failed to load resource|net::ERR_|ERR_BLOCKED_BY_CLIENT|Refused to (connect|load|frame)/i.test(text) || [...text.matchAll(/https?:\/\/[^\s'")]+/g)].some((m) => !own(m[0]));
|
|
1228
|
+
const errorGroups = (lines) => {
|
|
1229
|
+
const groups = new Map();
|
|
1230
|
+
for (const line of lines) {
|
|
1231
|
+
const key = line.text.replace(/\d+/g, "N").slice(0, 80);
|
|
1232
|
+
const items = groups.get(key) ?? [];
|
|
1233
|
+
if (!items.some((i) => i.url === line.page))
|
|
1234
|
+
items.push({ url: line.page, text: `${line.text} on ${line.page}` });
|
|
1235
|
+
groups.set(key, items);
|
|
1236
|
+
}
|
|
1237
|
+
return groups;
|
|
1238
|
+
};
|
|
1239
|
+
for (const [key, items] of errorGroups(consoleLines.filter((c) => !isExternalError(c.text))))
|
|
1240
|
+
add("javascript-errors", items, {
|
|
1241
|
+
group: key,
|
|
1242
|
+
title: `JavaScript error: ${items[0].text.replace(/ on https?:\/\/\S+$/, "")}`,
|
|
1243
|
+
});
|
|
1244
|
+
for (const [key, items] of errorGroups(consoleLines.filter((c) => isExternalError(c.text))))
|
|
1245
|
+
add("external-errors", items, {
|
|
1246
|
+
group: key,
|
|
1247
|
+
title: `Third-party or blocked request error: ${items[0].text.replace(/ on https?:\/\/\S+$/, "")}`,
|
|
1248
|
+
});
|
|
1249
|
+
// ---- Business and profiles ----
|
|
1250
|
+
if (home && home.data.html !== false) {
|
|
1251
|
+
const social = (home.data.social ?? []);
|
|
1252
|
+
const socialImages = social
|
|
1253
|
+
.filter((m) => /^(og:image|twitter:image)/.test(m.property ?? m.name ?? ""))
|
|
1254
|
+
.map((m) => m.content);
|
|
1255
|
+
if (!socialImages.length)
|
|
1256
|
+
add("missing-social-image", [withLink(home.key)]);
|
|
1257
|
+
else {
|
|
1258
|
+
const broken = socialImages.flatMap((src) => {
|
|
1259
|
+
const key = [...resourceByKey.keys()].find((k) => k === src || k.endsWith(src));
|
|
1260
|
+
const r = key ? resourceByKey.get(key) : null;
|
|
1261
|
+
if (!r)
|
|
1262
|
+
return [];
|
|
1263
|
+
const type = r.data.http?.headers?.["content-type"] ?? "";
|
|
1264
|
+
return (status(r) ?? 0) >= 400 ||
|
|
1265
|
+
r.data.http?.outcome === "error" ||
|
|
1266
|
+
(status(r) === 200 && type && !/^image\//i.test(type))
|
|
1267
|
+
? [
|
|
1268
|
+
{
|
|
1269
|
+
url: home.key,
|
|
1270
|
+
text: `${failure(r)}${type && !/^image\//i.test(type) ? ` ${type}` : ""} ${r.key}`,
|
|
1271
|
+
},
|
|
1272
|
+
]
|
|
1273
|
+
: [];
|
|
1274
|
+
});
|
|
1275
|
+
add("social-image-broken", broken);
|
|
1276
|
+
}
|
|
1277
|
+
const favicon = (home.data.icons ?? []).length > 0 ||
|
|
1278
|
+
resources.some((r) => /\/favicon\.ico$/.test(r.key) && r.data.http?.status === 200);
|
|
1279
|
+
if (!favicon)
|
|
1280
|
+
add("missing-favicon", [withLink(home.key)]);
|
|
1281
|
+
}
|
|
1282
|
+
if (business) {
|
|
1283
|
+
const profiles = business.profiles ?? [];
|
|
1284
|
+
add("profile-broken", profiles
|
|
1285
|
+
.filter((p) => p.verification === "broken")
|
|
1286
|
+
.map((p) => ({
|
|
1287
|
+
url: null,
|
|
1288
|
+
text: `${p.status ?? p.outcome} ${p.url} (${p.platform}, linked from ${p.pages} pages)`,
|
|
1289
|
+
})));
|
|
1290
|
+
add("profile-unverifiable", profiles
|
|
1291
|
+
.filter((p) => p.verification === "unverifiable")
|
|
1292
|
+
.map((p) => ({
|
|
1293
|
+
url: null,
|
|
1294
|
+
text: `${p.status ?? p.outcome} ${p.url} (${p.platform} blocks automated checks)`,
|
|
1295
|
+
})));
|
|
1296
|
+
add("profile-variants", (business.platformVariants ?? []).map((v) => `${v.platform}: ${v.urls.join(", ")}`));
|
|
1297
|
+
add("sameas-not-linked", (business.sameAs ?? [])
|
|
1298
|
+
.filter((s) => !s.linkedOnSite)
|
|
1299
|
+
.map((s) => s.url));
|
|
1300
|
+
if ((business.sameAs ?? []).length)
|
|
1301
|
+
add("linked-not-sameas", profiles
|
|
1302
|
+
.filter((p) => !p.inSameAs)
|
|
1303
|
+
.map((p) => `${p.url} (${p.platform})`));
|
|
1304
|
+
else
|
|
1305
|
+
skip("linked-not-sameas", "not-applicable", "no sameAs declared");
|
|
1306
|
+
const names = [
|
|
1307
|
+
...new Set([
|
|
1308
|
+
...(business.names?.structured ?? []).map((n) => n.name),
|
|
1309
|
+
...(business.names?.siteName ?? []),
|
|
1310
|
+
]),
|
|
1311
|
+
];
|
|
1312
|
+
if (names.length > 1)
|
|
1313
|
+
add("business-name-variants", names.map((n) => `"${n}"`));
|
|
1314
|
+
if ((business.phones?.structured ?? []).length &&
|
|
1315
|
+
(business.phones?.visibleNotDeclared?.length ||
|
|
1316
|
+
business.phones?.declaredNotVisible?.length))
|
|
1317
|
+
add("phone-mismatch", [
|
|
1318
|
+
...(business.phones.declaredNotVisible ?? []).map((p) => `declared ${p} not shown on any page`),
|
|
1319
|
+
...(business.phones.visibleNotDeclared ?? []).map((p) => `visible ${p} not in structured data`),
|
|
1320
|
+
]);
|
|
1321
|
+
else if (!(business.phones?.structured ?? []).length)
|
|
1322
|
+
skip("phone-mismatch", "not-applicable", "no telephone in structured data");
|
|
1323
|
+
if (!profiles.length) {
|
|
1324
|
+
skip("profile-broken", "not-applicable", "no profile links found");
|
|
1325
|
+
skip("profile-unverifiable", "not-applicable", "no profile links found");
|
|
1326
|
+
skip("profile-variants", "not-applicable", "no profile links found");
|
|
1327
|
+
}
|
|
1328
|
+
if (!(business.maps ?? []).length)
|
|
1329
|
+
skip("map-link-broken", "not-applicable", "no map links found");
|
|
1330
|
+
}
|
|
1331
|
+
else
|
|
1332
|
+
for (const code of [
|
|
1333
|
+
"profile-broken",
|
|
1334
|
+
"profile-unverifiable",
|
|
1335
|
+
"profile-variants",
|
|
1336
|
+
"sameas-not-linked",
|
|
1337
|
+
"linked-not-sameas",
|
|
1338
|
+
"business-name-variants",
|
|
1339
|
+
"phone-mismatch",
|
|
1340
|
+
"map-link-broken",
|
|
1341
|
+
])
|
|
1342
|
+
skip(code, "unavailable", "site analysis did not run");
|
|
1343
|
+
// ---- AI and agent access ----
|
|
1344
|
+
const ai = of("ai-discovery")[0]?.data;
|
|
1345
|
+
if (ai) {
|
|
1346
|
+
if (!ai.llms?.present)
|
|
1347
|
+
add("llms-missing", [
|
|
1348
|
+
withLink(ai.llms?.url ?? `${new URL(scan.url).origin}/llms.txt`),
|
|
1349
|
+
]);
|
|
1350
|
+
else {
|
|
1351
|
+
add("llms-links-broken", ai.llms.links
|
|
1352
|
+
.filter((l) => l.url &&
|
|
1353
|
+
resourceByKey.has(l.url) &&
|
|
1354
|
+
((status(resourceByKey.get(l.url)) ?? 0) >= 400 ||
|
|
1355
|
+
resourceByKey.get(l.url).data.http?.outcome === "error"))
|
|
1356
|
+
.map((l) => ({
|
|
1357
|
+
url: null,
|
|
1358
|
+
text: `${failure(resourceByKey.get(l.url))} ${l.url} ("${l.text}")`,
|
|
1359
|
+
})));
|
|
1360
|
+
const names = [
|
|
1361
|
+
...(business?.names?.structured ?? []).map((n) => n.name),
|
|
1362
|
+
...(business?.names?.siteName ?? []),
|
|
1363
|
+
home?.data.title ?? "",
|
|
1364
|
+
]
|
|
1365
|
+
.filter(Boolean)
|
|
1366
|
+
.map((n) => n.toLowerCase());
|
|
1367
|
+
if (ai.llms.title &&
|
|
1368
|
+
names.length &&
|
|
1369
|
+
!names.some((n) => n.includes(ai.llms.title.toLowerCase()) ||
|
|
1370
|
+
ai.llms.title.toLowerCase().includes(n.split(/[|–-]/)[0].trim())))
|
|
1371
|
+
add("llms-title-mismatch", [
|
|
1372
|
+
`llms.txt: "${ai.llms.title}"; site: ${names
|
|
1373
|
+
.slice(0, 3)
|
|
1374
|
+
.map((n) => `"${n}"`)
|
|
1375
|
+
.join(", ")}`,
|
|
1376
|
+
]);
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
else
|
|
1380
|
+
for (const code of [
|
|
1381
|
+
"llms-missing",
|
|
1382
|
+
"llms-links-broken",
|
|
1383
|
+
"llms-title-mismatch",
|
|
1384
|
+
])
|
|
1385
|
+
skip(code, "unavailable", "discovery did not run");
|
|
1386
|
+
const markdown = of("markdown");
|
|
1387
|
+
const probed = pages.filter((p) => p.data.markdownProbe === "full").length;
|
|
1388
|
+
if (markdown.length && !markdown.some((m) => m.data.usable))
|
|
1389
|
+
add("markdown-unavailable", [
|
|
1390
|
+
`${probed} pages probed with Accept: text/markdown and .md locations`,
|
|
1391
|
+
]);
|
|
1392
|
+
else if (!markdown.length)
|
|
1393
|
+
skip("markdown-unavailable", "unavailable", "no Markdown probes recorded");
|
|
1394
|
+
// ---- Secondary facts: security, scores, indexability ----
|
|
1395
|
+
const security = {
|
|
1396
|
+
safeBrowsing: safe?.status === "observed"
|
|
1397
|
+
? { flagged: !!safe.flagged, threats: safe.threats ?? [] }
|
|
1398
|
+
: null,
|
|
1399
|
+
tlsGrade: labs?.status === "observed" ? (labs.grade ?? null) : null,
|
|
1400
|
+
hstsPreload: of("hsts-preload")[0]?.data?.preload ?? null,
|
|
1401
|
+
blocklists: listResults.length
|
|
1402
|
+
? {
|
|
1403
|
+
listed: listResults
|
|
1404
|
+
.filter((r) => r.status === "listed")
|
|
1405
|
+
.map((r) => r.list),
|
|
1406
|
+
clean: listResults
|
|
1407
|
+
.filter((r) => r.status === "clean")
|
|
1408
|
+
.map((r) => r.list),
|
|
1409
|
+
unavailable: listResults
|
|
1410
|
+
.filter((r) => r.status === "unavailable")
|
|
1411
|
+
.map((r) => r.list),
|
|
1412
|
+
}
|
|
1413
|
+
: null,
|
|
1414
|
+
certificates: certificates?.status === "observed"
|
|
1415
|
+
? {
|
|
1416
|
+
hostnames: certificates.hostnames.length,
|
|
1417
|
+
issuedLast30Days: certificates.issuedLast30Days ?? 0,
|
|
1418
|
+
sample: certificates.hostnames.slice(0, 20),
|
|
1419
|
+
}
|
|
1420
|
+
: null,
|
|
1421
|
+
};
|
|
1422
|
+
const history = of("field-history")[0]?.data;
|
|
1423
|
+
if (field && history?.available)
|
|
1424
|
+
field.history = history.points.filter((p) => p.to).slice(-25);
|
|
1425
|
+
const scores = (device) => {
|
|
1426
|
+
const runs = lighthouse.filter((l) => l.data.device === device && l.data.scores);
|
|
1427
|
+
if (!runs.length)
|
|
1428
|
+
return null;
|
|
1429
|
+
const average = (id) => {
|
|
1430
|
+
const values = runs
|
|
1431
|
+
.map((r) => r.data.scores[id])
|
|
1432
|
+
.filter((v) => typeof v === "number");
|
|
1433
|
+
return values.length
|
|
1434
|
+
? Math.round((values.reduce((a, b) => a + b, 0) / values.length) * 100)
|
|
1435
|
+
: null;
|
|
1436
|
+
};
|
|
1437
|
+
return {
|
|
1438
|
+
performance: average("performance"),
|
|
1439
|
+
accessibility: average("accessibility"),
|
|
1440
|
+
bestPractices: average("best-practices"),
|
|
1441
|
+
seo: average("seo"),
|
|
1442
|
+
};
|
|
1443
|
+
};
|
|
1444
|
+
let mobile = scores("mobile");
|
|
1445
|
+
let desktop = scores("desktop");
|
|
1446
|
+
let measuredAt = lighthouse.length
|
|
1447
|
+
? scan.createdAt
|
|
1448
|
+
: null;
|
|
1449
|
+
let carried = false;
|
|
1450
|
+
if (!lighthouse.length && input.previous?.lastScores?.mobile) {
|
|
1451
|
+
// No Lighthouse in this scan: carry the last measured scores forward.
|
|
1452
|
+
const last = input.previous.lastScores;
|
|
1453
|
+
mobile = last.mobile;
|
|
1454
|
+
desktop = last.desktop ?? null;
|
|
1455
|
+
measuredAt = last.measuredAt ?? null;
|
|
1456
|
+
carried = true;
|
|
1457
|
+
}
|
|
1458
|
+
// ---- Ordering, priorities, counts ----
|
|
1459
|
+
findings.sort((a, b) => severityRank[a.severity] - severityRank[b.severity] ||
|
|
1460
|
+
confidenceRank[a.confidence] - confidenceRank[b.confidence] ||
|
|
1461
|
+
b.pages - a.pages ||
|
|
1462
|
+
b.count - a.count ||
|
|
1463
|
+
a.id.localeCompare(b.id));
|
|
1464
|
+
const counts = {
|
|
1465
|
+
critical: findings.filter((f) => f.severity === "critical").length,
|
|
1466
|
+
warning: findings.filter((f) => f.severity === "warning").length,
|
|
1467
|
+
hint: findings.filter((f) => f.severity === "hint").length,
|
|
1468
|
+
};
|
|
1469
|
+
const priorities = findings
|
|
1470
|
+
.filter((f) => f.severity === "critical" ||
|
|
1471
|
+
(f.severity === "warning" && f.confidence !== "review"))
|
|
1472
|
+
.slice(0, 5)
|
|
1473
|
+
.map((f) => f.id);
|
|
1474
|
+
const indexability = {
|
|
1475
|
+
indexable: htmlPages.filter((p) => !noindex(p) && !canonicalElsewhere(p))
|
|
1476
|
+
.length,
|
|
1477
|
+
noindex: htmlPages.filter((p) => noindex(p)).length,
|
|
1478
|
+
canonicalElsewhere: htmlPages.filter((p) => !noindex(p) && canonicalElsewhere(p)).length,
|
|
1479
|
+
broken: brokenPages.length,
|
|
1480
|
+
blocked: pages.filter((p) => ["blocked", "inconclusive"].includes(p.data.http?.outcome)).length,
|
|
1481
|
+
};
|
|
1482
|
+
// ---- Coverage: what was discovered, checked, skipped or left ----
|
|
1483
|
+
const jobs = scan.coverage?.jobs ?? [];
|
|
1484
|
+
const jobCount = (kind, ...statuses) => jobs
|
|
1485
|
+
.filter((j) => j.kind === kind && statuses.includes(j.status))
|
|
1486
|
+
.reduce((n, j) => n + j.count, 0);
|
|
1487
|
+
const remainingWork = jobs
|
|
1488
|
+
.filter((j) => ["cancelled", "failed", "queued", "running"].includes(j.status))
|
|
1489
|
+
.map((j) => ({ kind: j.kind, status: j.status, count: j.count }));
|
|
1490
|
+
const resourceRows = urlRows.filter((u) => u.kind === "resource");
|
|
1491
|
+
const sitemapRows = urlRows.filter((u) => u.kind === "sitemap");
|
|
1492
|
+
const notFollowed = {};
|
|
1493
|
+
for (const u of pageRows)
|
|
1494
|
+
if (u.skipReason)
|
|
1495
|
+
notFollowed[u.skipReason] = (notFollowed[u.skipReason] ?? 0) + 1;
|
|
1496
|
+
const browserStatus = of("browser");
|
|
1497
|
+
const lighthouseRuns = of("lighthouse-run");
|
|
1498
|
+
const browserCount = (s) => browserStatus.filter((b) => b.data.status === s).length;
|
|
1499
|
+
const renderedList = [...renderedByKey.values()];
|
|
1500
|
+
const external = {
|
|
1501
|
+
chromeUxReport: !fieldRows.length
|
|
1502
|
+
? "not configured"
|
|
1503
|
+
: field?.available
|
|
1504
|
+
? "origin data available"
|
|
1505
|
+
: (field?.note ?? "no data"),
|
|
1506
|
+
safeBrowsing: !safe
|
|
1507
|
+
? "not configured"
|
|
1508
|
+
: safe.status === "observed"
|
|
1509
|
+
? "checked"
|
|
1510
|
+
: `unavailable (${safe.error})`,
|
|
1511
|
+
sslLabs: labs?.status === "observed"
|
|
1512
|
+
? `grade ${labs.grade}, tested ${labs.testedAt ?? "unknown"}`
|
|
1513
|
+
: (labs?.status ?? "unavailable"),
|
|
1514
|
+
hstsPreload: of("hsts-preload")[0]?.data?.preload ?? "unavailable",
|
|
1515
|
+
certificateLogs: certificates?.status ?? "unavailable",
|
|
1516
|
+
blocklists: listResults.length
|
|
1517
|
+
? listResults.map((r) => `${r.list} ${r.status}`).join(", ")
|
|
1518
|
+
: "unavailable",
|
|
1519
|
+
registration: domainData?.status ?? "unavailable",
|
|
1520
|
+
};
|
|
1521
|
+
const limits = {};
|
|
1522
|
+
for (const key of [
|
|
1523
|
+
"maxPages",
|
|
1524
|
+
"maxDiscoveredUrls",
|
|
1525
|
+
"maxResources",
|
|
1526
|
+
"maxBrowserPages",
|
|
1527
|
+
"maxSitemaps",
|
|
1528
|
+
])
|
|
1529
|
+
if (scan.options[key] !== undefined)
|
|
1530
|
+
limits[key] = scan.options[key];
|
|
1531
|
+
for (const key of ["maxQueryVariantsPerPath"])
|
|
1532
|
+
if (scan.options[key] !== undefined)
|
|
1533
|
+
limits[key] = scan.options[key];
|
|
1534
|
+
const expectedSlash = new Set(htmlPages
|
|
1535
|
+
.filter((p) => slashTwin(p.key))
|
|
1536
|
+
.map((p) => p.key.replace(/\/$/, "")));
|
|
1537
|
+
const checkedSlash = new Set(slashVariants.filter((r) => r.data.result !== "unknown").map((r) => r.key));
|
|
1538
|
+
const missingSlash = [...expectedSlash].filter((key) => !checkedSlash.has(key));
|
|
1539
|
+
const notReachedPages = followedPages.filter((u) => !byKey.has(u.url)).length;
|
|
1540
|
+
const coverageNotes = [];
|
|
1541
|
+
if (scan.coverage?.expired)
|
|
1542
|
+
coverageNotes.push("The scan reached its time limit; remaining work was cancelled and is listed as remaining work.");
|
|
1543
|
+
if (notFollowed["query-variants-limited"])
|
|
1544
|
+
coverageNotes.push(`${notFollowed["query-variants-limited"]} query-string variants were not followed after ${scan.options.maxQueryVariantsPerPath} per path (crawl-trap rule).`);
|
|
1545
|
+
if (omitted)
|
|
1546
|
+
coverageNotes.push(`${omitted} discovered URLs were omitted by the explicit discovery limit.`);
|
|
1547
|
+
if (missingSlash.length)
|
|
1548
|
+
coverageNotes.push(`${missingSlash.length} trailing-slash pairs could not be verified.`);
|
|
1549
|
+
// Lighthouse eligibility was fixed at the crawl-to-enrichment transition;
|
|
1550
|
+
// every eligible page not selected was left out by an explicit limit.
|
|
1551
|
+
const lighthouseEligible = Number(scan.coverage?.lighthouseEligible ?? htmlPages.length);
|
|
1552
|
+
const lighthouseOmitted = scan.options.browser && lighthouseOn
|
|
1553
|
+
? Math.max(0, lighthouseEligible - (scan.coverage?.lighthouseSelected ?? []).length)
|
|
1554
|
+
: 0;
|
|
1555
|
+
if (lighthouseOmitted)
|
|
1556
|
+
coverageNotes.push(`${lighthouseOmitted} of ${lighthouseEligible} eligible pages were not measured with Lighthouse because of the explicit page limit.`);
|
|
1557
|
+
const coverage = {
|
|
1558
|
+
discovered: pageRows.length + omitted,
|
|
1559
|
+
crawled: pages.length,
|
|
1560
|
+
omitted,
|
|
1561
|
+
sampled: pageRows.length + omitted > pages.length,
|
|
1562
|
+
// Complete means every discovered page, file and check was covered;
|
|
1563
|
+
// explicit limits and unrun browser or Lighthouse passes make it false.
|
|
1564
|
+
complete: scan.status === "completed" &&
|
|
1565
|
+
remainingWork.length === 0 &&
|
|
1566
|
+
notReachedPages === 0 &&
|
|
1567
|
+
omitted === 0 &&
|
|
1568
|
+
!urlRows.some((u) => u.skipReason === "explicit-limit") &&
|
|
1569
|
+
(!scan.options.browser || browserStatus.length >= htmlPages.length) &&
|
|
1570
|
+
(!(scan.options.browser && lighthouseOn) ||
|
|
1571
|
+
(lighthouseOmitted === 0 &&
|
|
1572
|
+
(scan.coverage?.lighthouseSelected ?? []).length <=
|
|
1573
|
+
lighthouseRuns.length)) &&
|
|
1574
|
+
!scan.coverage?.expired,
|
|
1575
|
+
pages: {
|
|
1576
|
+
discovered: pageRows.length + omitted,
|
|
1577
|
+
followed: followedPages.length,
|
|
1578
|
+
notFollowed,
|
|
1579
|
+
observed: pages.length,
|
|
1580
|
+
ok: okPages.length,
|
|
1581
|
+
broken: brokenPages.length,
|
|
1582
|
+
blocked: pages.filter((p) => p.data.http?.outcome === "blocked").length,
|
|
1583
|
+
inconclusive: pages.filter((p) => p.data.http?.outcome === "inconclusive")
|
|
1584
|
+
.length,
|
|
1585
|
+
notReached: notReachedPages,
|
|
1586
|
+
},
|
|
1587
|
+
resources: {
|
|
1588
|
+
// Trailing-slash twins are recorded by page jobs without a discovery row.
|
|
1589
|
+
discovered: new Set([
|
|
1590
|
+
...resourceRows.map((u) => u.url),
|
|
1591
|
+
...resources.map((r) => r.key),
|
|
1592
|
+
]).size,
|
|
1593
|
+
checked: resources.length,
|
|
1594
|
+
ok: resources.filter((r) => r.data.http?.outcome === "ok").length,
|
|
1595
|
+
broken: brokenResources.length,
|
|
1596
|
+
inconclusive: resources.filter((r) => r.data.http?.outcome === "inconclusive" ||
|
|
1597
|
+
r.data.http?.outcome === "blocked").length,
|
|
1598
|
+
unverifiable: resources.filter((r) => !own(r.key) && platformBlocked(r.key) && (status(r) ?? 0) >= 400).length,
|
|
1599
|
+
notChecked: resourceRows.filter((u) => u.scheduled && !resourceByKey.has(u.url))
|
|
1600
|
+
.length +
|
|
1601
|
+
resourceRows.filter((u) => u.skipReason === "explicit-limit").length,
|
|
1602
|
+
},
|
|
1603
|
+
sitemaps: {
|
|
1604
|
+
discovered: sitemapRows.length,
|
|
1605
|
+
fetched: sitemaps.length,
|
|
1606
|
+
valid: validSitemaps.length,
|
|
1607
|
+
invalid: sitemaps.filter((s) => !s.data.valid &&
|
|
1608
|
+
(sources.get(s.key) ?? []).some((src) => src !== "default-location")).length,
|
|
1609
|
+
entries: sitemapEntries.size,
|
|
1610
|
+
notFetched: sitemapRows.filter((u) => u.scheduled).length - sitemaps.length,
|
|
1611
|
+
},
|
|
1612
|
+
browser: {
|
|
1613
|
+
enabled: scan.options.browser,
|
|
1614
|
+
eligible: htmlPages.length,
|
|
1615
|
+
completed: browserCount("completed"),
|
|
1616
|
+
partial: browserCount("partial"),
|
|
1617
|
+
skipped: browserCount("skipped"),
|
|
1618
|
+
notRun: Math.max(0, htmlPages.length - browserStatus.length),
|
|
1619
|
+
},
|
|
1620
|
+
lighthouse: {
|
|
1621
|
+
enabled: scan.options.browser && lighthouseOn,
|
|
1622
|
+
eligible: lighthouseEligible,
|
|
1623
|
+
selected: (scan.coverage?.lighthouseSelected ?? []).length,
|
|
1624
|
+
omitted: lighthouseOmitted,
|
|
1625
|
+
completed: lighthouseRuns.filter((r) => r.data.status === "completed")
|
|
1626
|
+
.length,
|
|
1627
|
+
partial: lighthouseRuns.filter((r) => r.data.status === "partial").length,
|
|
1628
|
+
notRun: Math.max(0, (scan.coverage?.lighthouseSelected ?? []).length -
|
|
1629
|
+
lighthouseRuns.length),
|
|
1630
|
+
devices: lighthouse.length
|
|
1631
|
+
? [...new Set(lighthouse.map((l) => l.data.device))]
|
|
1632
|
+
: [],
|
|
1633
|
+
},
|
|
1634
|
+
interactions: {
|
|
1635
|
+
pagesScrolled: renderedList.filter((r) => r.interactions?.scroll?.performed).length,
|
|
1636
|
+
loadMoreFound: renderedList.filter((r) => r.interactions?.loadMore?.found)
|
|
1637
|
+
.length,
|
|
1638
|
+
loadMoreClicked: renderedList.filter((r) => r.interactions?.loadMore?.clicked).length,
|
|
1639
|
+
notPerformed: renderedList[0]?.interactions?.notPerformed ?? [
|
|
1640
|
+
"form submissions",
|
|
1641
|
+
"filter, sort and dropdown controls",
|
|
1642
|
+
"sign-in or account actions",
|
|
1643
|
+
"cookie consent choices",
|
|
1644
|
+
"repeated infinite-scroll loads beyond one pass",
|
|
1645
|
+
],
|
|
1646
|
+
},
|
|
1647
|
+
slashVariants: {
|
|
1648
|
+
expected: expectedSlash.size,
|
|
1649
|
+
verified: expectedSlash.size - missingSlash.length,
|
|
1650
|
+
unknown: missingSlash,
|
|
1651
|
+
},
|
|
1652
|
+
remainingWork,
|
|
1653
|
+
expired: !!scan.coverage?.expired,
|
|
1654
|
+
external,
|
|
1655
|
+
limits,
|
|
1656
|
+
notes: coverageNotes,
|
|
1657
|
+
};
|
|
1658
|
+
// ---- Checks: every rule with its outcome ----
|
|
1659
|
+
const available = {
|
|
1660
|
+
pages: pages.length > 0,
|
|
1661
|
+
browser: details.length > 0,
|
|
1662
|
+
lighthouse: lighthouse.length > 0,
|
|
1663
|
+
rendered: renderedByKey.size > 0,
|
|
1664
|
+
dns: !!dns,
|
|
1665
|
+
tls: tls?.status === "observed",
|
|
1666
|
+
domain: !!domain,
|
|
1667
|
+
"url-variant": variants.length > 0,
|
|
1668
|
+
robots: !!homePolicy,
|
|
1669
|
+
sitemap: sitemaps.length > 0 || sitemapRows.length > 0,
|
|
1670
|
+
analysis: !!linkGraph,
|
|
1671
|
+
"structured-data": structured.length > 0,
|
|
1672
|
+
"field-data": !!field?.available,
|
|
1673
|
+
"safe-browsing": safe?.status === "observed",
|
|
1674
|
+
"ssl-labs": labs?.status === "observed",
|
|
1675
|
+
blocklists: listResults.some((r) => r.status !== "unavailable"),
|
|
1676
|
+
certificates: certificates?.status === "observed",
|
|
1677
|
+
"ai-discovery": !!ai,
|
|
1678
|
+
options: true,
|
|
1679
|
+
};
|
|
1680
|
+
const checks = Object.entries(rules).map(([code, rule]) => {
|
|
1681
|
+
const raised = findings.filter((f) => f.code === code);
|
|
1682
|
+
const skipped = skips.get(code);
|
|
1683
|
+
const outcome = raised.length
|
|
1684
|
+
? raised.every((f) => f.confidence === "review")
|
|
1685
|
+
? "review"
|
|
1686
|
+
: "issue"
|
|
1687
|
+
: skipped
|
|
1688
|
+
? skipped.outcome
|
|
1689
|
+
: available[rule.requires]
|
|
1690
|
+
? "passed"
|
|
1691
|
+
: "not-checked";
|
|
1692
|
+
return {
|
|
1693
|
+
code,
|
|
1694
|
+
topic: rule.topic,
|
|
1695
|
+
title: rule.title,
|
|
1696
|
+
outcome,
|
|
1697
|
+
detail: raised.length
|
|
1698
|
+
? `${raised.reduce((n, f) => n + f.count, 0)} items on ${new Set(raised.flatMap((f) => f.urls)).size} pages`
|
|
1699
|
+
: skipped?.detail,
|
|
1700
|
+
};
|
|
1701
|
+
});
|
|
1702
|
+
const topics = Object.fromEntries(topicOrder.map((topic) => {
|
|
1703
|
+
const own = findings.filter((f) => f.topic === topic);
|
|
1704
|
+
const topicChecks = checks.filter((c) => c.topic === topic);
|
|
1705
|
+
const status = own.some((f) => f.severity !== "hint")
|
|
1706
|
+
? "issues"
|
|
1707
|
+
: own.length
|
|
1708
|
+
? "review"
|
|
1709
|
+
: topicChecks.some((c) => c.outcome === "passed")
|
|
1710
|
+
? "passed"
|
|
1711
|
+
: topicChecks.every((c) => c.outcome === "not-applicable")
|
|
1712
|
+
? "not-applicable"
|
|
1713
|
+
: topicChecks.some((c) => c.outcome === "unavailable")
|
|
1714
|
+
? "unavailable"
|
|
1715
|
+
: "not-checked";
|
|
1716
|
+
return [
|
|
1717
|
+
topic,
|
|
1718
|
+
{
|
|
1719
|
+
status,
|
|
1720
|
+
findings: own.length,
|
|
1721
|
+
critical: own.filter((f) => f.severity === "critical").length,
|
|
1722
|
+
warning: own.filter((f) => f.severity === "warning").length,
|
|
1723
|
+
hint: own.filter((f) => f.severity === "hint").length,
|
|
1724
|
+
facts: [],
|
|
1725
|
+
},
|
|
1726
|
+
];
|
|
1727
|
+
}));
|
|
1728
|
+
// Facts: useful measurements that are not problems.
|
|
1729
|
+
const fact = (topic, text) => {
|
|
1730
|
+
if (text)
|
|
1731
|
+
topics[topic].facts.push(text);
|
|
1732
|
+
};
|
|
1733
|
+
if (tls?.status === "observed")
|
|
1734
|
+
fact("availability-security", `TLS ${tls.protocol ?? ""} certificate by ${tls.certificate?.issuer?.O ?? tls.certificate?.issuer?.CN ?? "unknown issuer"}, valid until ${String(tls.certificate?.validTo ?? "").slice(0, 24)} (${tls.daysRemaining} days)`);
|
|
1735
|
+
if (dns?.records)
|
|
1736
|
+
fact("availability-security", `Nameservers ${(dns.records.ns?.values ?? []).join(", ") || "unavailable"}; mail servers ${(dns.records.mx?.values ?? []).map((m) => m.exchange).join(", ") || "none"}; SPF ${dns.email?.spf?.length ? "present" : "absent"}, DMARC ${dns.email?.dmarc?.[0]?.match(/p=(\w+)/)?.[1] ?? "absent"}, MTA-STS ${dns.email?.mtaSts?.length ? "present" : "absent"}, BIMI ${dns.email?.bimi?.length ? "present" : "absent"}`);
|
|
1737
|
+
fact("availability-security", security.tlsGrade ? `SSL Labs grade ${security.tlsGrade}` : null);
|
|
1738
|
+
fact("availability-security", security.hstsPreload ? `HSTS preload status ${security.hstsPreload}` : null);
|
|
1739
|
+
fact("availability-security", security.certificates
|
|
1740
|
+
? `${security.certificates.hostnames} hostnames in certificate logs, ${security.certificates.issuedLast30Days} certificates issued in the last 30 days`
|
|
1741
|
+
: null);
|
|
1742
|
+
fact("availability-security", security.safeBrowsing
|
|
1743
|
+
? `Safe Browsing ${security.safeBrowsing.flagged ? "flagged" : "clean"}`
|
|
1744
|
+
: null);
|
|
1745
|
+
fact("crawling-indexability", `${indexability.indexable} indexable pages, ${indexability.noindex} noindex, ${indexability.canonicalElsewhere} canonical elsewhere, ${indexability.broken} broken, ${indexability.blocked} blocked or inconclusive`);
|
|
1746
|
+
fact("crawling-indexability", validSitemaps.length
|
|
1747
|
+
? `${validSitemaps.length} valid sitemaps with ${sitemapEntries.size} entries; ${[...sitemapEntries.keys()].filter((u) => byKey.has(u)).length} of them crawled`
|
|
1748
|
+
: null);
|
|
1749
|
+
if (homePolicy?.bots)
|
|
1750
|
+
for (const purpose of ["search", "training", "user-request"]) {
|
|
1751
|
+
const bots = homePolicy.bots.filter((b) => b.purpose === purpose);
|
|
1752
|
+
if (bots.length)
|
|
1753
|
+
fact("ai-agent-access", `robots.txt for ${purpose} crawlers: ${bots.map((b) => `${b.bot} ${b.allowed === null ? "unknown" : b.allowed ? "allowed" : "disallowed"}`).join(", ")}`);
|
|
1754
|
+
}
|
|
1755
|
+
fact("ai-agent-access", ai
|
|
1756
|
+
? `llms.txt ${ai.llms?.present ? `present (${ai.llms.links.length} links, ${ai.llms.characters} characters)` : "absent"}; llms-full.txt ${ai.llmsFull?.present ? "present" : "absent"}`
|
|
1757
|
+
: null);
|
|
1758
|
+
fact("ai-agent-access", markdown.length
|
|
1759
|
+
? `Markdown alternatives: ${markdown.filter((m) => m.data.usable).length} usable of ${markdown.length} probes on ${probed} fully probed pages`
|
|
1760
|
+
: null);
|
|
1761
|
+
fact("ai-agent-access", renderedList.length
|
|
1762
|
+
? `${renderedList.filter((r) => r.comparison?.text?.ratio !== null && r.comparison?.text?.ratio >= 0.5).length} of ${renderedList.length} browser-tested pages keep at least half of their text without JavaScript`
|
|
1763
|
+
: null);
|
|
1764
|
+
fact("content-structure", `${[...new Set(content.map((p) => languageOfTag(p.data.language)).filter(Boolean))].join(", ") || "no"} declared languages; median ${median(content.map((p) => p.data.wordCount ?? 0)) ?? 0} words per page`);
|
|
1765
|
+
fact("links-navigation", linkGraph
|
|
1766
|
+
? `${graphPages.filter((p) => p.clickDepth !== null).length} pages reachable from the homepage by links, deepest at ${Math.max(0, ...graphPages.map((p) => p.clickDepth ?? 0))} clicks; ${resources.length} linked files and external destinations checked`
|
|
1767
|
+
: null);
|
|
1768
|
+
fact("structured-data", structured.length
|
|
1769
|
+
? `Structured data on ${structured.length} pages: ${[...new Set(structured.flatMap((s) => s.types))].slice(0, 12).join(", ")}`
|
|
1770
|
+
: null);
|
|
1771
|
+
fact("performance", metrics
|
|
1772
|
+
? `Own browser medians over ${metrics.pages} pages: first byte ${metrics.ttfb ?? "-"} ms, first paint ${metrics.fcp ?? "-"} ms, largest paint ${metrics.lcp ?? "-"} ms, layout shift ${metrics.cls ?? "-"}, ${metrics.bytes ? `${(metrics.bytes / 1_000_000).toFixed(1)} MB` : "-"} transferred${metrics.thirdPartyShare !== null ? `, ${metrics.thirdPartyShare}% from other domains` : ""}`
|
|
1773
|
+
: null);
|
|
1774
|
+
fact("performance", mobile
|
|
1775
|
+
? `Lighthouse mobile averages${carried ? ` carried from ${measuredAt?.slice(0, 10)}` : ""}: performance ${mobile.performance ?? "-"}, accessibility ${mobile.accessibility ?? "-"}, best practices ${mobile.bestPractices ?? "-"}, SEO ${mobile.seo ?? "-"}`
|
|
1776
|
+
: null);
|
|
1777
|
+
fact("performance", field?.available
|
|
1778
|
+
? `Real users (origin): LCP ${field.lcp ?? "-"} ms, INP ${field.inp ?? "-"} ms, CLS ${field.cls ?? "-"}, ${field.verdict}`
|
|
1779
|
+
: field
|
|
1780
|
+
? `Real-user data: ${field.note}`
|
|
1781
|
+
: null);
|
|
1782
|
+
fact("performance", fieldPageRows.length
|
|
1783
|
+
? `Page-level real-user data available for ${fieldPages.length} of ${fieldPageRows.length} crawled pages; the rest fall back to the origin`
|
|
1784
|
+
: null);
|
|
1785
|
+
fact("accessibility-usability", details.length
|
|
1786
|
+
? `axe ${details[0].data.accessibility?.engine?.version ?? ""} on ${details.length} pages: ${details.reduce((n, d) => n + (d.data.accessibility?.passes ?? 0), 0)} rule passes, ${byRule.size} violated rules, ${byReview.size} rules for manual review; ${details.filter((d) => (d.data.overflow ?? []).every((o) => o.conclusive)).length} pages with conclusive overflow measurements`
|
|
1787
|
+
: null);
|
|
1788
|
+
fact("business-profiles", business
|
|
1789
|
+
? `${(business.profiles ?? []).length} linked profiles on ${[...new Set((business.profiles ?? []).map((p) => p.platform))].join(", ") || "no platforms"}; ${(business.phones?.visible ?? []).length} visible phone numbers, ${(business.emails ?? []).length} email addresses, ${(business.addresses ?? []).length} structured addresses, ${(business.hours ?? []).length} opening-hours entries, ${(business.maps ?? []).length} map links`
|
|
1790
|
+
: null);
|
|
1791
|
+
// ---- Overview ----
|
|
1792
|
+
const technologies = [
|
|
1793
|
+
...new Set(pages.flatMap((p) => (p.data.technologies ?? []).map((t) => t.name))),
|
|
1794
|
+
];
|
|
1795
|
+
const pageTypes = {};
|
|
1796
|
+
for (const p of htmlPages) {
|
|
1797
|
+
const type = graphPages.find((g) => g.url === p.key)?.type ?? pageType(p.key);
|
|
1798
|
+
pageTypes[type] = (pageTypes[type] ?? 0) + 1;
|
|
1799
|
+
}
|
|
1800
|
+
const languages = [
|
|
1801
|
+
...new Set(content
|
|
1802
|
+
.map((p) => languageOfTag(p.data.language))
|
|
1803
|
+
.filter((l) => !!l)),
|
|
1804
|
+
];
|
|
1805
|
+
const structuredDataTypes = [
|
|
1806
|
+
...new Set(structured.flatMap((s) => s.types)),
|
|
1807
|
+
].slice(0, 20);
|
|
1808
|
+
const characteristics = {
|
|
1809
|
+
technologies,
|
|
1810
|
+
languages,
|
|
1811
|
+
pageTypes,
|
|
1812
|
+
ecommerce: (pageTypes.product ?? 0) > 0 ||
|
|
1813
|
+
technologies.some((t) => /Shopify|WooCommerce|Magento|Lightspeed/.test(t)),
|
|
1814
|
+
structuredDataTypes,
|
|
1815
|
+
sitemap: validSitemaps.length > 0,
|
|
1816
|
+
llms: !!ai?.llms?.present,
|
|
1817
|
+
markdown: markdown.some((m) => m.data.usable),
|
|
1818
|
+
multilingual: languages.length > 1 || hreflangs.length > 0,
|
|
1819
|
+
};
|
|
1820
|
+
const summaryParts = [
|
|
1821
|
+
`${pages.length} pages observed (${okPages.length} OK, ${brokenPages.length} broken)`,
|
|
1822
|
+
`${counts.critical} errors, ${counts.warning} warnings and ${counts.hint} hints across ${new Set(findings.map((f) => f.topic)).size} topics`,
|
|
1823
|
+
coverage.complete
|
|
1824
|
+
? "coverage complete"
|
|
1825
|
+
: coverage.expired
|
|
1826
|
+
? "time limit reached, work remaining"
|
|
1827
|
+
: scan.status === "partial"
|
|
1828
|
+
? "coverage partial"
|
|
1829
|
+
: `status ${scan.status}`,
|
|
1830
|
+
];
|
|
1831
|
+
const summary = summaryParts.join("; ") + ".";
|
|
1832
|
+
const pending = [];
|
|
1833
|
+
if (scan.status !== "completed")
|
|
1834
|
+
pending.push(`Scan status: ${scan.status}`);
|
|
1835
|
+
if (!pages.length || notReachedPages)
|
|
1836
|
+
pending.push(`${pages.length}/${followedPages.length} followed pages observed`);
|
|
1837
|
+
if (missingSlash.length)
|
|
1838
|
+
pending.push(`Slash variants: ${expectedSlash.size - missingSlash.length}/${expectedSlash.size} verified`);
|
|
1839
|
+
const inconclusive = [...pages, ...resources].filter((r) => ["inconclusive", "blocked"].includes(r.data.http?.outcome));
|
|
1840
|
+
if (inconclusive.length)
|
|
1841
|
+
pending.push(`${inconclusive.length} URLs blocked or inconclusive`);
|
|
1842
|
+
if (!scan.options.browser || coverage.browser.completed < htmlPages.length)
|
|
1843
|
+
pending.push(`Browser: ${coverage.browser.completed}/${htmlPages.length} pages completed${!scan.options.browser ? " (disabled)" : ""}`);
|
|
1844
|
+
if (lighthouseOn && scan.options.browser) {
|
|
1845
|
+
if (coverage.lighthouse.completed < coverage.lighthouse.selected)
|
|
1846
|
+
pending.push(`Lighthouse: ${coverage.lighthouse.completed}/${coverage.lighthouse.selected} selected pages completed on both devices`);
|
|
1847
|
+
}
|
|
1848
|
+
else
|
|
1849
|
+
pending.push("Lighthouse not measured in this scan; historical averages do not prove current coverage");
|
|
1850
|
+
if (remainingWork.length)
|
|
1851
|
+
pending.push(`Remaining work: ${remainingWork.map((w) => `${w.count} ${w.kind} ${w.status}`).join(", ")}`);
|
|
1852
|
+
// ---- Changes since the previous report of the same site ----
|
|
1853
|
+
const previous = input.previous;
|
|
1854
|
+
let changes = null;
|
|
1855
|
+
if (previous) {
|
|
1856
|
+
const before = previous.report;
|
|
1857
|
+
const beforeFindings = previous.findings;
|
|
1858
|
+
const observedUrl = (url, code) => {
|
|
1859
|
+
const page = byKey.get(url);
|
|
1860
|
+
// HTML-derived checks did not run on an error response or a PDF/image.
|
|
1861
|
+
// Resource availability can instead be verified by its own successful fetch.
|
|
1862
|
+
if (["broken-resources", "broken-external-resources"].includes(code))
|
|
1863
|
+
return resourceByKey.get(url)?.data.http?.outcome === "ok";
|
|
1864
|
+
return page?.data.http?.outcome === "ok" && page.data.html !== false;
|
|
1865
|
+
};
|
|
1866
|
+
const checkRan = (code) => {
|
|
1867
|
+
const outcome = checks.find((c) => c.code === code)?.outcome;
|
|
1868
|
+
return (outcome !== undefined &&
|
|
1869
|
+
!["unavailable", "not-checked"].includes(outcome));
|
|
1870
|
+
};
|
|
1871
|
+
// Resolution needs the evidence the rule is built on, for the affected
|
|
1872
|
+
// page itself: a completed browser pass for axe and browser rules, a
|
|
1873
|
+
// usable Lighthouse run for Lighthouse rules, a rendered comparison for
|
|
1874
|
+
// rendered rules, real-user data for field rules, and a successful fetch
|
|
1875
|
+
// for everything derived from the HTML. Anything less stays unverified.
|
|
1876
|
+
const browserDone = new Set(browserStatus
|
|
1877
|
+
.filter((b) => b.data.status === "completed")
|
|
1878
|
+
.map((b) => b.key));
|
|
1879
|
+
const lighthouseDone = new Set(lighthouse
|
|
1880
|
+
.filter((l) => l.data.device === "mobile" &&
|
|
1881
|
+
!l.data.runtimeError &&
|
|
1882
|
+
typeof l.data.scores?.performance === "number")
|
|
1883
|
+
.map((l) => l.data.url));
|
|
1884
|
+
const perPage = [
|
|
1885
|
+
"pages",
|
|
1886
|
+
"browser",
|
|
1887
|
+
"rendered",
|
|
1888
|
+
"lighthouse",
|
|
1889
|
+
"analysis",
|
|
1890
|
+
"structured-data",
|
|
1891
|
+
"field-data",
|
|
1892
|
+
"sitemap",
|
|
1893
|
+
];
|
|
1894
|
+
// Grouped findings need the specific rule or audit to have run again on
|
|
1895
|
+
// that page: an axe rule counts only when the page's pass list names it
|
|
1896
|
+
// (a rule that is now "incomplete" or absent from an older record
|
|
1897
|
+
// without pass ids stays unverified); a Lighthouse audit counts only
|
|
1898
|
+
// when the run did not report an error for it.
|
|
1899
|
+
const groupVerified = (url, finding) => {
|
|
1900
|
+
const group = finding.group ?? finding.id?.slice(finding.code.length + 1);
|
|
1901
|
+
if (!group)
|
|
1902
|
+
return true;
|
|
1903
|
+
if (finding.code.startsWith("accessibility-")) {
|
|
1904
|
+
const detail = details.find((d) => d.key === url);
|
|
1905
|
+
const passed = detail?.data.accessibility?.passedRules ?? [];
|
|
1906
|
+
return passed.includes(group);
|
|
1907
|
+
}
|
|
1908
|
+
if (finding.code.endsWith("performance-opportunities"))
|
|
1909
|
+
return lighthouse.some((l) => l.data.url === url &&
|
|
1910
|
+
l.data.device === "mobile" &&
|
|
1911
|
+
!(l.data.auditErrors ?? []).some((e) => e.id === group));
|
|
1912
|
+
return true;
|
|
1913
|
+
};
|
|
1914
|
+
const evidenceFor = (url, requires, code) => {
|
|
1915
|
+
switch (requires) {
|
|
1916
|
+
case "browser":
|
|
1917
|
+
return browserDone.has(url) && details.some((d) => d.key === url);
|
|
1918
|
+
case "rendered":
|
|
1919
|
+
return renderedByKey.has(url);
|
|
1920
|
+
case "lighthouse":
|
|
1921
|
+
return lighthouseDone.has(url);
|
|
1922
|
+
case "field-data":
|
|
1923
|
+
return fieldRows.some((f) => f.key === url && f.data.available);
|
|
1924
|
+
default:
|
|
1925
|
+
return observedUrl(url, code);
|
|
1926
|
+
}
|
|
1927
|
+
};
|
|
1928
|
+
const verified = (item, finding) => {
|
|
1929
|
+
const code = finding.code;
|
|
1930
|
+
if (!checkRan(code))
|
|
1931
|
+
return false;
|
|
1932
|
+
const requires = rules[code]?.requires;
|
|
1933
|
+
const text = typeof item === "string" ? item : item.text;
|
|
1934
|
+
const url = (typeof item === "string" ? null : item.url) ??
|
|
1935
|
+
/https?:\/\/\S+/.exec(text ?? "")?.[0]?.replace(/[),.;]+$/, "") ??
|
|
1936
|
+
null;
|
|
1937
|
+
// Without an affected URL only site-level rules can be rechecked.
|
|
1938
|
+
if (!url)
|
|
1939
|
+
return !perPage.includes(requires ?? "pages");
|
|
1940
|
+
return evidenceFor(url, requires, code) && groupVerified(url, finding);
|
|
1941
|
+
};
|
|
1942
|
+
const diff = diffFindings(beforeFindings, findings, verified);
|
|
1943
|
+
const previousPages = previous.pages;
|
|
1944
|
+
const previousKeys = new Map(previousPages.map((p) => [p.key, p]));
|
|
1945
|
+
const pagesAdded = pages
|
|
1946
|
+
.filter((p) => p.data.http?.outcome === "ok" && !previousKeys.has(p.key))
|
|
1947
|
+
.map((p) => p.key);
|
|
1948
|
+
const pagesRemoved = previousPages
|
|
1949
|
+
.filter((p) => p.outcome === "ok" && !byKey.has(p.key))
|
|
1950
|
+
.map((p) => p.key);
|
|
1951
|
+
const titleChanges = pages.flatMap((p) => {
|
|
1952
|
+
const old = previousKeys.get(p.key);
|
|
1953
|
+
return old && old.title && p.data.title && old.title !== p.data.title
|
|
1954
|
+
? [{ url: p.key, from: old.title, to: p.data.title }]
|
|
1955
|
+
: [];
|
|
1956
|
+
});
|
|
1957
|
+
const changedPages = pages.flatMap((p) => {
|
|
1958
|
+
const old = previousKeys.get(p.key);
|
|
1959
|
+
if (!old)
|
|
1960
|
+
return [];
|
|
1961
|
+
const diffs = [];
|
|
1962
|
+
if (old.status !== null && status(p) !== null && old.status !== status(p))
|
|
1963
|
+
diffs.push(`status ${old.status} to ${status(p)}`);
|
|
1964
|
+
if (old.title && p.data.title && old.title !== p.data.title)
|
|
1965
|
+
diffs.push(`title "${old.title}" to "${p.data.title}"`);
|
|
1966
|
+
if (typeof old.words === "number" &&
|
|
1967
|
+
typeof p.data.wordCount === "number" &&
|
|
1968
|
+
Math.abs(old.words - p.data.wordCount) >= Math.max(50, old.words * 0.2))
|
|
1969
|
+
diffs.push(`words ${old.words} to ${p.data.wordCount}`);
|
|
1970
|
+
const canonical = p.data.canonicals?.[0]?.href ?? null;
|
|
1971
|
+
if (old.canonical && canonical && old.canonical !== canonical)
|
|
1972
|
+
diffs.push(`canonical ${old.canonical} to ${canonical}`);
|
|
1973
|
+
if (old.language && p.data.language && old.language !== p.data.language)
|
|
1974
|
+
diffs.push(`language ${old.language} to ${p.data.language}`);
|
|
1975
|
+
return diffs.length ? [{ url: p.key, changes: diffs }] : [];
|
|
1976
|
+
});
|
|
1977
|
+
// Infrastructure: nameservers, addresses, mail, certificate issuer, stack.
|
|
1978
|
+
const previousDns = previous.dns;
|
|
1979
|
+
const previousTls = previous.tls;
|
|
1980
|
+
const previousHome = previous.home;
|
|
1981
|
+
const list = (values) => (Array.isArray(values) ? values : [])
|
|
1982
|
+
.map((v) => typeof v === "string" ? v : (v?.exchange ?? JSON.stringify(v)))
|
|
1983
|
+
.sort()
|
|
1984
|
+
.join(", ");
|
|
1985
|
+
const infrastructure = [];
|
|
1986
|
+
for (const [record, label] of [
|
|
1987
|
+
["ns", "Nameservers"],
|
|
1988
|
+
["a", "IP addresses"],
|
|
1989
|
+
["mx", "Mail servers"],
|
|
1990
|
+
]) {
|
|
1991
|
+
const was = list(previousDns?.records?.[record]?.values), is = list(dns?.records?.[record]?.values);
|
|
1992
|
+
if (was && is && was !== is)
|
|
1993
|
+
infrastructure.push(`${label} changed from ${was} to ${is}`);
|
|
1994
|
+
}
|
|
1995
|
+
const issuerOf = (t) => t?.issuer?.O ??
|
|
1996
|
+
t?.issuer?.CN ??
|
|
1997
|
+
t?.certificate?.issuer?.O ??
|
|
1998
|
+
t?.certificate?.issuer?.CN;
|
|
1999
|
+
const wasIssuer = issuerOf(previousTls), isIssuer = issuerOf(tls);
|
|
2000
|
+
if (wasIssuer && isIssuer && wasIssuer !== isIssuer)
|
|
2001
|
+
infrastructure.push(`Certificate issuer changed from ${wasIssuer} to ${isIssuer}`);
|
|
2002
|
+
const techs = (p) => (p?.technologies ?? p?.data?.technologies ?? [])
|
|
2003
|
+
.map((t) => t.name)
|
|
2004
|
+
.sort()
|
|
2005
|
+
.join(", ");
|
|
2006
|
+
const wasTech = techs(previousHome), isTech = techs(home);
|
|
2007
|
+
if (wasTech && isTech && wasTech !== isTech)
|
|
2008
|
+
infrastructure.push(`Detected technology changed from ${wasTech} to ${isTech}`);
|
|
2009
|
+
const scoreChanges = ["performance", "accessibility", "bestPractices", "seo"].flatMap((category) => {
|
|
2010
|
+
const from = before.scores?.mobile?.[category], to = mobile?.[category];
|
|
2011
|
+
return typeof from === "number" &&
|
|
2012
|
+
typeof to === "number" &&
|
|
2013
|
+
from !== to &&
|
|
2014
|
+
!carried
|
|
2015
|
+
? [{ category, from, to }]
|
|
2016
|
+
: [];
|
|
2017
|
+
});
|
|
2018
|
+
const scoreDrops = scoreChanges.filter((c) => c.from - c.to >= 10);
|
|
2019
|
+
// Lab and field numbers wobble; only material moves are trends.
|
|
2020
|
+
const material = (metric, from, to) => Math.abs(to - from) >=
|
|
2021
|
+
Math.max(0.2 * Math.abs(from), metric === "cls" ? 0.05 : 50);
|
|
2022
|
+
const metricChanges = ["ttfb", "fcp", "lcp", "cls", "bytes"].flatMap((metric) => {
|
|
2023
|
+
const from = before.metrics?.[metric], to = metrics?.[metric];
|
|
2024
|
+
return typeof from === "number" &&
|
|
2025
|
+
typeof to === "number" &&
|
|
2026
|
+
material(metric, from, to)
|
|
2027
|
+
? [{ metric, from, to }]
|
|
2028
|
+
: [];
|
|
2029
|
+
});
|
|
2030
|
+
const fieldChanges = ["lcp", "inp", "cls"].flatMap((metric) => {
|
|
2031
|
+
const from = before.field?.[metric], to = field?.[metric];
|
|
2032
|
+
return typeof from === "number" &&
|
|
2033
|
+
typeof to === "number" &&
|
|
2034
|
+
material(metric, from, to)
|
|
2035
|
+
? [{ metric, from, to }]
|
|
2036
|
+
: [];
|
|
2037
|
+
});
|
|
2038
|
+
// Visual: this scan's screenshots against the latest earlier screenshot
|
|
2039
|
+
// of the same page, since the sample rotates.
|
|
2040
|
+
const visual = [];
|
|
2041
|
+
for (const current of input.artifacts.filter((a) => a.kind === "screenshot")) {
|
|
2042
|
+
const old = previous.screenshots?.find((s) => s.url === current.key);
|
|
2043
|
+
if (!old)
|
|
2044
|
+
continue;
|
|
2045
|
+
const changed = screenshotDifference(old.body, current.body);
|
|
2046
|
+
if (changed !== null && changed >= 5)
|
|
2047
|
+
visual.push({
|
|
2048
|
+
url: current.key,
|
|
2049
|
+
changed,
|
|
2050
|
+
screenshotId: current.sha256,
|
|
2051
|
+
previousScreenshotId: old.id,
|
|
2052
|
+
});
|
|
2053
|
+
}
|
|
2054
|
+
changes = {
|
|
2055
|
+
previousScanId: previous.id,
|
|
2056
|
+
previousCreatedAt: previous.createdAt,
|
|
2057
|
+
newFindings: diff.newFindings.map((f) => ({
|
|
2058
|
+
...f,
|
|
2059
|
+
items: f.items.slice(0, ITEM_LIMIT).map((i) => i.text),
|
|
2060
|
+
})),
|
|
2061
|
+
resolvedFindings: diff.resolvedFindings,
|
|
2062
|
+
unverifiedFindings: diff.unverifiedFindings,
|
|
2063
|
+
recurringFindings: diff.recurringFindings,
|
|
2064
|
+
worsenedFindings: diff.worsenedFindings,
|
|
2065
|
+
fixedFindings: diff.resolvedFindings,
|
|
2066
|
+
pagesAdded,
|
|
2067
|
+
pagesRemoved,
|
|
2068
|
+
titleChanges,
|
|
2069
|
+
changedPages,
|
|
2070
|
+
infrastructure,
|
|
2071
|
+
scoreDrops,
|
|
2072
|
+
performance: {
|
|
2073
|
+
scores: scoreChanges,
|
|
2074
|
+
metrics: metricChanges,
|
|
2075
|
+
field: fieldChanges,
|
|
2076
|
+
},
|
|
2077
|
+
visual,
|
|
2078
|
+
indexability: before.indexability &&
|
|
2079
|
+
before.indexability.indexable !== indexability.indexable
|
|
2080
|
+
? { from: before.indexability.indexable, to: indexability.indexable }
|
|
2081
|
+
: null,
|
|
2082
|
+
};
|
|
2083
|
+
}
|
|
2084
|
+
const collectorVersion = of("collector")[0]?.data?.version ?? null;
|
|
2085
|
+
const report = {
|
|
2086
|
+
version: 2,
|
|
2087
|
+
scanId: scan.id,
|
|
2088
|
+
siteUrl: scan.url,
|
|
2089
|
+
status: scan.status,
|
|
2090
|
+
createdAt: scan.createdAt,
|
|
2091
|
+
finishedAt: scan.finishedAt ?? null,
|
|
2092
|
+
overview: { characteristics, summary, priorities },
|
|
2093
|
+
health: counts.critical
|
|
2094
|
+
? "critical"
|
|
2095
|
+
: counts.warning || pending.length
|
|
2096
|
+
? "attention"
|
|
2097
|
+
: "healthy",
|
|
2098
|
+
pages: {
|
|
2099
|
+
observed: pages.length,
|
|
2100
|
+
ok: okPages.length,
|
|
2101
|
+
broken: brokenPages.length,
|
|
2102
|
+
tested: browserStatus.filter((b) => b.data.status === "completed").length,
|
|
2103
|
+
lighthouse: new Set(lighthouse.map((l) => l.data.url)).size,
|
|
2104
|
+
},
|
|
2105
|
+
coverage,
|
|
2106
|
+
indexability,
|
|
2107
|
+
security,
|
|
2108
|
+
weight: {
|
|
2109
|
+
averageBytes: weights.length
|
|
2110
|
+
? Math.round(weights.reduce((a, w) => a + w.bytes, 0) / weights.length)
|
|
2111
|
+
: null,
|
|
2112
|
+
heaviest: weights.length
|
|
2113
|
+
? weights.reduce((a, w) => (w.bytes > a.bytes ? w : a))
|
|
2114
|
+
: null,
|
|
2115
|
+
// 0.81 kWh per GB transferred, 442 g CO2 per kWh (global grid average).
|
|
2116
|
+
carbonGrams: weights.length
|
|
2117
|
+
? Math.round((weights.reduce((a, w) => a + w.bytes, 0) /
|
|
2118
|
+
weights.length /
|
|
2119
|
+
1073741824) *
|
|
2120
|
+
0.81 *
|
|
2121
|
+
442 *
|
|
2122
|
+
100) / 100
|
|
2123
|
+
: null,
|
|
2124
|
+
},
|
|
2125
|
+
scores: { mobile, desktop, measuredAt, carried },
|
|
2126
|
+
metrics,
|
|
2127
|
+
field,
|
|
2128
|
+
fieldPages,
|
|
2129
|
+
domain,
|
|
2130
|
+
topics,
|
|
2131
|
+
checks,
|
|
2132
|
+
methodology: {
|
|
2133
|
+
versions: {
|
|
2134
|
+
collector: collectorVersion,
|
|
2135
|
+
lighthouse: lighthouse[0]?.data?.lighthouseVersion ?? null,
|
|
2136
|
+
chrome: lighthouse[0]?.data?.chromeVersion ??
|
|
2137
|
+
details[0]?.data?.chromeVersion ??
|
|
2138
|
+
null,
|
|
2139
|
+
axe: details[0]?.data?.accessibility?.engine?.version ?? null,
|
|
2140
|
+
},
|
|
2141
|
+
measuredAt: {
|
|
2142
|
+
crawl: scan.createdAt,
|
|
2143
|
+
lighthouse: lighthouse[0]?.data?.fetchTime ?? measuredAt,
|
|
2144
|
+
chromeUxReport: field?.period?.to ?? null,
|
|
2145
|
+
sslLabs: labs?.testedAt ?? null,
|
|
2146
|
+
},
|
|
2147
|
+
notes: [
|
|
2148
|
+
"Findings describe observed conditions and their impact; confidence is separate from severity. Review findings are heuristics that need a human judgement.",
|
|
2149
|
+
"Unavailable and not-checked outcomes mean the check could not run; they are not failures of the website.",
|
|
2150
|
+
"Structured-data checks cover syntax, shapes and documented required properties, not rich-result eligibility.",
|
|
2151
|
+
"Accessibility checks are automated axe-core rules; they cover part of WCAG and make no conformance claim.",
|
|
2152
|
+
"Business and profile checks use the public website only; blocked platforms are unverifiable, not missing.",
|
|
2153
|
+
],
|
|
2154
|
+
},
|
|
2155
|
+
counts,
|
|
2156
|
+
findings: findings.map((f) => ({
|
|
2157
|
+
...f,
|
|
2158
|
+
items: f.items.slice(0, ITEM_LIMIT).map((i) => i.text),
|
|
2159
|
+
urls: f.urls.slice(0, ITEM_LIMIT),
|
|
2160
|
+
})),
|
|
2161
|
+
changes,
|
|
2162
|
+
};
|
|
2163
|
+
return { report, findings };
|
|
2164
|
+
}
|
|
2165
|
+
export const areaLabels = {
|
|
2166
|
+
availability: "Availability and security",
|
|
2167
|
+
search: "Search",
|
|
2168
|
+
performance: "Performance",
|
|
2169
|
+
accessibility: "Accessibility",
|
|
2170
|
+
quality: "Quality",
|
|
2171
|
+
};
|
|
2172
|
+
// Compact Markdown for agents and plain-text contexts: overview, priorities,
|
|
2173
|
+
// findings per topic, coverage and changes. No remediation advice.
|
|
2174
|
+
export function formatReport(report) {
|
|
2175
|
+
report = publicReport(report);
|
|
2176
|
+
const host = new URL(report.siteUrl).hostname;
|
|
2177
|
+
const topicOf = (f) => f.topic ?? areaTopics[f.area ?? ""] ?? "links-navigation";
|
|
2178
|
+
const sections = [`# ${host}`];
|
|
2179
|
+
sections.push(`Scan ${report.createdAt.slice(0, 10)}, status ${report.status}, health ${report.health ?? "unknown"}. ${report.overview?.summary ??
|
|
2180
|
+
`${report.pages.observed} pages crawled, ${report.pages.broken} broken, ${report.pages.tested ?? 0} tested in the browser.`}`);
|
|
2181
|
+
if (report.overview) {
|
|
2182
|
+
const c = report.overview.characteristics;
|
|
2183
|
+
sections.push(`Detected: ${c.technologies.join(", ") || "no recognised technology"}; languages ${c.languages.join(", ") || "not declared"}; page types ${Object.entries(c.pageTypes)
|
|
2184
|
+
.map(([k, v]) => `${v} ${k}`)
|
|
2185
|
+
.join(", ")}${c.ecommerce ? "; e-commerce" : ""}${c.multilingual ? "; multilingual" : ""}.`);
|
|
2186
|
+
const priorities = report.overview.priorities
|
|
2187
|
+
.map((id) => report.findings.find((f) => f.id === id))
|
|
2188
|
+
.filter((f) => !!f);
|
|
2189
|
+
if (priorities.length)
|
|
2190
|
+
sections.push(`## Priority problems\n\n${priorities.map((f) => `- ${f.title} (${f.severity}, ${f.confidence}, ${f.count} items on ${f.pages} pages): ${f.impact}`).join("\n")}`);
|
|
2191
|
+
}
|
|
2192
|
+
if (report.coverage) {
|
|
2193
|
+
const cov = report.coverage;
|
|
2194
|
+
sections.push(`Coverage: ${cov.crawled} of ${cov.discovered} discovered pages observed${cov.pages ? `, ${cov.pages.notReached} not reached` : ""}${cov.browser
|
|
2195
|
+
? `; browser pass on ${cov.browser.completed} of ${cov.browser.eligible} pages`
|
|
2196
|
+
: ""}${cov.lighthouse ? `; Lighthouse on ${cov.lighthouse.completed} of ${cov.lighthouse.selected} selected pages${cov.lighthouse.omitted ? ` (${cov.lighthouse.omitted} of ${cov.lighthouse.eligible} eligible pages left out by the page limit)` : ""}` : ""}${cov.remainingWork?.length
|
|
2197
|
+
? `; remaining work: ${cov.remainingWork.map((w) => `${w.count} ${w.kind} ${w.status}`).join(", ")}`
|
|
2198
|
+
: ""}. ${cov.complete === false ? "Coverage is incomplete; absent findings for unchecked pages are not evidence of health." : ""}`);
|
|
2199
|
+
if (cov.slashVariants)
|
|
2200
|
+
sections.push(`Trailing-slash pairs: ${cov.slashVariants.verified}/${cov.slashVariants.expected} verified.`);
|
|
2201
|
+
}
|
|
2202
|
+
if (report.indexability)
|
|
2203
|
+
sections.push(`Indexability: ${report.indexability.indexable} indexable, ${report.indexability.noindex} noindex, ${report.indexability.canonicalElsewhere} canonical elsewhere, ${report.indexability.broken} broken.`);
|
|
2204
|
+
const score = (s, key) => s?.[key] === null || s?.[key] === undefined ? "-" : String(s[key]);
|
|
2205
|
+
sections.push(`Lighthouse averages mobile / desktop${report.scores.carried ? ` (carried from ${report.scores.measuredAt?.slice(0, 10)})` : ""}: performance ${score(report.scores.mobile, "performance")} / ${score(report.scores.desktop, "performance")}, accessibility ${score(report.scores.mobile, "accessibility")} / ${score(report.scores.desktop, "accessibility")}, best practices ${score(report.scores.mobile, "bestPractices")} / ${score(report.scores.desktop, "bestPractices")}, SEO ${score(report.scores.mobile, "seo")} / ${score(report.scores.desktop, "seo")}.`);
|
|
2206
|
+
if (report.field)
|
|
2207
|
+
sections.push(report.field.available
|
|
2208
|
+
? `Real-user vitals (${report.field.note}): LCP ${report.field.lcp ?? "-"} ms, INP ${report.field.inp ?? "-"} ms, CLS ${report.field.cls ?? "-"}, verdict ${report.field.verdict ?? "unknown"}.`
|
|
2209
|
+
: `Real-user vitals: ${report.field.note}.`);
|
|
2210
|
+
if (report.domain)
|
|
2211
|
+
sections.push(`Domain ${report.domain.name} expires ${String(report.domain.expiresAt ?? "unknown").slice(0, 10)}${report.domain.daysRemaining !== null ? ` (${report.domain.daysRemaining} days)` : ""}.`);
|
|
2212
|
+
const findingLine = (f) => `- ${f.title} (${f.severity}, ${f.confidence ?? "confirmed"}, ${f.count}${f.pages ? ` on ${f.pages} pages` : ""}). ${f.problem ?? ""} ${f.impact ?? ""}${f.items.length
|
|
2213
|
+
? `\n Evidence: ${f.items.slice(0, 5).join("; ")}${f.count > 5 ? ` (and ${f.count - 5} more)` : ""}`
|
|
2214
|
+
: ""}`;
|
|
2215
|
+
for (const topic of topicOrder) {
|
|
2216
|
+
const items = report.findings.filter((f) => topicOf(f) === topic);
|
|
2217
|
+
const summary = report.topics?.[topic];
|
|
2218
|
+
if (!items.length && !summary?.facts?.length)
|
|
2219
|
+
continue;
|
|
2220
|
+
sections.push(`## ${topicLabels[topic]}${summary ? ` (${summary.status})` : ""}\n\n${[
|
|
2221
|
+
...(summary?.facts ?? []).map((fact) => `${fact}.`),
|
|
2222
|
+
...items.map(findingLine),
|
|
2223
|
+
].join("\n")}`);
|
|
2224
|
+
}
|
|
2225
|
+
if (!report.findings.length)
|
|
2226
|
+
sections.push("## Findings\n\nNone within the checks that ran.");
|
|
2227
|
+
if (report.checks) {
|
|
2228
|
+
const grouped = new Map();
|
|
2229
|
+
for (const c of report.checks)
|
|
2230
|
+
grouped.set(c.outcome, (grouped.get(c.outcome) ?? 0) + 1);
|
|
2231
|
+
sections.push(`## Checks\n\n${[...grouped].map(([outcome, n]) => `${n} ${outcome}`).join(", ")}. Not checked or unavailable: ${report.checks
|
|
2232
|
+
.filter((c) => ["unavailable", "not-checked"].includes(c.outcome))
|
|
2233
|
+
.map((c) => `${c.title}${c.detail ? ` (${c.detail})` : ""}`)
|
|
2234
|
+
.join("; ") || "none"}.`);
|
|
2235
|
+
}
|
|
2236
|
+
if (report.changes) {
|
|
2237
|
+
const c = report.changes;
|
|
2238
|
+
const lines = [
|
|
2239
|
+
...(c.infrastructure ?? []).map((l) => `- ${l}`),
|
|
2240
|
+
...(c.scoreDrops ?? []).map((d) => `- ${d.category} dropped from ${d.from} to ${d.to}`),
|
|
2241
|
+
...(c.performance?.metrics ?? []).map((m) => `- Own browser ${m.metric} ${m.from} to ${m.to}`),
|
|
2242
|
+
...(c.visual ?? []).map((v) => `- ${v.url} looks ${v.changed}% different`),
|
|
2243
|
+
...(c.indexability
|
|
2244
|
+
? [
|
|
2245
|
+
`- Indexable pages went from ${c.indexability.from} to ${c.indexability.to}`,
|
|
2246
|
+
]
|
|
2247
|
+
: []),
|
|
2248
|
+
...c.newFindings.map((f) => `- New: ${f.title} (${f.count})`),
|
|
2249
|
+
...(c.worsenedFindings ?? []).map((f) => `- Worsened: ${f.title} (${f.from} to ${f.to})`),
|
|
2250
|
+
...(c.resolvedFindings ?? []).map((f) => `- Resolved after recheck: ${f.title} (${f.count})`),
|
|
2251
|
+
...unverifiedChanges(c).map((f) => `- No longer listed, not rechecked: ${f.title} (${f.count})`),
|
|
2252
|
+
...(c.pagesAdded.length ? [`- Pages added: ${c.pagesAdded.length}`] : []),
|
|
2253
|
+
...(c.pagesRemoved.length
|
|
2254
|
+
? [`- Pages removed: ${c.pagesRemoved.length}`]
|
|
2255
|
+
: []),
|
|
2256
|
+
...((c.changedPages ?? []).length
|
|
2257
|
+
? [`- Pages changed: ${c.changedPages.length}`]
|
|
2258
|
+
: []),
|
|
2259
|
+
];
|
|
2260
|
+
sections.push(`## Since ${c.previousCreatedAt.slice(0, 10)}\n\n${lines.length ? lines.join("\n") : "No changes."}`);
|
|
2261
|
+
}
|
|
2262
|
+
return sections.join("\n\n");
|
|
2263
|
+
}
|