@rivi-software/agentic-seo-check 0.1.0 → 0.2.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 (144) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +124 -18
  3. package/dist/brand.d.ts +11 -0
  4. package/dist/brand.js +22 -0
  5. package/dist/brand.js.map +1 -0
  6. package/dist/cli.js +174 -86
  7. package/dist/cli.js.map +1 -1
  8. package/dist/core/aeo/audit.d.ts +44 -0
  9. package/dist/core/aeo/audit.js +310 -0
  10. package/dist/core/aeo/audit.js.map +1 -0
  11. package/dist/core/aeo/bots.d.ts +30 -0
  12. package/dist/core/aeo/bots.js +299 -0
  13. package/dist/core/aeo/bots.js.map +1 -0
  14. package/dist/core/aeo/llms-txt.d.ts +8 -0
  15. package/dist/core/aeo/llms-txt.js +88 -0
  16. package/dist/core/aeo/llms-txt.js.map +1 -0
  17. package/dist/core/aeo/page.d.ts +53 -0
  18. package/dist/core/aeo/page.js +312 -0
  19. package/dist/core/aeo/page.js.map +1 -0
  20. package/dist/core/aeo/probe.d.ts +10 -0
  21. package/dist/core/aeo/probe.js +53 -0
  22. package/dist/core/aeo/probe.js.map +1 -0
  23. package/dist/core/aeo/site-rules.d.ts +13 -0
  24. package/dist/core/aeo/site-rules.js +178 -0
  25. package/dist/core/aeo/site-rules.js.map +1 -0
  26. package/dist/core/audit.d.ts +37 -16
  27. package/dist/core/audit.js +389 -249
  28. package/dist/core/audit.js.map +1 -1
  29. package/dist/core/concurrency.d.ts +4 -4
  30. package/dist/core/concurrency.js +33 -23
  31. package/dist/core/concurrency.js.map +1 -1
  32. package/dist/core/crawl.d.ts +9 -5
  33. package/dist/core/crawl.js +271 -123
  34. package/dist/core/crawl.js.map +1 -1
  35. package/dist/core/fetch.d.ts +5 -5
  36. package/dist/core/fetch.js +151 -100
  37. package/dist/core/fetch.js.map +1 -1
  38. package/dist/core/html/answer.d.ts +4 -0
  39. package/dist/core/html/answer.js +198 -0
  40. package/dist/core/html/answer.js.map +1 -0
  41. package/dist/core/html/dom.d.ts +17 -0
  42. package/dist/core/html/dom.js +83 -0
  43. package/dist/core/html/dom.js.map +1 -0
  44. package/dist/core/html/extract.d.ts +2 -2
  45. package/dist/core/html/extract.js +102 -111
  46. package/dist/core/html/extract.js.map +1 -1
  47. package/dist/core/html/structured-data.d.ts +16 -6
  48. package/dist/core/html/structured-data.js +180 -145
  49. package/dist/core/html/structured-data.js.map +1 -1
  50. package/dist/core/html/text.d.ts +14 -14
  51. package/dist/core/html/text.js +110 -107
  52. package/dist/core/html/text.js.map +1 -1
  53. package/dist/core/lighthouse.d.ts +26 -25
  54. package/dist/core/lighthouse.js +181 -105
  55. package/dist/core/lighthouse.js.map +1 -1
  56. package/dist/core/performance-rules.d.ts +2 -2
  57. package/dist/core/performance-rules.js +105 -33
  58. package/dist/core/performance-rules.js.map +1 -1
  59. package/dist/core/render-rules.d.ts +5 -4
  60. package/dist/core/render-rules.js +129 -62
  61. package/dist/core/render-rules.js.map +1 -1
  62. package/dist/core/render.d.ts +2 -2
  63. package/dist/core/render.js +29 -23
  64. package/dist/core/render.js.map +1 -1
  65. package/dist/core/report/markdown.d.ts +4 -2
  66. package/dist/core/report/markdown.js +210 -129
  67. package/dist/core/report/markdown.js.map +1 -1
  68. package/dist/core/robots.d.ts +12 -5
  69. package/dist/core/robots.js +43 -17
  70. package/dist/core/robots.js.map +1 -1
  71. package/dist/core/rules/aeo.d.ts +11 -0
  72. package/dist/core/rules/aeo.js +423 -0
  73. package/dist/core/rules/aeo.js.map +1 -0
  74. package/dist/core/rules/canonical.d.ts +1 -1
  75. package/dist/core/rules/canonical.js +93 -57
  76. package/dist/core/rules/canonical.js.map +1 -1
  77. package/dist/core/rules/content.js +40 -26
  78. package/dist/core/rules/content.js.map +1 -1
  79. package/dist/core/rules/document.js +64 -41
  80. package/dist/core/rules/document.js.map +1 -1
  81. package/dist/core/rules/headings.js +58 -38
  82. package/dist/core/rules/headings.js.map +1 -1
  83. package/dist/core/rules/hreflang.js +70 -38
  84. package/dist/core/rules/hreflang.js.map +1 -1
  85. package/dist/core/rules/images.js +44 -26
  86. package/dist/core/rules/images.js.map +1 -1
  87. package/dist/core/rules/index.d.ts +10 -6
  88. package/dist/core/rules/index.js +43 -36
  89. package/dist/core/rules/index.js.map +1 -1
  90. package/dist/core/rules/indexing.d.ts +2 -2
  91. package/dist/core/rules/indexing.js +48 -26
  92. package/dist/core/rules/indexing.js.map +1 -1
  93. package/dist/core/rules/keyword.js +84 -47
  94. package/dist/core/rules/keyword.js.map +1 -1
  95. package/dist/core/rules/links.js +72 -37
  96. package/dist/core/rules/links.js.map +1 -1
  97. package/dist/core/rules/meta.js +55 -34
  98. package/dist/core/rules/meta.js.map +1 -1
  99. package/dist/core/rules/response.js +75 -41
  100. package/dist/core/rules/response.js.map +1 -1
  101. package/dist/core/rules/rule.d.ts +6 -6
  102. package/dist/core/rules/rule.js +36 -36
  103. package/dist/core/rules/rule.js.map +1 -1
  104. package/dist/core/rules/social.js +86 -45
  105. package/dist/core/rules/social.js.map +1 -1
  106. package/dist/core/rules/structured-data.d.ts +6 -4
  107. package/dist/core/rules/structured-data.js +314 -102
  108. package/dist/core/rules/structured-data.js.map +1 -1
  109. package/dist/core/rules/title.js +48 -33
  110. package/dist/core/rules/title.js.map +1 -1
  111. package/dist/core/score.d.ts +5 -3
  112. package/dist/core/score.js +11 -9
  113. package/dist/core/score.js.map +1 -1
  114. package/dist/core/site-rules.d.ts +3 -3
  115. package/dist/core/site-rules.js +409 -198
  116. package/dist/core/site-rules.js.map +1 -1
  117. package/dist/core/sitemap.d.ts +5 -5
  118. package/dist/core/sitemap.js +109 -64
  119. package/dist/core/sitemap.js.map +1 -1
  120. package/dist/core/static.d.ts +2 -2
  121. package/dist/core/static.js +210 -112
  122. package/dist/core/static.js.map +1 -1
  123. package/dist/core/summary.d.ts +2 -2
  124. package/dist/core/summary.js +11 -6
  125. package/dist/core/summary.js.map +1 -1
  126. package/dist/core/types.d.ts +189 -14
  127. package/dist/core/types.js +15 -8
  128. package/dist/core/types.js.map +1 -1
  129. package/dist/core/url.d.ts +11 -11
  130. package/dist/core/url.js +122 -70
  131. package/dist/core/url.js.map +1 -1
  132. package/dist/index.d.ts +15 -11
  133. package/dist/index.js +11 -9
  134. package/dist/index.js.map +1 -1
  135. package/dist/mcp-bin.js +7 -4
  136. package/dist/mcp-bin.js.map +1 -1
  137. package/dist/mcp.d.ts +3 -3
  138. package/dist/mcp.js +437 -121
  139. package/dist/mcp.js.map +1 -1
  140. package/dist/version.d.ts +0 -1
  141. package/dist/version.js +4 -2
  142. package/dist/version.js.map +1 -1
  143. package/package.json +32 -10
  144. package/skills/seo-audit/SKILL.md +8 -2
