@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/LICENSE +21 -0
- package/README.md +792 -0
- package/action.yml +194 -0
- package/bin/seo-audit.mjs +483 -0
- package/package.json +52 -0
- package/src/agents.mjs +122 -0
- package/src/areas.mjs +135 -0
- package/src/audit.mjs +700 -0
- package/src/baseline.mjs +71 -0
- package/src/causes.mjs +167 -0
- package/src/checks.mjs +1253 -0
- package/src/compare.mjs +100 -0
- package/src/config.mjs +156 -0
- package/src/console.mjs +146 -0
- package/src/dupes.mjs +164 -0
- package/src/graph.mjs +89 -0
- package/src/http.mjs +228 -0
- package/src/options.mjs +77 -0
- package/src/parse.mjs +347 -0
- package/src/prompt.mjs +37 -0
- package/src/psi.mjs +200 -0
- package/src/redirects.mjs +145 -0
- package/src/report.mjs +868 -0
- package/src/robots.mjs +92 -0
- package/src/serve.mjs +81 -0
- package/src/site.mjs +714 -0
- package/src/sitemap.mjs +183 -0
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
|
+
}
|