@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,629 @@
1
+ import { observeRendering, inspectRendering, inspectOverflow, } from "./browser-inspection.js";
2
+ import { linkCandidates } from "./crawl-scope.js";
3
+ import { compareRendered, extractPage, loadMoreText, resolveLink, } from "./html.js";
4
+ import { srcsetUrls } from "./srcset.js";
5
+ import puppeteer from "puppeteer";
6
+ import lighthouse from "lighthouse";
7
+ import axe from "axe-core";
8
+ import { createHash } from "node:crypto";
9
+ import { existsSync } from "node:fs";
10
+ import { createEgressProxy } from "./proxy.js";
11
+ import { resolvePublic, userAgent } from "./network.js";
12
+ import { env } from "./env.js";
13
+ import { errorMessage, } from "./types.js";
14
+ // One proxy and Chromium per worker; Lighthouse resets storage per run, so
15
+ // pages stay independent while the cold launch is paid once.
16
+ export async function openBrowserSession() {
17
+ const proxy = await createEgressProxy();
18
+ try {
19
+ // CHROME_PATH, then a system Chrome, then the Chrome puppeteer installs.
20
+ const localPaths = [
21
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
22
+ "/usr/bin/google-chrome",
23
+ "/usr/bin/chromium",
24
+ ];
25
+ const executablePath = env("CHROME_PATH") || localPaths.find(existsSync) || undefined;
26
+ const args = [
27
+ "--no-sandbox",
28
+ "--disable-dev-shm-usage",
29
+ `--proxy-server=http://127.0.0.1:${proxy.port}`,
30
+ "--proxy-bypass-list=<-loopback>",
31
+ "--disable-quic",
32
+ "--force-webrtc-ip-handling-policy=disable_non_proxied_udp",
33
+ "--disable-background-networking",
34
+ ];
35
+ const browser = await puppeteer.launch({
36
+ executablePath,
37
+ args,
38
+ headless: true,
39
+ protocolTimeout: 90000,
40
+ });
41
+ const chromeVersion = await browser.version();
42
+ return {
43
+ proxy,
44
+ lighthouse,
45
+ browser,
46
+ args,
47
+ chromeVersion,
48
+ get connected() {
49
+ return browser.connected;
50
+ },
51
+ close: async () => {
52
+ await browser.close().catch(() => { });
53
+ await proxy.close();
54
+ },
55
+ };
56
+ }
57
+ catch (error) {
58
+ await proxy.close();
59
+ throw error;
60
+ }
61
+ }
62
+ // Screenshots for the homepage and a rotating fifth of the pages: enough for
63
+ // visual comparison without storing every page every day.
64
+ export function screenshotSampled(url, rotation, home) {
65
+ if (url === home || new URL(url).pathname === "/")
66
+ return true;
67
+ return (createHash("sha256").update(url).digest()[0] % 5 === Math.abs(rotation) % 5);
68
+ }
69
+ const axeTags = [
70
+ "wcag2a",
71
+ "wcag2aa",
72
+ "wcag21a",
73
+ "wcag21aa",
74
+ "wcag22aa",
75
+ "best-practice",
76
+ ];
77
+ // The per-page browser pass: one mobile navigation for own metrics and the
78
+ // rendered DOM, then accessibility, overflow, keyboard and two safe
79
+ // interactions (scroll, one load-more click). No forms are submitted.
80
+ export async function collectBrowser(scan, url, http, session) {
81
+ await resolvePublic(new URL(url).hostname);
82
+ const robots = await http.getRobots(url);
83
+ if (!robots.allowed ||
84
+ robots.parser?.isAllowed(url, "FulldevScan") === false)
85
+ return {
86
+ observations: [
87
+ {
88
+ kind: "browser",
89
+ key: url,
90
+ data: { status: "skipped", reason: "robots" },
91
+ },
92
+ ],
93
+ };
94
+ const { proxy, browser, chromeVersion } = session;
95
+ const blockedBefore = proxy.blocked.length;
96
+ const result = { observations: [], artifacts: [], candidates: [] };
97
+ const errors = [];
98
+ let page;
99
+ const budgetSeconds = scan.options.deepScan ? 180 : 120;
100
+ const watchdog = setTimeout(() => {
101
+ errors.push({
102
+ stage: "budget",
103
+ error: `Browser job exceeded ${budgetSeconds} seconds`,
104
+ });
105
+ // Closing the browser aborts the page; the worker reopens a session.
106
+ void browser.close();
107
+ }, budgetSeconds * 1000);
108
+ watchdog.unref();
109
+ try {
110
+ page = await browser.newPage();
111
+ await page.setUserAgent(userAgent);
112
+ await page.setCacheEnabled(false);
113
+ // Loaded as a phone: real numbers without throttling, comparable
114
+ // between days. Axe and keyboard checks run on the desktop size after.
115
+ await page.setViewport({
116
+ width: 412,
117
+ height: 915,
118
+ deviceScaleFactor: 1.75,
119
+ isMobile: true,
120
+ hasTouch: true,
121
+ });
122
+ // tsx preserves nested function names with this helper in serialized callbacks.
123
+ await page.evaluateOnNewDocument("globalThis.__name = (fn) => fn;");
124
+ if (scan.options.deepScan)
125
+ await observeRendering(page);
126
+ const renderingResponses = [];
127
+ const transfer = new Map();
128
+ const requestMeta = new Map();
129
+ // Static files the page loaded (also the ones that failed), so CSS
130
+ // backgrounds, fonts and script-inserted assets get checked. Documents,
131
+ // XHR, fetch and beacons are not files and are left out.
132
+ const staticTypes = /^(Image|Font|Stylesheet|Script|Media)$/;
133
+ const staticResources = new Set();
134
+ const cdp = await page.createCDPSession();
135
+ await cdp.send("Network.enable");
136
+ cdp.on("Network.requestWillBeSent", (e) => {
137
+ if (staticTypes.test(e.type) && /^https?:/.test(e.request?.url ?? ""))
138
+ staticResources.add(e.request.url);
139
+ });
140
+ cdp.on("Network.responseReceived", (e) => {
141
+ requestMeta.set(e.requestId, { url: e.response.url, type: e.type });
142
+ if (scan.options.deepScan)
143
+ renderingResponses.push({
144
+ requestId: e.requestId,
145
+ url: e.response.url,
146
+ status: e.response.status,
147
+ mimeType: e.response.mimeType,
148
+ encodedBodySize: Number(e.response.headers["content-length"] ??
149
+ e.response.headers["Content-Length"]) || null,
150
+ contentEncoding: e.response.headers["content-encoding"] ?? null,
151
+ });
152
+ });
153
+ cdp.on("Network.loadingFinished", (e) => {
154
+ transfer.set(e.requestId, e.encodedDataLength ?? 0);
155
+ const response = renderingResponses.find((r) => r.requestId === e.requestId);
156
+ if (response)
157
+ response.transferBytes = e.encodedDataLength ?? null;
158
+ });
159
+ const consoleMessages = [], failures = [], network = [];
160
+ page.on("console", (message) => {
161
+ if (consoleMessages.length < 200)
162
+ consoleMessages.push({
163
+ type: message.type(),
164
+ text: message.text().slice(0, 2000),
165
+ location: message.location(),
166
+ });
167
+ });
168
+ page.on("pageerror", (error) => {
169
+ if (consoleMessages.length < 200)
170
+ consoleMessages.push({
171
+ type: "pageerror",
172
+ text: String(error).slice(0, 2000),
173
+ });
174
+ });
175
+ page.on("requestfailed", (request) => {
176
+ if (failures.length < 500)
177
+ failures.push({
178
+ url: request.url(),
179
+ type: request.resourceType(),
180
+ error: request.failure(),
181
+ });
182
+ });
183
+ page.on("response", (response) => {
184
+ if (network.length < 1000)
185
+ network.push({
186
+ url: response.url(),
187
+ status: response.status(),
188
+ type: response.request().resourceType(),
189
+ fromCache: response.fromCache(),
190
+ timing: response.timing(),
191
+ });
192
+ });
193
+ await http.pace(url);
194
+ const started = Date.now();
195
+ const navigation = await page.goto(url, {
196
+ waitUntil: "domcontentloaded",
197
+ timeout: 30000,
198
+ });
199
+ const initialHtml = (await navigation?.text().catch(() => "")) ?? "";
200
+ const responseHeaders = navigation?.headers() ?? {};
201
+ await page
202
+ .waitForNetworkIdle({ idleTime: 500, timeout: 5000 })
203
+ .catch(() => { });
204
+ // Own metrics from the browser's performance APIs; no throttling, so
205
+ // they are lab numbers on a fast connection, not Lighthouse scores.
206
+ const timings = await page.evaluate(() => new Promise((resolve) => {
207
+ const nav = performance.getEntriesByType("navigation")[0];
208
+ let lcp = null;
209
+ let cls = 0;
210
+ try {
211
+ new PerformanceObserver((list) => {
212
+ for (const entry of list.getEntries())
213
+ lcp = entry.renderTime || entry.startTime;
214
+ }).observe({ type: "largest-contentful-paint", buffered: true });
215
+ }
216
+ catch { }
217
+ try {
218
+ new PerformanceObserver((list) => {
219
+ for (const entry of list.getEntries())
220
+ if (!entry.hadRecentInput)
221
+ cls += entry.value;
222
+ }).observe({ type: "layout-shift", buffered: true });
223
+ }
224
+ catch { }
225
+ setTimeout(() => resolve({
226
+ ttfb: nav
227
+ ? Math.round(nav.responseStart - nav.startTime)
228
+ : null,
229
+ fcp: Math.round(performance
230
+ .getEntriesByType("paint")
231
+ .find((p) => p.name === "first-contentful-paint")
232
+ ?.startTime ?? -1),
233
+ lcp: lcp === null ? null : Math.round(lcp),
234
+ cls: Math.round(cls * 1000) / 1000,
235
+ load: nav ? Math.round(nav.loadEventEnd) : null,
236
+ }), 1000);
237
+ }));
238
+ if (scan.options.deepScan)
239
+ result.observations.push({
240
+ kind: "rendering",
241
+ key: `${url}|mobile`,
242
+ data: {
243
+ url,
244
+ device: "mobile",
245
+ ...(await inspectRendering(page, cdp, initialHtml, renderingResponses)),
246
+ },
247
+ });
248
+ // Rendered DOM against the HTML the browser received for this same
249
+ // navigation: what scripts added, removed or changed.
250
+ const finalUrl = page.url();
251
+ const httpShape = (body, headers) => ({
252
+ url,
253
+ finalUrl,
254
+ status: navigation?.status() ?? null,
255
+ headers,
256
+ redirects: [],
257
+ body,
258
+ bytes: body.length,
259
+ durationMs: 0,
260
+ fetchedAt: new Date(started).toISOString(),
261
+ outcome: "ok",
262
+ attempts: 1,
263
+ });
264
+ const original = extractPage(httpShape(initialHtml, responseHeaders));
265
+ const renderedHtml = await page.content();
266
+ const rendered = extractPage(httpShape(renderedHtml, responseHeaders), "rendered-dom");
267
+ const comparison = compareRendered(original, rendered);
268
+ const compactLinks = (links) => links.map((l) => ({
269
+ href: l.href,
270
+ name: l.name,
271
+ location: l.location,
272
+ rel: l.rel,
273
+ pagination: l.pagination,
274
+ hreflang: l.hreflang,
275
+ }));
276
+ const origin = new URL(url).origin;
277
+ const byType = {};
278
+ let thirdPartyBytes = 0, totalBytes = 0;
279
+ for (const [id, bytes] of transfer) {
280
+ const meta = requestMeta.get(id);
281
+ const type = (meta?.type ?? "Other").toLowerCase();
282
+ byType[type] = (byType[type] ?? 0) + bytes;
283
+ totalBytes += bytes;
284
+ if (meta && !meta.url.startsWith(origin))
285
+ thirdPartyBytes += bytes;
286
+ }
287
+ const cookies = (await page.cookies()).map((c) => ({
288
+ name: c.name,
289
+ domain: c.domain,
290
+ thirdParty: !new URL(url).hostname.endsWith(c.domain.replace(/^\./, "")),
291
+ }));
292
+ result.observations.push({
293
+ kind: "browser-metrics",
294
+ key: url,
295
+ data: {
296
+ device: "mobile",
297
+ viewport: { width: 412, height: 915, deviceScaleFactor: 1.75 },
298
+ measurementVersion: 2,
299
+ throttling: "none",
300
+ ttfb: timings.ttfb,
301
+ fcp: timings.fcp >= 0 ? timings.fcp : null,
302
+ lcp: timings.lcp,
303
+ cls: timings.cls,
304
+ load: timings.load,
305
+ wallMs: Date.now() - started,
306
+ requests: transfer.size,
307
+ bytes: totalBytes,
308
+ byType,
309
+ thirdPartyBytes,
310
+ cookies: {
311
+ total: cookies.length,
312
+ thirdParty: cookies.filter((c) => c.thirdParty).length,
313
+ names: cookies.slice(0, 50),
314
+ },
315
+ note: "Own headless Chrome at 412px, DPR 1.75 on a fast connection. Compare matching viewports/settings; older DPR 1 observations are not directly comparable.",
316
+ },
317
+ });
318
+ if (screenshotSampled(url, scan.rotation, scan.url))
319
+ result.artifacts.push({
320
+ kind: "screenshot",
321
+ key: url,
322
+ body: (await page.screenshot({ type: "jpeg", quality: 60 })),
323
+ contentType: "image/jpeg",
324
+ });
325
+ // Puppeteer's setViewport reloads when mobile/touch flags change. CDP lets
326
+ // us resize without a hidden navigation; deep mode navigates explicitly below.
327
+ const resize = (width, height) => cdp.send("Emulation.setDeviceMetricsOverride", {
328
+ width,
329
+ height,
330
+ deviceScaleFactor: scan.options.deepScan ? 1 : 1.75,
331
+ mobile: !scan.options.deepScan,
332
+ });
333
+ await resize(1350, 940);
334
+ if (scan.options.deepScan) {
335
+ await cdp.send("Emulation.setTouchEmulationEnabled", { enabled: false });
336
+ renderingResponses.length = 0;
337
+ const desktopNavigation = await page.goto(url, {
338
+ waitUntil: "domcontentloaded",
339
+ timeout: 30000,
340
+ });
341
+ await page
342
+ .waitForNetworkIdle({ idleTime: 500, timeout: 5000 })
343
+ .catch(() => { });
344
+ result.observations.push({
345
+ kind: "rendering",
346
+ key: `${url}|desktop`,
347
+ data: {
348
+ url,
349
+ device: "desktop",
350
+ ...(await inspectRendering(page, cdp, (await desktopNavigation?.text().catch(() => "")) ?? "", renderingResponses)),
351
+ },
352
+ });
353
+ }
354
+ await page.evaluate(axe.source);
355
+ const accessibility = await page.evaluate(async (tags) => await window.axe.run(document, {
356
+ runOnly: { type: "tag", values: tags },
357
+ }), axeTags);
358
+ const compactNodes = (nodes, limit) => nodes.slice(0, limit).map((n) => ({
359
+ target: [n.target].flat().map(String).join(" ").slice(0, 300),
360
+ html: String(n.html ?? "").slice(0, 300),
361
+ }));
362
+ const compactRules = (rules, limit) => rules.map((v) => ({
363
+ id: v.id,
364
+ impact: v.impact ?? null,
365
+ help: v.help,
366
+ helpUrl: v.helpUrl,
367
+ tags: (v.tags ?? []).filter((t) => /^(wcag|best-practice)/.test(t)),
368
+ nodeCount: v.nodes?.length ?? 0,
369
+ nodes: compactNodes(v.nodes ?? [], limit),
370
+ }));
371
+ const overflow = [];
372
+ for (const width of [320, 375, 768]) {
373
+ await resize(width, 900);
374
+ overflow.push(await inspectOverflow(page));
375
+ }
376
+ await resize(1280, 900);
377
+ const controls = await page.evaluate(() => [
378
+ ...document.querySelectorAll('a[href],button,input,select,textarea,[tabindex],[role="button"]'),
379
+ ].map((el, index) => {
380
+ const html = el;
381
+ const r = html.getBoundingClientRect();
382
+ return {
383
+ index,
384
+ tag: el.tagName,
385
+ id: el.id,
386
+ role: el.getAttribute("role"),
387
+ tabIndex: html.tabIndex,
388
+ disabled: el.disabled ?? false,
389
+ visible: r.width > 0 &&
390
+ r.height > 0 &&
391
+ getComputedStyle(el).visibility !== "hidden",
392
+ name: el.getAttribute("aria-label") ||
393
+ el.textContent?.trim().slice(0, 200) ||
394
+ null,
395
+ };
396
+ }));
397
+ await page.evaluate(() => {
398
+ document.activeElement?.blur();
399
+ window.scrollTo(0, 0);
400
+ });
401
+ const tabSequence = [];
402
+ for (let i = 0; i < Math.min(controls.length + 3, 80); i++) {
403
+ await page.keyboard.press("Tab");
404
+ tabSequence.push(await page.evaluate(() => {
405
+ const el = document.activeElement;
406
+ if (!el || el === document.body)
407
+ return null;
408
+ const r = el.getBoundingClientRect(), style = getComputedStyle(el);
409
+ // Focus indicator heuristic: an outline with width, a box shadow,
410
+ // or a focus ring class cannot be told apart from decoration here,
411
+ // so only "nothing at all" is recorded as absent.
412
+ const outline = style.outlineStyle !== "none" && parseFloat(style.outlineWidth) > 0;
413
+ return {
414
+ tag: el.tagName,
415
+ id: el.id,
416
+ role: el.getAttribute("role"),
417
+ name: el.getAttribute("aria-label") ||
418
+ el.textContent?.trim().slice(0, 200),
419
+ tabIndex: el.tabIndex,
420
+ outline: style.outline,
421
+ boxShadow: style.boxShadow,
422
+ visibleIndicator: outline || style.boxShadow !== "none",
423
+ rect: { x: r.x, y: r.y, width: r.width, height: r.height },
424
+ };
425
+ }));
426
+ }
427
+ const stops = tabSequence.filter(Boolean);
428
+ // Safe interactions: scroll to the bottom once and click one load-more
429
+ // control outside any form. Everything else (filters, forms, logins,
430
+ // consent choices, infinite scroll beyond this) is explicitly not done.
431
+ const linkSet = () => page.evaluate(() => [...document.querySelectorAll("a[href]")].map((a) => a.href));
432
+ const before = new Set(await linkSet());
433
+ await page.evaluate(() => window.scrollTo(0, document.documentElement.scrollHeight));
434
+ await page
435
+ .waitForNetworkIdle({ idleTime: 500, timeout: 3000 })
436
+ .catch(() => { });
437
+ const afterScroll = await linkSet();
438
+ const scrollAdded = afterScroll.filter((l) => !before.has(l));
439
+ const loadMore = await page.evaluate((pattern) => {
440
+ const regex = new RegExp(pattern, "i");
441
+ const candidates = [
442
+ ...document.querySelectorAll('button,a[href],[role="button"]'),
443
+ ].filter((el) => {
444
+ const text = (el.textContent ?? "").replace(/\s+/g, " ").trim();
445
+ const r = el.getBoundingClientRect();
446
+ // Links with a destination are followed by the crawl instead.
447
+ return (regex.test(text) &&
448
+ !el.hasAttribute("href") &&
449
+ !el.closest("form") &&
450
+ !(el instanceof HTMLButtonElement && el.form) &&
451
+ !el.hasAttribute("disabled") &&
452
+ el.getAttribute("aria-disabled") !== "true" &&
453
+ r.width > 0 &&
454
+ r.height > 0);
455
+ });
456
+ const el = candidates[0];
457
+ if (!el)
458
+ return { found: false };
459
+ el.scrollIntoView({ block: "center" });
460
+ const text = (el.textContent ?? "").replace(/\s+/g, " ").trim();
461
+ const href = el.href || null;
462
+ el.click();
463
+ return { found: true, text, tag: el.tagName, href };
464
+ }, loadMoreText.source);
465
+ let loadMoreAdded = [];
466
+ if (loadMore.found) {
467
+ await page
468
+ .waitForNetworkIdle({ idleTime: 500, timeout: 4000 })
469
+ .catch(() => { });
470
+ const known = new Set(afterScroll);
471
+ loadMoreAdded = (await linkSet()).filter((l) => !known.has(l));
472
+ }
473
+ const interactions = {
474
+ scroll: {
475
+ performed: true,
476
+ linksBefore: before.size,
477
+ linksAdded: scrollAdded.length,
478
+ },
479
+ loadMore: loadMore.found
480
+ ? {
481
+ found: true,
482
+ text: loadMore.text,
483
+ tag: loadMore.tag,
484
+ href: loadMore.href,
485
+ clicked: true,
486
+ navigated: page.url() !== finalUrl,
487
+ linksAdded: loadMoreAdded.length,
488
+ }
489
+ : { found: false, clicked: false, linksAdded: 0 },
490
+ notPerformed: [
491
+ "form submissions",
492
+ "filter, sort and dropdown controls",
493
+ "sign-in or account actions",
494
+ "cookie consent choices",
495
+ "repeated infinite-scroll loads beyond one pass",
496
+ ],
497
+ };
498
+ const discovered = [
499
+ ...new Set([
500
+ ...rendered.links.flatMap((l) => (l.href ? [l.href] : [])),
501
+ ...scrollAdded,
502
+ ...loadMoreAdded,
503
+ ]),
504
+ ];
505
+ for (const link of discovered) {
506
+ if (!/^https?:/.test(link))
507
+ continue;
508
+ result.candidates.push(...linkCandidates(link, scan, `browser:${url}`));
509
+ }
510
+ for (const declaration of rendered.declarations)
511
+ if (declaration.href) {
512
+ if (/canonical|alternate|next|prev/.test(declaration.rel) &&
513
+ !/markdown/i.test(declaration.type ?? ""))
514
+ result.candidates.push(...linkCandidates(declaration.href, scan, `${declaration.rel}:${url}`));
515
+ else
516
+ result.candidates.push({
517
+ url: declaration.href,
518
+ source: `${declaration.rel}:${url}`,
519
+ kind: "resource",
520
+ });
521
+ }
522
+ // Files the rendered document declares plus the static files the page
523
+ // requested; XHR, fetch and beacon traffic is not a file and is left out.
524
+ const renderedResources = [
525
+ ...rendered.images.flatMap((image) => [
526
+ image.src,
527
+ ...srcsetUrls(image.srcset ?? "").map((entry) => resolveLink(entry, rendered.base)),
528
+ ]),
529
+ ...rendered.scripts.map((script) => script.src),
530
+ ...staticResources,
531
+ ];
532
+ for (const resource of new Set(renderedResources))
533
+ if (resource && /^https?:/.test(resource))
534
+ result.candidates.push({
535
+ url: resource,
536
+ source: `browser-resource:${url}`,
537
+ kind: "resource",
538
+ });
539
+ result.observations.push({
540
+ kind: "rendered",
541
+ key: url,
542
+ data: {
543
+ url,
544
+ finalUrl,
545
+ status: navigation?.status() ?? null,
546
+ title: rendered.title,
547
+ descriptions: rendered.descriptions,
548
+ language: rendered.language,
549
+ canonicals: rendered.canonicals.map((c) => c.href),
550
+ robots: rendered.robots,
551
+ xRobotsTag: responseHeaders["x-robots-tag"] ?? null,
552
+ headings: rendered.headings.slice(0, 200),
553
+ links: compactLinks(rendered.links),
554
+ structuredData: rendered.structuredData.slice(0, 20),
555
+ hreflangs: rendered.hreflangs,
556
+ wordCount: rendered.wordCount,
557
+ textHash: rendered.textHash,
558
+ prices: rendered.prices,
559
+ availability: rendered.availability,
560
+ pagination: rendered.pagination,
561
+ loadMore: rendered.loadMore,
562
+ contacts: rendered.contacts,
563
+ comparison,
564
+ interactions,
565
+ interactionLinks: [...scrollAdded, ...loadMoreAdded],
566
+ representation: "rendered-dom",
567
+ },
568
+ }, {
569
+ kind: "browser-detail",
570
+ key: url,
571
+ data: {
572
+ chromeVersion,
573
+ navigationDevices: scan.options.deepScan
574
+ ? ["mobile", "desktop"]
575
+ : ["mobile"],
576
+ finalUrl,
577
+ console: consoleMessages,
578
+ failedRequests: failures,
579
+ network,
580
+ accessibility: {
581
+ engine: accessibility.testEngine,
582
+ tags: axeTags,
583
+ violations: compactRules(accessibility.violations, 10),
584
+ incomplete: compactRules(accessibility.incomplete, 5),
585
+ passes: accessibility.passes.length,
586
+ // Rule ids that passed: the proof a later scan needs before a
587
+ // formerly violated rule may count as resolved.
588
+ passedRules: accessibility.passes.map((p) => p.id),
589
+ inapplicable: accessibility.inapplicable.length,
590
+ scope: "axe-core rules for WCAG 2.0/2.1 A and AA, the WCAG 2.2 AA rules axe supports, and best practices; automated coverage only, no conformance claim",
591
+ },
592
+ overflow,
593
+ keyboard: {
594
+ controls,
595
+ tabSequence,
596
+ stepLimit: 80,
597
+ stops: stops.length,
598
+ withoutVisibleIndicator: stops.filter((s) => !s.visibleIndicator)
599
+ .length,
600
+ scope: "observed Tab sequence and computed styles; no clicks or submissions, no claim of complete keyboard accessibility",
601
+ },
602
+ limits: {
603
+ console: 200,
604
+ requests: 1000,
605
+ failures: 500,
606
+ overflowElements: 100,
607
+ axeNodesPerRule: 10,
608
+ },
609
+ },
610
+ });
611
+ }
612
+ catch (error) {
613
+ errors.push({ stage: "browser", error: errorMessage(error) });
614
+ }
615
+ finally {
616
+ clearTimeout(watchdog);
617
+ await page?.close().catch(() => { });
618
+ }
619
+ result.observations.push({
620
+ kind: "browser",
621
+ key: url,
622
+ data: {
623
+ status: errors.length ? "partial" : "completed",
624
+ errors,
625
+ blockedNetwork: proxy.blocked.slice(blockedBefore),
626
+ },
627
+ });
628
+ return result;
629
+ }
@@ -0,0 +1,6 @@
1
+ import { HttpClient } from "./network.js";
2
+ import { type Scan, type JobResult, type HttpResult } from "./types.js";
3
+ export declare function sampledForMarkdown(url: string): boolean;
4
+ export declare function resourceEvidence(response: HttpResult): any;
5
+ export declare function collectPage(scan: Scan, url: string, http: HttpClient, probeMarkdown?: boolean): Promise<JobResult>;
6
+ export declare function collectResource(url: string, http: HttpClient, scan?: Pick<Scan, "url" | "options">): Promise<JobResult>;