@nurkamol/seo-audit 1.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/action.yml ADDED
@@ -0,0 +1,194 @@
1
+ name: Full-site SEO Audit
2
+ description: Crawl a sitemap and check every page for SEO, metadata and structured-data problems that single-page graders miss
3
+ author: nurkamol
4
+
5
+ branding:
6
+ icon: search
7
+ color: orange
8
+
9
+ inputs:
10
+ url:
11
+ description: Site to audit, or a sitemap URL
12
+ required: true
13
+ baseline:
14
+ description: >
15
+ Path to a committed baseline file. When set, only findings new since that
16
+ baseline fail the run — the backlog you already know about is tolerated.
17
+ required: false
18
+ fail-on:
19
+ description: 'error | warn | new | never'
20
+ required: false
21
+ default: error
22
+ config:
23
+ description: Path to seo-audit.config.json
24
+ required: false
25
+ limit:
26
+ description: Maximum pages to crawl
27
+ required: false
28
+ psi:
29
+ description: >
30
+ Comma-separated pages to measure with PageSpeed Insights. Slow (~12s
31
+ each). A path glob names a section — /journal/** is every crawled page
32
+ under it, sampled. Set the PSI_API_KEY env var from a secret to lift the
33
+ quota.
34
+ required: false
35
+ check-external:
36
+ description: >
37
+ Also check links pointing off the site. Off by default because other
38
+ people's servers rate-limit and bot-block; only a 404, a 410 or no answer
39
+ at all is ever reported. Set to any non-empty value.
40
+ required: false
41
+ verbose:
42
+ description: >
43
+ Log every request as it happens. Useful when a run is slow or a site
44
+ stalls and the job log is all you have to go on. Set to any non-empty
45
+ value.
46
+ required: false
47
+ psi-sample:
48
+ description: >
49
+ Pages measured per section glob (default 3). The report says how many of
50
+ the matched pages were left unmeasured, so a sample never reads as a
51
+ clean bill of health for the whole section.
52
+ required: false
53
+ settle:
54
+ description: >
55
+ Seconds to wait for a deploy to reach every edge before crawling. A CDN
56
+ serves a fresh deploy unevenly for a minute or two, and auditing during
57
+ the rollout produces a snapshot that is wrong in a confusing way.
58
+ required: false
59
+ browser:
60
+ description: >
61
+ Crawl as a real browser or a search crawler instead of as this tool:
62
+ chrome, firefox, safari, edge, googlebot, googlebot-desktop, bingbot.
63
+ Googlebot is what Google is served, which is not always what a person
64
+ gets; a browser is what a host blocking crawlers will answer at all.
65
+ required: false
66
+ os:
67
+ description: >
68
+ The system that browser is running on — macos, windows, linux, android,
69
+ ios. Defaults to the runner's. Ignored for the crawlers, whose user agent
70
+ names no machine.
71
+ required: false
72
+ report:
73
+ description: Where to write the HTML report
74
+ required: false
75
+ default: seo-audit.html
76
+ json:
77
+ description: Where to write the JSON report
78
+ required: false
79
+ default: seo-audit.json
80
+ csv:
81
+ description: >
82
+ Where to write the findings as a spreadsheet, one row per finding. Off
83
+ unless a path is given.
84
+ required: false
85
+ comment:
86
+ description: Post the findings as a sticky comment on the pull request
87
+ required: false
88
+ default: 'false'
89
+ github-token:
90
+ description: Token used to post the PR comment
91
+ required: false
92
+ default: ${{ github.token }}
93
+
94
+ outputs:
95
+ errors:
96
+ description: Number of error-level findings
97
+ value: ${{ steps.run.outputs.errors }}
98
+ warnings:
99
+ description: Number of warning-level findings
100
+ value: ${{ steps.run.outputs.warnings }}
101
+ notes:
102
+ description: Number of info-level findings
103
+ value: ${{ steps.run.outputs.notes }}
104
+ pages:
105
+ description: Number of pages crawled
106
+ value: ${{ steps.run.outputs.pages }}
107
+ report:
108
+ description: Path to the HTML report
109
+ value: ${{ inputs.report }}
110
+
111
+ runs:
112
+ using: composite
113
+ steps:
114
+ - uses: actions/setup-node@v4
115
+ with:
116
+ node-version: 22
117
+
118
+ - id: run
119
+ shell: bash
120
+ env:
121
+ # Runs the code shipped with *this* version of the action, not whatever
122
+ # is on main — otherwise pinning @v1 would pin nothing.
123
+ SEO_AUDIT_BIN: ${{ github.action_path }}/bin/seo-audit.mjs
124
+ INPUT_URL: ${{ inputs.url }}
125
+ INPUT_BASELINE: ${{ inputs.baseline }}
126
+ INPUT_CONFIG: ${{ inputs.config }}
127
+ INPUT_LIMIT: ${{ inputs.limit }}
128
+ INPUT_PSI: ${{ inputs.psi }}
129
+ INPUT_PSI_SAMPLE: ${{ inputs.psi-sample }}
130
+ INPUT_VERBOSE: ${{ inputs.verbose }}
131
+ INPUT_CHECK_EXTERNAL: ${{ inputs.check-external }}
132
+ INPUT_SETTLE: ${{ inputs.settle }}
133
+ INPUT_BROWSER: ${{ inputs.browser }}
134
+ INPUT_OS: ${{ inputs.os }}
135
+ INPUT_FAIL_ON: ${{ inputs.fail-on }}
136
+ INPUT_REPORT: ${{ inputs.report }}
137
+ INPUT_JSON: ${{ inputs.json }}
138
+ INPUT_CSV: ${{ inputs.csv }}
139
+ run: |
140
+ args=("$INPUT_URL" --html "$INPUT_REPORT" --json "$INPUT_JSON" --fail-on "$INPUT_FAIL_ON")
141
+ [ -n "$INPUT_BASELINE" ] && args+=(--baseline "$INPUT_BASELINE")
142
+ [ -n "$INPUT_CONFIG" ] && args+=(--config "$INPUT_CONFIG")
143
+ [ -n "$INPUT_LIMIT" ] && args+=(--limit "$INPUT_LIMIT")
144
+ [ -n "$INPUT_PSI" ] && args+=(--psi "$INPUT_PSI")
145
+ [ -n "$INPUT_PSI_SAMPLE" ] && args+=(--psi-sample "$INPUT_PSI_SAMPLE")
146
+ [ -n "$INPUT_VERBOSE" ] && args+=(--verbose)
147
+ [ -n "$INPUT_CHECK_EXTERNAL" ] && args+=(--check-external)
148
+ [ -n "$INPUT_SETTLE" ] && args+=(--settle "$INPUT_SETTLE")
149
+ [ -n "$INPUT_BROWSER" ] && args+=(--browser "$INPUT_BROWSER")
150
+ [ -n "$INPUT_OS" ] && args+=(--os "$INPUT_OS")
151
+ [ -n "$INPUT_CSV" ] && args+=(--csv "$INPUT_CSV")
152
+
153
+ node "$SEO_AUDIT_BIN" "${args[@]}"
154
+ status=$?
155
+
156
+ node -e '
157
+ const fs = require("fs");
158
+ const d = JSON.parse(fs.readFileSync(process.env.INPUT_JSON, "utf8"));
159
+ const n = (l) => d.findings.filter((f) => f.level === l).length;
160
+ fs.appendFileSync(process.env.GITHUB_OUTPUT,
161
+ `errors=${n("error")}\nwarnings=${n("warn")}\nnotes=${n("info")}\npages=${d.meta.pages}\n`);
162
+
163
+ const icon = { error: "🔴", warn: "🟡", info: "🔵" };
164
+ const rows = d.findings
165
+ .map((f) => `| ${icon[f.level]} | ${f.title} | ${f.url ? `<${f.url}>` : ""} |`)
166
+ .join("\n");
167
+ const body =
168
+ `## SEO audit — ${d.meta.origin}\n\n` +
169
+ `**${n("error")}** errors · **${n("warn")}** warnings · **${n("info")}** notes ` +
170
+ `across ${d.meta.pages} pages\n\n` +
171
+ (d.findings.length ? `| | Finding | Page |\n|:-:|---|---|\n${rows}\n` : "Nothing to report. ✅\n");
172
+
173
+ fs.writeFileSync("seo-audit-comment.md", body);
174
+ if (process.env.GITHUB_STEP_SUMMARY) fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, body);
175
+ '
176
+ exit $status
177
+
178
+ # Sticky: edits its own previous comment instead of adding one per push.
179
+ - if: ${{ always() && inputs.comment == 'true' && github.event_name == 'pull_request' }}
180
+ shell: bash
181
+ env:
182
+ GH_TOKEN: ${{ inputs.github-token }}
183
+ PR: ${{ github.event.pull_request.number }}
184
+ REPO: ${{ github.repository }}
185
+ run: |
186
+ marker="<!-- seo-audit -->"
187
+ body="$marker"$'\n'"$(cat seo-audit-comment.md)"
188
+ existing=$(gh api "repos/$REPO/issues/$PR/comments" --jq \
189
+ "[.[] | select(.body | startswith(\"$marker\")) | .id] | first // empty")
190
+ if [ -n "$existing" ]; then
191
+ gh api -X PATCH "repos/$REPO/issues/comments/$existing" -f body="$body" >/dev/null
192
+ else
193
+ gh api -X POST "repos/$REPO/issues/$PR/comments" -f body="$body" >/dev/null
194
+ fi
@@ -0,0 +1,483 @@
1
+ #!/usr/bin/env node
2
+ import { writeFileSync, readFileSync, existsSync } from 'node:fs';
3
+ import { audit, preview } from '../src/audit.mjs';
4
+ import { terminal, markdown, html, csv, diffReport, dryRunReport, counts, portfolio, portfolioMarkdown, portfolioHtml, progressLine } from '../src/report.mjs';
5
+ import { loadConfig, resolveSites, optionsForSite } from '../src/config.mjs';
6
+ import { readFileSync as read } from 'node:fs';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { dirname, join } from 'node:path';
9
+ import { serialize, parse, diff } from '../src/baseline.mjs';
10
+ import { parseRedirectMap } from '../src/redirects.mjs';
11
+ import { describe } from '../src/sitemap.mjs';
12
+ import { askForSite, isInteractive, invocation } from '../src/prompt.mjs';
13
+ import { userAgentFor, BROWSER_NAMES, OS_NAMES, thisPlatform } from '../src/agents.mjs';
14
+
15
+ const HELP = `
16
+ seo-audit — crawl a site's sitemap and check every page
17
+
18
+ Usage
19
+ npx github:nurkamol/seo-audit <url> [more urls…] [options]
20
+
21
+ Name more than one site — or list them under "sites" in the config — and the
22
+ report becomes a portfolio table, one row per site, worst first.
23
+
24
+ Reporting
25
+ --md <file> write a Markdown report
26
+ --html <file> write a self-contained HTML report (one file, no assets)
27
+ --json <file> write a JSON report (also usable as a baseline)
28
+ --csv <file> write the findings as a spreadsheet, one row each
29
+ --quiet print nothing; rely on the exit code and the files
30
+ --write-sitemap <file>
31
+ write the sitemap this site should have had — every
32
+ page that answered 200, is HTML, is indexable and is
33
+ its own canonical. Refuses on a crawl that did not
34
+ see the whole site, rather than writing a short one
35
+ --since <date> crawl only URLs the sitemap says changed on or after
36
+ this date. Refuses when lastmod cannot answer it —
37
+ absent, or one build stamp on every URL
38
+ --exclude <glob> leave URLs out of the crawl. Repeatable. Globs match
39
+ paths, where * stops at a slash and ** does not
40
+ --dry-run say what would be crawled and stop. A handful of
41
+ requests instead of hundreds, for checking the tool is
42
+ pointed at the right site before spending the minutes
43
+ --verbose print each request as it happens, to stderr. A long
44
+ crawl is otherwise silent from start to finish, and a
45
+ slow site looks exactly like a hung one
46
+
47
+ Comparing
48
+ --baseline <file> compare against a previous --json run and show only
49
+ what changed. With --fail-on new, a build fails on a
50
+ regression but tolerates findings you already knew about
51
+ --update-baseline write the baseline file after comparing
52
+ --against <url> compare against another deployment right now — a
53
+ preview against production, say. Hosts are ignored, so
54
+ only genuine differences show up
55
+
56
+ Crawling
57
+ --settle <seconds> wait until the site serves consistent HTML before
58
+ crawling. A CDN rolls a deploy out unevenly, and a crawl
59
+ during that window is wrong in a confusing way
60
+ --limit <n> maximum pages to check (default 200)
61
+ --concurrency <n> parallel requests (default 6)
62
+ --sitemap <url> sitemap location, if not declared in robots.txt
63
+ --redirects <file> a migration's redirect map (Netlify _redirects shape:
64
+ "/old /new 301" per line). Every old URL is asked for,
65
+ and what actually happens is reported
66
+ --check-external also check links pointing off the site. Off by default:
67
+ other people's servers rate-limit and bot-block, so only
68
+ a 404, a 410 or no answer at all is ever reported
69
+ --serve [port] open the same form the hosted version serves, on this
70
+ machine (default 4321). No account, no bill, and none of
71
+ the limits a Worker has — the crawl is only bounded by
72
+ what this computer will do
73
+ --browser <name> crawl as a real browser or a search crawler:
74
+ ${BROWSER_NAMES.join(', ')}.
75
+ Googlebot is what Google is served; a browser is what a
76
+ host blocking crawlers will answer
77
+ --os <name> the system that browser is running on, default this one:
78
+ ${OS_NAMES.join(', ')}
79
+ --search-console [property]
80
+ order findings by what the pages actually do in Google.
81
+ Needs GSC_CLIENT_ID, GSC_CLIENT_SECRET and
82
+ GSC_REFRESH_TOKEN in the environment or in
83
+ ~/.config/seo-audit/.env. A domain property is named
84
+ "sc-domain:example.com" rather than by its URL
85
+ --compare-as <name> fetch a sample of pages a second time as this browser
86
+ or crawler and report what changed. A page that differs
87
+ with the reader is cloaking, or bot protection misfiring
88
+ --user-agent <ua> identify as something else. Some hosts stall clients
89
+ that do not look like a browser
90
+
91
+ Filtering
92
+ --config <file> default: seo-audit.config.json in the working directory
93
+ --ignore <ids> comma-separated check ids to silence for this run
94
+
95
+ Performance (asks Google, does not guess)
96
+ --psi <urls> comma-separated pages to measure with PageSpeed
97
+ Insights. Slow (~12s each) and rate-limited, so name a
98
+ handful. A path glob names a section — /journal/** is
99
+ every crawled page under it, sampled. Uses PSI_API_KEY,
100
+ or ~/.config/seo-audit/.env
101
+ --psi-sample <n> pages to measure per section glob (default 3). The
102
+ report says what was matched but not measured
103
+ --psi-strategy mobile (default) | desktop
104
+
105
+ Exit code
106
+ --fail-on <level> error (default) | warn | new | never
107
+ "new" needs --baseline
108
+
109
+ Examples
110
+ npx github:nurkamol/seo-audit https://example.com
111
+ npx github:nurkamol/seo-audit https://example.com --md audit.md
112
+ npx github:nurkamol/seo-audit https://example.com \\
113
+ --baseline seo-baseline.json --fail-on new
114
+ npx github:nurkamol/seo-audit one.example two.example --html portfolio.html
115
+
116
+ Correctness is checked on every page. Performance is never estimated — with
117
+ --psi it is measured by Google, and otherwise left to pagespeed.web.dev.
118
+ `;
119
+
120
+ function parseArgs(argv) {
121
+ const opts = {};
122
+ const rest = [];
123
+ for (let i = 0; i < argv.length; i++) {
124
+ const arg = argv[i];
125
+ const value = () => argv[++i];
126
+ if (arg === '--help' || arg === '-h') opts.help = true;
127
+ else if (arg === '--version' || arg === '-v') opts.version = true;
128
+ else if (arg === '--against') opts.against = value();
129
+ else if (arg === '--settle') opts.settle = Number(value());
130
+ else if (arg === '--quiet' || arg === '-q') opts.quiet = true;
131
+ else if (arg === '--verbose') opts.verbose = true;
132
+ else if (arg === '--dry-run') opts.dryRun = true;
133
+ else if (arg === '--since') opts.since = value();
134
+ // Repeatable: one pattern per flag reads better than one flag with a
135
+ // comma-separated list, and a URL can contain a comma.
136
+ else if (arg === '--exclude') (opts.exclude ??= []).push(value());
137
+ else if (arg === '--write-sitemap') opts.writeSitemap = value();
138
+ else if (arg === '--md') opts.md = value();
139
+ else if (arg === '--html') opts.html = value();
140
+ else if (arg === '--json') opts.json = value();
141
+ else if (arg === '--csv') opts.csv = value();
142
+ else if (arg === '--baseline') opts.baseline = value();
143
+ else if (arg === '--update-baseline') opts.updateBaseline = true;
144
+ else if (arg === '--limit') opts.limit = Number(value());
145
+ else if (arg === '--concurrency') opts.concurrency = Number(value());
146
+ else if (arg === '--sitemap') opts.sitemap = value();
147
+ else if (arg === '--redirects') opts.redirects = value();
148
+ else if (arg === '--check-external') opts.checkExternal = true;
149
+ else if (arg === '--user-agent') opts.userAgent = value();
150
+ else if (arg === '--serve') {
151
+ // The port is optional: --serve on its own, or --serve 8080.
152
+ const next = argv[i + 1];
153
+ opts.serve = next && /^\d+$/.test(next) ? Number(argv[++i]) : true;
154
+ }
155
+ else if (arg === '--search-console') {
156
+ // Optionally the property name, since a domain property is not a URL.
157
+ const next = argv[i + 1];
158
+ opts.searchConsole = next && !next.startsWith('--') ? argv[++i] : true;
159
+ }
160
+ else if (arg === '--compare-as') opts.compareAs = value();
161
+ else if (arg === '--compare-sample') opts.compareSample = Number(value());
162
+ else if (arg === '--browser') opts.browser = value();
163
+ else if (arg === '--os') opts.os = value();
164
+ else if (arg === '--config') opts.config = value();
165
+ else if (arg === '--ignore') opts.ignore = value().split(',').map((s) => s.trim()).filter(Boolean);
166
+ else if (arg === '--fail-on') opts.failOn = value();
167
+ else if (arg === '--psi') opts.psi = value().split(',').map((s) => s.trim()).filter(Boolean);
168
+ else if (arg === '--psi-sample') opts.psiSample = Number(value());
169
+ else if (arg === '--psi-strategy') opts.psiStrategy = value();
170
+ else if (arg.startsWith('-')) {
171
+ console.error(`Unknown option: ${arg}`);
172
+ process.exit(2);
173
+ } else rest.push(arg);
174
+ }
175
+ opts.target = rest[0];
176
+ opts.targets = rest;
177
+ return opts;
178
+ }
179
+
180
+ const cli = parseArgs(process.argv.slice(2));
181
+
182
+ if (cli.version) {
183
+ const here = dirname(fileURLToPath(import.meta.url));
184
+ console.log(JSON.parse(read(join(here, '..', 'package.json'), 'utf8')).version);
185
+ process.exit(0);
186
+ }
187
+
188
+ if (cli.help) {
189
+ console.log(HELP);
190
+ process.exit(0);
191
+ }
192
+
193
+ let file;
194
+ try {
195
+ file = loadConfig(cli.config);
196
+ } catch (err) {
197
+ console.error(` ${err.message}`);
198
+ process.exit(2);
199
+ }
200
+
201
+ // A `psi: ["/", "/journal/**"]` in the config is written as paths and globs.
202
+ // Both are resolved against the crawled site later, in src/psi.mjs, which knows
203
+ // the origin the audit actually settled on and can match a glob against the
204
+ // pages that were really found.
205
+ const psiFromConfig = file.psi ?? [];
206
+
207
+ // CLI wins over the config file; ignore rules from both are combined, since
208
+ // one is "this site always" and the other is "just this run".
209
+ const opts = {
210
+ ...file,
211
+ ...Object.fromEntries(Object.entries(cli).filter(([, v]) => v !== undefined)),
212
+ ignore: [...(file.ignore ?? []), ...(cli.ignore ?? [])],
213
+ psi: cli.psi ?? (psiFromConfig.length ? psiFromConfig : undefined),
214
+ failOn: cli.failOn ?? file.failOn ?? 'error',
215
+ };
216
+
217
+ // A browser or a crawler to present as, resolved once and refused loudly. An
218
+ // impossible pair describes a machine that does not exist, and the whole point
219
+ // of the flag is to be believed by a server.
220
+ if (opts.browser) {
221
+ const { ua, error, ignoredOs } = userAgentFor(opts.browser, opts.os ?? thisPlatform());
222
+ if (error) {
223
+ console.error(` ${error}`);
224
+ process.exit(2);
225
+ }
226
+ if (ignoredOs) {
227
+ console.error(` --os is ignored for ${opts.browser}: a crawler's user agent names no machine.`);
228
+ }
229
+ // An explicit --user-agent is a literal string and outranks a preset.
230
+ opts.userAgent = opts.userAgent ?? ua;
231
+ } else if (opts.os) {
232
+ console.error(' --os needs --browser: it says which system the browser is running on.');
233
+ process.exit(2);
234
+ }
235
+
236
+ // The second reader, resolved the same way as the first and refused as loudly.
237
+ if (opts.compareAs) {
238
+ const { ua, error } = userAgentFor(opts.compareAs, opts.os ?? thisPlatform());
239
+ if (error) {
240
+ console.error(` ${error}`);
241
+ process.exit(2);
242
+ }
243
+ opts.compareAs = { ua, label: opts.compareAs };
244
+ }
245
+
246
+ if (opts.failOn === 'new' && !opts.baseline) {
247
+ console.error(' --fail-on new needs --baseline <file> to compare against.');
248
+ process.exit(2);
249
+ }
250
+
251
+ // Read the redirect map once, here, so a portfolio does not re-read it per site
252
+ // and a missing file fails before anything is crawled.
253
+ if (opts.redirects) {
254
+ if (!existsSync(opts.redirects)) {
255
+ console.error(` Redirect map not found: ${opts.redirects}`);
256
+ process.exit(2);
257
+ }
258
+ opts.redirectRules = parseRedirectMap(readFileSync(opts.redirects, 'utf8'));
259
+ if (!opts.redirectRules.length) {
260
+ console.error(` ${opts.redirects} has no rules in it.`);
261
+ process.exit(2);
262
+ }
263
+ }
264
+
265
+ // Live progress, on stderr so it never contaminates a piped report. --quiet
266
+ // wins over --verbose: asking for silence and getting a running commentary
267
+ // would be the more surprising of the two.
268
+ const live = (origin) =>
269
+ opts.verbose && !opts.quiet
270
+ ? (event) => process.stderr.write(`${progressLine(event, origin)}\n`)
271
+ : undefined;
272
+
273
+ // One site or twenty: the same options, resolved the same way. A site entry in
274
+ // the config may carry its own overrides, which land on top of the shared ones.
275
+ let sites = resolveSites(cli.targets ?? [], file);
276
+
277
+ // The local UI, which is a different program from here on: no target, no
278
+ // report file, and it runs until interrupted.
279
+ // `!== undefined` rather than truthiness: --serve 0 asks the operating system
280
+ // to pick a free port, which is what the macOS app does, and zero is falsy.
281
+ // That bug shipped as "the app opens and the engine never starts".
282
+ if (opts.serve !== undefined) {
283
+ const { serve } = await import('../src/serve.mjs');
284
+ const { url } = await serve({
285
+ port: opts.serve === true ? 4321 : opts.serve,
286
+ maxPages: opts.limit,
287
+ userAgent: opts.userAgent,
288
+ });
289
+ console.log(`\n seo-audit is serving at ${url}\n Nothing leaves this machine. Ctrl-C to stop.\n`);
290
+
291
+ // Started by something rather than by somebody: when stdin is a *pipe*, its
292
+ // closing is the parent going away, and a server that outlives the window
293
+ // that opened it holds the port against the next launch.
294
+ //
295
+ // A pipe specifically, not merely "not a terminal". `--serve < /dev/null` is
296
+ // also not a TTY, and reading it ends at once — which shut the server down
297
+ // the instant it started, in the CI job added to catch exactly this kind of
298
+ // thing. A parent that wants to be noticed hands over a pipe.
299
+ const { fstatSync } = await import('node:fs');
300
+ const stdinIsPipe = (() => {
301
+ try {
302
+ const stdin = fstatSync(0);
303
+ // A named pipe or a socket: Node hands a child a socketpair rather than a
304
+ // FIFO, so checking only for one of them makes this fire in a terminal
305
+ // and not fire where it matters. /dev/null is a character device, which
306
+ // is neither.
307
+ return stdin.isFIFO() || stdin.isSocket();
308
+ } catch {
309
+ return false;
310
+ }
311
+ })();
312
+ if (stdinIsPipe) {
313
+ process.stdin.resume();
314
+ process.stdin.on('end', () => process.exit(0));
315
+ process.stdin.on('close', () => process.exit(0));
316
+ }
317
+ } else {
318
+
319
+ // Nothing to audit. If a person is there to ask, ask; otherwise this is a
320
+ // script or a CI runner and the help text is the right answer.
321
+ if (!sites.length) {
322
+ let answers = null;
323
+ if (isInteractive()) {
324
+ const { createInterface } = await import('node:readline/promises');
325
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
326
+ console.log('');
327
+ answers = await askForSite(rl);
328
+ rl.close();
329
+ }
330
+
331
+ if (!answers) {
332
+ console.log(HELP);
333
+ process.exit(2);
334
+ }
335
+
336
+ sites = resolveSites([answers.url], file);
337
+ if (answers.html) opts.html = answers.html;
338
+ console.log(`\n Next time, in one line:\n ${invocation(sites[0].url, answers)}\n`);
339
+ }
340
+
341
+ for (const site of sites) {
342
+ try {
343
+ new URL(site.url);
344
+ } catch {
345
+ console.error(`Not a URL: ${site.url}`);
346
+ process.exit(2);
347
+ }
348
+ }
349
+
350
+ // --- A portfolio ---------------------------------------------------------
351
+ if (sites.length > 1) {
352
+ // Comparing two deployments, or against a stored baseline, is a question
353
+ // about one site. Rather than half-answer it across twenty, say so.
354
+ for (const [flag, name] of [['baseline', '--baseline'], ['against', '--against'], ['updateBaseline', '--update-baseline']]) {
355
+ if (opts[flag]) {
356
+ console.error(` ${name} audits one site at a time — it compares a site against itself.`);
357
+ console.error(` Run it per site, or drop the flag to get the portfolio table.`);
358
+ process.exit(2);
359
+ }
360
+ }
361
+
362
+ const runs = [];
363
+ for (const [i, site] of sites.entries()) {
364
+ if (!opts.quiet) process.stderr.write(` [${i + 1}/${sites.length}] ${site.url} …\n`);
365
+ const siteOpts = optionsForSite(opts, site.overrides);
366
+ // Sites run one at a time on purpose: interleaved progress from twenty
367
+ // hosts is unreadable, and each audit is already parallel internally.
368
+ const { findings, meta } = await audit(site.url, {
369
+ ...siteOpts,
370
+ onNote: (m) => !opts.quiet && process.stderr.write(` ${m}\n`),
371
+ onProgress: live(site.url),
372
+ });
373
+ runs.push({ findings, meta });
374
+ }
375
+
376
+ if (!opts.quiet) console.log(portfolio(runs));
377
+ if (opts.md) writeFileSync(opts.md, portfolioMarkdown(runs));
378
+ if (opts.html) writeFileSync(opts.html, portfolioHtml(runs));
379
+ if (opts.json) {
380
+ writeFileSync(
381
+ opts.json,
382
+ JSON.stringify(
383
+ { tool: 'seo-audit', date: runs[0]?.meta.date, sites: runs.map((r) => ({ ...r.meta, findings: r.findings })) },
384
+ null,
385
+ 2,
386
+ ),
387
+ );
388
+ }
389
+ if (!opts.quiet && (opts.md || opts.html || opts.json)) {
390
+ console.log(` ${[opts.md, opts.html, opts.json].filter(Boolean).join(' ')}\n`);
391
+ }
392
+
393
+ // One bad site fails the run: a portfolio check that passes while a site in
394
+ // it is broken is a check nobody can trust.
395
+ const failed = runs.some(({ findings }) => {
396
+ const n = counts(findings);
397
+ return (opts.failOn === 'error' && n.error > 0) || (opts.failOn === 'warn' && n.error + n.warn > 0);
398
+ });
399
+ process.exit(failed ? 1 : 0);
400
+ }
401
+
402
+ const target = sites[0].url;
403
+
404
+ // --- say what would happen, and stop --------------------------------------
405
+ // Before spending minutes and a few hundred requests on somebody else's
406
+ // server, find out whether this is pointed at the right site.
407
+ if (opts.dryRun) {
408
+ const plan = await preview(target, opts);
409
+ process.stdout.write(dryRunReport(plan));
410
+ process.exit(plan.reachable && plan.listed ? 0 : 1);
411
+ }
412
+
413
+ if (!opts.quiet) {
414
+ process.stderr.write(` crawling ${target} …${file.source ? ` (${file.source})` : ''}\n`);
415
+ }
416
+
417
+ if (!opts.quiet && opts.settle) {
418
+ process.stderr.write(` waiting up to ${opts.settle}s for the site to serve consistent HTML …\n`);
419
+ }
420
+
421
+ const { findings, meta, sitemap } = await audit(target, {
422
+ ...opts,
423
+ onNote: (m) => !opts.quiet && process.stderr.write(` ${m}\n`),
424
+ onProgress: live(target),
425
+ });
426
+
427
+ // --- the sitemap this site should have had -------------------------------
428
+ // Written before the report, so a refusal is read rather than scrolled past at
429
+ // the bottom of two hundred findings.
430
+ if (opts.writeSitemap && sitemap) {
431
+ if (sitemap.xml) writeFileSync(opts.writeSitemap, sitemap.xml);
432
+ process.stderr.write('\n' + describe(sitemap, opts.writeSitemap));
433
+ }
434
+
435
+ // --- Compare against another deployment, if asked -----------------------
436
+ let against = null;
437
+ if (opts.against) {
438
+ const reference = /^https?:\/\//i.test(opts.against) ? opts.against : `https://${opts.against}`;
439
+ if (!opts.quiet) process.stderr.write(` crawling ${reference} to compare …\n`);
440
+ const other = await audit(reference, { ...opts, against: undefined, settle: undefined });
441
+ against = diff({ findings: other.findings, meta: other.meta }, findings, { ignoreHost: true });
442
+ against.previousDate = reference;
443
+ }
444
+
445
+ // --- Compare against a stored baseline, if asked ------------------------
446
+ let comparison = against;
447
+ if (opts.baseline && !against) {
448
+ if (existsSync(opts.baseline)) {
449
+ try {
450
+ comparison = diff(parse(readFileSync(opts.baseline, 'utf8'), opts.baseline), findings);
451
+ } catch (err) {
452
+ console.error(` ${err.message}`);
453
+ process.exit(2);
454
+ }
455
+ } else if (!opts.quiet) {
456
+ process.stderr.write(` no baseline at ${opts.baseline} yet — writing one\n`);
457
+ }
458
+ if (!existsSync(opts.baseline) || opts.updateBaseline) {
459
+ writeFileSync(opts.baseline, serialize(findings, meta));
460
+ }
461
+ }
462
+
463
+ // --- Report -------------------------------------------------------------
464
+ if (!opts.quiet) {
465
+ console.log(comparison ? diffReport(comparison) : terminal(findings, meta));
466
+ }
467
+ if (opts.md) writeFileSync(opts.md, markdown(findings, meta));
468
+ if (opts.html) writeFileSync(opts.html, html(findings, meta));
469
+ if (opts.json) writeFileSync(opts.json, serialize(findings, meta, { full: true }));
470
+ if (opts.csv) writeFileSync(opts.csv, csv(findings, meta));
471
+ if (!opts.quiet && (opts.md || opts.html || opts.json)) {
472
+ console.log(` ${[opts.md, opts.html, opts.json].filter(Boolean).join(' ')}\n`);
473
+ }
474
+
475
+ // --- Exit ---------------------------------------------------------------
476
+ const n = counts(findings);
477
+ const failed =
478
+ (opts.failOn === 'error' && n.error > 0) ||
479
+ (opts.failOn === 'warn' && n.error + n.warn > 0) ||
480
+ (opts.failOn === 'new' && (comparison?.added.length ?? 0) > 0);
481
+ process.exit(failed ? 1 : 0);
482
+
483
+ }