ssrwire 0.2.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 (51) hide show
  1. package/CHANGELOG.md +24 -1
  2. package/CONTRIBUTING.md +1 -0
  3. package/PUBLISHING.md +15 -15
  4. package/README.md +70 -23
  5. package/dist/analyze.d.ts.map +1 -1
  6. package/dist/analyze.js +162 -6
  7. package/dist/analyze.js.map +1 -1
  8. package/dist/cli.d.ts.map +1 -1
  9. package/dist/cli.js +3 -1
  10. package/dist/cli.js.map +1 -1
  11. package/dist/config.d.ts.map +1 -1
  12. package/dist/config.js +4 -0
  13. package/dist/config.js.map +1 -1
  14. package/dist/http-probe.d.ts.map +1 -1
  15. package/dist/http-probe.js +4 -0
  16. package/dist/http-probe.js.map +1 -1
  17. package/dist/index.d.ts +1 -1
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js.map +1 -1
  20. package/dist/redact.d.ts.map +1 -1
  21. package/dist/redact.js +5 -0
  22. package/dist/redact.js.map +1 -1
  23. package/dist/reporters.d.ts.map +1 -1
  24. package/dist/reporters.js +27 -0
  25. package/dist/reporters.js.map +1 -1
  26. package/dist/social.d.ts +14 -0
  27. package/dist/social.d.ts.map +1 -0
  28. package/dist/social.js +88 -0
  29. package/dist/social.js.map +1 -0
  30. package/dist/stability.d.ts.map +1 -1
  31. package/dist/stability.js +84 -4
  32. package/dist/stability.js.map +1 -1
  33. package/dist/stream-parser.d.ts.map +1 -1
  34. package/dist/stream-parser.js +21 -1
  35. package/dist/stream-parser.js.map +1 -1
  36. package/dist/types.d.ts +10 -0
  37. package/dist/types.d.ts.map +1 -1
  38. package/examples/github-actions.yml +2 -2
  39. package/examples/ssrwire.config.yml +4 -0
  40. package/package.json +5 -2
  41. package/src/analyze.ts +208 -7
  42. package/src/cli.ts +3 -1
  43. package/src/config.ts +4 -0
  44. package/src/http-probe.ts +4 -0
  45. package/src/index.ts +2 -0
  46. package/src/redact.ts +5 -0
  47. package/src/reporters.ts +46 -0
  48. package/src/social.ts +116 -0
  49. package/src/stability.ts +123 -4
  50. package/src/stream-parser.ts +31 -1
  51. package/src/types.ts +21 -0
package/CHANGELOG.md CHANGED
@@ -5,6 +5,28 @@ All notable changes to SSRWire are documented here. The project follows
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.3.0] - 2026-08-28
9
+
10
+ ### Added
11
+
12
+ - Streamed capture for core Open Graph and Twitter Card metadata with arrival
13
+ time, observed byte position, document location, bounded repeated values, and
14
+ report redaction.
15
+ - Opt-in `require.openGraph` and `require.twitterCard` target contracts with
16
+ missing-field, invalid-URL, duplicate/conflict, and head/body delivery
17
+ findings.
18
+ - A terminal social-preview readiness table and public social metadata signal
19
+ types in JSON and the programmatic API.
20
+
21
+ ### Changed
22
+
23
+ - Enabled social contracts now participate in required-signal timing,
24
+ cross-agent comparison, and repeated-sample stability analysis.
25
+ - Twitter Card readiness prefers native Twitter metadata and falls back to the
26
+ corresponding Open Graph title, description, and image.
27
+ - Multiple `og:image` values are retained in document order without being
28
+ treated as conflicting scalar metadata.
29
+
8
30
  ## [0.2.0] - 2026-08-24
9
31
 
10
32
  ### Added
@@ -36,6 +58,7 @@ All notable changes to SSRWire are documented here. The project follows
36
58
  - Terminal, JSON, and SARIF reports with CI-safe exit codes.
37
59
  - YAML configuration, one-off URL checks, Docker support, and GitHub Actions examples.
38
60
 
