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.
Files changed (58) hide show
  1. package/CHANGELOG.md +45 -1
  2. package/CONTRIBUTING.md +8 -5
  3. package/PUBLISHING.md +92 -78
  4. package/README.md +117 -28
  5. package/SECURITY.md +8 -4
  6. package/dist/analyze.d.ts.map +1 -1
  7. package/dist/analyze.js +162 -6
  8. package/dist/analyze.js.map +1 -1
  9. package/dist/audit.d.ts.map +1 -1
  10. package/dist/audit.js +82 -7
  11. package/dist/audit.js.map +1 -1
  12. package/dist/cli.d.ts.map +1 -1
  13. package/dist/cli.js +6 -1
  14. package/dist/cli.js.map +1 -1
  15. package/dist/config.d.ts +1 -0
  16. package/dist/config.d.ts.map +1 -1
  17. package/dist/config.js +11 -0
  18. package/dist/config.js.map +1 -1
  19. package/dist/http-probe.d.ts.map +1 -1
  20. package/dist/http-probe.js +4 -0
  21. package/dist/http-probe.js.map +1 -1
  22. package/dist/index.d.ts +1 -1
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js.map +1 -1
  25. package/dist/redact.d.ts.map +1 -1
  26. package/dist/redact.js +17 -0
  27. package/dist/redact.js.map +1 -1
  28. package/dist/reporters.d.ts.map +1 -1
  29. package/dist/reporters.js +69 -2
  30. package/dist/reporters.js.map +1 -1
  31. package/dist/social.d.ts +14 -0
  32. package/dist/social.d.ts.map +1 -0
  33. package/dist/social.js +88 -0
  34. package/dist/social.js.map +1 -0
  35. package/dist/stability.d.ts +9 -0
  36. package/dist/stability.d.ts.map +1 -0
  37. package/dist/stability.js +326 -0
  38. package/dist/stability.js.map +1 -0
  39. package/dist/stream-parser.d.ts.map +1 -1
  40. package/dist/stream-parser.js +21 -1
  41. package/dist/stream-parser.js.map +1 -1
  42. package/dist/types.d.ts +47 -0
  43. package/dist/types.d.ts.map +1 -1
  44. package/examples/github-actions.yml +2 -2
  45. package/examples/ssrwire.config.yml +8 -0
  46. package/package.json +5 -2
  47. package/src/analyze.ts +208 -7
  48. package/src/audit.ts +116 -8
  49. package/src/cli.ts +7 -1
  50. package/src/config.ts +12 -0
  51. package/src/http-probe.ts +4 -0
  52. package/src/index.ts +6 -0
  53. package/src/redact.ts +17 -0
  54. package/src/reporters.ts +97 -2
  55. package/src/social.ts +116 -0
  56. package/src/stability.ts +475 -0
  57. package/src/stream-parser.ts +31 -1
  58. package/src/types.ts +62 -0
@@ -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
+ }
@@ -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 { name: metaNameAttribute, content, rel, href, type: scriptType } = attributes;
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
  }