ssrwire 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/CHANGELOG.md +20 -0
- package/CONTRIBUTING.md +49 -0
- package/LICENSE +21 -0
- package/PUBLISHING.md +134 -0
- package/README.md +403 -0
- package/SECURITY.md +26 -0
- package/dist/agents.d.ts +14 -0
- package/dist/agents.d.ts.map +1 -0
- package/dist/agents.js +100 -0
- package/dist/agents.js.map +1 -0
- package/dist/analyze.d.ts +4 -0
- package/dist/analyze.d.ts.map +1 -0
- package/dist/analyze.js +494 -0
- package/dist/analyze.js.map +1 -0
- package/dist/audit.d.ts +3 -0
- package/dist/audit.d.ts.map +1 -0
- package/dist/audit.js +69 -0
- package/dist/audit.js.map +1 -0
- package/dist/bin.d.ts +3 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +4 -0
- package/dist/bin.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +173 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +17 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +262 -0
- package/dist/config.js.map +1 -0
- package/dist/http-probe.d.ts +7 -0
- package/dist/http-probe.d.ts.map +1 -0
- package/dist/http-probe.js +406 -0
- package/dist/http-probe.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/redact.d.ts +10 -0
- package/dist/redact.d.ts.map +1 -0
- package/dist/redact.js +154 -0
- package/dist/redact.js.map +1 -0
- package/dist/reporters.d.ts +9 -0
- package/dist/reporters.d.ts.map +1 -0
- package/dist/reporters.js +220 -0
- package/dist/reporters.js.map +1 -0
- package/dist/stream-parser.d.ts +9 -0
- package/dist/stream-parser.d.ts.map +1 -0
- package/dist/stream-parser.js +366 -0
- package/dist/stream-parser.js.map +1 -0
- package/dist/types.d.ts +134 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +14 -0
- package/dist/version.js.map +1 -0
- package/examples/github-actions.yml +73 -0
- package/examples/ssrwire.config.yml +37 -0
- package/package.json +91 -0
- package/src/agents.ts +129 -0
- package/src/analyze.ts +628 -0
- package/src/audit.ts +89 -0
- package/src/bin.ts +5 -0
- package/src/cli.ts +207 -0
- package/src/config.ts +313 -0
- package/src/http-probe.ts +461 -0
- package/src/index.ts +34 -0
- package/src/redact.ts +173 -0
- package/src/reporters.ts +274 -0
- package/src/stream-parser.ts +424 -0
- package/src/types.ts +160 -0
- package/src/version.ts +19 -0
package/src/reporters.ts
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AuditResult,
|
|
3
|
+
ElementSignal,
|
|
4
|
+
Finding,
|
|
5
|
+
ProbeResult,
|
|
6
|
+
ReportFormat,
|
|
7
|
+
Severity,
|
|
8
|
+
} from "./types.js";
|
|
9
|
+
|
|
10
|
+
export interface ReporterOptions {
|
|
11
|
+
readonly color?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const ANSI = {
|
|
15
|
+
red: "\u001b[31m",
|
|
16
|
+
yellow: "\u001b[33m",
|
|
17
|
+
blue: "\u001b[36m",
|
|
18
|
+
bold: "\u001b[1m",
|
|
19
|
+
dim: "\u001b[2m",
|
|
20
|
+
reset: "\u001b[0m",
|
|
21
|
+
} as const;
|
|
22
|
+
|
|
23
|
+
function paint(value: string, code: string, enabled: boolean): string {
|
|
24
|
+
return enabled ? `${code}${value}${ANSI.reset}` : value;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function severityColor(severity: Severity): string {
|
|
28
|
+
if (severity === "error") {
|
|
29
|
+
return ANSI.red;
|
|
30
|
+
}
|
|
31
|
+
if (severity === "warning") {
|
|
32
|
+
return ANSI.yellow;
|
|
33
|
+
}
|
|
34
|
+
return ANSI.blue;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function terminalSafe(value: string): string {
|
|
38
|
+
let safe = "";
|
|
39
|
+
for (const character of value) {
|
|
40
|
+
const codePoint = character.codePointAt(0) ?? 0;
|
|
41
|
+
safe +=
|
|
42
|
+
codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f)
|
|
43
|
+
? `\\u${codePoint.toString(16).toUpperCase().padStart(4, "0")}`
|
|
44
|
+
: character;
|
|
45
|
+
}
|
|
46
|
+
return safe;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function oneLine(value: string): string {
|
|
50
|
+
return terminalSafe(value).replace(/\s+/g, " ").trim();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function truncate(value: string, length: number): string {
|
|
54
|
+
const clean = oneLine(value);
|
|
55
|
+
if (clean.length <= length) {
|
|
56
|
+
return clean;
|
|
57
|
+
}
|
|
58
|
+
return `${clean.slice(0, Math.max(0, length - 1))}…`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function formatMs(value: number | undefined): string {
|
|
62
|
+
return value === undefined ? "—" : `${Math.round(value)} ms`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function formatSignal(signal: ElementSignal | undefined): string {
|
|
66
|
+
return signal === undefined ? "—" : `${Math.round(signal.atMs)} ms/${signal.location}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function firstSignal(signals: readonly ElementSignal[]): ElementSignal | undefined {
|
|
70
|
+
return signals.find((signal) => signal.value.trim().length > 0) ?? signals[0];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function probeRow(probe: ProbeResult): readonly string[] {
|
|
74
|
+
return [
|
|
75
|
+
truncate(probe.agent.label, 24),
|
|
76
|
+
probe.status === undefined ? "—" : String(probe.status),
|
|
77
|
+
probe.completion,
|
|
78
|
+
formatMs(probe.timings.firstByteMs),
|
|
79
|
+
formatMs(probe.timings.completeMs),
|
|
80
|
+
formatSignal(probe.signals.title),
|
|
81
|
+
formatSignal(firstSignal(probe.signals.descriptions)),
|
|
82
|
+
formatSignal(firstSignal(probe.signals.canonicals)),
|
|
83
|
+
formatSignal(probe.signals.firstMainText),
|
|
84
|
+
];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function renderTable(headers: readonly string[], rows: readonly (readonly string[])[]): string {
|
|
88
|
+
const widths = headers.map((header, column) =>
|
|
89
|
+
Math.max(header.length, ...rows.map((row) => row[column]?.length ?? 0)),
|
|
90
|
+
);
|
|
91
|
+
const line = (cells: readonly string[]): string =>
|
|
92
|
+
cells
|
|
93
|
+
.map((cell, column) => cell.padEnd(widths[column] ?? cell.length))
|
|
94
|
+
.join(" ")
|
|
95
|
+
.trimEnd();
|
|
96
|
+
const separator = widths.map((width) => "-".repeat(width)).join(" ");
|
|
97
|
+
return [line(headers), separator, ...rows.map(line)].join("\n");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function formatFinding(finding: Finding, color: boolean): string {
|
|
101
|
+
const severity = finding.severity.toUpperCase().padEnd(7);
|
|
102
|
+
const prefix = paint(severity, severityColor(finding.severity), color);
|
|
103
|
+
const agent = finding.agent === undefined ? "" : ` [${terminalSafe(finding.agent)}]`;
|
|
104
|
+
const evidence =
|
|
105
|
+
finding.evidence === undefined
|
|
106
|
+
? ""
|
|
107
|
+
: ` (${Object.entries(finding.evidence)
|
|
108
|
+
.map(([key, value]) => `${terminalSafe(key)}: ${terminalSafe(String(value))}`)
|
|
109
|
+
.join(", ")})`;
|
|
110
|
+
return ` ${prefix} ${terminalSafe(finding.code)}${agent}: ${terminalSafe(finding.message)}${evidence}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function renderTerminal(audit: AuditResult, options: ReporterOptions = {}): string {
|
|
114
|
+
const color = options.color ?? Boolean(process.stdout.isTTY);
|
|
115
|
+
const lines: string[] = [
|
|
116
|
+
paint(`SSRWire ${audit.version}`, ANSI.bold, color),
|
|
117
|
+
paint(`Generated ${audit.generatedAt} in ${formatMs(audit.durationMs)}`, ANSI.dim, color),
|
|
118
|
+
];
|
|
119
|
+
|
|
120
|
+
for (const result of audit.results) {
|
|
121
|
+
lines.push("", paint(terminalSafe(result.target.url), ANSI.bold, color));
|
|
122
|
+
lines.push(
|
|
123
|
+
renderTable(
|
|
124
|
+
[
|
|
125
|
+
"Agent",
|
|
126
|
+
"HTTP",
|
|
127
|
+
"Result",
|
|
128
|
+
"First byte",
|
|
129
|
+
"Complete",
|
|
130
|
+
"Title",
|
|
131
|
+
"Description",
|
|
132
|
+
"Canonical",
|
|
133
|
+
"Main",
|
|
134
|
+
],
|
|
135
|
+
result.probes.map(probeRow),
|
|
136
|
+
),
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
if (result.findings.length === 0) {
|
|
140
|
+
lines.push("Findings: none");
|
|
141
|
+
} else {
|
|
142
|
+
lines.push("Findings:", ...result.findings.map((finding) => formatFinding(finding, color)));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const { summary } = audit;
|
|
147
|
+
const summaryText =
|
|
148
|
+
`Summary: ${summary.targets} target(s), ${summary.probes} probe(s), ` +
|
|
149
|
+
`${summary.errors} error(s), ${summary.warnings} warning(s), ` +
|
|
150
|
+
`${summary.info} info, ${summary.incomplete} incomplete`;
|
|
151
|
+
const summaryColor =
|
|
152
|
+
summary.errors > 0 ? ANSI.red : summary.warnings > 0 ? ANSI.yellow : ANSI.blue;
|
|
153
|
+
lines.push("", paint(summaryText, summaryColor, color));
|
|
154
|
+
|
|
155
|
+
return `${lines.join("\n")}\n`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function stableValue(value: unknown): unknown {
|
|
159
|
+
if (Array.isArray(value)) {
|
|
160
|
+
return value.map(stableValue);
|
|
161
|
+
}
|
|
162
|
+
if (value !== null && typeof value === "object") {
|
|
163
|
+
return Object.fromEntries(
|
|
164
|
+
Object.entries(value)
|
|
165
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
166
|
+
.map(([key, entry]) => [key, stableValue(entry)]),
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
return value;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function stableJson(value: unknown): string {
|
|
173
|
+
return `${JSON.stringify(stableValue(value), null, 2)}\n`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function renderJson(audit: AuditResult): string {
|
|
177
|
+
return stableJson(audit);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function sarifLevel(severity: Severity): "error" | "warning" | "note" {
|
|
181
|
+
return severity === "info" ? "note" : severity;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function ruleDescription(code: string): string {
|
|
185
|
+
const description = code.replaceAll("-", " ");
|
|
186
|
+
return `${description.charAt(0).toUpperCase()}${description.slice(1)}`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function artifactUri(url: string): string {
|
|
190
|
+
try {
|
|
191
|
+
return new URL(url).href;
|
|
192
|
+
} catch {
|
|
193
|
+
return encodeURI(url);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function renderSarif(audit: AuditResult): string {
|
|
198
|
+
const findings = audit.results.flatMap((result) => result.findings);
|
|
199
|
+
const codes = [...new Set(findings.map((finding) => finding.code))].sort();
|
|
200
|
+
const rules = codes.map((code) => {
|
|
201
|
+
const severities = findings
|
|
202
|
+
.filter((finding) => finding.code === code)
|
|
203
|
+
.map((finding) => finding.severity);
|
|
204
|
+
const defaultSeverity: Severity = severities.includes("error")
|
|
205
|
+
? "error"
|
|
206
|
+
: severities.includes("warning")
|
|
207
|
+
? "warning"
|
|
208
|
+
: "info";
|
|
209
|
+
return {
|
|
210
|
+
id: code,
|
|
211
|
+
name: code,
|
|
212
|
+
shortDescription: { text: ruleDescription(code) },
|
|
213
|
+
defaultConfiguration: { level: sarifLevel(defaultSeverity) },
|
|
214
|
+
};
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
const results = findings.map((finding) => ({
|
|
218
|
+
ruleId: finding.code,
|
|
219
|
+
level: sarifLevel(finding.severity),
|
|
220
|
+
message: { text: finding.message },
|
|
221
|
+
locations: [
|
|
222
|
+
{
|
|
223
|
+
physicalLocation: {
|
|
224
|
+
artifactLocation: { uri: artifactUri(finding.url) },
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
],
|
|
228
|
+
...(finding.agent === undefined && finding.evidence === undefined
|
|
229
|
+
? {}
|
|
230
|
+
: {
|
|
231
|
+
properties: {
|
|
232
|
+
...(finding.agent === undefined ? {} : { agent: finding.agent }),
|
|
233
|
+
...(finding.evidence ?? {}),
|
|
234
|
+
},
|
|
235
|
+
}),
|
|
236
|
+
}));
|
|
237
|
+
|
|
238
|
+
return stableJson({
|
|
239
|
+
$schema: "https://json.schemastore.org/sarif-2.1.0.json",
|
|
240
|
+
version: "2.1.0",
|
|
241
|
+
runs: [
|
|
242
|
+
{
|
|
243
|
+
tool: {
|
|
244
|
+
driver: {
|
|
245
|
+
name: "SSRWire",
|
|
246
|
+
version: audit.version,
|
|
247
|
+
informationUri: "https://nikom.work",
|
|
248
|
+
rules,
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
results,
|
|
252
|
+
},
|
|
253
|
+
],
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function renderReport(
|
|
258
|
+
audit: AuditResult,
|
|
259
|
+
format: ReportFormat,
|
|
260
|
+
options: ReporterOptions = {},
|
|
261
|
+
): string {
|
|
262
|
+
if (format === "terminal") {
|
|
263
|
+
return renderTerminal(audit, options);
|
|
264
|
+
}
|
|
265
|
+
if (format === "json") {
|
|
266
|
+
return renderJson(audit);
|
|
267
|
+
}
|
|
268
|
+
if (format === "sarif") {
|
|
269
|
+
return renderSarif(audit);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const exhaustive: never = format;
|
|
273
|
+
return exhaustive;
|
|
274
|
+
}
|
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
import { Parser } from "htmlparser2";
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
DocumentSignals,
|
|
5
|
+
ElementLocation,
|
|
6
|
+
ElementSignal,
|
|
7
|
+
JsonLdSignal,
|
|
8
|
+
RobotsAudience,
|
|
9
|
+
RobotsSignal,
|
|
10
|
+
TimingMark,
|
|
11
|
+
} from "./types.js";
|
|
12
|
+
|
|
13
|
+
const ELEMENT_VALUE_LIMIT = 4_096;
|
|
14
|
+
const MAIN_TEXT_LIMIT = 240;
|
|
15
|
+
const SIGNAL_LIMIT = 256;
|
|
16
|
+
const JSON_LD_BLOCK_LIMIT = 64;
|
|
17
|
+
const JSON_LD_CAPTURE_LIMIT = 1_048_576;
|
|
18
|
+
const JSON_LD_NODE_LIMIT = 10_000;
|
|
19
|
+
const JSON_LD_TYPE_LIMIT = 256;
|
|
20
|
+
|
|
21
|
+
interface TextCapture {
|
|
22
|
+
readonly location: ElementLocation;
|
|
23
|
+
value: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface ScriptCapture extends TextCapture {
|
|
27
|
+
readonly jsonLd: boolean;
|
|
28
|
+
bytes: number;
|
|
29
|
+
truncated: boolean;
|
|
30
|
+
omitted: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface StreamInspector {
|
|
34
|
+
readonly bytesObserved: number;
|
|
35
|
+
write(chunk: Uint8Array, atMs: number): void;
|
|
36
|
+
end(atMs?: number): DocumentSignals;
|
|
37
|
+
finish(atMs?: number): DocumentSignals;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function normalizedText(value: string, limit = ELEMENT_VALUE_LIMIT): string {
|
|
41
|
+
return value.replace(/\s+/gu, " ").trim().slice(0, limit);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function timing(atMs: number, observedByByte: number): TimingMark {
|
|
45
|
+
return {
|
|
46
|
+
atMs: Number.isFinite(atMs) ? Math.max(0, atMs) : 0,
|
|
47
|
+
observedByByte,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function isRobotsAudience(value: string | undefined): value is RobotsAudience {
|
|
52
|
+
return value === "robots" || value === "googlebot" || value === "bingbot";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function collectJsonLdTypes(value: unknown, output: Set<string>): void {
|
|
56
|
+
const pending: unknown[] = [value];
|
|
57
|
+
let visited = 0;
|
|
58
|
+
while (pending.length > 0 && visited < JSON_LD_NODE_LIMIT) {
|
|
59
|
+
const current = pending.pop();
|
|
60
|
+
visited += 1;
|
|
61
|
+
if (Array.isArray(current)) {
|
|
62
|
+
for (const entry of current) {
|
|
63
|
+
if (pending.length + visited >= JSON_LD_NODE_LIMIT) break;
|
|
64
|
+
pending.push(entry);
|
|
65
|
+
}
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (typeof current !== "object" || current === null) continue;
|
|
69
|
+
|
|
70
|
+
const object = current as Readonly<Record<string, unknown>>;
|
|
71
|
+
const type = object["@type"];
|
|
72
|
+
if (typeof type === "string" && type.trim().length > 0) {
|
|
73
|
+
if (output.size < JSON_LD_TYPE_LIMIT) output.add(type.trim().slice(0, ELEMENT_VALUE_LIMIT));
|
|
74
|
+
} else if (Array.isArray(type)) {
|
|
75
|
+
for (const entry of type) {
|
|
76
|
+
if (output.size >= JSON_LD_TYPE_LIMIT) break;
|
|
77
|
+
if (typeof entry === "string" && entry.trim().length > 0) {
|
|
78
|
+
output.add(entry.trim().slice(0, ELEMENT_VALUE_LIMIT));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
for (const key of Object.keys(object)) {
|
|
84
|
+
if (pending.length + visited >= JSON_LD_NODE_LIMIT) break;
|
|
85
|
+
pending.push(object[key]);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
class HtmlStreamInspector implements StreamInspector {
|
|
91
|
+
readonly #decoder = new TextDecoder();
|
|
92
|
+
readonly #parser: Parser;
|
|
93
|
+
readonly #descriptions: ElementSignal[] = [];
|
|
94
|
+
readonly #canonicals: ElementSignal[] = [];
|
|
95
|
+
readonly #robots: RobotsSignal[] = [];
|
|
96
|
+
readonly #h1s: ElementSignal[] = [];
|
|
97
|
+
readonly #jsonLd: JsonLdSignal[] = [];
|
|
98
|
+
readonly #titles: ElementSignal[] = [];
|
|
99
|
+
|
|
100
|
+
#bytesObserved = 0;
|
|
101
|
+
#currentAtMs = 0;
|
|
102
|
+
#ended = false;
|
|
103
|
+
#result?: DocumentSignals;
|
|
104
|
+
#inHead = false;
|
|
105
|
+
#inBody = false;
|
|
106
|
+
#templateDepth = 0;
|
|
107
|
+
#foreignContentDepth = 0;
|
|
108
|
+
#mainDepth = 0;
|
|
109
|
+
#ignoredMainTextDepth = 0;
|
|
110
|
+
#titleCapture: TextCapture | undefined;
|
|
111
|
+
#h1Capture: TextCapture | undefined;
|
|
112
|
+
#scriptCapture: ScriptCapture | undefined;
|
|
113
|
+
#jsonLdLimitReported = false;
|
|
114
|
+
#mainText = "";
|
|
115
|
+
#mainTextStarted?: TimingMark;
|
|
116
|
+
#mainTextLocation: ElementLocation = "document";
|
|
117
|
+
#headClosed?: TimingMark;
|
|
118
|
+
#bodyStarted?: TimingMark;
|
|
119
|
+
#documentClosed?: TimingMark;
|
|
120
|
+
|
|
121
|
+
constructor() {
|
|
122
|
+
this.#parser = new Parser(
|
|
123
|
+
{
|
|
124
|
+
onopentag: (name, attributes) => this.#onOpenTag(name, attributes),
|
|
125
|
+
ontext: (value) => this.#onText(value),
|
|
126
|
+
onclosetag: (name, isImplied) => this.#onCloseTag(name, isImplied),
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
decodeEntities: true,
|
|
130
|
+
lowerCaseAttributeNames: true,
|
|
131
|
+
lowerCaseTags: true,
|
|
132
|
+
recognizeCDATA: true,
|
|
133
|
+
},
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
get bytesObserved(): number {
|
|
138
|
+
return this.#bytesObserved;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
write(chunk: Uint8Array, atMs: number): void {
|
|
142
|
+
if (this.#ended) throw new Error("Cannot write to a finished stream inspector.");
|
|
143
|
+
if (chunk.byteLength === 0) return;
|
|
144
|
+
|
|
145
|
+
this.#bytesObserved += chunk.byteLength;
|
|
146
|
+
this.#currentAtMs = Number.isFinite(atMs) ? Math.max(0, atMs) : 0;
|
|
147
|
+
const decoded = this.#decoder.decode(chunk, { stream: true });
|
|
148
|
+
if (decoded.length > 0) this.#parser.write(decoded);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
end(atMs = this.#currentAtMs): DocumentSignals {
|
|
152
|
+
if (this.#result !== undefined) return this.#result;
|
|
153
|
+
|
|
154
|
+
this.#currentAtMs = Number.isFinite(atMs) ? Math.max(0, atMs) : this.#currentAtMs;
|
|
155
|
+
const tail = this.#decoder.decode();
|
|
156
|
+
this.#parser.end(tail.length > 0 ? tail : undefined);
|
|
157
|
+
this.#ended = true;
|
|
158
|
+
|
|
159
|
+
const firstMainText = this.#mainTextStarted
|
|
160
|
+
? {
|
|
161
|
+
value: normalizedText(this.#mainText, MAIN_TEXT_LIMIT),
|
|
162
|
+
location: this.#mainTextLocation,
|
|
163
|
+
...this.#mainTextStarted,
|
|
164
|
+
}
|
|
165
|
+
: undefined;
|
|
166
|
+
|
|
167
|
+
this.#result = {
|
|
168
|
+
...(this.#titles[0] === undefined ? {} : { title: this.#titles[0] }),
|
|
169
|
+
titles: [...this.#titles],
|
|
170
|
+
descriptions: [...this.#descriptions],
|
|
171
|
+
canonicals: [...this.#canonicals],
|
|
172
|
+
robots: [...this.#robots],
|
|
173
|
+
h1s: [...this.#h1s],
|
|
174
|
+
...(firstMainText === undefined || firstMainText.value.length === 0 ? {} : { firstMainText }),
|
|
175
|
+
jsonLd: [...this.#jsonLd],
|
|
176
|
+
...(this.#headClosed === undefined ? {} : { headClosed: this.#headClosed }),
|
|
177
|
+
...(this.#bodyStarted === undefined ? {} : { bodyStarted: this.#bodyStarted }),
|
|
178
|
+
...(this.#documentClosed === undefined ? {} : { documentClosed: this.#documentClosed }),
|
|
179
|
+
};
|
|
180
|
+
return this.#result;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
finish(atMs = this.#currentAtMs): DocumentSignals {
|
|
184
|
+
return this.end(atMs);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
#mark(): TimingMark {
|
|
188
|
+
return timing(this.#currentAtMs, this.#bytesObserved);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
#location(): ElementLocation {
|
|
192
|
+
if (this.#inHead) return "head";
|
|
193
|
+
if (this.#inBody) return "body";
|
|
194
|
+
return "document";
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
#elementSignal(value: string, location = this.#location()): ElementSignal {
|
|
198
|
+
return {
|
|
199
|
+
value: normalizedText(value),
|
|
200
|
+
location,
|
|
201
|
+
...this.#mark(),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
#onOpenTag(name: string, attributes: Readonly<Record<string, string>>): void {
|
|
206
|
+
const alreadyExcluded = this.#templateDepth > 0 || this.#foreignContentDepth > 0;
|
|
207
|
+
if (name === "template") this.#templateDepth += 1;
|
|
208
|
+
if (name === "svg" || name === "math") this.#foreignContentDepth += 1;
|
|
209
|
+
|
|
210
|
+
// A template's inert document fragment and SVG/MathML foreign content are not
|
|
211
|
+
// document SEO signals, even when they contain HTML-looking element names.
|
|
212
|
+
if (alreadyExcluded || name === "template" || name === "svg" || name === "math") {
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const { name: metaNameAttribute, content, rel, href, type: scriptType } = attributes;
|
|
217
|
+
if (name === "head") this.#inHead = true;
|
|
218
|
+
if (name === "body") {
|
|
219
|
+
if (this.#bodyStarted === undefined) this.#bodyStarted = this.#mark();
|
|
220
|
+
this.#inBody = true;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const location = this.#location();
|
|
224
|
+
|
|
225
|
+
if (
|
|
226
|
+
name === "title" &&
|
|
227
|
+
this.#titleCapture === undefined &&
|
|
228
|
+
this.#titles.length < SIGNAL_LIMIT
|
|
229
|
+
) {
|
|
230
|
+
this.#titleCapture = { location, value: "" };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (name === "meta") {
|
|
234
|
+
const metaName = metaNameAttribute?.trim().toLowerCase();
|
|
235
|
+
if (
|
|
236
|
+
content !== undefined &&
|
|
237
|
+
metaName === "description" &&
|
|
238
|
+
this.#descriptions.length < SIGNAL_LIMIT
|
|
239
|
+
) {
|
|
240
|
+
this.#descriptions.push(this.#elementSignal(content, location));
|
|
241
|
+
}
|
|
242
|
+
if (
|
|
243
|
+
content !== undefined &&
|
|
244
|
+
isRobotsAudience(metaName) &&
|
|
245
|
+
this.#robots.length < SIGNAL_LIMIT
|
|
246
|
+
) {
|
|
247
|
+
this.#robots.push({
|
|
248
|
+
...this.#elementSignal(content, location),
|
|
249
|
+
audience: metaName,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (name === "link") {
|
|
255
|
+
const relations = rel?.toLowerCase().split(/\s+/u) ?? [];
|
|
256
|
+
if (
|
|
257
|
+
href !== undefined &&
|
|
258
|
+
relations.includes("canonical") &&
|
|
259
|
+
this.#canonicals.length < SIGNAL_LIMIT
|
|
260
|
+
) {
|
|
261
|
+
this.#canonicals.push(this.#elementSignal(href, location));
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (name === "h1" && this.#h1Capture === undefined && this.#h1s.length < SIGNAL_LIMIT) {
|
|
266
|
+
this.#h1Capture = { location, value: "" };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (name === "main") this.#mainDepth += 1;
|
|
270
|
+
if (this.#mainDepth > 0 && ["script", "style", "template", "noscript"].includes(name)) {
|
|
271
|
+
this.#ignoredMainTextDepth += 1;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const scriptMediaType = scriptType?.split(";", 1)[0]?.trim().toLowerCase();
|
|
275
|
+
if (name === "script" && scriptMediaType === "application/ld+json") {
|
|
276
|
+
this.#scriptCapture = {
|
|
277
|
+
jsonLd: true,
|
|
278
|
+
location,
|
|
279
|
+
value: "",
|
|
280
|
+
bytes: 0,
|
|
281
|
+
truncated: false,
|
|
282
|
+
omitted: this.#jsonLd.length >= JSON_LD_BLOCK_LIMIT,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
#onText(value: string): void {
|
|
288
|
+
if (this.#templateDepth > 0 || this.#foreignContentDepth > 0) return;
|
|
289
|
+
|
|
290
|
+
if (this.#titleCapture !== undefined) {
|
|
291
|
+
this.#titleCapture.value = `${this.#titleCapture.value}${value}`.slice(
|
|
292
|
+
0,
|
|
293
|
+
ELEMENT_VALUE_LIMIT,
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
if (this.#h1Capture !== undefined) {
|
|
297
|
+
this.#h1Capture.value = `${this.#h1Capture.value}${value}`.slice(0, ELEMENT_VALUE_LIMIT);
|
|
298
|
+
}
|
|
299
|
+
if (this.#scriptCapture !== undefined) {
|
|
300
|
+
this.#scriptCapture.bytes += Buffer.byteLength(value, "utf8");
|
|
301
|
+
if (!this.#scriptCapture.omitted) {
|
|
302
|
+
const remaining = JSON_LD_CAPTURE_LIMIT - this.#scriptCapture.value.length;
|
|
303
|
+
if (remaining > 0) this.#scriptCapture.value += value.slice(0, remaining);
|
|
304
|
+
if (value.length > remaining) this.#scriptCapture.truncated = true;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (this.#mainDepth > 0 && this.#ignoredMainTextDepth === 0 && this.#mainText.length < 1_024) {
|
|
309
|
+
if (this.#mainTextStarted === undefined && /\S/u.test(value)) {
|
|
310
|
+
this.#mainTextStarted = this.#mark();
|
|
311
|
+
this.#mainTextLocation = this.#location();
|
|
312
|
+
}
|
|
313
|
+
if (this.#mainTextStarted !== undefined) {
|
|
314
|
+
this.#mainText += value.slice(0, 1_024 - this.#mainText.length);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
#onCloseTag(name: string, isImplied: boolean): void {
|
|
320
|
+
if (this.#templateDepth > 0 || this.#foreignContentDepth > 0) {
|
|
321
|
+
if (name === "template") this.#templateDepth = Math.max(0, this.#templateDepth - 1);
|
|
322
|
+
if (name === "svg" || name === "math") {
|
|
323
|
+
this.#foreignContentDepth = Math.max(0, this.#foreignContentDepth - 1);
|
|
324
|
+
}
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (name === "title" && this.#titleCapture !== undefined) {
|
|
329
|
+
const value = normalizedText(this.#titleCapture.value);
|
|
330
|
+
if (value.length > 0) {
|
|
331
|
+
this.#titles.push({
|
|
332
|
+
value,
|
|
333
|
+
location: this.#titleCapture.location,
|
|
334
|
+
...this.#mark(),
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
this.#titleCapture = undefined;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (name === "h1" && this.#h1Capture !== undefined) {
|
|
341
|
+
const value = normalizedText(this.#h1Capture.value);
|
|
342
|
+
if (value.length > 0) {
|
|
343
|
+
this.#h1s.push({
|
|
344
|
+
value,
|
|
345
|
+
location: this.#h1Capture.location,
|
|
346
|
+
...this.#mark(),
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
this.#h1Capture = undefined;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (name === "script" && this.#scriptCapture !== undefined) {
|
|
353
|
+
if (this.#scriptCapture.jsonLd) this.#recordJsonLd(this.#scriptCapture);
|
|
354
|
+
this.#scriptCapture = undefined;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (this.#mainDepth > 0 && ["script", "style", "template", "noscript"].includes(name)) {
|
|
358
|
+
this.#ignoredMainTextDepth = Math.max(0, this.#ignoredMainTextDepth - 1);
|
|
359
|
+
}
|
|
360
|
+
if (name === "main") this.#mainDepth = Math.max(0, this.#mainDepth - 1);
|
|
361
|
+
|
|
362
|
+
if (name === "head") {
|
|
363
|
+
if (this.#headClosed === undefined) this.#headClosed = this.#mark();
|
|
364
|
+
this.#inHead = false;
|
|
365
|
+
}
|
|
366
|
+
if (name === "body") this.#inBody = false;
|
|
367
|
+
if (name === "html" && !isImplied && this.#documentClosed === undefined) {
|
|
368
|
+
this.#documentClosed = this.#mark();
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
#recordJsonLd(capture: ScriptCapture): void {
|
|
373
|
+
const mark = this.#mark();
|
|
374
|
+
if (capture.omitted) {
|
|
375
|
+
if (!this.#jsonLdLimitReported) {
|
|
376
|
+
this.#jsonLdLimitReported = true;
|
|
377
|
+
this.#jsonLd.push({
|
|
378
|
+
location: capture.location,
|
|
379
|
+
types: [],
|
|
380
|
+
bytes: capture.bytes,
|
|
381
|
+
analysisLimit: `Additional JSON-LD blocks exceeded the ${JSON_LD_BLOCK_LIMIT}-block analysis limit.`,
|
|
382
|
+
...mark,
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
if (capture.truncated) {
|
|
388
|
+
this.#jsonLd.push({
|
|
389
|
+
location: capture.location,
|
|
390
|
+
types: [],
|
|
391
|
+
bytes: capture.bytes,
|
|
392
|
+
analysisLimit: `JSON-LD block exceeded the ${JSON_LD_CAPTURE_LIMIT}-character analysis limit.`,
|
|
393
|
+
...mark,
|
|
394
|
+
});
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
try {
|
|
398
|
+
const parsed: unknown = JSON.parse(capture.value);
|
|
399
|
+
const types = new Set<string>();
|
|
400
|
+
collectJsonLdTypes(parsed, types);
|
|
401
|
+
this.#jsonLd.push({
|
|
402
|
+
location: capture.location,
|
|
403
|
+
valid: true,
|
|
404
|
+
types: [...types].sort(),
|
|
405
|
+
bytes: capture.bytes,
|
|
406
|
+
...mark,
|
|
407
|
+
});
|
|
408
|
+
} catch (error) {
|
|
409
|
+
const message = error instanceof Error ? error.message : "Invalid JSON";
|
|
410
|
+
this.#jsonLd.push({
|
|
411
|
+
location: capture.location,
|
|
412
|
+
valid: false,
|
|
413
|
+
types: [],
|
|
414
|
+
bytes: capture.bytes,
|
|
415
|
+
error: message.slice(0, 240),
|
|
416
|
+
...mark,
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
export function createStreamInspector(): StreamInspector {
|
|
423
|
+
return new HtmlStreamInspector();
|
|
424
|
+
}
|