39
- [Unreleased]: https://github.com/lame13/ssrwire/compare/v0.2.0...HEAD
61
+ [Unreleased]: https://github.com/lame13/ssrwire/compare/v0.3.0...HEAD
62
+ [0.3.0]: https://github.com/lame13/ssrwire/compare/v0.2.0...v0.3.0
40
63
  [0.2.0]: https://github.com/lame13/ssrwire/compare/v0.1.0...v0.2.0
41
64
  [0.1.0]: https://github.com/lame13/ssrwire/releases/tag/v0.1.0
package/CONTRIBUTING.md CHANGED
@@ -28,6 +28,7 @@ Keep each change narrow. Include tests for behavior changes, especially for:
28
28
  - response chunk boundaries and elements split across chunks;
29
29
  - redirects, timeouts, aborted bodies, and size limits;
30
30
  - malformed, duplicated, late, or body-located metadata;
31
+ - Open Graph and Twitter Card requirements, fallbacks, arrays, and URL values;
31
32
  - user-agent differences and comparison findings;
32
33
  - repeated-sample ordering, aggregation, and instability classification;
33
34
  - redaction of configured header values;
package/PUBLISHING.md CHANGED
@@ -1,4 +1,4 @@
1
- # Publish SSRWire 0.2.0 from a local machine
1
+ # Publish SSRWire 0.3.0 from a local machine
2
2
 
3
3
  This repository intentionally includes no npm publishing workflow. Publish from
4
4
  a foreground local terminal only after the GitHub `CI` workflow passes. Do not
@@ -10,7 +10,7 @@ request, and do not use an administrator bypass or a direct push to `main`.
10
10
 
11
11
  ## 1. Update the existing repository
12
12
 
13
- Work from a clean clone of the existing public repository. Copy the 0.2.0
13
+ Work from a clean clone of the existing public repository. Copy the 0.3.0
14
14
  source files into that clone while preserving its `.git` directory.
15
15
 
16
16
  ```bash
@@ -18,7 +18,7 @@ cd ssrwire
18
18
  git switch main
19
19
  git pull --ff-only origin main
20
20
  git status --short
21
- git switch -c release/0.2.0
21
+ git switch -c release/0.3.0
22
22
  ```
23
23
 
24
24
  `git status --short` must be empty before creating the release branch and
@@ -36,7 +36,7 @@ npm pack --dry-run
36
36
  node dist/bin.js --version
37
37
  ```
38
38
 
39
- The final command must print `0.2.0`. Inspect the dry-run file list. It must not
39
+ The final command must print `0.3.0`. Inspect the dry-run file list. It must not
40
40
  contain `.env`, `.github`, `node_modules`, `test`, ZIP files, or tarballs.
41
41
 
42
42
  Review the release diff and version references:
@@ -45,7 +45,7 @@ Review the release diff and version references:
45
45
  git diff --check
46
46
  git diff --stat
47
47
  git diff -- package.json package-lock.json CHANGELOG.md README.md PUBLISHING.md
48
- rg '0\.1\.0' README.md examples package.json package-lock.json src test
48
+ rg '0\.2\.0' README.md examples package.json package-lock.json scripts src test
49
49
  ```
50
50
 
51
51
  The final search should return nothing. Historical entries in `CHANGELOG.md`
@@ -58,10 +58,10 @@ gh auth status -h github.com || gh auth login -h github.com --web
58
58
  git add --all
59
59
  git diff --cached --check
60
60
  git diff --cached --stat
61
- git commit -m "feat: release SSRWire 0.2.0"
62
- git push --set-upstream origin release/0.2.0
63
- gh pr create --base main --head release/0.2.0 --fill
64
- PR_NUMBER="$(gh pr view release/0.2.0 --json number --jq .number)"
61
+ git commit -m "feat: release SSRWire 0.3.0"
62
+ git push --set-upstream origin release/0.3.0
63
+ gh pr create --base main --head release/0.3.0 --fill
64
+ PR_NUMBER="$(gh pr view release/0.3.0 --json number --jq .number)"
65
65
  test -n "$PR_NUMBER"
66
66
  gh pr checks "$PR_NUMBER" --watch --fail-fast
67
67
  ```
@@ -102,8 +102,8 @@ npm config get registry
102
102
  npm config get provenance
103
103
  ```
104
104
 
