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.
- package/CHANGELOG.md +24 -1
- package/CONTRIBUTING.md +1 -0
- package/PUBLISHING.md +15 -15
- package/README.md +70 -23
- package/dist/analyze.d.ts.map +1 -1
- package/dist/analyze.js +162 -6
- package/dist/analyze.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +3 -1
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +4 -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 +5 -0
- package/dist/redact.js.map +1 -1
- package/dist/reporters.d.ts.map +1 -1
- package/dist/reporters.js +27 -0
- 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.map +1 -1
- package/dist/stability.js +84 -4
- package/dist/stability.js.map +1 -1
- 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 +10 -0
- package/dist/types.d.ts.map +1 -1
- package/examples/github-actions.yml +2 -2
- package/examples/ssrwire.config.yml +4 -0
- package/package.json +5 -2
- package/src/analyze.ts +208 -7
- package/src/cli.ts +3 -1
- package/src/config.ts +4 -0
- package/src/http-probe.ts +4 -0
- package/src/index.ts +2 -0
- package/src/redact.ts +5 -0
- package/src/reporters.ts +46 -0
- package/src/social.ts +116 -0
- package/src/stability.ts +123 -4
- package/src/stream-parser.ts +31 -1
- 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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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\.
|
|
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.
|
|
62
|
-
git push --set-upstream origin release/0.
|
|
63
|
-
gh pr create --base main --head release/0.
|
|
64
|
-
PR_NUMBER="$(gh pr view release/0.
|
|
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.
|
|
106
|
-
reports `0.
|
|
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.
|
|
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.
|
|
144
|
-
git push origin v0.
|
|
145
|
-
gh release create v0.
|
|
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
|
[](https://www.npmjs.com/package/ssrwire)
|
|
5
5
|
[](LICENSE)
|
|
6
6
|
|
|
7
|
-
Inspect streamed SSR HTML, metadata timing, and crawler-specific
|
|
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
|
|
12
|
-
observable to its parser. It reports their elapsed time,
|
|
13
|
-
position, and document location without launching a browser or
|
|
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
|
|
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,
|
|
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
|
|
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,
|
|
266
|
-
and captures at most 1,048,576
|
|
267
|
-
JSON-LD
|
|
268
|
-
mislabeled as invalid JSON. 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.
|
|
315
|
-
|
|
316
|
-
|
|
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
|
|
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,
|
|
424
|
-
|
|
425
|
-
testing, or emulate a crawler's
|
|
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,
|
package/dist/analyze.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"analyze.d.ts","sourceRoot":"","sources":["../src/analyze.ts"],"names":[],"mappings":"
|
|
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,
|
|
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,
|
|
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
|
|
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,
|
|
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
|
|
614
|
+
checkHeadRequirements(findings, target, probe);
|
|
459
615
|
checkTimings(findings, target, probe);
|
|
460
616
|
}
|
|
461
|
-
checkAgentDrift(findings, target
|
|
617
|
+
checkAgentDrift(findings, target, probes);
|
|
462
618
|
return findings;
|
|
463
619
|
}
|
|
464
620
|
export function summarizeAudit(results) {
|