ssrwire 0.1.0 → 0.3.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 +45 -1
- package/CONTRIBUTING.md +8 -5
- package/PUBLISHING.md +92 -78
- package/README.md +117 -28
- package/SECURITY.md +8 -4
- package/dist/analyze.d.ts.map +1 -1
- package/dist/analyze.js +162 -6
- package/dist/analyze.js.map +1 -1
- package/dist/audit.d.ts.map +1 -1
- package/dist/audit.js +82 -7
- package/dist/audit.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +6 -1
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +1 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +11 -0
- package/dist/config.js.map +1 -1
- package/dist/http-probe.d.ts.map +1 -1
- package/dist/http-probe.js +4 -0
- package/dist/http-probe.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/redact.d.ts.map +1 -1
- package/dist/redact.js +17 -0
- package/dist/redact.js.map +1 -1
- package/dist/reporters.d.ts.map +1 -1
- package/dist/reporters.js +69 -2
- package/dist/reporters.js.map +1 -1
- package/dist/social.d.ts +14 -0
- package/dist/social.d.ts.map +1 -0
- package/dist/social.js +88 -0
- package/dist/social.js.map +1 -0
- package/dist/stability.d.ts +9 -0
- package/dist/stability.d.ts.map +1 -0
- package/dist/stability.js +326 -0
- package/dist/stability.js.map +1 -0
- package/dist/stream-parser.d.ts.map +1 -1
- package/dist/stream-parser.js +21 -1
- package/dist/stream-parser.js.map +1 -1
- package/dist/types.d.ts +47 -0
- package/dist/types.d.ts.map +1 -1
- package/examples/github-actions.yml +2 -2
- package/examples/ssrwire.config.yml +8 -0
- package/package.json +5 -2
- package/src/analyze.ts +208 -7
- package/src/audit.ts +116 -8
- package/src/cli.ts +7 -1
- package/src/config.ts +12 -0
- package/src/http-probe.ts +4 -0
- package/src/index.ts +6 -0
- package/src/redact.ts +17 -0
- package/src/reporters.ts +97 -2
- package/src/social.ts +116 -0
- package/src/stability.ts +475 -0
- package/src/stream-parser.ts +31 -1
- package/src/types.ts +62 -0
package/src/stability.ts
ADDED
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
import {
|
|
2
|
+
effectiveTwitterCardSignal,
|
|
3
|
+
firstSocialSignal,
|
|
4
|
+
normalizeSocialValue,
|
|
5
|
+
OPEN_GRAPH_PROPERTIES,
|
|
6
|
+
OPEN_GRAPH_REQUIRED_PROPERTIES,
|
|
7
|
+
socialSignals,
|
|
8
|
+
TWITTER_CARD_REQUIRED_FIELDS,
|
|
9
|
+
} from "./social.js";
|
|
10
|
+
import type {
|
|
11
|
+
AgentProfile,
|
|
12
|
+
AgentStability,
|
|
13
|
+
AuditTarget,
|
|
14
|
+
ElementSignal,
|
|
15
|
+
Finding,
|
|
16
|
+
ProbeResult,
|
|
17
|
+
RobotsAudience,
|
|
18
|
+
RobotsSignal,
|
|
19
|
+
SocialMetadataProperty,
|
|
20
|
+
TimingStats,
|
|
21
|
+
} from "./types.js";
|
|
22
|
+
|
|
23
|
+
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
24
|
+
const ROBOTS_OPPOSITES = [
|
|
25
|
+
["index", "noindex"],
|
|
26
|
+
["follow", "nofollow"],
|
|
27
|
+
["archive", "noarchive"],
|
|
28
|
+
["snippet", "nosnippet"],
|
|
29
|
+
["translate", "notranslate"],
|
|
30
|
+
["imageindex", "noimageindex"],
|
|
31
|
+
] as const;
|
|
32
|
+
|
|
33
|
+
export interface StabilityAnalysis {
|
|
34
|
+
readonly stability: readonly AgentStability[];
|
|
35
|
+
readonly findings: readonly Finding[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface SocialSignature {
|
|
39
|
+
openGraph?: unknown;
|
|
40
|
+
twitterCard?: unknown;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function normalizeText(value: string): string {
|
|
44
|
+
return value.trim().replace(/\s+/g, " ");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function normalizeUrl(value: string, baseUrl?: string): string {
|
|
48
|
+
try {
|
|
49
|
+
const url = baseUrl === undefined ? new URL(value) : new URL(value, baseUrl);
|
|
50
|
+
url.hash = "";
|
|
51
|
+
return url.href;
|
|
52
|
+
} catch {
|
|
53
|
+
return normalizeText(value);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function normalizeCanonical(value: string, baseUrl: string): string {
|
|
58
|
+
const normalized = normalizeText(value);
|
|
59
|
+
return normalized.length === 0 ? "" : normalizeUrl(normalized, baseUrl);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function normalizedSignalValues(
|
|
63
|
+
signals: readonly ElementSignal[],
|
|
64
|
+
normalize: (value: string) => string = normalizeText,
|
|
65
|
+
): readonly string[] {
|
|
66
|
+
return [
|
|
67
|
+
...new Set(
|
|
68
|
+
signals.map((signal) => normalize(signal.value)).filter((value) => value.length > 0),
|
|
69
|
+
),
|
|
70
|
+
].sort();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function robotsAudienceForAgent(agent: AgentProfile): RobotsAudience {
|
|
74
|
+
const key = agent.key.trim().toLowerCase();
|
|
75
|
+
if (key === "googlebot" || key === "bingbot") return key;
|
|
76
|
+
return "robots";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function effectiveRobotsSignals(probe: ProbeResult): readonly RobotsSignal[] {
|
|
80
|
+
const audience = robotsAudienceForAgent(probe.agent);
|
|
81
|
+
const generic = probe.signals.robots.filter((signal) => signal.audience === "robots");
|
|
82
|
+
if (audience === "robots") return generic;
|
|
83
|
+
return [...generic, ...probe.signals.robots.filter((signal) => signal.audience === audience)];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function normalizedRobotsValue(signals: readonly RobotsSignal[]): string {
|
|
87
|
+
const directives = new Set(
|
|
88
|
+
signals
|
|
89
|
+
.flatMap((signal) => normalizeText(signal.value).toLowerCase().split(/[;,]/))
|
|
90
|
+
.map((directive) => directive.trim())
|
|
91
|
+
.filter((directive) => directive.length > 0),
|
|
92
|
+
);
|
|
93
|
+
if (directives.delete("none")) {
|
|
94
|
+
directives.add("noindex");
|
|
95
|
+
directives.add("nofollow");
|
|
96
|
+
}
|
|
97
|
+
if (directives.delete("all")) {
|
|
98
|
+
directives.add("index");
|
|
99
|
+
directives.add("follow");
|
|
100
|
+
}
|
|
101
|
+
for (const [permissive, restrictive] of ROBOTS_OPPOSITES) {
|
|
102
|
+
if (directives.has(restrictive)) directives.delete(permissive);
|
|
103
|
+
}
|
|
104
|
+
return [...directives].sort().join(",") || "<missing>";
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function effectiveRobotsValue(probe: ProbeResult): string {
|
|
108
|
+
return normalizedRobotsValue(effectiveRobotsSignals(probe));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function signalValueLocations(
|
|
112
|
+
signals: readonly ElementSignal[],
|
|
113
|
+
normalize: (value: string) => string = normalizeText,
|
|
114
|
+
): readonly (readonly [string, string])[] {
|
|
115
|
+
const entries = signals
|
|
116
|
+
.map((signal) => [normalize(signal.value), signal.location] as const)
|
|
117
|
+
.filter(([value]) => value.length > 0);
|
|
118
|
+
return [...new Map(entries.map((entry) => [JSON.stringify(entry), entry])).values()].sort(
|
|
119
|
+
([leftValue, leftLocation], [rightValue, rightLocation]) => {
|
|
120
|
+
const valueOrder = leftValue.localeCompare(rightValue);
|
|
121
|
+
return valueOrder === 0 ? leftLocation.localeCompare(rightLocation) : valueOrder;
|
|
122
|
+
},
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function robotsValueLocations(probe: ProbeResult): readonly (readonly [string, string])[] {
|
|
127
|
+
const entries = effectiveRobotsSignals(probe)
|
|
128
|
+
.map((signal) => [normalizedRobotsValue([signal]), signal.location] as const)
|
|
129
|
+
.filter(([value]) => value !== "<missing>");
|
|
130
|
+
return [...new Map(entries.map((entry) => [JSON.stringify(entry), entry])).values()].sort(
|
|
131
|
+
(left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right)),
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function socialValuesForSignature(
|
|
136
|
+
probe: ProbeResult,
|
|
137
|
+
property: SocialMetadataProperty,
|
|
138
|
+
): readonly string[] {
|
|
139
|
+
const values = [
|
|
140
|
+
...new Set(
|
|
141
|
+
socialSignals(probe.signals, property)
|
|
142
|
+
.map((signal) => normalizeSocialValue(property, signal.value, probe.finalUrl))
|
|
143
|
+
.filter((value) => value.length > 0),
|
|
144
|
+
),
|
|
145
|
+
];
|
|
146
|
+
return property === "og:image" ? values : values.sort();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function socialLocationsForSignature(
|
|
150
|
+
probe: ProbeResult,
|
|
151
|
+
property: SocialMetadataProperty,
|
|
152
|
+
): readonly (readonly [string, string])[] {
|
|
153
|
+
if (property !== "og:image") {
|
|
154
|
+
return signalValueLocations(socialSignals(probe.signals, property), (value) =>
|
|
155
|
+
normalizeSocialValue(property, value, probe.finalUrl),
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
return socialSignals(probe.signals, property)
|
|
159
|
+
.map(
|
|
160
|
+
(signal) =>
|
|
161
|
+
[normalizeSocialValue(property, signal.value, probe.finalUrl), signal.location] as const,
|
|
162
|
+
)
|
|
163
|
+
.filter(([value]) => value.length > 0);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function socialValueSignature(target: AuditTarget, probe: ProbeResult): unknown {
|
|
167
|
+
const signature: SocialSignature = {};
|
|
168
|
+
if (target.expectations.requireOpenGraph === true) {
|
|
169
|
+
signature.openGraph = Object.fromEntries(
|
|
170
|
+
OPEN_GRAPH_PROPERTIES.map((property) => [
|
|
171
|
+
property,
|
|
172
|
+
socialValuesForSignature(probe, property),
|
|
173
|
+
]),
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
if (target.expectations.requireTwitterCard === true) {
|
|
177
|
+
signature.twitterCard = Object.fromEntries(
|
|
178
|
+
TWITTER_CARD_REQUIRED_FIELDS.map((field) => {
|
|
179
|
+
const signal = effectiveTwitterCardSignal(probe.signals, field);
|
|
180
|
+
return [
|
|
181
|
+
field,
|
|
182
|
+
signal === undefined
|
|
183
|
+
? "<missing>"
|
|
184
|
+
: normalizeSocialValue(signal.property, signal.value, probe.finalUrl) || "<missing>",
|
|
185
|
+
];
|
|
186
|
+
}),
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
return Object.keys(signature).length === 0 ? undefined : signature;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function socialLocationSignature(target: AuditTarget, probe: ProbeResult): unknown {
|
|
193
|
+
const signature: SocialSignature = {};
|
|
194
|
+
if (target.expectations.requireOpenGraph === true) {
|
|
195
|
+
signature.openGraph = Object.fromEntries(
|
|
196
|
+
OPEN_GRAPH_PROPERTIES.map((property) => [
|
|
197
|
+
property,
|
|
198
|
+
socialLocationsForSignature(probe, property),
|
|
199
|
+
]),
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
if (target.expectations.requireTwitterCard === true) {
|
|
203
|
+
signature.twitterCard = Object.fromEntries(
|
|
204
|
+
TWITTER_CARD_REQUIRED_FIELDS.map((field) => {
|
|
205
|
+
const signal = effectiveTwitterCardSignal(probe.signals, field);
|
|
206
|
+
return [
|
|
207
|
+
field,
|
|
208
|
+
signal === undefined
|
|
209
|
+
? []
|
|
210
|
+
: [
|
|
211
|
+
[
|
|
212
|
+
normalizeSocialValue(signal.property, signal.value, probe.finalUrl),
|
|
213
|
+
signal.location,
|
|
214
|
+
],
|
|
215
|
+
],
|
|
216
|
+
];
|
|
217
|
+
}),
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
return Object.keys(signature).length === 0 ? undefined : signature;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function metadataValueSignature(target: AuditTarget, probe: ProbeResult): string {
|
|
224
|
+
const titles = probe.signals.titles ?? (probe.signals.title ? [probe.signals.title] : []);
|
|
225
|
+
const social = socialValueSignature(target, probe);
|
|
226
|
+
return JSON.stringify({
|
|
227
|
+
title: normalizedSignalValues(titles),
|
|
228
|
+
description: normalizedSignalValues(probe.signals.descriptions),
|
|
229
|
+
canonical: normalizedSignalValues(probe.signals.canonicals, (value) =>
|
|
230
|
+
normalizeCanonical(value, probe.finalUrl),
|
|
231
|
+
),
|
|
232
|
+
robots: effectiveRobotsValue(probe),
|
|
233
|
+
...(social === undefined ? {} : { social }),
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function metadataLocationSignature(target: AuditTarget, probe: ProbeResult): string {
|
|
238
|
+
const titles = probe.signals.titles ?? (probe.signals.title ? [probe.signals.title] : []);
|
|
239
|
+
const social = socialLocationSignature(target, probe);
|
|
240
|
+
return JSON.stringify({
|
|
241
|
+
title: signalValueLocations(titles),
|
|
242
|
+
description: signalValueLocations(probe.signals.descriptions),
|
|
243
|
+
canonical: signalValueLocations(probe.signals.canonicals, (value) =>
|
|
244
|
+
normalizeCanonical(value, probe.finalUrl),
|
|
245
|
+
),
|
|
246
|
+
robots: robotsValueLocations(probe),
|
|
247
|
+
...(social === undefined ? {} : { social }),
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function redirectChainSignature(probe: ProbeResult): string {
|
|
252
|
+
return JSON.stringify(
|
|
253
|
+
probe.redirects.map((redirect) => ({
|
|
254
|
+
status: redirect.status,
|
|
255
|
+
url: normalizeUrl(redirect.url),
|
|
256
|
+
location: normalizeUrl(redirect.location, redirect.url),
|
|
257
|
+
})),
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function uniqueCount(values: readonly string[]): number {
|
|
262
|
+
return new Set(values).size;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function calculateTimingStats(values: readonly number[]): TimingStats | undefined {
|
|
266
|
+
const sorted = values
|
|
267
|
+
.filter((value) => Number.isFinite(value) && value >= 0)
|
|
268
|
+
.sort((a, b) => a - b);
|
|
269
|
+
if (sorted.length === 0) return undefined;
|
|
270
|
+
|
|
271
|
+
const middle = Math.floor(sorted.length / 2);
|
|
272
|
+
const lower = sorted[middle - 1];
|
|
273
|
+
const upper = sorted[middle];
|
|
274
|
+
const medianMs =
|
|
275
|
+
sorted.length % 2 === 0 && lower !== undefined && upper !== undefined
|
|
276
|
+
? (lower + upper) / 2
|
|
277
|
+
: (upper ?? sorted[0] ?? 0);
|
|
278
|
+
const p95Index = Math.max(0, Math.ceil(sorted.length * 0.95) - 1);
|
|
279
|
+
const minMs = sorted[0] ?? 0;
|
|
280
|
+
const maxMs = sorted[sorted.length - 1] ?? minMs;
|
|
281
|
+
|
|
282
|
+
return {
|
|
283
|
+
samples: sorted.length,
|
|
284
|
+
minMs,
|
|
285
|
+
medianMs,
|
|
286
|
+
p95Ms: sorted[p95Index] ?? maxMs,
|
|
287
|
+
maxMs,
|
|
288
|
+
spreadMs: maxMs - minMs,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function firstNonEmpty(signals: readonly ElementSignal[]): ElementSignal | undefined {
|
|
293
|
+
return signals.find((signal) => normalizeText(signal.value).length > 0);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export function criticalSignalsArrivalMs(
|
|
297
|
+
target: AuditTarget,
|
|
298
|
+
probe: ProbeResult,
|
|
299
|
+
): number | undefined {
|
|
300
|
+
const marks: number[] = [];
|
|
301
|
+
const { expectations } = target;
|
|
302
|
+
let required = 0;
|
|
303
|
+
|
|
304
|
+
const addRequired = (signal: ElementSignal | undefined): boolean => {
|
|
305
|
+
required += 1;
|
|
306
|
+
if (signal === undefined || normalizeText(signal.value).length === 0) return false;
|
|
307
|
+
marks.push(signal.atMs);
|
|
308
|
+
return true;
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
if (expectations.requireTitle && !addRequired(probe.signals.title)) return undefined;
|
|
312
|
+
if (expectations.requireDescription && !addRequired(firstNonEmpty(probe.signals.descriptions))) {
|
|
313
|
+
return undefined;
|
|
314
|
+
}
|
|
315
|
+
if (expectations.requireCanonical) {
|
|
316
|
+
const canonical = probe.signals.canonicals.find(
|
|
317
|
+
(signal) => normalizeCanonical(signal.value, probe.finalUrl).length > 0,
|
|
318
|
+
);
|
|
319
|
+
if (!addRequired(canonical)) return undefined;
|
|
320
|
+
}
|
|
321
|
+
if (expectations.requireH1 && !addRequired(firstNonEmpty(probe.signals.h1s))) return undefined;
|
|
322
|
+
if (expectations.requireMainText && !addRequired(probe.signals.firstMainText)) return undefined;
|
|
323
|
+
if (expectations.requireOpenGraph === true) {
|
|
324
|
+
for (const property of OPEN_GRAPH_REQUIRED_PROPERTIES) {
|
|
325
|
+
if (!addRequired(firstSocialSignal(probe.signals, property))) return undefined;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
if (expectations.requireTwitterCard === true) {
|
|
329
|
+
for (const field of TWITTER_CARD_REQUIRED_FIELDS) {
|
|
330
|
+
if (!addRequired(effectiveTwitterCardSignal(probe.signals, field))) return undefined;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return required === 0 ? undefined : Math.max(...marks);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function addStats(
|
|
338
|
+
values: readonly number[],
|
|
339
|
+
key: "headers" | "firstByte" | "criticalSignals" | "complete",
|
|
340
|
+
target: Record<string, TimingStats>,
|
|
341
|
+
): void {
|
|
342
|
+
const stats = calculateTimingStats(values);
|
|
343
|
+
if (stats !== undefined) target[key] = stats;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function summarizeAgent(target: AuditTarget, probes: readonly ProbeResult[]): AgentStability {
|
|
347
|
+
const agent = probes[0]?.agent;
|
|
348
|
+
if (agent === undefined) throw new Error("Cannot summarize an empty probe group.");
|
|
349
|
+
const complete = probes.filter((probe) => probe.completion === "complete");
|
|
350
|
+
const finalResponses = probes.filter(
|
|
351
|
+
(probe) => probe.status !== undefined && !REDIRECT_STATUSES.has(probe.status),
|
|
352
|
+
);
|
|
353
|
+
const timings: Record<string, TimingStats> = {};
|
|
354
|
+
|
|
355
|
+
addStats(
|
|
356
|
+
finalResponses.map((probe) => probe.timings.headersMs),
|
|
357
|
+
"headers",
|
|
358
|
+
timings,
|
|
359
|
+
);
|
|
360
|
+
addStats(
|
|
361
|
+
probes.flatMap((probe) =>
|
|
362
|
+
probe.timings.firstByteMs === undefined ? [] : [probe.timings.firstByteMs],
|
|
363
|
+
),
|
|
364
|
+
"firstByte",
|
|
365
|
+
timings,
|
|
366
|
+
);
|
|
367
|
+
addStats(
|
|
368
|
+
complete.flatMap((probe) => {
|
|
369
|
+
const value = criticalSignalsArrivalMs(target, probe);
|
|
370
|
+
return value === undefined ? [] : [value];
|
|
371
|
+
}),
|
|
372
|
+
"criticalSignals",
|
|
373
|
+
timings,
|
|
374
|
+
);
|
|
375
|
+
addStats(
|
|
376
|
+
complete.flatMap((probe) =>
|
|
377
|
+
probe.timings.completeMs === undefined ? [] : [probe.timings.completeMs],
|
|
378
|
+
),
|
|
379
|
+
"complete",
|
|
380
|
+
timings,
|
|
381
|
+
);
|
|
382
|
+
|
|
383
|
+
return {
|
|
384
|
+
agent,
|
|
385
|
+
samples: probes.length,
|
|
386
|
+
complete: complete.length,
|
|
387
|
+
incomplete: probes.length - complete.length,
|
|
388
|
+
timings,
|
|
389
|
+
variants: {
|
|
390
|
+
completion: uniqueCount(probes.map((probe) => probe.completion)),
|
|
391
|
+
status: uniqueCount(finalResponses.map((probe) => String(probe.status))),
|
|
392
|
+
finalUrl: uniqueCount(finalResponses.map((probe) => normalizeUrl(probe.finalUrl))),
|
|
393
|
+
redirectChain: uniqueCount(probes.map(redirectChainSignature)),
|
|
394
|
+
bodySha256: uniqueCount(complete.map((probe) => probe.bodySha256 ?? "<missing>")),
|
|
395
|
+
metadataValues: uniqueCount(complete.map((probe) => metadataValueSignature(target, probe))),
|
|
396
|
+
metadataLocations: uniqueCount(
|
|
397
|
+
complete.map((probe) => metadataLocationSignature(target, probe)),
|
|
398
|
+
),
|
|
399
|
+
},
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function finding(
|
|
404
|
+
target: AuditTarget,
|
|
405
|
+
summary: AgentStability,
|
|
406
|
+
code: "response-instability" | "stream-instability",
|
|
407
|
+
severity: "info" | "warning",
|
|
408
|
+
fields: readonly string[],
|
|
409
|
+
): Finding {
|
|
410
|
+
const variants = summary.variants;
|
|
411
|
+
return {
|
|
412
|
+
code,
|
|
413
|
+
severity,
|
|
414
|
+
message:
|
|
415
|
+
code === "response-instability"
|
|
416
|
+
? `${summary.agent.label} returned inconsistent HTTP response evidence across samples.`
|
|
417
|
+
: `${summary.agent.label} returned inconsistent streamed HTML evidence across samples.`,
|
|
418
|
+
url: target.url,
|
|
419
|
+
agent: summary.agent.key,
|
|
420
|
+
evidence: {
|
|
421
|
+
samples: summary.samples,
|
|
422
|
+
completeSamples: summary.complete,
|
|
423
|
+
fields: fields.join(", "),
|
|
424
|
+
variantCounts: fields
|
|
425
|
+
.map((field) => `${field}=${variants[field as keyof typeof variants]}`)
|
|
426
|
+
.join("; "),
|
|
427
|
+
},
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function findingsFor(target: AuditTarget, summary: AgentStability): readonly Finding[] {
|
|
432
|
+
const findings: Finding[] = [];
|
|
433
|
+
const responseFields = (["completion", "status", "finalUrl", "redirectChain"] as const).filter(
|
|
434
|
+
(field) => summary.variants[field] > 1,
|
|
435
|
+
);
|
|
436
|
+
if (responseFields.length > 0) {
|
|
437
|
+
findings.push(finding(target, summary, "response-instability", "warning", responseFields));
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const streamFields = (["bodySha256", "metadataValues", "metadataLocations"] as const).filter(
|
|
441
|
+
(field) => summary.variants[field] > 1,
|
|
442
|
+
);
|
|
443
|
+
if (streamFields.length > 0) {
|
|
444
|
+
const metadataChanged = streamFields.some((field) => field !== "bodySha256");
|
|
445
|
+
findings.push(
|
|
446
|
+
finding(
|
|
447
|
+
target,
|
|
448
|
+
summary,
|
|
449
|
+
"stream-instability",
|
|
450
|
+
metadataChanged ? "warning" : "info",
|
|
451
|
+
streamFields,
|
|
452
|
+
),
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
return findings;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
export function analyzeStability(
|
|
460
|
+
target: AuditTarget,
|
|
461
|
+
probes: readonly ProbeResult[],
|
|
462
|
+
): StabilityAnalysis {
|
|
463
|
+
const groups = new Map<string, ProbeResult[]>();
|
|
464
|
+
for (const probe of probes) {
|
|
465
|
+
const group = groups.get(probe.agent.key) ?? [];
|
|
466
|
+
group.push(probe);
|
|
467
|
+
groups.set(probe.agent.key, group);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const stability = [...groups.values()].map((group) => summarizeAgent(target, group));
|
|
471
|
+
return {
|
|
472
|
+
stability,
|
|
473
|
+
findings: stability.flatMap((summary) => findingsFor(target, summary)),
|
|
474
|
+
};
|
|
475
|
+
}
|
package/src/stream-parser.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { Parser } from "htmlparser2";
|
|
2
2
|
|
|
3
|
+
import { isSocialMetadataProperty } from "./social.js";
|
|
4
|
+
|
|
3
5
|
import type {
|
|
4
6
|
DocumentSignals,
|
|
5
7
|
ElementLocation,
|
|
@@ -7,6 +9,8 @@ import type {
|
|
|
7
9
|
JsonLdSignal,
|
|
8
10
|
RobotsAudience,
|
|
9
11
|
RobotsSignal,
|
|
12
|
+
SocialMetadataProperty,
|
|
13
|
+
SocialMetadataSignal,
|
|
10
14
|
TimingMark,
|
|
11
15
|
} from "./types.js";
|
|
12
16
|
|
|
@@ -93,6 +97,8 @@ class HtmlStreamInspector implements StreamInspector {
|
|
|
93
97
|
readonly #descriptions: ElementSignal[] = [];
|
|
94
98
|
readonly #canonicals: ElementSignal[] = [];
|
|
95
99
|
readonly #robots: RobotsSignal[] = [];
|
|
100
|
+
readonly #socialMetadata: SocialMetadataSignal[] = [];
|
|
101
|
+
readonly #socialMetadataCounts = new Map<SocialMetadataProperty, number>();
|
|
96
102
|
readonly #h1s: ElementSignal[] = [];
|
|
97
103
|
readonly #jsonLd: JsonLdSignal[] = [];
|
|
98
104
|
readonly #titles: ElementSignal[] = [];
|
|
@@ -170,6 +176,7 @@ class HtmlStreamInspector implements StreamInspector {
|
|
|
170
176
|
descriptions: [...this.#descriptions],
|
|
171
177
|
canonicals: [...this.#canonicals],
|
|
172
178
|
robots: [...this.#robots],
|
|
179
|
+
socialMetadata: [...this.#socialMetadata],
|
|
173
180
|
h1s: [...this.#h1s],
|
|
174
181
|
...(firstMainText === undefined || firstMainText.value.length === 0 ? {} : { firstMainText }),
|
|
175
182
|
jsonLd: [...this.#jsonLd],
|
|
@@ -213,7 +220,14 @@ class HtmlStreamInspector implements StreamInspector {
|
|
|
213
220
|
return;
|
|
214
221
|
}
|
|
215
222
|
|
|
216
|
-
const {
|
|
223
|
+
const {
|
|
224
|
+
name: metaNameAttribute,
|
|
225
|
+
property: metaPropertyAttribute,
|
|
226
|
+
content,
|
|
227
|
+
rel,
|
|
228
|
+
href,
|
|
229
|
+
type: scriptType,
|
|
230
|
+
} = attributes;
|
|
217
231
|
if (name === "head") this.#inHead = true;
|
|
218
232
|
if (name === "body") {
|
|
219
233
|
if (this.#bodyStarted === undefined) this.#bodyStarted = this.#mark();
|
|
@@ -232,6 +246,7 @@ class HtmlStreamInspector implements StreamInspector {
|
|
|
232
246
|
|
|
233
247
|
if (name === "meta") {
|
|
234
248
|
const metaName = metaNameAttribute?.trim().toLowerCase();
|
|
249
|
+
const metaProperty = metaPropertyAttribute?.trim().toLowerCase();
|
|
235
250
|
if (
|
|
236
251
|
content !== undefined &&
|
|
237
252
|
metaName === "description" &&
|
|
@@ -249,6 +264,21 @@ class HtmlStreamInspector implements StreamInspector {
|
|
|
249
264
|
audience: metaName,
|
|
250
265
|
});
|
|
251
266
|
}
|
|
267
|
+
const socialProperty = isSocialMetadataProperty(metaProperty)
|
|
268
|
+
? metaProperty
|
|
269
|
+
: isSocialMetadataProperty(metaName)
|
|
270
|
+
? metaName
|
|
271
|
+
: undefined;
|
|
272
|
+
if (content !== undefined && socialProperty !== undefined) {
|
|
273
|
+
const count = this.#socialMetadataCounts.get(socialProperty) ?? 0;
|
|
274
|
+
if (count < SIGNAL_LIMIT) {
|
|
275
|
+
this.#socialMetadata.push({
|
|
276
|
+
...this.#elementSignal(content, location),
|
|
277
|
+
property: socialProperty,
|
|
278
|
+
});
|
|
279
|
+
this.#socialMetadataCounts.set(socialProperty, count + 1);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
252
282
|
}
|
|
253
283
|
|
|
254
284
|
if (name === "link") {
|
package/src/types.ts
CHANGED
|
@@ -18,6 +18,21 @@ export interface RobotsSignal extends ElementSignal {
|
|
|
18
18
|
readonly audience: RobotsAudience;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
export type SocialMetadataProperty =
|
|
22
|
+
| "og:title"
|
|
23
|
+
| "og:type"
|
|
24
|
+
| "og:url"
|
|
25
|
+
| "og:image"
|
|
26
|
+
| "og:description"
|
|
27
|
+
| "twitter:card"
|
|
28
|
+
| "twitter:title"
|
|
29
|
+
| "twitter:description"
|
|
30
|
+
| "twitter:image";
|
|
31
|
+
|
|
32
|
+
export interface SocialMetadataSignal extends ElementSignal {
|
|
33
|
+
readonly property: SocialMetadataProperty;
|
|
34
|
+
}
|
|
35
|
+
|
|
21
36
|
export interface JsonLdSignal extends TimingMark {
|
|
22
37
|
readonly location: ElementLocation;
|
|
23
38
|
readonly valid?: boolean;
|
|
@@ -33,6 +48,8 @@ export interface DocumentSignals {
|
|
|
33
48
|
readonly descriptions: readonly ElementSignal[];
|
|
34
49
|
readonly canonicals: readonly ElementSignal[];
|
|
35
50
|
readonly robots: readonly RobotsSignal[];
|
|
51
|
+
/** Present on probes produced by SSRWire 0.3.0 and newer. */
|
|
52
|
+
readonly socialMetadata?: readonly SocialMetadataSignal[];
|
|
36
53
|
readonly h1s: readonly ElementSignal[];
|
|
37
54
|
readonly firstMainText?: ElementSignal;
|
|
38
55
|
readonly jsonLd: readonly JsonLdSignal[];
|
|
@@ -97,6 +114,8 @@ export interface ProbeResult {
|
|
|
97
114
|
readonly signals: DocumentSignals;
|
|
98
115
|
readonly completion: ProbeCompletion;
|
|
99
116
|
readonly error?: string;
|
|
117
|
+
/** One-based audit sample number. Low-level probeUrl() calls leave this unset. */
|
|
118
|
+
readonly sample?: number;
|
|
100
119
|
}
|
|
101
120
|
|
|
102
121
|
export interface TargetExpectations {
|
|
@@ -107,6 +126,10 @@ export interface TargetExpectations {
|
|
|
107
126
|
readonly requireCanonical: boolean;
|
|
108
127
|
readonly requireH1: boolean;
|
|
109
128
|
readonly requireMainText: boolean;
|
|
129
|
+
/** Require the four Open Graph protocol basic metadata properties. Defaults to false. */
|
|
130
|
+
readonly requireOpenGraph?: boolean;
|
|
131
|
+
/** Require SSRWire's Twitter Card readiness contract. Defaults to false. */
|
|
132
|
+
readonly requireTwitterCard?: boolean;
|
|
110
133
|
readonly maxFirstByteMs?: number;
|
|
111
134
|
readonly maxCriticalMs?: number;
|
|
112
135
|
}
|
|
@@ -123,6 +146,43 @@ export interface SsrWireConfig {
|
|
|
123
146
|
readonly timeoutMs: number;
|
|
124
147
|
readonly maxBytes: number;
|
|
125
148
|
readonly maxRedirects: number;
|
|
149
|
+
/** Total samples per target and agent. Defaults to one for programmatic callers. */
|
|
150
|
+
readonly repeat?: number;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export interface TimingStats {
|
|
154
|
+
readonly samples: number;
|
|
155
|
+
readonly minMs: number;
|
|
156
|
+
readonly medianMs: number;
|
|
157
|
+
readonly p95Ms: number;
|
|
158
|
+
readonly maxMs: number;
|
|
159
|
+
readonly spreadMs: number;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export interface StabilityTimings {
|
|
163
|
+
readonly headers?: TimingStats;
|
|
164
|
+
readonly firstByte?: TimingStats;
|
|
165
|
+
readonly criticalSignals?: TimingStats;
|
|
166
|
+
readonly complete?: TimingStats;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export interface StabilityVariants {
|
|
170
|
+
readonly completion: number;
|
|
171
|
+
readonly status: number;
|
|
172
|
+
readonly finalUrl: number;
|
|
173
|
+
readonly redirectChain: number;
|
|
174
|
+
readonly bodySha256: number;
|
|
175
|
+
readonly metadataValues: number;
|
|
176
|
+
readonly metadataLocations: number;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export interface AgentStability {
|
|
180
|
+
readonly agent: AgentProfile;
|
|
181
|
+
readonly samples: number;
|
|
182
|
+
readonly complete: number;
|
|
183
|
+
readonly incomplete: number;
|
|
184
|
+
readonly timings: StabilityTimings;
|
|
185
|
+
readonly variants: StabilityVariants;
|
|
126
186
|
}
|
|
127
187
|
|
|
128
188
|
export interface Finding {
|
|
@@ -138,6 +198,7 @@ export interface TargetAuditResult {
|
|
|
138
198
|
readonly target: AuditTarget;
|
|
139
199
|
readonly probes: readonly ProbeResult[];
|
|
140
200
|
readonly findings: readonly Finding[];
|
|
201
|
+
readonly stability?: readonly AgentStability[];
|
|
141
202
|
}
|
|
142
203
|
|
|
143
204
|
export interface AuditSummary {
|
|
@@ -153,6 +214,7 @@ export interface AuditResult {
|
|
|
153
214
|
readonly version: string;
|
|
154
215
|
readonly generatedAt: string;
|
|
155
216
|
readonly durationMs: number;
|
|
217
|
+
readonly repeat?: number;
|
|
156
218
|
readonly results: readonly TargetAuditResult[];
|
|
157
219
|
readonly summary: AuditSummary;
|
|
158
220
|
}
|