105
- The published version and `latest` tag must still be `0.1.0`. If npm already
106
- reports `0.2.0`, stop: never reuse a version that npm accepted.
105
+ The published version and `latest` tag must still be `0.2.0`. If npm already
106
+ reports `0.3.0`, stop: never reuse a version that npm accepted.
107
107
 
108
108
  In the npm package settings, select **Require two-factor authentication and
109
109
  disallow tokens**. npm documents this as the strongest package publishing
@@ -132,7 +132,7 @@ or add an npm credential to the repository or GitHub Actions. The package-level
132
132
  "disallow tokens" setting ensures that publication remains interactive.
133
133
 
134
134
  If npm's publish-time scanning delays package visibility, wait. Do not publish
135
- `0.2.0` again or change the tag to work around propagation.
135
+ `0.3.0` again or change the tag to work around propagation.
136
136
 
137
137
  ## 6. Tag the exact published commit
138
138
 
@@ -140,9 +140,9 @@ If npm's publish-time scanning delays package visibility, wait. Do not publish
140
140
  node scripts/clean.mjs
141
141
  rm -rf node_modules/.vite
142
142
  git status --short
143
- git tag -a v0.2.0 -m "SSRWire v0.2.0"
144
- git push origin v0.2.0
145
- gh release create v0.2.0 --generate-notes --title "SSRWire v0.2.0"
143
+ git tag -a v0.3.0 -m "SSRWire v0.3.0"
144
+ git push origin v0.3.0
145
+ gh release create v0.3.0 --generate-notes --title "SSRWire v0.3.0"
146
146
  ```
147
147
 
148
148
  `git status --short` must print nothing before tagging.
package/README.md CHANGED
@@ -4,14 +4,14 @@
4
4
  [![npm version](https://img.shields.io/npm/v/ssrwire.svg)](https://www.npmjs.com/package/ssrwire)
5
5
  [![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
6
6
 
7
- Inspect streamed SSR HTML, metadata timing, and crawler-specific delivery from
8
- the command line.
7
+ Inspect streamed SSR HTML, SEO and social metadata timing, and crawler-specific
8
+ delivery from the command line.
9
9
 
10
10
  SSRWire makes a real HTTP request for each selected user-agent profile, reads
11
- the response incrementally, and records when important SEO signals become
12
- observable to its parser. It reports their elapsed time, observed byte
13
- position, and document location without launching a browser or executing
14
- JavaScript.
11
+ the response incrementally, and records when important SEO and social-preview
12
+ signals become observable to its parser. It reports their elapsed time,
13
+ observed byte position, and document location without launching a browser or
14
+ executing JavaScript.
15
15
 
16
16
  ```bash
17
17
  npx ssrwire https://example.com/product
@@ -25,7 +25,8 @@ Modern SSR output is not always one complete HTML document delivered at once:
25
25
  - a framework may intentionally stream metadata into `<body>` for a capable
26
26
  crawler while blocking for an HTML-limited bot;
27
27
  - browser, search-crawler, and social-crawler user agents may receive different
28
- titles, canonicals, robots directives, redirects, or statuses;
28
+ titles, canonicals, social-preview metadata, robots directives, redirects, or
29
+ statuses;
29
30
  - the same URL and user agent may receive inconsistent SSR output between requests;
30
31
  - an interrupted or oversized stream may never deliver the expected elements;
31
32
  - a working hydrated page can hide thin or incomplete source HTML.
@@ -91,8 +92,8 @@ For each target, agent, and configured sample, SSRWire captures:
91
92
  - response status, final URL, redirect chain, and an allowlisted response-header snapshot;
92
93
  - time to response headers, first response-body bytes, and completed body;
93
94
  - total bytes delivered to the stream parser and a body fingerprint;
94
- - title, meta description, canonical, meta robots, H1, first main-content
95
- text, and JSON-LD blocks;
95
+ - title, meta description, canonical, meta robots, Open Graph, Twitter Card,
96
+ H1, first main-content text, and JSON-LD blocks;
96
97
  - elapsed arrival time, observed byte position, and `head`/`body` location for
97
98
  each signal;
98
99
  - clean completion, timeout, network failure, invalid response, or configured
@@ -107,10 +108,12 @@ It then checks:
107
108
  | Missing title | Error |