@@ -1,32 +1,274 @@
1
- import { promises as fs } from 'node:fs';
2
- import path from 'node:path';
3
- import { crawlSite } from './crawl.js';
4
- import { DEFAULT_USER_AGENT, fetchPage, toResponseInfo } from './fetch.js';
5
- import { extractPage } from './html/extract.js';
6
- import { runLighthouse, LighthouseUnavailableError } from './lighthouse.js';
7
- import { performanceFindings } from './performance-rules.js';
8
- import { compareRender } from './render-rules.js';
9
- import { renderPage, RenderUnavailableError } from './render.js';
10
- import { runPageRules, sortFindings } from './rules/index.js';
11
- import { pageScore, siteScore } from './score.js';
12
- import { runSiteRules } from './site-rules.js';
13
- import { scanDirectory } from './static.js';
14
- import { buildSummary } from './summary.js';
15
- import { countFindings, DEFAULT_THRESHOLDS, } from './types.js';
16
- import { isHttpUrl } from './url.js';
17
- export const DEFAULT_CRAWL = { maxPages: 50, maxDepth: 3, concurrency: 5, delayMs: 0, maxLinkChecks: 200 };
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+ import { crawlSite } from "./crawl.js";
4
+ import { DEFAULT_USER_AGENT, fetchPage, toResponseInfo } from "./fetch.js";
5
+ import { extractPage } from "./html/extract.js";
6
+ import { runLighthouse, LighthouseUnavailableError } from "./lighthouse.js";
7
+ import { performanceFindings } from "./performance-rules.js";
8
+ import { compareRender } from "./render-rules.js";
9
+ import { renderPage, RenderUnavailableError } from "./render.js";
10
+ import { runPageRules, sortFindings } from "./rules/index.js";
11
+ import { pageScore, siteScore } from "./score.js";
12
+ import { runSiteRules } from "./site-rules.js";
13
+ import { scanDirectory } from "./static.js";
14
+ import { buildSummary } from "./summary.js";
15
+ import { countFindings, DEFAULT_THRESHOLDS } from "./types.js";
16
+ import { isHttpUrl } from "./url.js";
17
+ export const DEFAULT_CRAWL = {
18
+ maxPages: 50,
19
+ maxDepth: 3,
20
+ concurrency: 5,
21
+ delayMs: 0,
22
+ maxLinkChecks: 200,
23
+ };
24
+ const toolFinding = (rule, message, url) => ({
25
+ rule,
26
+ severity: "info",
27
+ category: "tool",
28
+ message,
29
+ recommendation: "Install the optional dependency (see message) or run without this option. The rest of the audit is complete.",
30
+ url,
31
+ });
32
+ // The raw HTML is not kept on the audit (too large); re-fetch it for the render comparison.
33
+ const rawHtmlFor = async (audit, options) => {
34
+ const res = await fetchPage(audit.response?.finalUrl ?? audit.url, options.fetch ?? {});
35
+ return res.body ?? "";
36
+ };
37
+ /** Renders the page in Chromium and compares it with the server HTML, on `audit.rendering`. */
38
+ const renderFindings = async (audit, options, url) => {
39
+ options.onProgress?.({
40
+ phase: "render",
41
+ message: `Rendering ${url} with Playwright`,
42
+ });
43
+ try {
44
+ const rendered = await renderPage(url, {
45
+ timeoutMs: Math.max(options.fetch?.timeoutMs ?? 0, 30_000),
46
+ userAgent: options.fetch?.userAgent,
47
+ headers: options.fetch?.headers,
48
+ });
49
+ const rawPage = extractPage(await rawHtmlFor(audit, options), {
50
+ url,
51
+ includeSubdomains: options.includeSubdomains,
52
+ });
53
+ const renderedPage = extractPage(rendered.html, {
54
+ url: rendered.finalUrl,
55
+ siteHost: new URL(url).hostname,
56
+ includeSubdomains: options.includeSubdomains,
57
+ });
58
+ const { comparison, findings } = compareRender(rawPage, renderedPage, rendered.finalUrl, rendered.consoleErrors, audit.response);
59
+ audit.rendering = comparison;
60
+ return findings;
61
+ }
62
+ catch (error) {
63
+ return [
64
+ toolFinding("tool.render-unavailable", error instanceof RenderUnavailableError
65
+ ? error.message
66
+ : `Rendering failed: ${error instanceof Error ? error.message : String(error)}`, url),
67
+ ];
68
+ }
69
+ };
70
+ /** Runs Lighthouse on the page, on `audit.performance`. */
71
+ const lighthouseFindings = async (audit, options, url) => {
72
+ options.onProgress?.({
73
+ phase: "lighthouse",
74
+ message: `Running Lighthouse (${options.formFactor ?? "mobile"}) on ${url}`,
75
+ });
76
+ try {
77
+ const perf = await runLighthouse(url, {
78
+ formFactor: options.formFactor ?? "mobile",
79
+ userAgent: options.fetch?.userAgent,
80
+ headers: options.fetch?.headers,
81
+ });
82
+ audit.performance = perf;
83
+ return performanceFindings(perf);
84
+ }
85
+ catch (error) {
86
+ return [
87
+ toolFinding("tool.lighthouse-unavailable", error instanceof LighthouseUnavailableError
88
+ ? error.message
89
+ : `Lighthouse failed: ${error instanceof Error ? error.message : String(error)}`, url),
90
+ ];
91
+ }
92
+ };
93
+ const enrichPage = async (audit, options) => {
94
+ const url = audit.response?.finalUrl ?? audit.url;
95
+ const extra = [];
96
+ if (options.render) {
97
+ extra.push(...(await renderFindings(audit, options, url)));
98
+ }
99
+ if (options.lighthouse) {
100
+ extra.push(...(await lighthouseFindings(audit, options, url)));
101
+ }
102
+ if (extra.length) {
103
+ const filtered = options.includeInfo === false
104
+ ? extra.filter((f) => f.severity !== "info")
105
+ : extra;
106
+ audit.findings = sortFindings([...audit.findings, ...filtered]);
107
+ audit.counts = countFindings(audit.findings);
108
+ audit.score = pageScore(audit.counts);
109
+ }
110
+ };
111
+ const auditRecord = (record, source, options) => {
112
+ const thresholds = { ...DEFAULT_THRESHOLDS, ...options.thresholds };
113
+ let findings = runPageRules({
114
+ page: record.page,
115
+ source,
116
+ response: record.response,
117
+ keyword: options.keyword,
118
+ thresholds,
119
+ depth: record.depth,
120
+ }, { disable: new Set(options.disableRules) });
121
+ if (options.includeInfo === false) {
122
+ findings = findings.filter((f) => f.severity !== "info");
123
+ }
124
+ const audit = {
125
+ schemaVersion: 1,
126
+ kind: "page",
127
+ target: record.file ?? record.requestedUrl,
128
+ url: record.file ?? record.url,
129
+ source,
130
+ auditedAt: new Date().toISOString(),
131
+ summary: buildSummary(record.page, record.response),
132
+ findings,
133
+ counts: countFindings(findings),
134
+ score: 0,
135
+ depth: record.depth,
136
+ };
137
+ if (record.response) {
138
+ audit.response = record.response;
139
+ }
140
+ audit.score = pageScore(audit.counts);
141
+ return audit;
142
+ };
143
+ export const rollupIssues = (pages) => {
144
+ const map = new Map();
145
+ for (const page of pages) {
146
+ const seenHere = new Set();
147
+ for (const f of page.findings) {
148
+ if (seenHere.has(f.rule)) {
149
+ continue;
150
+ }
151
+ seenHere.add(f.rule);
152
+ let entry = map.get(f.rule);
153
+ if (!entry) {
154
+ entry = {
155
+ rule: f.rule,
156
+ severity: f.severity,
157
+ category: f.category,
158
+ pages: 0,
159
+ message: f.message,
160
+ recommendation: f.recommendation,
161
+ examples: [],
162
+ urls: new Set(),
163
+ };
164
+ map.set(f.rule, entry);
165
+ }
166
+ entry.pages += 1;
167
+ entry.urls.add(page.url);
168
+ }
169
+ }
170
+ const rank = { error: 0, warning: 1, info: 2 };
171
+ return [...map.values()]
172
+ .toSorted((a, b) => rank[a.severity] - rank[b.severity] || b.pages - a.pages)
173
+ .map(({ urls, ...rest }) => ({
174
+ ...rest,
175
+ examples: [...urls].slice(0, 10),
176
+ }));
177
+ };
178
+ export const recomputeSite = (site) => {
179
+ const counts = countFindings(site.siteFindings);
180
+ for (const p of site.pages) {
181
+ counts.error += p.counts.error;
182
+ counts.warning += p.counts.warning;
183
+ counts.info += p.counts.info;
184
+ }
185
+ site.counts = counts;
186
+ site.issues = rollupIssues(site.pages);
187
+ site.score = siteScore(site.pages, site.siteFindings);
188
+ };
189
+ const buildSiteAudit = (result, source, target, options) => {
190
+ options.onProgress?.({
191
+ phase: "rules",
192
+ message: `Evaluating rules for ${result.records.length} pages`,
193
+ });
194
+ const thresholds = { ...DEFAULT_THRESHOLDS, ...options.thresholds };
195
+ const pages = result.records.map((record) => auditRecord(record, source === "directory" ? "file" : "url", options));
196
+ let siteFindings = runSiteRules({
197
+ startUrl: result.startUrl,
198
+ source,
199
+ pages: result.records,
200
+ linkChecks: result.linkChecks,
201
+ linkSources: result.linkSources,
202
+ externalChecks: result.externalChecks,
203
+ externalSources: result.externalSources,
204
+ robots: result.robots,
205
+ robotsAllows: result.robotsAllows,
206
+ blockedByRobots: result.blockedByRobots,
207
+ sitemaps: result.sitemaps,
208
+ probes: result.probes,
209
+ crawl: result.stats,
210
+ thresholds,
211
+ pathOnly: source === "directory",
212
+ unlinkedPages: result.unlinkedPages,
213
+ });
214
+ const disabled = new Set(options.disableRules);
215
+ siteFindings = sortFindings(siteFindings.filter((f) => !disabled.has(f.rule)));
216
+ if (options.includeInfo === false) {
217
+ siteFindings = siteFindings.filter((f) => f.severity !== "info");
218
+ }
219
+ const site = {
220
+ schemaVersion: 1,
221
+ kind: "site",
222
+ target,
223
+ source,
224
+ auditedAt: new Date().toISOString(),
225
+ crawl: result.stats,
226
+ robotsTxt: result.robots,
227
+ sitemaps: { ...result.sitemaps, urls: result.sitemaps.urls.slice(0, 200) },
228
+ pages: pages.toSorted((a, b) => (a.depth ?? 0) - (b.depth ?? 0) || a.score - b.score),
229
+ siteFindings,
230
+ issues: [],
231
+ counts: { error: 0, warning: 0, info: 0 },
232
+ score: 0,
233
+ };
234
+ recomputeSite(site);
235
+ return site;
236
+ };
237
+ /** Keeps only each page's summary when the caller asked for `pageDetail: "summary"`. */
238
+ const applyPageDetail = (site, options) => {
239
+ if (options.pageDetail === "summary") {
240
+ site.pages = site.pages.map((p) => ({
241
+ ...p,
242
+ findings: [],
243
+ summary: { ...p.summary, headings: [], openGraph: {} },
244
+ }));
245
+ }
246
+ return site;
247
+ };
18
248
  /** Audit an HTML string. `url` is used to resolve links and for display. */
