@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.
Files changed (55) hide show
  1. package/README.md +79 -0
  2. package/bin/fullscan.js +2 -0
  3. package/dist/cli.d.ts +1 -0
  4. package/dist/cli.js +239 -0
  5. package/dist/engine/analysis.d.ts +2 -0
  6. package/dist/engine/analysis.js +747 -0
  7. package/dist/engine/browser-inspection.d.ts +143 -0
  8. package/dist/engine/browser-inspection.js +567 -0
  9. package/dist/engine/browser.d.ts +22 -0
  10. package/dist/engine/browser.js +629 -0
  11. package/dist/engine/collect.d.ts +6 -0
  12. package/dist/engine/collect.js +359 -0
  13. package/dist/engine/crawl-scope.d.ts +22 -0
  14. package/dist/engine/crawl-scope.js +145 -0
  15. package/dist/engine/env.d.ts +1 -0
  16. package/dist/engine/env.js +3 -0
  17. package/dist/engine/html.d.ts +307 -0
  18. package/dist/engine/html.js +645 -0
  19. package/dist/engine/language.d.ts +13 -0
  20. package/dist/engine/language.js +75 -0
  21. package/dist/engine/lighthouse-evidence.d.ts +36 -0
  22. package/dist/engine/lighthouse-evidence.js +69 -0
  23. package/dist/engine/lighthouse.d.ts +4 -0
  24. package/dist/engine/lighthouse.js +284 -0
  25. package/dist/engine/log.d.ts +1 -0
  26. package/dist/engine/log.js +4 -0
  27. package/dist/engine/network.d.ts +53 -0
  28. package/dist/engine/network.js +296 -0
  29. package/dist/engine/proxy.d.ts +8 -0
  30. package/dist/engine/proxy.js +95 -0
  31. package/dist/engine/run.d.ts +38 -0
  32. package/dist/engine/run.js +202 -0
  33. package/dist/engine/select.d.ts +6 -0
  34. package/dist/engine/select.js +38 -0
  35. package/dist/engine/site.d.ts +186 -0
  36. package/dist/engine/site.js +758 -0
  37. package/dist/engine/srcset.d.ts +1 -0
  38. package/dist/engine/srcset.js +31 -0
  39. package/dist/engine/state.d.ts +30 -0
  40. package/dist/engine/state.js +198 -0
  41. package/dist/engine/structured-data.d.ts +83 -0
  42. package/dist/engine/structured-data.js +331 -0
  43. package/dist/engine/types.d.ts +128 -0
  44. package/dist/engine/types.js +63 -0
  45. package/dist/engine.d.ts +1 -0
  46. package/dist/engine.js +1 -0
  47. package/dist/index.d.ts +8 -0
  48. package/dist/index.js +7 -0
  49. package/dist/report/build.d.ts +377 -0
  50. package/dist/report/build.js +2263 -0
  51. package/dist/report/evidence.d.ts +58 -0
  52. package/dist/report/evidence.js +192 -0
  53. package/dist/report/rules.d.ts +41 -0
  54. package/dist/report/rules.js +301 -0
  55. 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
+ }