108
109
  | Missing description, canonical, H1, or main text | Warning |
109
110
  | Duplicate or conflicting title, description, canonical, or robots values | Warning |
111
+ | Missing an enabled Open Graph or Twitter Card contract | Warning |
112
+ | Invalid social metadata URL or conflicting scalar social metadata | Warning |
110
113
  | Invalid JSON-LD | Warning |
111
114
  | JSON-LD block/count exceeds the bounded analysis budget | Warning |
112
- | Critical metadata in `<body>` for a profile that requires head metadata | Error |
113
- | Status, final URL, title, canonical, or robots drift between profiles | Warning |
115
+ | Critical or enabled social metadata in `<body>` for a profile that requires head metadata | Error |
116
+ | Status, final URL, title, canonical, robots, or enabled social metadata drift between profiles | Warning |
114
117
  | Completion, status, final URL, or redirect-chain drift between samples | Warning |
115
118
  | Metadata value or document-location drift between complete samples | Warning |
116
119
  | Exact body fingerprint drift without metadata drift | Information |
@@ -162,6 +165,41 @@ agents:
162
165
  requiresHeadMetadata: true
163
166
  ```
164
167
 
168
+ ## Social preview metadata
169
+
170
+ Every probe captures these bounded, ordered metadata signals when they arrive
171
+ before completion or termination:
172
+
173
+ - Open Graph: `og:title`, `og:type`, `og:url`, `og:image`, and
174
+ `og:description`;
175
+ - Twitter Card: `twitter:card`, `twitter:title`, `twitter:description`, and
176
+ `twitter:image`.
177
+
178
+ SSRWire accepts either the conventional `property` attribute or a `name`
179
+ attribute for those keys. Each captured value carries the same arrival time,
180
+ observed byte position, and document location as the existing metadata
181
+ signals. The terminal report shows a compact readiness table whenever social
182
+ metadata is observed or required, while JSON retains every captured value.
183
+
184
+ Social policy is opt-in per target. `openGraph: true` requires the four basic
185
+ [Open Graph protocol](https://ogp.me/) properties: title, type, URL, and image.
186
+ The `twitterCard: true` option requires `twitter:card` plus a usable title,
187
+ description, and image; SSRWire prefers the corresponding `twitter:*` value
188
+ and falls back to `og:title`, `og:description`, or `og:image`. These are
189
+ explicit SSRWire audit contracts, not a claim that a social platform will
190
+ render a particular preview.
191
+
192
+ Enabled contracts also participate in critical-signal timing, head/body
193
+ policy, cross-agent drift, and repeated-sample stability checks. URL-valued
194
+ fields must be absolute HTTP or HTTPS URLs. Open Graph permits multiple images,
195
+ so SSRWire retains them in order without treating the array as a scalar
196
+ conflict; the first non-empty image satisfies readiness.
197
+
198
+ When both options are false, SSRWire still records and reports raw social
199
+ signals but emits no social-policy or social-drift findings. It does not fetch
200
+ images, verify dimensions or media types, execute JavaScript, or simulate a
201
+ platform's rendered preview.
202
+
165
203
  ## Configuration
166
204
 
167
205
  SSRWire automatically looks for `ssrwire.config.yml`,
@@ -179,6 +217,8 @@ targets:
179
217
  canonical: true
180
218
  h1: true
181
219
  mainText: true
220
+ openGraph: true
221
+ twitterCard: true
182
222
  maxFirstByteMs: 1200
183
223
  maxCriticalMs: 2500
184
224
 
@@ -190,6 +230,8 @@ targets:
190
230
  canonical: false
191
231
  h1: true
192
232
  mainText: true
233
+ openGraph: false
234
+ twitterCard: false
193
235
 
194
236
  agents:
195
237
  - browser
@@ -218,6 +260,7 @@ Defaults:
218
260
 
219
261
  - expected status: `200`;
220
262
  - title, description, canonical, H1, and main text: required;
263
+ - Open Graph and Twitter Card contracts: disabled;
221
264
  - agents: `browser`, `googlebot`, `bingbot`, and `twitterbot`;
222
265
  - timeout: 15 seconds per probe;
223
266
  - response limit: 10 MiB;
@@ -262,11 +305,11 @@ The final response must declare `text/html` or `application/xhtml+xml` as its
262
305
  sniff headerless, JSON, text, or binary responses for HTML-looking fragments.
263
306
 
264
307
  To keep hostile or accidentally huge pages bounded, SSRWire retains at most
265
- 256 signals of each repeated metadata kind, analyzes at most 64 JSON-LD blocks,
266
- and captures at most 1,048,576 characters from one JSON-LD block. Exceeding a
267
- JSON-LD analysis budget produces a dedicated warning rather than being
268
- mislabeled as invalid JSON. The configured response-byte limit remains the
269
- outer bound.
308
+ 256 signals of each repeated metadata kind, including each supported social
309
+ property, analyzes at most 64 JSON-LD blocks, and captures at most 1,048,576
310
+ characters from one JSON-LD block. Exceeding a JSON-LD analysis budget produces
311
+ a dedicated warning rather than being mislabeled as invalid JSON. The
312
+ configured response-byte limit remains the outer bound.
270
313
 
271
314
  ## Timing interpretation
272
315
 
@@ -311,9 +354,11 @@ the maximum.
311
354
 
312
355
  Timing spread alone is evidence, not a failure. Network and cache variation can
313
356
  change timings without changing the response contract. SSRWire warns when HTTP
314
- response evidence or streamed metadata changes across samples. Exact body-hash
315
- variation by itself is informational because timestamps, nonces, and other
316
- legitimate dynamic values commonly change source HTML.
357
+ response evidence or streamed metadata changes across samples. Enabled social
358
+ contracts are included in metadata stability; observed social tags remain
359
+ evidence-only when their contracts are disabled. Exact body-hash variation by
360
+ itself is informational because timestamps, nonces, and other legitimate
361
+ dynamic values commonly change source HTML.
317
362
 
318
363
  ## CLI reference
319
364
 
@@ -344,7 +389,8 @@ removed.
344
389
 
345
390
  ## Reports and exit codes
346
391
 
347
- - `terminal`: compact sample, aggregate-timing, and finding tables for local use.
392
+ - `terminal`: compact sample, social-readiness, aggregate-timing, and finding
393
+ tables for local use.
348
394
  - `json`: structured machine-readable evidence, including every probe and timing
349
395
  signal plus repeated-run stability summaries.
350
396
  - `sarif`: findings suitable for GitHub Code Scanning and other SARIF 2.1.0
@@ -420,9 +466,10 @@ versions.
420
466
 
421
467
  ## Scope
422
468
 
423
- SSRWire does not execute JavaScript, inspect a hydrated DOM, measure Core Web
424
- Vitals, discover URLs, validate indexing, bypass access controls, perform load
425
- testing, or emulate a crawler's rendering pipeline. Use
469
+ SSRWire does not execute JavaScript, inspect a hydrated DOM, render social
470
+ previews, fetch social images, measure Core Web Vitals, discover URLs, validate
471
+ indexing, bypass access controls, perform load testing, or emulate a crawler's
472
+ rendering pipeline. Use
426
473
  [RoutePlay](https://github.com/lame13/routeplay) for server HTML versus a cold
427
474
  browser versus real client-side navigation. Use
428
475
  [RouteLint](https://github.com/lame13/routelint) for route discovery,
@@ -1 +1 @@
1
- {"version":3,"file":"analyze.d.ts","sourceRoot":"","sources":["../src/analyze.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,YAAY,EACZ,WAAW,EAEX,OAAO,EACP,WAAW,EAIX,iBAAiB,EAClB,MAAM,YAAY,CAAC;AAgepB,wBAAgB,aAAa,CAC3B,MAAM,EAAE,WAAW,EACnB,MAAM,EAAE,SAAS,WAAW,EAAE,GAC7B,SAAS,OAAO,EAAE,CAuGpB;AAED,wBAAgB,cAAc,CAAC,OAAO,EAAE,SAAS,iBAAiB,EAAE,GAAG,YAAY,CA6BlF"}
1
+ {"version":3,"file":"analyze.d.ts","sourceRoot":"","sources":["../src/analyze.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EACV,YAAY,EACZ,WAAW,EAEX,OAAO,EACP,WAAW,EAMX,iBAAiB,EAClB,MAAM,YAAY,CAAC;AA2pBpB,wBAAgB,aAAa,CAC3B,MAAM,EAAE,WAAW,EACnB,MAAM,EAAE,SAAS,WAAW,EAAE,GAC7B,SAAS,OAAO,EAAE,CAwGpB;AAED,wBAAgB,cAAc,CAAC,OAAO,EAAE,SAAS,iBAAiB,EAAE,GAAG,YAAY,CA6BlF"}
package/dist/analyze.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { effectiveTwitterCardSignal, firstSocialSignal, isAbsoluteHttpSocialUrl, normalizeSocialValue, OPEN_GRAPH_PROPERTIES, OPEN_GRAPH_REQUIRED_PROPERTIES, socialSignals, TWITTER_CARD_REQUIRED_FIELDS, TWITTER_PROPERTIES, } from "./social.js";
1
2
  const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
2
3
  const ROBOTS_OPPOSITES = [
3
4
  ["index", "noindex"],
@@ -201,6 +202,120 @@ function checkRepeatedRobots(findings, targetUrl, probe) {
201
202
  });
202
203
  }
203
204
  }
205
+ function auditedSocialProperties(target, probe) {
206
+ const properties = new Set();
207
+ if (target.expectations.requireOpenGraph === true) {
208
+ for (const property of OPEN_GRAPH_PROPERTIES)
209
+ properties.add(property);
210
+ }
211
+ if (target.expectations.requireTwitterCard === true) {
212
+ for (const property of TWITTER_PROPERTIES)
213
+ properties.add(property);
214
+ if (firstSocialSignal(probe.signals, "twitter:title") === undefined) {
215
+ properties.add("og:title");
216
+ }
217
+ if (firstSocialSignal(probe.signals, "twitter:description") === undefined) {
218
+ properties.add("og:description");
219
+ }
220
+ if (firstSocialSignal(probe.signals, "twitter:image") === undefined) {
221
+ properties.add("og:image");
222
+ }
223
+ }
224
+ return [...properties];
225
+ }
226
+ function normalizedSocialValues(property, signals, baseUrl) {
227
+ return signals
228
+ .map((signal) => normalizeSocialValue(property, signal.value, baseUrl))
229
+ .filter((value) => value.length > 0);
230
+ }
231
+ function checkRepeatedSocialMetadata(findings, target, probe) {
232
+ for (const property of auditedSocialProperties(target, probe)) {
233
+ // Open Graph explicitly permits multiple images and gives the first one precedence.
234
+ if (property === "og:image")
235
+ continue;
236
+ const values = normalizedSocialValues(property, socialSignals(probe.signals, property), probe.finalUrl);
237
+ if (values.length < 2)
238
+ continue;
239
+ const distinct = new Set(values);
240
+ const conflicting = distinct.size > 1;
241
+ addFinding(findings, target.url, {
242
+ code: conflicting ? "conflicting-social-metadata" : "duplicate-social-metadata",
243
+ severity: "warning",
244
+ message: conflicting
245
+ ? `${probe.agent.label} received conflicting ${property} values.`
246
+ : `${probe.agent.label} received duplicate ${property} values.`,
247
+ agent: probe.agent.key,
248
+ evidence: {
249
+ property,
250
+ count: values.length,
251
+ distinctValues: distinct.size,
252
+ values: [...distinct].join(" | "),
253
+ },
254
+ });
255
+ }
256
+ }
257
+ function checkSocialUrls(findings, target, probe) {
258
+ const candidates = new Set();
259
+ if (target.expectations.requireOpenGraph === true) {
260
+ for (const signal of socialSignals(probe.signals, "og:url"))
261
+ candidates.add(signal);
262
+ for (const signal of socialSignals(probe.signals, "og:image"))
263
+ candidates.add(signal);
264
+ }
265
+ if (target.expectations.requireTwitterCard === true) {
266
+ const image = effectiveTwitterCardSignal(probe.signals, "image");
267
+ if (image !== undefined)
268
+ candidates.add(image);
269
+ }
270
+ for (const signal of candidates) {
271
+ if (signal.value.trim().length === 0 || isAbsoluteHttpSocialUrl(signal.value))
272
+ continue;
273
+ addFinding(findings, target.url, {
274
+ code: "invalid-social-metadata-url",
275
+ severity: "warning",
276
+ message: `${probe.agent.label} received a non-absolute HTTP(S) ${signal.property} URL.`,
277
+ agent: probe.agent.key,
278
+ evidence: { property: signal.property, value: signal.value },
279
+ });
280
+ }
281
+ }
282
+ function checkRequiredSocialMetadata(findings, target, probe) {
283
+ if (probe.completion !== "complete")
284
+ return;
285
+ if (target.expectations.requireOpenGraph === true) {
286
+ const missing = OPEN_GRAPH_REQUIRED_PROPERTIES.filter((property) => firstSocialSignal(probe.signals, property) === undefined);
287
+ if (missing.length > 0) {
288
+ addFinding(findings, target.url, {
289
+ code: "missing-open-graph-metadata",
290
+ severity: "warning",
291
+ message: `${probe.agent.label} received an incomplete Open Graph metadata set.`,
292
+ agent: probe.agent.key,
293
+ evidence: { fields: missing.join(", ") },
294
+ });
295
+ }
296
+ }
297
+ if (target.expectations.requireTwitterCard === true) {
298
+ const missing = TWITTER_CARD_REQUIRED_FIELDS.filter((field) => effectiveTwitterCardSignal(probe.signals, field) === undefined);
299
+ if (missing.length > 0) {
300
+ addFinding(findings, target.url, {
301
+ code: "missing-twitter-card-metadata",
302
+ severity: "warning",
303
+ message: `${probe.agent.label} received an incomplete Twitter Card metadata set.`,
304
+ agent: probe.agent.key,
305
+ evidence: { fields: missing.join(", ") },
306
+ });
307
+ }
308
+ }
309
+ }
310
+ function checkSocialMetadata(findings, target, probe) {
311
+ if (target.expectations.requireOpenGraph !== true &&
312
+ target.expectations.requireTwitterCard !== true) {
313
+ return;
314
+ }
315
+ checkRequiredSocialMetadata(findings, target, probe);
316
+ checkRepeatedSocialMetadata(findings, target, probe);
317
+ checkSocialUrls(findings, target, probe);
318
+ }
204
319
  function criticalArrivalMs(target, probe) {
205
320
  const marks = [];
206
321
  const { expectations } = target;
@@ -229,6 +344,18 @@ function criticalArrivalMs(target, probe) {
229
344
  normalizeText(probe.signals.firstMainText?.value ?? "").length > 0) {
230
345
  marks.push(probe.signals.firstMainText?.atMs ?? 0);
231
346
  }
347
+ if (expectations.requireOpenGraph === true) {
348
+ const signals = OPEN_GRAPH_REQUIRED_PROPERTIES.map((property) => firstSocialSignal(probe.signals, property));
349
+ if (!signals.every((signal) => signal !== undefined))
350
+ return undefined;
351
+ marks.push(...signals.map((signal) => signal.atMs));
352
+ }
353
+ if (expectations.requireTwitterCard === true) {
354
+ const signals = TWITTER_CARD_REQUIRED_FIELDS.map((field) => effectiveTwitterCardSignal(probe.signals, field));
355
+ if (!signals.every((signal) => signal !== undefined))
356
+ return undefined;
357
+ marks.push(...signals.map((signal) => signal.atMs));
358
+ }
232
359
  return marks.length === 0 ? undefined : Math.max(...marks);
233
360
  }
234
361
  function checkRequiredSignals(findings, target, probe) {
@@ -283,7 +410,7 @@ function checkRequiredSignals(findings, target, probe) {
283
410
  });
284
411
  }
285
412
  }
286
- function checkHeadRequirements(findings, targetUrl, probe) {
413
+ function checkHeadRequirements(findings, target, probe) {
287
414
  if (!probe.agent.requiresHeadMetadata) {
288
415
  return;
289
416
  }
@@ -301,10 +428,15 @@ function checkHeadRequirements(findings, targetUrl, probe) {
301
428
  if (effectiveRobotsSignals(probe).some((signal) => signal.location === "body")) {
302
429
  bodyFields.add("robots");
303
430
  }
431
+ for (const property of auditedSocialProperties(target, probe)) {
432
+ if (socialSignals(probe.signals, property).some((signal) => signal.location === "body")) {
433
+ bodyFields.add(property);
434
+ }
435
+ }
304
436
  if (bodyFields.size === 0) {
305
437
  return;
306
438
  }
307
- addFinding(findings, targetUrl, {
439
+ addFinding(findings, target.url, {
308
440
  code: "head-metadata-in-body",
309
441
  severity: "error",
310
442
  message: `${probe.agent.label} requires head metadata but received ${[...bodyFields].join(", ")} in the body.`,
@@ -342,7 +474,24 @@ function checkTimings(findings, target, probe) {
342
474
  function agentValueSummary(probes, valueFor) {
343
475
  return probes.map((probe) => `${probe.agent.key}=${valueFor(probe)}`).join("; ");
344
476
  }
345
- function checkAgentDrift(findings, targetUrl, probes) {
477
+ function openGraphValue(probe) {
478
+ return OPEN_GRAPH_PROPERTIES.map((property) => {
479
+ const values = [
480
+ ...new Set(normalizedSocialValues(property, socialSignals(probe.signals, property), probe.finalUrl)),
481
+ ];
482
+ return `${property}=${values.join(" | ") || "<missing>"}`;
483
+ }).join(", ");
484
+ }
485
+ function twitterCardValue(probe) {
486
+ return TWITTER_CARD_REQUIRED_FIELDS.map((field) => {
487
+ const signal = effectiveTwitterCardSignal(probe.signals, field);
488
+ const value = signal === undefined
489
+ ? "<missing>"
490
+ : normalizeSocialValue(signal.property, signal.value, probe.finalUrl);
491
+ return `${field}=${value || "<missing>"}`;
492
+ }).join(", ");
493
+ }
494
+ function checkAgentDrift(findings, target, probes) {
346
495
  const complete = probes.filter((probe) => probe.completion === "complete");
347
496
  if (complete.length < 2) {
348
497
  return;
@@ -365,12 +514,18 @@ function checkAgentDrift(findings, targetUrl, probes) {
365
514
  valueFor: effectiveRobotsValue,
366
515
  },
367
516
  ];
517
+ if (target.expectations.requireOpenGraph === true) {
518
+ comparisons.push({ field: "open-graph", valueFor: openGraphValue });
519
+ }
520
+ if (target.expectations.requireTwitterCard === true) {
521
+ comparisons.push({ field: "twitter-card", valueFor: twitterCardValue });
522
+ }
368
523
  for (const comparison of comparisons) {
369
524
  const values = complete.map(comparison.valueFor);
370
525
  if (new Set(values).size < 2) {
371
526
  continue;
372
527
  }
373
- addFinding(findings, targetUrl, {
528
+ addFinding(findings, target.url, {
374
529
  code: `agent-${comparison.field}-drift`,
375
530
  severity: "warning",
376
531
  message: `Crawler profiles received different ${comparison.field.replace("-", " ")} values.`,
@@ -429,6 +584,7 @@ export function analyzeTarget(target, probes) {
429
584
  checkRepeatedMetadata(findings, target.url, probe, "description", probe.signals.descriptions);
430
585
  checkRepeatedMetadata(findings, target.url, probe, "canonical", probe.signals.canonicals);
431
586
  checkRepeatedRobots(findings, target.url, probe);
587
+ checkSocialMetadata(findings, target, probe);
432
588
  const invalidJsonLd = probe.signals.jsonLd.filter((signal) => signal.valid === false);
433
589
  if (probe.completion === "complete" && invalidJsonLd.length > 0) {
434
590
  addFinding(findings, target.url, {
@@ -455,10 +611,10 @@ export function analyzeTarget(target, probes) {
455
611
  },
456
612
  });
457
613
  }
458
- checkHeadRequirements(findings, target.url, probe);
614
+ checkHeadRequirements(findings, target, probe);
459
615
  checkTimings(findings, target, probe);
460
616
  }
461
- checkAgentDrift(findings, target.url, probes);
617
+ checkAgentDrift(findings, target, probes);
462
618
  return findings;
463
619
  }
464
620
  export function summarizeAudit(results) {