19
- export async function auditHtml(html, input = {}, options = {}) {
20
- const url = input.url ?? options.baseUrl ?? 'http://localhost/';
21
- const page = extractPage(html, { url, includeSubdomains: options.includeSubdomains });
22
- const source = input.source ?? 'html';
23
- const thresholds = { ...DEFAULT_THRESHOLDS, ...(options.thresholds ?? {}) };
24
- let findings = runPageRules({ page, source, response: input.response, keyword: options.keyword, thresholds, depth: input.depth }, { disable: new Set(options.disableRules ?? []) });
25
- if (options.includeInfo === false)
26
- findings = findings.filter((f) => f.severity !== 'info');
249
+ // oxlint-disable-next-line eslint/require-await -- public API: it returns a promise like the other audit functions, so a throw reaches callers as a rejection
250
+ export const auditHtml = async (html, input = {}, options = {}) => {
251
+ const url = input.url ?? options.baseUrl ?? "http://localhost/";
252
+ const page = extractPage(html, {
253
+ url,
254
+ includeSubdomains: options.includeSubdomains,
255
+ });
256
+ const source = input.source ?? "html";
257
+ const thresholds = { ...DEFAULT_THRESHOLDS, ...options.thresholds };
258
+ let findings = runPageRules({
259
+ page,
260
+ source,
261
+ response: input.response,
262
+ keyword: options.keyword,
263
+ thresholds,
264
+ depth: input.depth,
265
+ }, { disable: new Set(options.disableRules) });
266
+ if (options.includeInfo === false) {
267
+ findings = findings.filter((f) => f.severity !== "info");
268
+ }
27
269
  const audit = {
28
270
  schemaVersion: 1,
29
- kind: 'page',
271
+ kind: "page",
30
272
  target: input.target ?? url,
31
273
  url: input.response?.finalUrl ?? url,
32
274
  source,
@@ -36,25 +278,27 @@ export async function auditHtml(html, input = {}, options = {}) {
36
278
  counts: countFindings(findings),
37
279
  score: 0,
38
280
  };
39
- if (input.response)
281
+ if (input.response) {
40
282
  audit.response = input.response;
41
- if (input.depth !== undefined)
283
+ }
284
+ if (input.depth !== undefined) {
42
285
  audit.depth = input.depth;
286
+ }
43
287
  audit.score = pageScore(audit.counts);
44
288
  return audit;
45
- }
289
+ };
46
290
  /** Fetch and audit one URL. Optionally render and/or run Lighthouse. */
47
- export async function auditUrl(url, options = {}) {
291
+ export const auditUrl = async (url, options = {}) => {
48
292
  const res = await fetchPage(url, options.fetch ?? {});
49
293
  const response = toResponseInfo(res);
50
- const audit = await auditHtml(res.body ?? '', { url: res.finalUrl, response, source: 'url', target: url }, options);
294
+ const audit = await auditHtml(res.body ?? "", { url: res.finalUrl, response, source: "url", target: url }, options);
51
295
  if (res.error) {
52
296
  audit.findings.unshift({
53
- rule: 'response.fetch-failed',
54
- severity: 'error',
55
- category: 'response',
297
+ rule: "response.fetch-failed",
298
+ severity: "error",
299
+ category: "response",
56
300
  message: `Could not fetch ${url}: ${res.error}`,
57
- recommendation: 'Check that the URL is reachable from this machine (DNS, TLS, firewall, dev server running) and that it responds within the timeout.',
301
+ recommendation: "Check that the URL is reachable from this machine (DNS, TLS, firewall, dev server running) and that it responds within the timeout.",
58
302
  url,
59
303
  evidence: { error: res.error, redirectChain: res.redirectChain },
60
304
  });
@@ -64,17 +308,17 @@ export async function auditUrl(url, options = {}) {
64
308
  }
65
309
  await enrichPage(audit, options);
66
310
  return audit;
67
- }
311
+ };
68
312
  /** Audit a single .html file from disk. */
69
- export async function auditFile(filePath, options = {}) {
70
- const html = await fs.readFile(filePath, 'utf8');
71
- const base = options.baseUrl ?? 'http://localhost/';
72
- const url = new URL(`/${path.basename(filePath)}`, base.endsWith('/') ? base : `${base}/`).href;
73
- return auditHtml(html, { url, source: 'file', target: filePath }, options);
74
- }
313
+ export const auditFile = async (filePath, options = {}) => {
314
+ const html = await fs.readFile(filePath, "utf-8");
315
+ const base = options.baseUrl ?? "http://localhost/";
316
+ const url = new URL(`/${path.basename(filePath)}`, base.endsWith("/") ? base : `${base}/`).href;
317
+ return auditHtml(html, { url, source: "file", target: filePath }, options);
318
+ };
75
319
  /** Crawl a site and audit every page plus site-level checks. */
76
- export async function auditSite(startUrl, options = {}) {
77
- const fetchOpts = { ...(options.fetch ?? {}) };
320
+ export const auditSite = async (startUrl, options = {}) => {
321
+ const fetchOpts = { ...options.fetch };
78
322
  fetchOpts.userAgent ??= DEFAULT_USER_AGENT;
79
323
  const internal = {
80
324
  maxPages: options.maxPages ?? DEFAULT_CRAWL.maxPages,
@@ -85,247 +329,143 @@ export async function auditSite(startUrl, options = {}) {
85
329
  includeSubdomains: options.includeSubdomains ?? false,
86
330
  checkExternalLinks: options.checkExternalLinks ?? false,
87
331
  maxLinkChecks: options.maxLinkChecks ?? DEFAULT_CRAWL.maxLinkChecks,
332
+ // oxlint-disable-next-line eslint/require-unicode-regexp -- the user's own patterns, in JavaScript's default syntax; the u flag would reject some of them
88
333
  includePatterns: (options.includePatterns ?? []).map((p) => new RegExp(p)),
334
+ // oxlint-disable-next-line eslint/require-unicode-regexp -- the user's own patterns, in JavaScript's default syntax; the u flag would reject some of them
89
335
  excludePatterns: (options.excludePatterns ?? []).map((p) => new RegExp(p)),
90
336
  fetch: fetchOpts,
91
337
  onProgress: options.onProgress,
92
338
  };
93
339
  const result = await crawlSite(startUrl, internal);
94
- const site = await buildSiteAudit(result, 'crawl', startUrl, options);
340
+ const site = buildSiteAudit(result, "crawl", startUrl, options);
95
341
  // Render/Lighthouse are expensive: apply them to the start page only.
96
342
  const startPage = site.pages.find((p) => p.depth === 0) ?? site.pages[0];
97
343
  if (startPage && (options.render || options.lighthouse)) {
98
344
  await enrichPage(startPage, options);
99
345
  recomputeSite(site);
100
346
  }
101
- return shapeSite(site, options);
102
- }
347
+ return applyPageDetail(site, options);
348
+ };
103
349
  /** Audit a directory of built HTML files (dist/, out/, build/). */
104
- export async function auditDirectory(dir, options = {}) {
350
+ export const auditDirectory = async (dir, options = {}) => {
105
351
  const result = await scanDirectory(dir, {
106
- baseUrl: options.baseUrl ?? 'http://localhost/',
352
+ baseUrl: options.baseUrl ?? "http://localhost/",
107
353
  includeSubdomains: options.includeSubdomains ?? false,
108
354
  maxPages: options.maxPages ?? 500,
109
355
  userAgent: options.fetch?.userAgent,
110
356
  });
111
- const site = await buildSiteAudit(result, 'directory', dir, options);
112
- return shapeSite(site, options);
113
- }
357
+ const site = buildSiteAudit(result, "directory", dir, options);
358
+ return applyPageDetail(site, options);
359
+ };
114
360
  /**
115
- * Auto-detect the target type: http(s) URL, directory, .html file, or "-" for stdin.
116
- * `crawl` forces a site crawl for URLs.
361
+ * What a target names: "-" for stdin, an http(s) URL, a bare host such as `example.com/page` or
362
+ * `localhost:3000`, a directory or a file.
117
363
  */
118
- export async function auditTarget(target, options = {}) {
119
- if (target === '-' || options.stdinHtml !== undefined) {
120
- const html = options.stdinHtml ?? (await readStdin());
121
- return auditHtml(html, { url: options.baseUrl, source: 'html', target: 'stdin' }, options);
364
+ export const resolveTarget = async (target) => {
365
+ if (target === "-") {
366
+ return { kind: "stdin" };
367
+ }
368
+ if (isHttpUrl(target)) {
369
+ return { kind: "url", url: target };
122
370
  }
123
- if (isHttpUrl(target))
124
- return options.crawl ? auditSite(target, options) : auditUrl(target, options);
125
- if (/^localhost(:\d+)?(\/|$)/i.test(target) || /^[\w.-]+\.[a-z]{2,}(\/|$)/i.test(target)) {
126
- const url = `http${/^localhost/i.test(target) ? '' : 's'}://${target}`;
127
- return options.crawl ? auditSite(url, options) : auditUrl(url, options);
371
+ if (/^localhost(?::\d+)?(?:\/|$)/iu.test(target) ||
372
+ /^[\w.-]+\.[a-z]{2,}(?:\/|$)/iu.test(target)) {
373
+ return {
374
+ kind: "url",
375
+ url: `http${/^localhost/iu.test(target) ? "" : "s"}://${target}`,
376
+ };
128
377
  }
129
378
  const stat = await fs.stat(target).catch(() => null);
130
- if (!stat)
379
+ if (!stat) {
131
380
  throw new Error(`Target "${target}" is not a URL, file or directory.`);
132
- if (stat.isDirectory())
133
- return auditDirectory(target, options);
134
- return auditFile(target, options);
135
- }
136
- async function enrichPage(audit, options) {
137
- const url = audit.response?.finalUrl ?? audit.url;
138
- const extra = [];
139
- if (options.render) {
140
- options.onProgress?.({ phase: 'render', message: `Rendering ${url} with Playwright` });
141
- try {
142
- const rendered = await renderPage(url, { timeoutMs: Math.max(options.fetch?.timeoutMs ?? 0, 30_000), userAgent: options.fetch?.userAgent, headers: options.fetch?.headers });
143
- const rawPage = extractPage(await rawHtmlFor(audit, options), { url, includeSubdomains: options.includeSubdomains });
144
- const renderedPage = extractPage(rendered.html, { url: rendered.finalUrl, siteHost: new URL(url).hostname, includeSubdomains: options.includeSubdomains });
145
- const { comparison, findings } = compareRender(rawPage, renderedPage, rendered.finalUrl, rendered.consoleErrors, audit.response);
146
- audit.rendering = comparison;
147
- extra.push(...findings);
381
+ }
382
+ return stat.isDirectory()
383
+ ? { kind: "directory", path: target }
384
+ : { kind: "file", path: target };
385
+ };
386
+ /** HTML piped to the process. */
387
+ export const readStdin = async () => {
388
+ const chunks = [];
389
+ for await (const chunk of process.stdin) {
390
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
391
+ }
392
+ return Buffer.concat(chunks).toString("utf-8");
393
+ };
394
+ /**
395
+ * Auto-detect the target type: http(s) URL, directory, .html file, or "-" for stdin.
396
+ * `crawl` forces a site crawl for URLs.
397
+ */
398
+ export const auditTarget = async (target, options = {}) => {
399
+ const resolved = options.stdinHtml === undefined
400
+ ? await resolveTarget(target)
401
+ : { kind: "stdin" };
402
+ switch (resolved.kind) {
403
+ case "stdin": {
404
+ const html = options.stdinHtml ?? (await readStdin());
405
+ return auditHtml(html, { url: options.baseUrl, source: "html", target: "stdin" }, options);
148
406
  }
149
- catch (err) {
150
- extra.push(toolFinding('tool.render-unavailable', err instanceof RenderUnavailableError ? err.message : `Rendering failed: ${err.message}`, url));
407
+ case "url": {
408
+ return options.crawl
409
+ ? auditSite(resolved.url, options)
410
+ : auditUrl(resolved.url, options);
151
411
  }
152
- }
153
- if (options.lighthouse) {
154
- options.onProgress?.({ phase: 'lighthouse', message: `Running Lighthouse (${options.formFactor ?? 'mobile'}) on ${url}` });
155
- try {
156
- const perf = await runLighthouse(url, { formFactor: options.formFactor ?? 'mobile', userAgent: options.fetch?.userAgent, headers: options.fetch?.headers });
157
- audit.performance = perf;
158
- extra.push(...performanceFindings(perf));
412
+ case "directory": {
413
+ return auditDirectory(resolved.path, options);
159
414
  }
160
- catch (err) {
161
- extra.push(toolFinding('tool.lighthouse-unavailable', err instanceof LighthouseUnavailableError ? err.message : `Lighthouse failed: ${err.message}`, url));
415
+ default: {
416
+ return auditFile(resolved.path, options);
162
417
  }
163
418
  }
164
- if (extra.length) {
165
- const filtered = options.includeInfo === false ? extra.filter((f) => f.severity !== 'info') : extra;
166
- audit.findings = sortFindings([...audit.findings, ...filtered]);
167
- audit.counts = countFindings(audit.findings);
168
- audit.score = pageScore(audit.counts);
419
+ };
420
+ /** Keeps the first three items of every evidence list. */
421
+ export const trimFinding = (f) => {
422
+ if (!f.evidence) {
423
+ return f;
169
424
  }
170
- }
171
- // The raw HTML is not kept on the audit (too large); re-fetch it for the render comparison.
172
- async function rawHtmlFor(audit, options) {
173
- const res = await fetchPage(audit.response?.finalUrl ?? audit.url, options.fetch ?? {});
174
- return res.body ?? '';
175
- }
176
- function toolFinding(rule, message, url) {
177
- return {
178
- rule,
179
- severity: 'info',
180
- category: 'tool',
181
- message,
182
- recommendation: 'Install the optional dependency (see message) or run without this option. The rest of the audit is complete.',
183
- url,
184
- };
185
- }
186
- async function buildSiteAudit(result, source, target, options) {
187
- options.onProgress?.({ phase: 'rules', message: `Evaluating rules for ${result.records.length} pages` });
188
- const thresholds = { ...DEFAULT_THRESHOLDS, ...(options.thresholds ?? {}) };
189
- const pages = [];
190
- for (const record of result.records) {
191
- const audit = await auditRecord(record, source === 'directory' ? 'file' : 'url', options);
192
- pages.push(audit);
425
+ const evidence = {};
426
+ for (const [k, v] of Object.entries(f.evidence)) {
427
+ evidence[k] = Array.isArray(v) ? v.slice(0, 3) : v;
193
428
  }
194
- let siteFindings = runSiteRules({
195
- startUrl: result.startUrl,
196
- source,
197
- pages: result.records,
198
- linkChecks: result.linkChecks,
199
- linkSources: result.linkSources,
200
- externalChecks: result.externalChecks,
201
- externalSources: result.externalSources,
202
- robots: result.robots,
203
- robotsAllows: result.robotsAllows,
204
- blockedByRobots: result.blockedByRobots,
205
- sitemaps: result.sitemaps,
206
- probes: result.probes,
207
- crawl: result.stats,
208
- thresholds,
209
- pathOnly: source === 'directory',
210
- unlinkedPages: result.unlinkedPages,
211
- });
212
- const disabled = new Set(options.disableRules ?? []);
213
- siteFindings = sortFindings(siteFindings.filter((f) => !disabled.has(f.rule)));
214
- if (options.includeInfo === false)
215
- siteFindings = siteFindings.filter((f) => f.severity !== 'info');
216
- const site = {
217
- schemaVersion: 1,
218
- kind: 'site',
219
- target,
220
- source,
221
- auditedAt: new Date().toISOString(),
222
- crawl: result.stats,
223
- robotsTxt: result.robots,
224
- sitemaps: { ...result.sitemaps, urls: result.sitemaps.urls.slice(0, 200) },
225
- pages: pages.sort((a, b) => (a.depth ?? 0) - (b.depth ?? 0) || a.score - b.score),
226
- siteFindings,
227
- issues: [],
228
- counts: { error: 0, warning: 0, info: 0 },
229
- score: 0,
429
+ return { ...f, evidence };
430
+ };
431
+ const trimPage = (p) => {
432
+ const { performance, rendering, ...rest } = p;
433
+ const out = {
434
+ ...rest,
435
+ summary: {
436
+ ...p.summary,
437
+ headings: p.summary.headings.slice(0, 10),
438
+ openGraph: {},
439
+ },
440
+ findings: p.findings.map(trimFinding),
230
441
  };
231
- recomputeSite(site);
232
- return site;
233
- }
234
- async function auditRecord(record, source, options) {
235
- const thresholds = { ...DEFAULT_THRESHOLDS, ...(options.thresholds ?? {}) };
236
- let findings = runPageRules({ page: record.page, source, response: record.response, keyword: options.keyword, thresholds, depth: record.depth }, { disable: new Set(options.disableRules ?? []) });
237
- if (options.includeInfo === false)
238
- findings = findings.filter((f) => f.severity !== 'info');
239
- const audit = {
240
- schemaVersion: 1,
241
- kind: 'page',
242
- target: record.file ?? record.requestedUrl,
243
- url: record.file ?? record.url,
244
- source,
245
- auditedAt: new Date().toISOString(),
246
- summary: buildSummary(record.page, record.response),
247
- findings,
248
- counts: countFindings(findings),
249
- score: 0,
250
- depth: record.depth,
251
- };
252
- if (record.response)
253
- audit.response = record.response;
254
- audit.score = pageScore(audit.counts);
255
- return audit;
256
- }
257
- export function recomputeSite(site) {
258
- const counts = countFindings(site.siteFindings);
259
- for (const p of site.pages) {
260
- counts.error += p.counts.error;
261
- counts.warning += p.counts.warning;
262
- counts.info += p.counts.info;
442
+ if (p.response) {
443
+ out.response = { ...p.response, headers: {} };
263
444
  }
264
- site.counts = counts;
265
- site.issues = rollupIssues(site.pages);
266
- site.score = siteScore(site.pages, site.siteFindings);
267
- }
268
- export function rollupIssues(pages) {
269
- const map = new Map();
270
- for (const page of pages) {
271
- const seenHere = new Set();
272
- for (const f of page.findings) {
273
- if (seenHere.has(f.rule))
274
- continue;
275
- seenHere.add(f.rule);
276
- let entry = map.get(f.rule);
277
- if (!entry) {
278
- entry = { rule: f.rule, severity: f.severity, category: f.category, pages: 0, message: f.message, recommendation: f.recommendation, examples: [], urls: new Set() };
279
- map.set(f.rule, entry);
280
- }
281
- entry.pages++;
282
- entry.urls.add(page.url);
283
- }
445
+ if (performance) {
446
+ out.performance = {
447
+ ...performance,
448
+ opportunities: performance.opportunities.slice(0, 5),
449
+ agenticBrowsingAudits: [],
450
+ failedSeoAudits: performance.failedSeoAudits.slice(0, 5),
451
+ };
284
452
  }
285
- const rank = { error: 0, warning: 1, info: 2 };
286
- return [...map.values()]
287
- .sort((a, b) => rank[a.severity] - rank[b.severity] || b.pages - a.pages)
288
- .map(({ urls, ...rest }) => ({ ...rest, examples: [...urls].slice(0, 10) }));
289
- }
290
- function shapeSite(site, options) {
291
- if (options.pageDetail === 'summary') {
292
- site.pages = site.pages.map((p) => ({ ...p, findings: [], summary: { ...p.summary, headings: [], openGraph: {} } }));
453
+ if (rendering) {
454
+ out.rendering = {
455
+ ...rendering,
456
+ consoleErrors: rendering.consoleErrors.slice(0, 3),
457
+ };
293
458
  }
294
- return site;
295
- }
296
- async function readStdin() {
297
- const chunks = [];
298
- for await (const chunk of process.stdin)
299
- chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
300
- return Buffer.concat(chunks).toString('utf8');
301
- }
459
+ return out;
460
+ };
302
461
  /** Drop bulky, low-value fields to save tokens when the result is consumed by an LLM. */
303
- export function compactAudit(audit) {
304
- const trimFinding = (f) => {
305
- if (!f.evidence)
306
- return f;
307
- const evidence = {};
308
- for (const [k, v] of Object.entries(f.evidence))
309
- evidence[k] = Array.isArray(v) ? v.slice(0, 3) : v;
310
- return { ...f, evidence };
311
- };
312
- const trimPage = (p) => {
313
- const { performance, rendering, ...rest } = p;
314
- const out = {
315
- ...rest,
316
- summary: { ...p.summary, headings: p.summary.headings.slice(0, 10), openGraph: {} },
317
- findings: p.findings.map(trimFinding),
318
- };
319
- if (p.response)
320
- out.response = { ...p.response, headers: {} };
321
- if (performance)
322
- out.performance = { ...performance, opportunities: performance.opportunities.slice(0, 5), agenticBrowsingAudits: [], failedSeoAudits: performance.failedSeoAudits.slice(0, 5) };
323
- if (rendering)
324
- out.rendering = { ...rendering, consoleErrors: rendering.consoleErrors.slice(0, 3) };
325
- return out;
326
- };
327
- if (audit.kind === 'page')
462
+ export const compactAudit = (audit) => {
463
+ if (audit.kind === "page") {
464
+ // SAFETY: T is the page audit here (kind is "page"), and trimPage returns a page audit with
465
+ // every field it was given.
328
466
  return trimPage(audit);
467
+ }
468
+ // SAFETY: T is the site audit here; the spread keeps every field and only shortens lists.
329
469
  return {
330
470
  ...audit,
331
471
  robotsTxt: audit.robotsTxt ? { ...audit.robotsTxt, text: null } : null,
@@ -333,5 +473,5 @@ export function compactAudit(audit) {
333
473
  pages: audit.pages.map(trimPage),
334
474
  siteFindings: audit.siteFindings.map(trimFinding),
335
475
  };
336
- }
476
+ };
337
477
  //# sourceMappingURL=audit.js.map