@fulldotdev/scan 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +79 -0
- package/bin/fullscan.js +2 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +239 -0
- package/dist/engine/analysis.d.ts +2 -0
- package/dist/engine/analysis.js +747 -0
- package/dist/engine/browser-inspection.d.ts +143 -0
- package/dist/engine/browser-inspection.js +567 -0
- package/dist/engine/browser.d.ts +22 -0
- package/dist/engine/browser.js +629 -0
- package/dist/engine/collect.d.ts +6 -0
- package/dist/engine/collect.js +359 -0
- package/dist/engine/crawl-scope.d.ts +22 -0
- package/dist/engine/crawl-scope.js +145 -0
- package/dist/engine/env.d.ts +1 -0
- package/dist/engine/env.js +3 -0
- package/dist/engine/html.d.ts +307 -0
- package/dist/engine/html.js +645 -0
- package/dist/engine/language.d.ts +13 -0
- package/dist/engine/language.js +75 -0
- package/dist/engine/lighthouse-evidence.d.ts +36 -0
- package/dist/engine/lighthouse-evidence.js +69 -0
- package/dist/engine/lighthouse.d.ts +4 -0
- package/dist/engine/lighthouse.js +284 -0
- package/dist/engine/log.d.ts +1 -0
- package/dist/engine/log.js +4 -0
- package/dist/engine/network.d.ts +53 -0
- package/dist/engine/network.js +296 -0
- package/dist/engine/proxy.d.ts +8 -0
- package/dist/engine/proxy.js +95 -0
- package/dist/engine/run.d.ts +38 -0
- package/dist/engine/run.js +202 -0
- package/dist/engine/select.d.ts +6 -0
- package/dist/engine/select.js +38 -0
- package/dist/engine/site.d.ts +186 -0
- package/dist/engine/site.js +758 -0
- package/dist/engine/srcset.d.ts +1 -0
- package/dist/engine/srcset.js +31 -0
- package/dist/engine/state.d.ts +30 -0
- package/dist/engine/state.js +198 -0
- package/dist/engine/structured-data.d.ts +83 -0
- package/dist/engine/structured-data.js +331 -0
- package/dist/engine/types.d.ts +128 -0
- package/dist/engine/types.js +63 -0
- package/dist/engine.d.ts +1 -0
- package/dist/engine.js +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +7 -0
- package/dist/report/build.d.ts +377 -0
- package/dist/report/build.js +2263 -0
- package/dist/report/evidence.d.ts +58 -0
- package/dist/report/evidence.js +192 -0
- package/dist/report/rules.d.ts +41 -0
- package/dist/report/rules.js +301 -0
- package/package.json +52 -0
package/README.md
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# @fulldotdev/scan
|
|
2
|
+
|
|
3
|
+
Whole-site website scanner. Give it a URL and it crawls every page, checks every linked file and destination, renders every page in headless Chrome, runs Lighthouse per page template and axe-core on every page, and writes every problem it finds as JSON and Markdown. It reports problems and their impact. It does not give fixes.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npx @fulldotdev/scan https://example.com
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Requires Node 22 or newer. Chrome is installed with the package; set `CHROME_PATH` to use another one.
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
fullscan https://example.com # whole site: crawl, browser pass, Lighthouse per template
|
|
15
|
+
fullscan http://localhost:3000 --quick # local dev server, crawl only
|
|
16
|
+
fullscan https://example.com --lighthouse all
|
|
17
|
+
fullscan https://example.com --pages 200 --budget 10
|
|
18
|
+
fullscan https://example.com --previous scan/result.json # change tracking against the last run
|
|
19
|
+
fullscan https://example.com --fail-on critical # exit 1 when a critical finding exists
|
|
20
|
+
fullscan https://example.com --json > result.json
|
|
21
|
+
fullscan https://example.com --push --key $FULLSCAN_KEY # store the result on scan.full.dev
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Options:
|
|
25
|
+
|
|
26
|
+
| Flag | Meaning |
|
|
27
|
+
| --- | --- |
|
|
28
|
+
| `--out <dir>` | Output directory, default `./scan` |
|
|
29
|
+
| `--quick` | Crawl only: no browser pass, no Lighthouse |
|
|
30
|
+
| `--deep` | Deeper measurement: rendering observation and Lighthouse repeats |
|
|
31
|
+
| `--lighthouse <mode>` | `templates` (default: homepage plus one rotating page per template), `all`, or `off` |
|
|
32
|
+
| `--no-browser` | Skip the browser pass and Lighthouse |
|
|
33
|
+
| `--no-external` | Skip external sources (RDAP, SSL Labs, Chrome UX Report, Safe Browsing, HSTS preload, certificate logs, blocklists) |
|
|
34
|
+
| `--pages <n>` | Stop following new pages after n |
|
|
35
|
+
| `--budget <minutes>` | Cancel remaining crawl work after this long; the report still builds |
|
|
36
|
+
| `--previous <file>` | `result.json` of an earlier run, for new, resolved and unverified findings |
|
|
37
|
+
| `--rotation <n>` | Template sample rotation; taken from `--previous` when absent |
|
|
38
|
+
| `--fail-on critical\|warning` | Exit code 1 when such findings exist |
|
|
39
|
+
| `--push` | Push the result to the platform after the scan and print the site and run id |
|
|
40
|
+
| `--key <key>` | API key for `--push`; `FULLSCAN_KEY` is used when the flag is absent |
|
|
41
|
+
| `--platform <url>` | Where `--push` sends the result, default `https://scan.full.dev` |
|
|
42
|
+
| `--json` | Print the result JSON to stdout, no progress |
|
|
43
|
+
| `--quiet` | No progress output |
|
|
44
|
+
|
|
45
|
+
Local targets (localhost, private addresses, any port) are allowed automatically. External sources are then reported as not applicable.
|
|
46
|
+
|
|
47
|
+
`--push` sends the scan, report, findings, pages and assets to your account on the platform, which creates the site on the first push and keeps the run in its history. Raw records and artifacts stay on your machine. Create a key on the account page of the platform.
|
|
48
|
+
|
|
49
|
+
`GOOGLE_API_KEY` (a Google Cloud key with the Chrome UX Report API and Safe Browsing API enabled) enables real-user vitals and the Safe Browsing check.
|
|
50
|
+
|
|
51
|
+
## Output
|
|
52
|
+
|
|
53
|
+
- `result.json`: `scan` (options, coverage, status), `report` (overview, findings per topic, checks with outcome, coverage, changes), `findings` (every finding with all affected items), `pages` (one compact row per page: status, type, language, indexable, depth, word count, template, Lighthouse scores, finding counts), `assets` (every file and external destination with status, size and the pages that use it).
|
|
54
|
+
- `report.md`: the same report as readable Markdown, made for people and language models.
|
|
55
|
+
- `records.ndjson`: every raw observation (page extraction, rendered comparison, axe results, Lighthouse evidence, DNS, TLS, sitemaps and so on).
|
|
56
|
+
- `artifacts/`: screenshots, retrieved sitemaps, robots.txt and agent files, with `index.json`.
|
|
57
|
+
|
|
58
|
+
Every finding has a stable id, a topic, a severity (`critical`, `warning`, `hint`), a confidence (`confirmed` observed, `probable` inferred, `review` heuristic that needs a human), a problem sentence, an impact sentence and the affected pages with evidence. Every rule the scanner knows is listed under `checks` with its outcome (`passed`, `issue`, `review`, `not-applicable`, `unavailable`, `not-checked`), so absence of a finding is never mistaken for a pass of a check that did not run.
|
|
59
|
+
|
|
60
|
+
## Topics
|
|
61
|
+
|
|
62
|
+
Availability and security, crawling and indexability, content and structure, links and navigation, structured data, performance, accessibility and usability, business and profiles, AI and agent access.
|
|
63
|
+
|
|
64
|
+
## Programmatic use
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
import { runScan, formatReport } from "@fulldotdev/scan"
|
|
68
|
+
|
|
69
|
+
const { scan, report, findings, state } = await runScan({
|
|
70
|
+
url: "https://example.com",
|
|
71
|
+
options: { lighthouse: "templates" },
|
|
72
|
+
onEvent: (event) => console.error(event),
|
|
73
|
+
})
|
|
74
|
+
console.log(formatReport(report))
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Safety
|
|
78
|
+
|
|
79
|
+
Requests resolve and pin public addresses and refuse private ranges, credentials and nonstandard ports unless the target itself is local. Robots is checked before every request. Every URL is fetched once per scan. Crawl-trap rules stop URLs over 2,000 characters, paths deeper than 12 segments and repeating segments. Forms are never submitted; the browser pass scrolls once and clicks one load-more control at most. A 429 slows the scan down for the rest of the run.
|
package/bin/fullscan.js
ADDED
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { parseArgs } from "node:util";
|
|
4
|
+
import { runScan } from "./engine/run.js";
|
|
5
|
+
import { templateKey } from "./engine/select.js";
|
|
6
|
+
import { version } from "./engine/types.js";
|
|
7
|
+
import { formatReport } from "./report/build.js";
|
|
8
|
+
import { assetInventory, pageRows } from "./report/evidence.js";
|
|
9
|
+
import { publicReport } from "./report/rules.js";
|
|
10
|
+
const usage = `fullscan ${version}
|
|
11
|
+
|
|
12
|
+
Usage: fullscan <url> [options]
|
|
13
|
+
|
|
14
|
+
Scans a whole website: every page is crawled and rendered, Lighthouse runs
|
|
15
|
+
per template, and every problem found is written as JSON and Markdown.
|
|
16
|
+
|
|
17
|
+
Options:
|
|
18
|
+
--out <dir> Output directory (default: ./scan)
|
|
19
|
+
--quick Crawl only: no browser pass, no Lighthouse
|
|
20
|
+
--deep Deeper measurement: rendering observation, Lighthouse repeats
|
|
21
|
+
--lighthouse <mode> templates (default), all, or off
|
|
22
|
+
--no-browser Skip the browser pass and Lighthouse
|
|
23
|
+
--no-external Skip external sources (RDAP, SSL Labs, CrUX, ...)
|
|
24
|
+
--pages <n> Stop following new pages after n
|
|
25
|
+
--budget <minutes> Cancel remaining crawl work after this long
|
|
26
|
+
--previous <file> result.json of an earlier run, for change tracking
|
|
27
|
+
--rotation <n> Template sample rotation (default: from --previous, else 0)
|
|
28
|
+
--fail-on <severity> Exit 1 when findings of this severity exist: critical or warning
|
|
29
|
+
--push Push the result to the platform after the scan
|
|
30
|
+
--key <key> API key for --push (default: FULLSCAN_KEY)
|
|
31
|
+
--platform <url> Platform to push to (default: https://scan.full.dev)
|
|
32
|
+
--json Print the result JSON to stdout instead of progress
|
|
33
|
+
--quiet No progress output
|
|
34
|
+
-h, --help Show this help
|
|
35
|
+
|
|
36
|
+
Local targets (localhost, private addresses, any port) are allowed
|
|
37
|
+
automatically; external sources are then reported as not applicable.
|
|
38
|
+
`;
|
|
39
|
+
const { values, positionals } = parseArgs({
|
|
40
|
+
allowPositionals: true,
|
|
41
|
+
options: {
|
|
42
|
+
out: { type: "string", default: "scan" },
|
|
43
|
+
quick: { type: "boolean", default: false },
|
|
44
|
+
deep: { type: "boolean", default: false },
|
|
45
|
+
lighthouse: { type: "string" },
|
|
46
|
+
browser: { type: "boolean", default: true },
|
|
47
|
+
external: { type: "boolean", default: true },
|
|
48
|
+
pages: { type: "string" },
|
|
49
|
+
budget: { type: "string" },
|
|
50
|
+
previous: { type: "string" },
|
|
51
|
+
rotation: { type: "string" },
|
|
52
|
+
"fail-on": { type: "string" },
|
|
53
|
+
push: { type: "boolean", default: false },
|
|
54
|
+
key: { type: "string" },
|
|
55
|
+
platform: { type: "string", default: "https://scan.full.dev" },
|
|
56
|
+
json: { type: "boolean", default: false },
|
|
57
|
+
quiet: { type: "boolean", default: false },
|
|
58
|
+
help: { type: "boolean", short: "h", default: false },
|
|
59
|
+
},
|
|
60
|
+
allowNegative: true,
|
|
61
|
+
});
|
|
62
|
+
const target = positionals[0];
|
|
63
|
+
if (values.help || !target) {
|
|
64
|
+
process.stdout.write(usage);
|
|
65
|
+
process.exit(values.help ? 0 : 1);
|
|
66
|
+
}
|
|
67
|
+
const url = /^https?:\/\//i.test(target) ? target : `https://${target}`;
|
|
68
|
+
const host = new URL(url).hostname;
|
|
69
|
+
const local = host === "localhost" ||
|
|
70
|
+
host.endsWith(".localhost") ||
|
|
71
|
+
/^(127\.|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|0\.0\.0\.0|\[::1\])/.test(host);
|
|
72
|
+
const options = {
|
|
73
|
+
deepScan: values.deep,
|
|
74
|
+
browser: values.browser && !values.quick,
|
|
75
|
+
lighthouse: values.quick
|
|
76
|
+
? "off"
|
|
77
|
+
: (values.lighthouse ??
|
|
78
|
+
"templates"),
|
|
79
|
+
external: values.external,
|
|
80
|
+
allowLocal: local,
|
|
81
|
+
...(values.pages ? { maxPages: Number(values.pages) } : {}),
|
|
82
|
+
};
|
|
83
|
+
let previous;
|
|
84
|
+
if (values.previous) {
|
|
85
|
+
const file = JSON.parse(await readFile(values.previous, "utf8"));
|
|
86
|
+
previous = {
|
|
87
|
+
id: file.scan?.id ?? "previous",
|
|
88
|
+
createdAt: file.scan?.createdAt ?? file.report?.createdAt,
|
|
89
|
+
report: file.report,
|
|
90
|
+
findings: file.findings ?? [],
|
|
91
|
+
pages: (file.pages ?? []).map((p) => ({
|
|
92
|
+
key: p.url,
|
|
93
|
+
title: p.title,
|
|
94
|
+
outcome: p.outcome,
|
|
95
|
+
status: p.status,
|
|
96
|
+
words: p.wordCount,
|
|
97
|
+
canonical: p.canonical,
|
|
98
|
+
language: p.language,
|
|
99
|
+
})),
|
|
100
|
+
lastScores: file.report?.scores?.carried ? null : file.report?.scores,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
const rotation = values.rotation
|
|
104
|
+
? Number(values.rotation)
|
|
105
|
+
: previous
|
|
106
|
+
? Number(JSON.parse(await readFile(values.previous, "utf8")).scan?.rotation ??
|
|
107
|
+
-1) + 1
|
|
108
|
+
: 0;
|
|
109
|
+
const quiet = values.quiet || values.json;
|
|
110
|
+
const log = (line) => {
|
|
111
|
+
if (!quiet)
|
|
112
|
+
process.stderr.write(`${line}\n`);
|
|
113
|
+
};
|
|
114
|
+
const onEvent = (event) => {
|
|
115
|
+
switch (event.type) {
|
|
116
|
+
case "start":
|
|
117
|
+
log(`Scanning ${event.scan.url}`);
|
|
118
|
+
break;
|
|
119
|
+
case "phase":
|
|
120
|
+
log(`Phase: ${event.phase}`);
|
|
121
|
+
break;
|
|
122
|
+
case "job":
|
|
123
|
+
if (event.outcome === "completed")
|
|
124
|
+
log(` ${event.kind} ${event.key} (${event.durationMs} ms, ${event.pending} left)`);
|
|
125
|
+
else
|
|
126
|
+
log(` ${event.kind} ${event.key} ${event.outcome}: ${event.error} (attempt ${event.attempt})`);
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
const started = Date.now();
|
|
131
|
+
const output = await runScan({
|
|
132
|
+
url,
|
|
133
|
+
options,
|
|
134
|
+
rotation,
|
|
135
|
+
previous,
|
|
136
|
+
budgetMs: values.budget ? Number(values.budget) * 60000 : undefined,
|
|
137
|
+
onEvent,
|
|
138
|
+
});
|
|
139
|
+
const { scan, report, findings, state } = output;
|
|
140
|
+
const records = state.records();
|
|
141
|
+
const urls = state.urlRows();
|
|
142
|
+
const pages = pageRows(records, urls, findings, templateKey);
|
|
143
|
+
const assets = assetInventory(records, urls, scan.url);
|
|
144
|
+
const result = {
|
|
145
|
+
version: 1,
|
|
146
|
+
tool: { name: "@fulldotdev/scan", version },
|
|
147
|
+
scan,
|
|
148
|
+
report: publicReport(report),
|
|
149
|
+
findings,
|
|
150
|
+
pages,
|
|
151
|
+
assets,
|
|
152
|
+
};
|
|
153
|
+
const out = values.out;
|
|
154
|
+
await mkdir(join(out, "artifacts"), { recursive: true });
|
|
155
|
+
await writeFile(join(out, "result.json"), JSON.stringify(result, null, 2));
|
|
156
|
+
await writeFile(join(out, "report.md"), formatReport(report) + "\n");
|
|
157
|
+
await writeFile(join(out, "records.ndjson"), records.map((r) => JSON.stringify(r)).join("\n") + "\n");
|
|
158
|
+
const index = {};
|
|
159
|
+
for (const artifact of state.artifacts.values()) {
|
|
160
|
+
const name = `${artifact.sha256.slice(0, 16)}${extension(artifact.contentType)}`;
|
|
161
|
+
await writeFile(join(out, "artifacts", name), artifact.body);
|
|
162
|
+
index[name] = {
|
|
163
|
+
kind: artifact.kind,
|
|
164
|
+
key: artifact.key,
|
|
165
|
+
contentType: artifact.contentType,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
await writeFile(join(out, "artifacts", "index.json"), JSON.stringify(index, null, 2));
|
|
169
|
+
// The push carries the result only: records and artifacts stay local.
|
|
170
|
+
let pushed;
|
|
171
|
+
if (values.push) {
|
|
172
|
+
const key = values.key ?? process.env.FULLSCAN_KEY;
|
|
173
|
+
if (!key) {
|
|
174
|
+
process.stderr.write("A key is required to push: pass --key or set FULLSCAN_KEY.\n");
|
|
175
|
+
process.exit(1);
|
|
176
|
+
}
|
|
177
|
+
const platform = values.platform.replace(/\/+$/, "");
|
|
178
|
+
const response = await fetch(`${platform}/api/command`, {
|
|
179
|
+
method: "POST",
|
|
180
|
+
headers: {
|
|
181
|
+
"Content-Type": "application/json",
|
|
182
|
+
Authorization: `Bearer ${key}`,
|
|
183
|
+
},
|
|
184
|
+
body: JSON.stringify({
|
|
185
|
+
operation: "runs.push",
|
|
186
|
+
args: {
|
|
187
|
+
url: scan.url,
|
|
188
|
+
scan,
|
|
189
|
+
report: result.report,
|
|
190
|
+
findings,
|
|
191
|
+
pages,
|
|
192
|
+
assets,
|
|
193
|
+
},
|
|
194
|
+
}),
|
|
195
|
+
});
|
|
196
|
+
const payload = (await response.json().catch(() => null));
|
|
197
|
+
if (!response.ok || !payload?.result) {
|
|
198
|
+
process.stderr.write(`Push failed: ${payload?.error?.message ?? `${response.status} ${response.statusText}`}\n`);
|
|
199
|
+
process.exit(1);
|
|
200
|
+
}
|
|
201
|
+
pushed = payload.result;
|
|
202
|
+
if (values.json)
|
|
203
|
+
process.stderr.write(`Pushed to ${platform}: site ${pushed.siteId}, run ${pushed.runId}\n`);
|
|
204
|
+
}
|
|
205
|
+
if (values.json)
|
|
206
|
+
process.stdout.write(JSON.stringify(result) + "\n");
|
|
207
|
+
else {
|
|
208
|
+
const c = report.counts;
|
|
209
|
+
process.stdout.write([
|
|
210
|
+
`${scan.url}: ${report.pages.observed} pages, ${c.critical} critical, ${c.warning} warnings, ${c.hint ?? 0} hints (${scan.status}, ${Math.round((Date.now() - started) / 1000)} s)`,
|
|
211
|
+
`Report: ${join(out, "report.md")}`,
|
|
212
|
+
`Result: ${join(out, "result.json")}`,
|
|
213
|
+
...(pushed
|
|
214
|
+
? [`Pushed: site ${pushed.siteId}, run ${pushed.runId}`]
|
|
215
|
+
: []),
|
|
216
|
+
].join("\n") + "\n");
|
|
217
|
+
}
|
|
218
|
+
if (values["fail-on"]) {
|
|
219
|
+
const c = report.counts;
|
|
220
|
+
const failed = values["fail-on"] === "critical"
|
|
221
|
+
? c.critical > 0
|
|
222
|
+
: c.critical > 0 || c.warning > 0;
|
|
223
|
+
process.exit(failed ? 1 : 0);
|
|
224
|
+
}
|
|
225
|
+
function extension(contentType) {
|
|
226
|
+
if (/png/.test(contentType))
|
|
227
|
+
return ".png";
|
|
228
|
+
if (/jpe?g/.test(contentType))
|
|
229
|
+
return ".jpg";
|
|
230
|
+
if (/webp/.test(contentType))
|
|
231
|
+
return ".webp";
|
|
232
|
+
if (/xml/.test(contentType))
|
|
233
|
+
return ".xml";
|
|
234
|
+
if (/json/.test(contentType))
|
|
235
|
+
return ".json";
|
|
236
|
+
if (/markdown/.test(contentType))
|
|
237
|
+
return ".md";
|
|
238
|
+
return ".txt";
|
|
239
|
+
}
|