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/analyze.ts
ADDED
|
@@ -0,0 +1,628 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AuditSummary,
|
|
3
|
+
AuditTarget,
|
|
4
|
+
ElementSignal,
|
|
5
|
+
Finding,
|
|
6
|
+
ProbeResult,
|
|
7
|
+
RobotsAudience,
|
|
8
|
+
RobotsSignal,
|
|
9
|
+
Severity,
|
|
10
|
+
TargetAuditResult,
|
|
11
|
+
} from "./types.js";
|
|
12
|
+
|
|
13
|
+
type MetadataKind = "title" | "description" | "canonical" | "robots";
|
|
14
|
+
|
|
15
|
+
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
16
|
+
const ROBOTS_OPPOSITES = [
|
|
17
|
+
["index", "noindex"],
|
|
18
|
+
["follow", "nofollow"],
|
|
19
|
+
["archive", "noarchive"],
|
|
20
|
+
["snippet", "nosnippet"],
|
|
21
|
+
["translate", "notranslate"],
|
|
22
|
+
["imageindex", "noimageindex"],
|
|
23
|
+
] as const;
|
|
24
|
+
|
|
25
|
+
interface FindingInput {
|
|
26
|
+
readonly code: string;
|
|
27
|
+
readonly severity: Severity;
|
|
28
|
+
readonly message: string;
|
|
29
|
+
readonly agent?: string;
|
|
30
|
+
readonly evidence?: Readonly<Record<string, string | number | boolean>>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function normalizeText(value: string): string {
|
|
34
|
+
return value.trim().replace(/\s+/g, " ");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function normalizeCanonical(value: string, baseUrl: string): string {
|
|
38
|
+
const trimmed = normalizeText(value);
|
|
39
|
+
if (trimmed.length === 0) {
|
|
40
|
+
return "";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const url = new URL(trimmed, baseUrl);
|
|
45
|
+
url.hash = "";
|
|
46
|
+
return url.href;
|
|
47
|
+
} catch {
|
|
48
|
+
return trimmed;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function normalizeFinalUrl(value: string): string {
|
|
53
|
+
try {
|
|
54
|
+
const url = new URL(value);
|
|
55
|
+
url.hash = "";
|
|
56
|
+
return url.href;
|
|
57
|
+
} catch {
|
|
58
|
+
return value;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function normalizeRobots(value: string): string {
|
|
63
|
+
return normalizeText(value)
|
|
64
|
+
.toLowerCase()
|
|
65
|
+
.split(/[;,]/)
|
|
66
|
+
.map((directive) => directive.trim())
|
|
67
|
+
.filter((directive) => directive.length > 0)
|
|
68
|
+
.sort()
|
|
69
|
+
.join(",");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function normalizedValues(
|
|
73
|
+
kind: MetadataKind,
|
|
74
|
+
signals: readonly ElementSignal[],
|
|
75
|
+
baseUrl: string,
|
|
76
|
+
): readonly string[] {
|
|
77
|
+
return signals
|
|
78
|
+
.map((signal) => {
|
|
79
|
+
if (kind === "canonical") {
|
|
80
|
+
return normalizeCanonical(signal.value, baseUrl);
|
|
81
|
+
}
|
|
82
|
+
if (kind === "robots") {
|
|
83
|
+
return normalizeRobots(signal.value);
|
|
84
|
+
}
|
|
85
|
+
return normalizeText(signal.value);
|
|
86
|
+
})
|
|
87
|
+
.filter((value) => value.length > 0);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function robotsAudienceForAgent(probe: ProbeResult): RobotsAudience {
|
|
91
|
+
const key = probe.agent.key.trim().toLowerCase();
|
|
92
|
+
if (key === "googlebot" || key === "bingbot") return key;
|
|
93
|
+
return "robots";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function effectiveRobotsSignals(probe: ProbeResult): readonly RobotsSignal[] {
|
|
97
|
+
const audience = robotsAudienceForAgent(probe);
|
|
98
|
+
const generic = probe.signals.robots.filter((signal) => signal.audience === "robots");
|
|
99
|
+
if (audience === "robots") return generic;
|
|
100
|
+
|
|
101
|
+
const specific = probe.signals.robots.filter(
|
|
102
|
+
(signal) => signal.audience === audience && normalizeRobots(signal.value).length > 0,
|
|
103
|
+
);
|
|
104
|
+
return [...generic, ...specific];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function normalizedRobotsDirectives(signals: readonly RobotsSignal[]): Set<string> {
|
|
108
|
+
const directives = new Set(
|
|
109
|
+
signals
|
|
110
|
+
.flatMap((signal) => normalizeRobots(signal.value).split(","))
|
|
111
|
+
.filter((directive) => directive.length > 0),
|
|
112
|
+
);
|
|
113
|
+
if (directives.delete("none")) {
|
|
114
|
+
directives.add("noindex");
|
|
115
|
+
directives.add("nofollow");
|
|
116
|
+
}
|
|
117
|
+
if (directives.delete("all")) {
|
|
118
|
+
directives.add("index");
|
|
119
|
+
directives.add("follow");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
for (const [permissive, restrictive] of ROBOTS_OPPOSITES) {
|
|
123
|
+
if (directives.has(restrictive)) directives.delete(permissive);
|
|
124
|
+
}
|
|
125
|
+
return directives;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function effectiveRobotsValue(probe: ProbeResult): string {
|
|
129
|
+
return (
|
|
130
|
+
[...normalizedRobotsDirectives(effectiveRobotsSignals(probe))].sort().join(",") || "<missing>"
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function addFinding(findings: Finding[], url: string, input: FindingInput): void {
|
|
135
|
+
findings.push({
|
|
136
|
+
code: input.code,
|
|
137
|
+
severity: input.severity,
|
|
138
|
+
message: input.message,
|
|
139
|
+
url,
|
|
140
|
+
...(input.agent === undefined ? {} : { agent: input.agent }),
|
|
141
|
+
...(input.evidence === undefined ? {} : { evidence: input.evidence }),
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function checkRepeatedMetadata(
|
|
146
|
+
findings: Finding[],
|
|
147
|
+
targetUrl: string,
|
|
148
|
+
probe: ProbeResult,
|
|
149
|
+
kind: MetadataKind,
|
|
150
|
+
signals: readonly ElementSignal[],
|
|
151
|
+
): void {
|
|
152
|
+
const values = normalizedValues(kind, signals, probe.finalUrl);
|
|
153
|
+
if (values.length < 2) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const distinct = new Set(values);
|
|
158
|
+
const conflicting = distinct.size > 1;
|
|
159
|
+
const label = kind === "robots" ? "meta robots directives" : `${kind} elements`;
|
|
160
|
+
addFinding(findings, targetUrl, {
|
|
161
|
+
code: conflicting ? `conflicting-${kind}` : `duplicate-${kind}`,
|
|
162
|
+
severity: "warning",
|
|
163
|
+
message: conflicting
|
|
164
|
+
? `${probe.agent.label} received conflicting ${label}.`
|
|
165
|
+
: `${probe.agent.label} received duplicate ${label}.`,
|
|
166
|
+
agent: probe.agent.key,
|
|
167
|
+
evidence: {
|
|
168
|
+
count: values.length,
|
|
169
|
+
distinctValues: distinct.size,
|
|
170
|
+
values: [...distinct].join(" | "),
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function checkRepeatedRobots(findings: Finding[], targetUrl: string, probe: ProbeResult): void {
|
|
176
|
+
const audience = robotsAudienceForAgent(probe);
|
|
177
|
+
const relevantAudiences: readonly RobotsAudience[] =
|
|
178
|
+
audience === "robots" ? ["robots"] : ["robots", audience];
|
|
179
|
+
let conflictReported = false;
|
|
180
|
+
|
|
181
|
+
for (const currentAudience of relevantAudiences) {
|
|
182
|
+
const signals = probe.signals.robots.filter(
|
|
183
|
+
(signal) => signal.audience === currentAudience && normalizeRobots(signal.value).length > 0,
|
|
184
|
+
);
|
|
185
|
+
if (signals.length < 2) continue;
|
|
186
|
+
|
|
187
|
+
const values = signals.map((signal) => normalizeRobots(signal.value));
|
|
188
|
+
const distinct = new Set(values);
|
|
189
|
+
const rawDirectives = new Set(values.flatMap((value) => value.split(",")));
|
|
190
|
+
if (rawDirectives.has("none")) {
|
|
191
|
+
rawDirectives.add("noindex");
|
|
192
|
+
rawDirectives.add("nofollow");
|
|
193
|
+
}
|
|
194
|
+
if (rawDirectives.has("all")) {
|
|
195
|
+
rawDirectives.add("index");
|
|
196
|
+
rawDirectives.add("follow");
|
|
197
|
+
}
|
|
198
|
+
const conflicting = ROBOTS_OPPOSITES.some(
|
|
199
|
+
([left, right]) => rawDirectives.has(left) && rawDirectives.has(right),
|
|
200
|
+
);
|
|
201
|
+
if (conflicting) conflictReported = true;
|
|
202
|
+
const code = conflicting
|
|
203
|
+
? "conflicting-robots"
|
|
204
|
+
: distinct.size === 1
|
|
205
|
+
? "duplicate-robots"
|
|
206
|
+
: "multiple-robots";
|
|
207
|
+
addFinding(findings, targetUrl, {
|
|
208
|
+
code,
|
|
209
|
+
severity: "warning",
|
|
210
|
+
message: conflicting
|
|
211
|
+
? `${probe.agent.label} received contradictory ${currentAudience} directives.`
|
|
212
|
+
: `${probe.agent.label} received multiple ${currentAudience} meta directives.`,
|
|
213
|
+
agent: probe.agent.key,
|
|
214
|
+
evidence: {
|
|
215
|
+
audience: currentAudience,
|
|
216
|
+
count: values.length,
|
|
217
|
+
distinctValues: distinct.size,
|
|
218
|
+
values: [...distinct].join(" | "),
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const effectiveSignals = effectiveRobotsSignals(probe);
|
|
224
|
+
const genericDirectives = new Set(
|
|
225
|
+
probe.signals.robots
|
|
226
|
+
.filter((signal) => signal.audience === "robots")
|
|
227
|
+
.flatMap((signal) => normalizeRobots(signal.value).split(",")),
|
|
228
|
+
);
|
|
229
|
+
const specificAudience = robotsAudienceForAgent(probe);
|
|
230
|
+
const specificDirectives = new Set(
|
|
231
|
+
specificAudience === "robots"
|
|
232
|
+
? []
|
|
233
|
+
: probe.signals.robots
|
|
234
|
+
.filter((signal) => signal.audience === specificAudience)
|
|
235
|
+
.flatMap((signal) => normalizeRobots(signal.value).split(",")),
|
|
236
|
+
);
|
|
237
|
+
if (genericDirectives.has("none")) {
|
|
238
|
+
genericDirectives.add("noindex");
|
|
239
|
+
genericDirectives.add("nofollow");
|
|
240
|
+
}
|
|
241
|
+
if (specificDirectives.has("all")) {
|
|
242
|
+
specificDirectives.add("index");
|
|
243
|
+
specificDirectives.add("follow");
|
|
244
|
+
}
|
|
245
|
+
// A crawler-specific restriction can intentionally tighten a generic rule. The inverse cannot
|
|
246
|
+
// relax an already-applicable generic restriction and is therefore worth flagging.
|
|
247
|
+
const effectiveConflict = ROBOTS_OPPOSITES.some(
|
|
248
|
+
([permissive, restrictive]) =>
|
|
249
|
+
genericDirectives.has(restrictive) && specificDirectives.has(permissive),
|
|
250
|
+
);
|
|
251
|
+
if (effectiveConflict && !conflictReported) {
|
|
252
|
+
addFinding(findings, targetUrl, {
|
|
253
|
+
code: "conflicting-robots",
|
|
254
|
+
severity: "warning",
|
|
255
|
+
message: `${probe.agent.label} received contradictory effective robots directives.`,
|
|
256
|
+
agent: probe.agent.key,
|
|
257
|
+
evidence: {
|
|
258
|
+
audience: "effective",
|
|
259
|
+
count: effectiveSignals.length,
|
|
260
|
+
distinctValues: new Set([...genericDirectives, ...specificDirectives]).size,
|
|
261
|
+
values: [...new Set([...genericDirectives, ...specificDirectives])].sort().join(","),
|
|
262
|
+
},
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function criticalArrivalMs(target: AuditTarget, probe: ProbeResult): number | undefined {
|
|
268
|
+
const marks: number[] = [];
|
|
269
|
+
const { expectations } = target;
|
|
270
|
+
|
|
271
|
+
if (expectations.requireTitle && normalizeText(probe.signals.title?.value ?? "").length > 0) {
|
|
272
|
+
marks.push(probe.signals.title?.atMs ?? 0);
|
|
273
|
+
}
|
|
274
|
+
if (expectations.requireDescription) {
|
|
275
|
+
const description = probe.signals.descriptions.find(
|
|
276
|
+
(signal) => normalizeText(signal.value).length > 0,
|
|
277
|
+
);
|
|
278
|
+
if (description !== undefined) {
|
|
279
|
+
marks.push(description.atMs);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
if (expectations.requireCanonical) {
|
|
283
|
+
const canonical = probe.signals.canonicals.find(
|
|
284
|
+
(signal) => normalizeCanonical(signal.value, probe.finalUrl).length > 0,
|
|
285
|
+
);
|
|
286
|
+
if (canonical !== undefined) {
|
|
287
|
+
marks.push(canonical.atMs);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
if (expectations.requireH1) {
|
|
291
|
+
const h1 = probe.signals.h1s.find((signal) => normalizeText(signal.value).length > 0);
|
|
292
|
+
if (h1 !== undefined) {
|
|
293
|
+
marks.push(h1.atMs);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
if (
|
|
297
|
+
expectations.requireMainText &&
|
|
298
|
+
normalizeText(probe.signals.firstMainText?.value ?? "").length > 0
|
|
299
|
+
) {
|
|
300
|
+
marks.push(probe.signals.firstMainText?.atMs ?? 0);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return marks.length === 0 ? undefined : Math.max(...marks);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function checkRequiredSignals(findings: Finding[], target: AuditTarget, probe: ProbeResult): void {
|
|
307
|
+
// An interrupted or deliberately truncated stream cannot prove that an element is absent.
|
|
308
|
+
if (probe.completion !== "complete") {
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const { expectations } = target;
|
|
313
|
+
const agent = probe.agent.key;
|
|
314
|
+
if (expectations.requireTitle && normalizeText(probe.signals.title?.value ?? "").length === 0) {
|
|
315
|
+
addFinding(findings, target.url, {
|
|
316
|
+
code: "missing-title",
|
|
317
|
+
severity: "error",
|
|
318
|
+
message: `${probe.agent.label} received no non-empty title.`,
|
|
319
|
+
agent,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
if (
|
|
323
|
+
expectations.requireDescription &&
|
|
324
|
+
normalizedValues("description", probe.signals.descriptions, probe.finalUrl).length === 0
|
|
325
|
+
) {
|
|
326
|
+
addFinding(findings, target.url, {
|
|
327
|
+
code: "missing-description",
|
|
328
|
+
severity: "warning",
|
|
329
|
+
message: `${probe.agent.label} received no non-empty meta description.`,
|
|
330
|
+
agent,
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
if (
|
|
334
|
+
expectations.requireCanonical &&
|
|
335
|
+
normalizedValues("canonical", probe.signals.canonicals, probe.finalUrl).length === 0
|
|
336
|
+
) {
|
|
337
|
+
addFinding(findings, target.url, {
|
|
338
|
+
code: "missing-canonical",
|
|
339
|
+
severity: "warning",
|
|
340
|
+
message: `${probe.agent.label} received no non-empty canonical link.`,
|
|
341
|
+
agent,
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
if (
|
|
345
|
+
expectations.requireH1 &&
|
|
346
|
+
!probe.signals.h1s.some((signal) => normalizeText(signal.value).length > 0)
|
|
347
|
+
) {
|
|
348
|
+
addFinding(findings, target.url, {
|
|
349
|
+
code: "missing-h1",
|
|
350
|
+
severity: "warning",
|
|
351
|
+
message: `${probe.agent.label} received no non-empty H1.`,
|
|
352
|
+
agent,
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
if (
|
|
356
|
+
expectations.requireMainText &&
|
|
357
|
+
normalizeText(probe.signals.firstMainText?.value ?? "").length === 0
|
|
358
|
+
) {
|
|
359
|
+
addFinding(findings, target.url, {
|
|
360
|
+
code: "missing-main-text",
|
|
361
|
+
severity: "warning",
|
|
362
|
+
message: `${probe.agent.label} received no main-content text.`,
|
|
363
|
+
agent,
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function checkHeadRequirements(findings: Finding[], targetUrl: string, probe: ProbeResult): void {
|
|
369
|
+
if (!probe.agent.requiresHeadMetadata) {
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const bodyFields = new Set<string>();
|
|
374
|
+
const titles = probe.signals.titles ?? (probe.signals.title ? [probe.signals.title] : []);
|
|
375
|
+
if (titles.some((signal) => signal.location === "body")) {
|
|
376
|
+
bodyFields.add("title");
|
|
377
|
+
}
|
|
378
|
+
if (probe.signals.descriptions.some((signal) => signal.location === "body")) {
|
|
379
|
+
bodyFields.add("description");
|
|
380
|
+
}
|
|
381
|
+
if (probe.signals.canonicals.some((signal) => signal.location === "body")) {
|
|
382
|
+
bodyFields.add("canonical");
|
|
383
|
+
}
|
|
384
|
+
if (effectiveRobotsSignals(probe).some((signal) => signal.location === "body")) {
|
|
385
|
+
bodyFields.add("robots");
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (bodyFields.size === 0) {
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
addFinding(findings, targetUrl, {
|
|
393
|
+
code: "head-metadata-in-body",
|
|
394
|
+
severity: "error",
|
|
395
|
+
message: `${probe.agent.label} requires head metadata but received ${[...bodyFields].join(
|
|
396
|
+
", ",
|
|
397
|
+
)} in the body.`,
|
|
398
|
+
agent: probe.agent.key,
|
|
399
|
+
evidence: { fields: [...bodyFields].join(", ") },
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function checkTimings(findings: Finding[], target: AuditTarget, probe: ProbeResult): void {
|
|
404
|
+
const { expectations } = target;
|
|
405
|
+
const firstByteMs = probe.timings.firstByteMs;
|
|
406
|
+
if (
|
|
407
|
+
expectations.maxFirstByteMs !== undefined &&
|
|
408
|
+
firstByteMs !== undefined &&
|
|
409
|
+
firstByteMs > expectations.maxFirstByteMs
|
|
410
|
+
) {
|
|
411
|
+
addFinding(findings, target.url, {
|
|
412
|
+
code: "slow-first-byte",
|
|
413
|
+
severity: "warning",
|
|
414
|
+
message: `${probe.agent.label} first byte arrived after the configured limit.`,
|
|
415
|
+
agent: probe.agent.key,
|
|
416
|
+
evidence: { observedMs: firstByteMs, limitMs: expectations.maxFirstByteMs },
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const arrivalMs = criticalArrivalMs(target, probe);
|
|
421
|
+
if (
|
|
422
|
+
expectations.maxCriticalMs !== undefined &&
|
|
423
|
+
arrivalMs !== undefined &&
|
|
424
|
+
arrivalMs > expectations.maxCriticalMs
|
|
425
|
+
) {
|
|
426
|
+
addFinding(findings, target.url, {
|
|
427
|
+
code: "slow-critical-signals",
|
|
428
|
+
severity: "warning",
|
|
429
|
+
message: `${probe.agent.label} required signals arrived after the configured limit.`,
|
|
430
|
+
agent: probe.agent.key,
|
|
431
|
+
evidence: { observedMs: arrivalMs, limitMs: expectations.maxCriticalMs },
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function agentValueSummary(
|
|
437
|
+
probes: readonly ProbeResult[],
|
|
438
|
+
valueFor: (probe: ProbeResult) => string,
|
|
439
|
+
): string {
|
|
440
|
+
return probes.map((probe) => `${probe.agent.key}=${valueFor(probe)}`).join("; ");
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function checkAgentDrift(
|
|
444
|
+
findings: Finding[],
|
|
445
|
+
targetUrl: string,
|
|
446
|
+
probes: readonly ProbeResult[],
|
|
447
|
+
): void {
|
|
448
|
+
const complete = probes.filter((probe) => probe.completion === "complete");
|
|
449
|
+
if (complete.length < 2) {
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const comparisons: readonly {
|
|
454
|
+
readonly field: string;
|
|
455
|
+
readonly valueFor: (probe: ProbeResult) => string;
|
|
456
|
+
}[] = [
|
|
457
|
+
{ field: "status", valueFor: (probe) => String(probe.status ?? "missing") },
|
|
458
|
+
{ field: "final-url", valueFor: (probe) => normalizeFinalUrl(probe.finalUrl) },
|
|
459
|
+
{
|
|
460
|
+
field: "title",
|
|
461
|
+
valueFor: (probe) => normalizeText(probe.signals.title?.value ?? "<missing>"),
|
|
462
|
+
},
|
|
463
|
+
{
|
|
464
|
+
field: "canonical",
|
|
465
|
+
valueFor: (probe) =>
|
|
466
|
+
[...new Set(normalizedValues("canonical", probe.signals.canonicals, probe.finalUrl))]
|
|
467
|
+
.sort()
|
|
468
|
+
.join(" | ") || "<missing>",
|
|
469
|
+
},
|
|
470
|
+
{
|
|
471
|
+
field: "robots",
|
|
472
|
+
valueFor: effectiveRobotsValue,
|
|
473
|
+
},
|
|
474
|
+
];
|
|
475
|
+
|
|
476
|
+
for (const comparison of comparisons) {
|
|
477
|
+
const values = complete.map(comparison.valueFor);
|
|
478
|
+
if (new Set(values).size < 2) {
|
|
479
|
+
continue;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
addFinding(findings, targetUrl, {
|
|
483
|
+
code: `agent-${comparison.field}-drift`,
|
|
484
|
+
severity: "warning",
|
|
485
|
+
message: `Crawler profiles received different ${comparison.field.replace("-", " ")} values.`,
|
|
486
|
+
evidence: { values: agentValueSummary(complete, comparison.valueFor) },
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
export function analyzeTarget(
|
|
492
|
+
target: AuditTarget,
|
|
493
|
+
probes: readonly ProbeResult[],
|
|
494
|
+
): readonly Finding[] {
|
|
495
|
+
const findings: Finding[] = [];
|
|
496
|
+
|
|
497
|
+
for (const probe of probes) {
|
|
498
|
+
// A status retained from the previous redirect is not evidence that a final response arrived.
|
|
499
|
+
const hasFinalResponse = probe.status !== undefined && !REDIRECT_STATUSES.has(probe.status);
|
|
500
|
+
if (probe.completion !== "complete") {
|
|
501
|
+
addFinding(findings, target.url, {
|
|
502
|
+
code: "incomplete-probe",
|
|
503
|
+
severity: "error",
|
|
504
|
+
message: `${probe.agent.label} probe did not complete (${probe.completion}).`,
|
|
505
|
+
agent: probe.agent.key,
|
|
506
|
+
evidence: {
|
|
507
|
+
completion: probe.completion,
|
|
508
|
+
bytesRead: probe.bytesRead,
|
|
509
|
+
...(probe.error === undefined ? {} : { error: probe.error }),
|
|
510
|
+
},
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if (
|
|
515
|
+
probe.status !== undefined &&
|
|
516
|
+
!REDIRECT_STATUSES.has(probe.status) &&
|
|
517
|
+
!target.expectations.statuses.includes(probe.status)
|
|
518
|
+
) {
|
|
519
|
+
addFinding(findings, target.url, {
|
|
520
|
+
code: "status-mismatch",
|
|
521
|
+
severity: "error",
|
|
522
|
+
message: `${probe.agent.label} returned unexpected HTTP status ${probe.status}.`,
|
|
523
|
+
agent: probe.agent.key,
|
|
524
|
+
evidence: {
|
|
525
|
+
actual: probe.status,
|
|
526
|
+
expected: target.expectations.statuses.join(", "),
|
|
527
|
+
},
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
if (
|
|
532
|
+
hasFinalResponse &&
|
|
533
|
+
target.expectations.finalUrl !== undefined &&
|
|
534
|
+
normalizeFinalUrl(probe.finalUrl) !== normalizeFinalUrl(target.expectations.finalUrl)
|
|
535
|
+
) {
|
|
536
|
+
addFinding(findings, target.url, {
|
|
537
|
+
code: "final-url-mismatch",
|
|
538
|
+
severity: "error",
|
|
539
|
+
message: `${probe.agent.label} finished at an unexpected URL.`,
|
|
540
|
+
agent: probe.agent.key,
|
|
541
|
+
evidence: {
|
|
542
|
+
actual: probe.finalUrl,
|
|
543
|
+
expected: target.expectations.finalUrl,
|
|
544
|
+
},
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
checkRequiredSignals(findings, target, probe);
|
|
549
|
+
checkRepeatedMetadata(
|
|
550
|
+
findings,
|
|
551
|
+
target.url,
|
|
552
|
+
probe,
|
|
553
|
+
"title",
|
|
554
|
+
probe.signals.titles ?? (probe.signals.title ? [probe.signals.title] : []),
|
|
555
|
+
);
|
|
556
|
+
checkRepeatedMetadata(findings, target.url, probe, "description", probe.signals.descriptions);
|
|
557
|
+
checkRepeatedMetadata(findings, target.url, probe, "canonical", probe.signals.canonicals);
|
|
558
|
+
checkRepeatedRobots(findings, target.url, probe);
|
|
559
|
+
|
|
560
|
+
const invalidJsonLd = probe.signals.jsonLd.filter((signal) => signal.valid === false);
|
|
561
|
+
if (probe.completion === "complete" && invalidJsonLd.length > 0) {
|
|
562
|
+
addFinding(findings, target.url, {
|
|
563
|
+
code: "invalid-json-ld",
|
|
564
|
+
severity: "warning",
|
|
565
|
+
message: `${probe.agent.label} received invalid JSON-LD.`,
|
|
566
|
+
agent: probe.agent.key,
|
|
567
|
+
evidence: {
|
|
568
|
+
invalidBlocks: invalidJsonLd.length,
|
|
569
|
+
errors: invalidJsonLd.map((signal) => signal.error ?? "invalid JSON").join(" | "),
|
|
570
|
+
},
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
const limitedJsonLd = probe.signals.jsonLd.filter(
|
|
575
|
+
(signal) => signal.valid === undefined && signal.analysisLimit !== undefined,
|
|
576
|
+
);
|
|
577
|
+
if (limitedJsonLd.length > 0) {
|
|
578
|
+
addFinding(findings, target.url, {
|
|
579
|
+
code: "json-ld-analysis-limit",
|
|
580
|
+
severity: "warning",
|
|
581
|
+
message: `${probe.agent.label} exceeded an SSRWire JSON-LD analysis limit.`,
|
|
582
|
+
agent: probe.agent.key,
|
|
583
|
+
evidence: {
|
|
584
|
+
blocks: limitedJsonLd.length,
|
|
585
|
+
reasons: limitedJsonLd.map((signal) => signal.analysisLimit).join(" | "),
|
|
586
|
+
},
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
checkHeadRequirements(findings, target.url, probe);
|
|
591
|
+
checkTimings(findings, target, probe);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
checkAgentDrift(findings, target.url, probes);
|
|
595
|
+
|
|
596
|
+
return findings;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
export function summarizeAudit(results: readonly TargetAuditResult[]): AuditSummary {
|
|
600
|
+
let errors = 0;
|
|
601
|
+
let warnings = 0;
|
|
602
|
+
let info = 0;
|
|
603
|
+
let probes = 0;
|
|
604
|
+
let incomplete = 0;
|
|
605
|
+
|
|
606
|
+
for (const result of results) {
|
|
607
|
+
probes += result.probes.length;
|
|
608
|
+
incomplete += result.probes.filter((probe) => probe.completion !== "complete").length;
|
|
609
|
+
for (const finding of result.findings) {
|
|
610
|
+
if (finding.severity === "error") {
|
|
611
|
+
errors += 1;
|
|
612
|
+
} else if (finding.severity === "warning") {
|
|
613
|
+
warnings += 1;
|
|
614
|
+
} else {
|
|
615
|
+
info += 1;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
return {
|
|
621
|
+
targets: results.length,
|
|
622
|
+
probes,
|
|
623
|
+
errors,
|
|
624
|
+
warnings,
|
|
625
|
+
info,
|
|
626
|
+
incomplete,
|
|
627
|
+
};
|
|
628
|
+
}
|
package/src/audit.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { analyzeTarget, summarizeAudit } from "./analyze.js";
|
|
2
|
+
import { probeUrl } from "./http-probe.js";
|
|
3
|
+
import { redactAudit } from "./redact.js";
|
|
4
|
+
import type { AuditResult, ProbeOptions, SsrWireConfig, TargetAuditResult } from "./types.js";
|
|
5
|
+
import { VERSION } from "./version.js";
|
|
6
|
+
|
|
7
|
+
const DEFAULT_CONCURRENCY = 4;
|
|
8
|
+
|
|
9
|
+
interface ProbeTask {
|
|
10
|
+
readonly targetIndex: number;
|
|
11
|
+
readonly agentIndex: number;
|
|
12
|
+
readonly options: ProbeOptions;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function runPool<T, R>(
|
|
16
|
+
inputs: readonly T[],
|
|
17
|
+
limit: number,
|
|
18
|
+
worker: (input: T) => Promise<R>,
|
|
19
|
+
): Promise<R[]> {
|
|
20
|
+
const results = new Array<R>(inputs.length);
|
|
21
|
+
let cursor = 0;
|
|
22
|
+
|
|
23
|
+
async function consume(): Promise<void> {
|
|
24
|
+
while (cursor < inputs.length) {
|
|
25
|
+
const index = cursor;
|
|
26
|
+
cursor += 1;
|
|
27
|
+
const input = inputs[index];
|
|
28
|
+
if (input === undefined) {
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
results[index] = await worker(input);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const workers = Array.from({ length: Math.min(limit, inputs.length) }, () => consume());
|
|
36
|
+
await Promise.all(workers);
|
|
37
|
+
return results;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function runAudit(config: SsrWireConfig): Promise<AuditResult> {
|
|
41
|
+
const started = performance.now();
|
|
42
|
+
const tasks: ProbeTask[] = [];
|
|
43
|
+
|
|
44
|
+
for (const [targetIndex, target] of config.targets.entries()) {
|
|
45
|
+
for (const [agentIndex, agent] of config.agents.entries()) {
|
|
46
|
+
tasks.push({
|
|
47
|
+
targetIndex,
|
|
48
|
+
agentIndex,
|
|
49
|
+
options: {
|
|
50
|
+
url: target.url,
|
|
51
|
+
agent,
|
|
52
|
+
headers: config.headers,
|
|
53
|
+
timeoutMs: config.timeoutMs,
|
|
54
|
+
maxBytes: config.maxBytes,
|
|
55
|
+
maxRedirects: config.maxRedirects,
|
|
56
|
+
redactHeaderValues: false,
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const secrets = Object.values(config.headers);
|
|
63
|
+
const probes = await runPool(tasks, DEFAULT_CONCURRENCY, async (task) => {
|
|
64
|
+
const probe = await probeUrl(task.options);
|
|
65
|
+
return { task, probe };
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const results: TargetAuditResult[] = config.targets.map((target, targetIndex) => {
|
|
69
|
+
const targetProbes = probes
|
|
70
|
+
.filter((item) => item.task.targetIndex === targetIndex)
|
|
71
|
+
.sort((a, b) => a.task.agentIndex - b.task.agentIndex)
|
|
72
|
+
.map((item) => item.probe);
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
target,
|
|
76
|
+
probes: targetProbes,
|
|
77
|
+
findings: analyzeTarget(target, targetProbes),
|
|
78
|
+
};
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const audit: AuditResult = {
|
|
82
|
+
version: VERSION,
|
|
83
|
+
generatedAt: new Date().toISOString(),
|
|
84
|
+
durationMs: Math.round(performance.now() - started),
|
|
85
|
+
results,
|
|
86
|
+
summary: summarizeAudit(results),
|
|
87
|
+
};
|
|
88
|
+
return redactAudit(audit, secrets);
|
|
89
|
+
}
|