optbrew 0.1.3 → 0.2.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +2 -2
- package/AGENT_INSTALL.md +13 -11
- package/CHANGELOG.md +52 -0
- package/README.ko.md +40 -15
- package/README.md +40 -15
- package/THIRD_PARTY_NOTICES.md +88 -0
- package/docs/platforms.md +10 -10
- package/docs/release.md +37 -5
- package/lib/cli.mjs +5 -2
- package/lib/install.mjs +2 -1
- package/lib/records.mjs +2 -1
- package/lib/transaction.mjs +12 -5
- package/lib/update.mjs +3 -2
- package/package.json +6 -4
- package/plugin.json +2 -2
- package/skills/.claude-plugin/icon.png +0 -0
- package/skills/.claude-plugin/plugin.json +23 -0
- package/skills/README.md +27 -0
- package/skills/optbrew/SKILL.md +223 -8
- package/skills/optbrew/THIRD_PARTY_NOTICES.md +88 -0
- package/skills/optbrew/bundle-manifest.json +407 -47
- package/skills/optbrew/modules/naver-searchadvisor/scripts/audit.mjs +4 -4
- package/skills/optbrew/references/api.md +5 -3
- package/skills/optbrew/references/check-registry.json +16 -16
- package/skills/optbrew/references/chrome-paths.json +31 -0
- package/skills/optbrew/references/endpoints.json +10 -0
- package/skills/optbrew/references/harness-signals.json +5 -0
- package/skills/optbrew/references/intake.md +76 -5
- package/skills/optbrew/references/judging.md +233 -0
- package/skills/optbrew/references/limits.json +26 -0
- package/skills/optbrew/references/measurement.md +1 -1
- package/skills/optbrew/references/messages.ko.json +170 -0
- package/skills/optbrew/references/report.md +18 -6
- package/skills/optbrew/references/routing.md +2 -2
- package/skills/optbrew/references/runbooks/anthropic-api-key.md +34 -0
- package/skills/optbrew/references/runbooks/chrome-install.md +56 -0
- package/skills/optbrew/references/runbooks/cwv-site-collection.md +38 -0
- package/skills/optbrew/references/runbooks/google-api-key.md +51 -0
- package/skills/optbrew/references/runbooks/gsc-export.md +32 -0
- package/skills/optbrew/references/runbooks/index.json +18 -0
- package/skills/optbrew/references/runbooks/key-set-claude-cloud.md +32 -0
- package/skills/optbrew/references/runbooks/key-set-codex.md +33 -0
- package/skills/optbrew/references/runbooks/key-set-local.md +40 -0
- package/skills/optbrew/references/runbooks/naver-searchadvisor-export.md +32 -0
- package/skills/optbrew/references/runbooks/network-claude-cloud-custom.md +33 -0
- package/skills/optbrew/references/runbooks/network-codex-approval.md +26 -0
- package/skills/optbrew/references/runbooks/network-codex-config.md +36 -0
- package/skills/optbrew/references/runbooks/seranking.md +38 -0
- package/skills/optbrew/references/schemas/contract.schema.json +42 -15
- package/skills/optbrew/references/scoring.md +40 -5
- package/skills/optbrew/references/source-analysis.md +40 -0
- package/skills/optbrew/references/versions.json +9 -0
- package/skills/optbrew/scripts/adapters/anthropic.mjs +5 -5
- package/skills/optbrew/scripts/adapters/imports.mjs +138 -1
- package/skills/optbrew/scripts/audit.mjs +10 -112
- package/skills/optbrew/scripts/cli.mjs +5 -2
- package/skills/optbrew/scripts/core/bundle.mjs +30 -13
- package/skills/optbrew/scripts/core/capabilities.mjs +8 -4
- package/skills/optbrew/scripts/core/cdp.mjs +105 -0
- package/skills/optbrew/scripts/core/checks/canonical-consistency.mjs +56 -0
- package/skills/optbrew/scripts/core/checks/cwv-field.mjs +77 -0
- package/skills/optbrew/scripts/core/checks/discovery.mjs +43 -0
- package/skills/optbrew/scripts/core/checks/document-title.mjs +62 -0
- package/skills/optbrew/scripts/core/checks/entity-identity.mjs +31 -0
- package/skills/optbrew/scripts/core/checks/fact-consistency.mjs +31 -0
- package/skills/optbrew/scripts/core/checks/fetch-response.mjs +18 -0
- package/skills/optbrew/scripts/core/checks/index-policy.mjs +16 -0
- package/skills/optbrew/scripts/core/checks/index.mjs +18 -0
- package/skills/optbrew/scripts/core/checks/internal-navigation.mjs +67 -0
- package/skills/optbrew/scripts/core/checks/locale-links.mjs +55 -0
- package/skills/optbrew/scripts/core/checks/naver-markup.mjs +21 -0
- package/skills/optbrew/scripts/core/checks/observation.mjs +37 -0
- package/skills/optbrew/scripts/core/checks/prepare.mjs +103 -0
- package/skills/optbrew/scripts/core/checks/render-content.mjs +104 -0
- package/skills/optbrew/scripts/core/checks/search-crawl-policy.mjs +29 -0
- package/skills/optbrew/scripts/core/checks/structured-data-truth.mjs +46 -0
- package/skills/optbrew/scripts/core/chrome-temp.mjs +68 -0
- package/skills/optbrew/scripts/core/chrome.mjs +546 -0
- package/skills/optbrew/scripts/core/collector.mjs +45 -49
- package/skills/optbrew/scripts/core/compare.mjs +18 -1
- package/skills/optbrew/scripts/core/config.mjs +31 -0
- package/skills/optbrew/scripts/core/contracts.mjs +31 -2
- package/skills/optbrew/scripts/core/cwv.mjs +100 -0
- package/skills/optbrew/scripts/core/derive.mjs +10 -186
- package/skills/optbrew/scripts/core/evidence-store.mjs +112 -0
- package/skills/optbrew/scripts/core/facts.mjs +175 -17
- package/skills/optbrew/scripts/core/findings.mjs +19 -1
- package/skills/optbrew/scripts/core/html.mjs +41 -3
- package/skills/optbrew/scripts/core/index-targets.mjs +118 -0
- package/skills/optbrew/scripts/core/judgments.mjs +675 -0
- package/skills/optbrew/scripts/core/keys.mjs +86 -0
- package/skills/optbrew/scripts/core/link-graph.mjs +93 -0
- package/skills/optbrew/scripts/core/messages.mjs +23 -0
- package/skills/optbrew/scripts/core/metrics.mjs +4 -2
- package/skills/optbrew/scripts/core/migration.mjs +37 -0
- package/skills/optbrew/scripts/core/page-select.mjs +136 -0
- package/skills/optbrew/scripts/core/probe.mjs +83 -0
- package/skills/optbrew/scripts/core/protocol.mjs +38 -14
- package/skills/optbrew/scripts/core/render.mjs +160 -0
- package/skills/optbrew/scripts/core/report-compose.mjs +377 -0
- package/skills/optbrew/scripts/core/report-html.mjs +198 -0
- package/skills/optbrew/scripts/core/report-page.mjs +14 -0
- package/skills/optbrew/scripts/core/report.mjs +327 -122
- package/skills/optbrew/scripts/core/runbooks.mjs +63 -0
- package/skills/optbrew/scripts/core/scope.mjs +23 -5
- package/skills/optbrew/scripts/core/score.mjs +12 -4
- package/skills/optbrew/scripts/core/secrets.mjs +2 -0
- package/skills/optbrew/scripts/core/stages.mjs +694 -0
- package/skills/optbrew/scripts/core/transport.mjs +157 -0
- package/skills/optbrew/scripts/core/weights.mjs +2 -1
- package/skills/optbrew/scripts/key.mjs +90 -0
- package/skills/optbrew/scripts/preflight.mjs +176 -0
- package/skills/optbrew/scripts/run.mjs +81 -0
- package/skills/optbrew/source-lock.json +1 -1
- package/source-lock.json +1 -1
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import { lookup } from 'node:dns/promises';
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
|
-
import http from 'node:http';
|
|
4
|
-
import https from 'node:https';
|
|
5
3
|
import { isIP } from 'node:net';
|
|
6
4
|
import { performance } from 'node:perf_hooks';
|
|
7
|
-
import { assertInScope, assertUrlInScope } from './scope.mjs';
|
|
5
|
+
import { assertInScope, assertUrlInScope, urlTarget } from './scope.mjs';
|
|
8
6
|
import { anchorLinks, canonicalLink } from './html.mjs';
|
|
7
|
+
import { limits, requestLimit } from './config.mjs';
|
|
8
|
+
import { directTransport } from './transport.mjs';
|
|
9
9
|
|
|
10
10
|
const HEADER_NAMES = ['content-type', 'content-length', 'content-range', 'content-encoding', 'location', 'x-robots-tag', 'last-modified', 'etag'];
|
|
11
11
|
const REDIRECTS = new Set([301, 302, 303, 307, 308]);
|
|
12
|
+
// An API call follows no redirect: zero hops.
|
|
13
|
+
const FOLLOW_NONE = 0;
|
|
12
14
|
const text = value => Buffer.isBuffer(value) ? value : Buffer.from(value ?? '');
|
|
13
15
|
|
|
14
16
|
function auxiliaryDocument(url) {
|
|
@@ -24,14 +26,14 @@ function undeclaredAuxiliary(url, allowed) {
|
|
|
24
26
|
throw error;
|
|
25
27
|
}
|
|
26
28
|
|
|
27
|
-
function safeHeaders(headers) {
|
|
29
|
+
function safeHeaders(headers, extra = []) {
|
|
28
30
|
const source = headers instanceof Headers ? Object.fromEntries(headers) : headers ?? {};
|
|
29
|
-
return Object.fromEntries(Object.entries(source).filter(([name]) => HEADER_NAMES.includes(name.toLowerCase()))
|
|
31
|
+
return Object.fromEntries(Object.entries(source).filter(([name]) => HEADER_NAMES.includes(name.toLowerCase()) || extra.includes(name.toLowerCase()))
|
|
30
32
|
.map(([name, value]) => [name.toLowerCase(), String(value)]));
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
/** Response header lines in wire order; repeated lines (for example two X-Robots-Tag lines) stay separate. */
|
|
34
|
-
|
|
36
|
+
function headerLines(response) {
|
|
35
37
|
const lines = [];
|
|
36
38
|
if (Array.isArray(response.rawHeaders)) {
|
|
37
39
|
for (let i = 0; i + 1 < response.rawHeaders.length; i += 2) {
|
|
@@ -47,38 +49,8 @@ export function headerLines(response) {
|
|
|
47
49
|
return lines;
|
|
48
50
|
}
|
|
49
51
|
|
|
50
|
-
/** Node's lookup callback pins the already-vetted address while TLS still verifies the URL hostname. */
|
|
51
|
-
async function nodeTransport({ url, address, timeoutMs, maxBytes, headers = {}, signal }) {
|
|
52
|
-
const client = url.protocol === 'https:' ? https : http;
|
|
53
|
-
const hostname = url.hostname.replace(/^\[|\]$/g, '');
|
|
54
|
-
return new Promise((resolve, reject) => {
|
|
55
|
-
const req = client.request(url, {
|
|
56
|
-
method: 'GET', agent: false, signal, headers: { 'User-Agent': 'optbrew/0.1 (bounded public audit)', Accept: '*/*', 'Accept-Encoding': 'identity', ...headers },
|
|
57
|
-
lookup: (_hostname, options, callback) => options.all ?
|
|
58
|
-
callback(null, [{ address: address.address, family: address.family }]) : callback(null, address.address, address.family),
|
|
59
|
-
...(url.protocol === 'https:' ? { rejectUnauthorized: true,
|
|
60
|
-
...(!isIP(hostname) ? { servername: hostname } : {}) } : {}),
|
|
61
|
-
}, res => {
|
|
62
|
-
const chunks = []; let size = 0;
|
|
63
|
-
if (Number(res.headers['content-length']) > maxBytes) {
|
|
64
|
-
req.destroy(new Error('response byte limit exceeded')); return;
|
|
65
|
-
}
|
|
66
|
-
res.on('data', chunk => {
|
|
67
|
-
size += chunk.length;
|
|
68
|
-
if (size > maxBytes) { req.destroy(new Error('response byte limit exceeded')); return; }
|
|
69
|
-
chunks.push(chunk);
|
|
70
|
-
});
|
|
71
|
-
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, rawHeaders: res.rawHeaders, body: Buffer.concat(chunks) }));
|
|
72
|
-
res.on('error', reject);
|
|
73
|
-
});
|
|
74
|
-
req.setTimeout(timeoutMs, () => req.destroy(new Error('request timeout')));
|
|
75
|
-
req.on('error', reject);
|
|
76
|
-
req.end();
|
|
77
|
-
});
|
|
78
|
-
}
|
|
79
|
-
|
|
80
52
|
async function addressesFor(url, resolve) {
|
|
81
|
-
const host = url
|
|
53
|
+
const { host } = urlTarget(url);
|
|
82
54
|
if (isIP(host)) return [{ address: host, family: isIP(host) }];
|
|
83
55
|
const values = await resolve(host);
|
|
84
56
|
return (Array.isArray(values) ? values : [values]).map(x => {
|
|
@@ -88,9 +60,15 @@ async function addressesFor(url, resolve) {
|
|
|
88
60
|
}
|
|
89
61
|
|
|
90
62
|
/** Fetch one URL with fresh DNS validation at every redirect and a pinned connection. */
|
|
91
|
-
export async function fetchScoped(input, { context, transport =
|
|
92
|
-
|
|
63
|
+
export async function fetchScoped(input, { context, transport = directTransport, resolve = host => lookup(host, { all: true, verbatim: true }), timeoutMs = limits().timeout_ms, maxBytes = limits().max_bytes, maxRedirects = limits().max_redirects, headers = {}, allowedAuxiliary = null, reserveRequest = null, addressVetting = 'direct', extraHeaders = [], method = 'GET', body: requestBody = null, ceiling = { timeoutMs: limits().timeout_ms, maxBytes: limits().max_bytes }, fieldData = false }) {
|
|
64
|
+
// A caller may ask for a lower ceiling, never a higher one than the config fixes: the crawl's limits, or the field-data limits
|
|
65
|
+
// for the one adapter that says it is a field-data call (`transportFetch`).
|
|
66
|
+
const fixed = fieldData ? { timeoutMs: limits().cwv_timeout_ms, maxBytes: limits().cwv_max_bytes } : { timeoutMs: limits().timeout_ms, maxBytes: limits().max_bytes };
|
|
67
|
+
if (![timeoutMs, maxBytes, maxRedirects].every(Number.isInteger) || timeoutMs < 1 || timeoutMs > Math.min(ceiling.timeoutMs, fixed.timeoutMs) ||
|
|
68
|
+
maxBytes < 1 || maxBytes > Math.min(ceiling.maxBytes, fixed.maxBytes) || maxRedirects < 0 || maxRedirects > limits().max_redirects)
|
|
93
69
|
throw new Error('fetch limits exceed context ceiling');
|
|
70
|
+
// Proxy vetting skips DNS-name checks, which is only safe when the proxy transport (not a direct connection) carries the request.
|
|
71
|
+
if (addressVetting === 'proxy' && transport.addressVetting !== 'proxy') throw new Error('proxy address vetting requires the proxy transport');
|
|
94
72
|
let url = assertUrlInScope(input, context.scope);
|
|
95
73
|
if (allowedAuxiliary) undeclaredAuxiliary(url, allowedAuxiliary);
|
|
96
74
|
const requested_url = String(input);
|
|
@@ -105,7 +83,7 @@ export async function fetchScoped(input, { context, transport = nodeTransport, r
|
|
|
105
83
|
try {
|
|
106
84
|
for (let hop = 0; hop <= maxRedirects; hop++) {
|
|
107
85
|
const addresses = await withinDeadline(addressesFor(url, resolve));
|
|
108
|
-
try { assertInScope(url.href, context.scope, addresses); }
|
|
86
|
+
try { assertInScope(url.href, context.scope, addresses, { addressVetting }); }
|
|
109
87
|
catch (error) { error.code = 'scope_rejected'; error.redirect_chain = redirect_chain; throw error; }
|
|
110
88
|
// Pick only a vetted address. The transport must use this address, not resolve again.
|
|
111
89
|
const remainingMs = Math.ceil(deadline - performance.now());
|
|
@@ -118,11 +96,11 @@ export async function fetchScoped(input, { context, transport = nodeTransport, r
|
|
|
118
96
|
throw error;
|
|
119
97
|
}
|
|
120
98
|
made++;
|
|
121
|
-
const response = await withinDeadline(transport({ url, address: addresses[0], timeoutMs: remainingMs, maxBytes, headers, signal: controller.signal }));
|
|
99
|
+
const response = await withinDeadline(transport({ url, address: addresses[0], timeoutMs: remainingMs, maxBytes, headers, signal: controller.signal, method, body: requestBody }));
|
|
122
100
|
if (!Number.isInteger(response?.status) || response.status < 100 || response.status > 599) throw new Error('invalid transport status');
|
|
123
101
|
const body = text(response.body);
|
|
124
102
|
if (body.length > maxBytes) throw new Error('response byte limit exceeded');
|
|
125
|
-
const savedHeaders = safeHeaders(response.headers);
|
|
103
|
+
const savedHeaders = safeHeaders(response.headers, extraHeaders);
|
|
126
104
|
if (REDIRECTS.has(response.status) && savedHeaders.location) {
|
|
127
105
|
if (hop === maxRedirects) {
|
|
128
106
|
redirect_chain.push({ url: url.href, status: response.status, location: '[limit]' });
|
|
@@ -133,6 +111,7 @@ export async function fetchScoped(input, { context, transport = nodeTransport, r
|
|
|
133
111
|
catch {
|
|
134
112
|
const error = new Error('redirect outside scope');
|
|
135
113
|
error.code = 'redirect_outside_scope';
|
|
114
|
+
try { error.redirect_to = new URL(savedHeaders.location, url).href; } catch { /* an unparsable location names no site */ }
|
|
136
115
|
error.redirect_chain = [...redirect_chain, { url: url.href, status: response.status, location: '[blocked]' }];
|
|
137
116
|
throw error;
|
|
138
117
|
}
|
|
@@ -162,6 +141,21 @@ export async function fetchScoped(input, { context, transport = nodeTransport, r
|
|
|
162
141
|
finally { clearTimeout(timer); }
|
|
163
142
|
}
|
|
164
143
|
|
|
144
|
+
/**
|
|
145
|
+
* A fetch-like function over the transport `selectTransport` chose, for API calls the audit makes itself (field CWV): the
|
|
146
|
+
* host is resolved and vetted exactly as `fetchScoped` does it (direct: pinned vetted address; proxy: the proxy resolves,
|
|
147
|
+
* literals are vetted), a redirect is never followed, and the limits are the caller's, not the page collection's.
|
|
148
|
+
* It answers `{ status, text() }` and throws what `fetchScoped` throws.
|
|
149
|
+
*/
|
|
150
|
+
export function transportFetch(selected, { timeoutMs, maxBytes }) {
|
|
151
|
+
return async (url, { method = 'GET', headers = {}, body = null } = {}) => {
|
|
152
|
+
const result = await fetchScoped(url, { context: { scope: { allowed: [{ origin: new URL(url).origin }] } }, transport: selected.transport,
|
|
153
|
+
resolve: selected.resolve, addressVetting: selected.address_vetting, method, headers, body, maxRedirects: FOLLOW_NONE,
|
|
154
|
+
timeoutMs, maxBytes, ceiling: { timeoutMs, maxBytes }, fieldData: true });
|
|
155
|
+
return { status: result.status, text: async () => result.body.toString('utf8') };
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
165
159
|
/** Index of the declared robots.txt artifact on the same origin as the page, as the audit and the report both select it. */
|
|
166
160
|
export function declaredRobotsIndex(artifacts, declaredRobots, pageFinalUrl) {
|
|
167
161
|
return artifacts.findIndex(x => {
|
|
@@ -179,11 +173,11 @@ export function links(html, base) {
|
|
|
179
173
|
}
|
|
180
174
|
|
|
181
175
|
/** Bounded crawl of fixed protocol URLs and their in-scope links. */
|
|
182
|
-
export async function collect(context, { transport, resolve } = {}) {
|
|
176
|
+
export async function collect(context, { transport, resolve, addressVetting } = {}) {
|
|
183
177
|
const scope = context?.scope;
|
|
184
|
-
const limit =
|
|
185
|
-
const depthLimit = Math.min(scope?.max_depth ??
|
|
186
|
-
const concurrency = Math.min(scope?.concurrency ??
|
|
178
|
+
const limit = requestLimit(scope);
|
|
179
|
+
const depthLimit = Math.min(scope?.max_depth ?? limits().max_depth, limits().max_depth);
|
|
180
|
+
const concurrency = Math.min(scope?.concurrency ?? limits().concurrency, limits().concurrency);
|
|
187
181
|
if (![limit, depthLimit, concurrency].every(Number.isInteger) || limit < 1 || depthLimit < 0 || concurrency < 1)
|
|
188
182
|
throw new Error('invalid collection limits');
|
|
189
183
|
const initial = context.protocol.page_types.flatMap(type => type.urls.map(ref => ref.normalized));
|
|
@@ -209,14 +203,16 @@ export async function collect(context, { transport, resolve } = {}) {
|
|
|
209
203
|
if (depth > depthLimit) { skip(normalized, 'depth_limit'); return; }
|
|
210
204
|
queue.push({ url: normalized, depth, auxiliaryResource });
|
|
211
205
|
};
|
|
212
|
-
|
|
206
|
+
if (auxiliary.some(resource => initial.includes(resource.normalized))) throw new Error('auxiliary resource duplicates selected URL');
|
|
207
|
+
// Declared resources (robots, sitemap) are evidence for every page, so they go first: a selected page's redirect hop
|
|
208
|
+
// takes a request the page budget did not count, and that overrun must cost a page, never the site-wide evidence.
|
|
213
209
|
for (const resource of auxiliary) {
|
|
214
|
-
if (seen.has(resource.normalized)) throw new Error('auxiliary resource duplicates selected URL');
|
|
215
210
|
const normalized = assertUrlInScope(resource.normalized, scope).href;
|
|
216
211
|
if (normalized !== resource.normalized || assertUrlInScope(resource.original, scope).href !== normalized)
|
|
217
212
|
throw new Error('auxiliary resource URL mismatch');
|
|
218
213
|
enqueue(normalized, 0, true);
|
|
219
214
|
}
|
|
215
|
+
initial.forEach(url => enqueue(url, 0));
|
|
220
216
|
const artifacts = []; let cursor = 0; let requestCount = 0;
|
|
221
217
|
async function worker() {
|
|
222
218
|
while (cursor < queue.length) {
|
|
@@ -231,7 +227,7 @@ export async function collect(context, { transport, resolve } = {}) {
|
|
|
231
227
|
return true;
|
|
232
228
|
};
|
|
233
229
|
let artifact;
|
|
234
|
-
try { artifact = await fetchScoped(item.url, { context, transport, resolve, allowedAuxiliary: allowedDocuments, reserveRequest }); }
|
|
230
|
+
try { artifact = await fetchScoped(item.url, { context, transport, resolve, addressVetting, allowedAuxiliary: allowedDocuments, reserveRequest }); }
|
|
235
231
|
catch (error) {
|
|
236
232
|
const reason = ['scope_rejected', 'redirect_outside_scope', 'auxiliary_undeclared', 'request_limit'].includes(error.code) ? error.code : 'fetch_failed';
|
|
237
233
|
if (reason === 'request_limit' && !used) { skip(item.url, 'url_limit'); continue; }
|
|
@@ -2,6 +2,7 @@ import { validateEntity } from './contracts.mjs';
|
|
|
2
2
|
import { freezeProtocol } from './protocol.mjs';
|
|
3
3
|
import { compileUnits } from './weights.mjs';
|
|
4
4
|
import { score } from './score.mjs';
|
|
5
|
+
import { olderVersion } from './migration.mjs';
|
|
5
6
|
|
|
6
7
|
const close = (a, b) => Math.abs(a - b) <= 1e-9;
|
|
7
8
|
const rawScore = result => result.M === 0 ? null : 100 * result.N / result.M;
|
|
@@ -9,11 +10,27 @@ const rawCoverage = result => result.A === 0 ? null : 100 * result.M / result.A;
|
|
|
9
10
|
const difference = (before, after) => before === null || after === null ? null : after - before;
|
|
10
11
|
const relative = (before, after) => before === null || after === null || close(before, 0) ? null : (after - before) / before;
|
|
11
12
|
|
|
13
|
+
/**
|
|
14
|
+
* An input made by an older optbrew was a valid input then, so it is reported as not comparable in plain words instead of
|
|
15
|
+
* failing the schema. It is found by its version labels, never by the shape the older protocol lacked.
|
|
16
|
+
*/
|
|
17
|
+
const olderReason = (input, label) => {
|
|
18
|
+
const older = olderVersion(input);
|
|
19
|
+
return older && `the ${label} was made with ${older.name} ${older.found}, which is older than the current one; run a new audit to compare`;
|
|
20
|
+
};
|
|
21
|
+
|
|
12
22
|
/** Compare two ScoreInputs only when their frozen conditions and applicable units match. */
|
|
13
23
|
export function compare(before, after) {
|
|
24
|
+
const oldBaseline = olderReason(before, 'baseline');
|
|
25
|
+
if (after === null || after === undefined) {
|
|
26
|
+
if (!oldBaseline) { validateEntity('ScoreInput', before); freezeProtocol(before.run_context); }
|
|
27
|
+
return { status: 'no_after', score_deltas: [], reasons: ['after_missing'] };
|
|
28
|
+
}
|
|
29
|
+
if (oldBaseline) return { status: 'not_comparable', score_deltas: [], reasons: [oldBaseline] };
|
|
30
|
+
const oldFollowup = olderReason(after, 'follow-up');
|
|
31
|
+
if (oldFollowup) return { status: 'not_comparable', score_deltas: [], reasons: [oldFollowup] };
|
|
14
32
|
validateEntity('ScoreInput', before);
|
|
15
33
|
const baseline = freezeProtocol(before.run_context);
|
|
16
|
-
if (after === null || after === undefined) return { status: 'no_after', score_deltas: [], reasons: ['after_missing'] };
|
|
17
34
|
validateEntity('ScoreInput', after);
|
|
18
35
|
const followup = freezeProtocol(after.run_context);
|
|
19
36
|
const reasons = [];
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
|
|
3
|
+
const cache = new Map();
|
|
4
|
+
|
|
5
|
+
/** The one place that reads a config block: first call parses it, every call returns the same frozen object. */
|
|
6
|
+
const block = name => () => {
|
|
7
|
+
if (!cache.has(name)) {
|
|
8
|
+
const file = new URL(`../../references/${name}.json`, import.meta.url);
|
|
9
|
+
cache.set(name, Object.freeze(JSON.parse(readFileSync(file, 'utf8'))));
|
|
10
|
+
}
|
|
11
|
+
return cache.get(name);
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const limits = block('limits');
|
|
15
|
+
export const versions = block('versions');
|
|
16
|
+
export const endpoints = block('endpoints');
|
|
17
|
+
export const chromePaths = block('chrome-paths');
|
|
18
|
+
export const harnessSignals = block('harness-signals');
|
|
19
|
+
export const messagesKo = block('messages.ko');
|
|
20
|
+
|
|
21
|
+
/** The request ceiling of one run: the start question's `max_urls` (default 50), never above the cap (200). */
|
|
22
|
+
export const requestLimit = scope => Math.min(scope?.max_urls ?? limits().max_urls_default, limits().max_urls_cap);
|
|
23
|
+
|
|
24
|
+
export const checkRegistry = block('check-registry');
|
|
25
|
+
/**
|
|
26
|
+
* Whether a family matters only for pages meant to be indexed (`public_only` in `check-registry.json`, the one list): a page with
|
|
27
|
+
* `public_indexing:false` has no unit of it, no verdict and no fix. Protocol, unit compilation and the derivation all read this.
|
|
28
|
+
*/
|
|
29
|
+
export const publicOnlyFamily = familyId => checkRegistry().families.find(family => family.family_id === familyId)?.public_only === true;
|
|
30
|
+
/** The judgment thresholds one family's check block reads from `check-registry.json`. */
|
|
31
|
+
export const thresholds = familyId => checkRegistry().families.find(family => family.family_id === familyId).thresholds;
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { limits, versions } from './config.mjs';
|
|
2
3
|
|
|
3
4
|
const schema = JSON.parse(readFileSync(new URL('../../references/schemas/contract.schema.json', import.meta.url)));
|
|
4
5
|
const registry = JSON.parse(readFileSync(new URL('../../references/check-registry.json', import.meta.url)));
|
|
5
6
|
const families = new Map(registry.families.map(family => [family.family_id, family]));
|
|
6
7
|
const tolerance = 1e-9;
|
|
7
|
-
|
|
8
|
+
// [good, poor, unit] of each field CWV family, read from the registry so the thresholds are part of the evaluation source.
|
|
9
|
+
const cwv = Object.fromEntries(registry.families.filter(family => family.thresholds?.good !== undefined)
|
|
10
|
+
.map(({ family_id, thresholds }) => [family_id, [thresholds.good, thresholds.poor, thresholds.unit]]));
|
|
8
11
|
|
|
9
12
|
export class ContractError extends Error {
|
|
10
13
|
constructor(message) { super(message); this.name = 'ContractError'; }
|
|
@@ -26,13 +29,19 @@ function jsonSafe(value, path = '$', seen = new Set()) {
|
|
|
26
29
|
}
|
|
27
30
|
}
|
|
28
31
|
|
|
32
|
+
/** Whether a value satisfies a rule; the `if` and `not` keywords test this instead of failing. */
|
|
33
|
+
function matches(rule, value) {
|
|
34
|
+
try { checkRule(rule, value, '$'); return true; } catch (error) { if (error instanceof ContractError) return false; throw error; }
|
|
35
|
+
}
|
|
36
|
+
|
|
29
37
|
function checkRule(rule, value, path) {
|
|
30
38
|
if (rule.$ref) return checkRule(schema.$defs[rule.$ref.split('/').at(-1)], value, path);
|
|
31
39
|
if ('const' in rule && value !== rule.const) fail(path, `expected ${JSON.stringify(rule.const)}`);
|
|
40
|
+
if (rule.not && matches(rule.not, value)) fail(path, 'value not allowed here');
|
|
32
41
|
if (rule.enum && !rule.enum.includes(value)) fail(path, 'unrecognized value');
|
|
33
42
|
if (rule.type) {
|
|
34
43
|
const types = Array.isArray(rule.type) ? rule.type : [rule.type];
|
|
35
|
-
const valid = types.some(type => type === 'null' ? value === null : type === 'array' ? Array.isArray(value) : type === 'object' ? plainObject(value) : typeof value === type);
|
|
44
|
+
const valid = types.some(type => type === 'null' ? value === null : type === 'integer' ? Number.isInteger(value) : type === 'array' ? Array.isArray(value) : type === 'object' ? plainObject(value) : typeof value === type);
|
|
36
45
|
if (!valid) fail(path, `expected ${types.join(' or ')}`);
|
|
37
46
|
}
|
|
38
47
|
if (typeof value === 'number') {
|
|
@@ -44,7 +53,10 @@ function checkRule(rule, value, path) {
|
|
|
44
53
|
if (rule.additionalProperties === false) for (const key of Object.keys(value))
|
|
45
54
|
if (!Object.hasOwn(rule.properties ?? {}, key)) fail(`${path}.${key}`, 'unexpected field');
|
|
46
55
|
for (const [key, child] of Object.entries(rule.properties ?? {})) if (Object.hasOwn(value, key)) checkRule(child, value[key], `${path}.${key}`);
|
|
56
|
+
if (rule.if && rule.then && matches(rule.if, value)) checkRule(rule.then, value, path);
|
|
47
57
|
}
|
|
58
|
+
if (typeof value === 'string' && rule.minLength !== undefined && value.length < rule.minLength) fail(path, 'too short');
|
|
59
|
+
if (Array.isArray(value) && rule.minItems !== undefined && value.length < rule.minItems) fail(path, 'too few items');
|
|
48
60
|
if (Array.isArray(value) && rule.items) value.forEach((item, i) => checkRule(rule.items, item, `${path}[${i}]`));
|
|
49
61
|
}
|
|
50
62
|
|
|
@@ -131,12 +143,29 @@ function checkMetric(value) {
|
|
|
131
143
|
/(?:^|:)citation_response_rate$/.test(value.metric_id)) fail(path, 'positive-only sample cannot estimate all-question citation rate');
|
|
132
144
|
}
|
|
133
145
|
|
|
146
|
+
/** The request limit a run may ask for: a whole number from 1 up to the configured cap. */
|
|
147
|
+
function checkMaxUrls(value, path) {
|
|
148
|
+
if (!Number.isInteger(value) || value < 1 || value > limits().max_urls_cap) fail(path, `must be a whole number from 1 to ${limits().max_urls_cap}`);
|
|
149
|
+
}
|
|
150
|
+
|
|
134
151
|
/** Validate a persisted entity and return its original value. */
|
|
135
152
|
export function validateEntity(typeName, value) {
|
|
136
153
|
const rule = schema.$defs[typeName];
|
|
137
154
|
if (!rule) throw new ContractError(`unknown entity type: ${typeName}`);
|
|
138
155
|
jsonSafe(value);
|
|
156
|
+
// Answers are written by an agent from the installed skill's text: a different contract version means the text and the scripts disagree.
|
|
157
|
+
// Checked before the schema so the user reads one plain sentence, not a schema path.
|
|
158
|
+
if (typeName === 'IntakeAnswers' && value?.schema_version !== versions().contract_schema)
|
|
159
|
+
fail('IntakeAnswers.schema_version', `the answers file ${value?.schema_version === undefined ? 'has no schema_version' : `says ${JSON.stringify(value.schema_version)}`} but this optbrew needs ${JSON.stringify(versions().contract_schema)}; copy the contract_schema value from references/versions.json`);
|
|
139
160
|
checkRule(rule, value, typeName);
|
|
161
|
+
if (typeName === 'RunContext' && value.scope?.max_urls !== undefined) checkMaxUrls(value.scope.max_urls, 'RunContext.scope.max_urls');
|
|
162
|
+
if (typeName === 'IntakeAnswers') {
|
|
163
|
+
checkMaxUrls(value.max_urls, 'IntakeAnswers.max_urls');
|
|
164
|
+
// The start question is "sample or raise the limit": the two answers and the number must agree.
|
|
165
|
+
const raised = value.sample_mode === 'raise';
|
|
166
|
+
if (raised ? value.max_urls <= limits().max_urls_default : value.max_urls !== limits().max_urls_default)
|
|
167
|
+
fail('IntakeAnswers.max_urls', raised ? 'a raised limit must be above the default' : `a sample uses the default limit (${limits().max_urls_default}); answer "raise" to go higher`);
|
|
168
|
+
}
|
|
140
169
|
if (typeName === 'CheckObservation') checkObservation(value);
|
|
141
170
|
if (typeName === 'ScoreInput') value.observations.forEach(checkObservation);
|
|
142
171
|
if (typeName === 'MetricObservation') checkMetric(value);
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { endpoints, limits } from './config.mjs';
|
|
2
|
+
|
|
3
|
+
export const HTTP_OK = 200;
|
|
4
|
+
const HTTP_NOT_FOUND = 404;
|
|
5
|
+
const NOT_FOUND = /chrome ux report data not found/i;
|
|
6
|
+
// Google API keys are letters, digits, ".", "_" and "-" (AI Studio's AQ. keys have a dot); anything else is not a key optbrew can send, and a short string could not be removed safely.
|
|
7
|
+
const KEY_SHAPE = /^[A-Za-z0-9._-]{20,}$/;
|
|
8
|
+
// CrUX is asked for phones, as PageSpeed Insights is (strategy=mobile in endpoints.json).
|
|
9
|
+
const FORM_FACTOR = 'PHONE';
|
|
10
|
+
// CLS counts shifts in session windows: a gap of 1 s ends a window and a window ends after 5 s (the web.dev definition).
|
|
11
|
+
const SESSION_GAP_MS = 1000;
|
|
12
|
+
const SESSION_CAP_MS = 5000;
|
|
13
|
+
|
|
14
|
+
/** `text` with the key removed, in the form it was sent and in its URL-encoded form. */
|
|
15
|
+
const without = (text, key) => key ? [key, encodeURIComponent(key)].reduce((rest, secret) => rest.split(secret).join(''), String(text)) : String(text);
|
|
16
|
+
|
|
17
|
+
/** One request. A network failure is a result too (`status: null`), so a dead network leaves evidence instead of an exception. */
|
|
18
|
+
async function ask(fetchFn, url, init, key) {
|
|
19
|
+
try {
|
|
20
|
+
const response = await fetchFn(url, init);
|
|
21
|
+
return { status: response.status, text: without(await response.text(), key) };
|
|
22
|
+
} catch (error) {
|
|
23
|
+
return { status: null, error: without(error?.message ?? error, key) };
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Store what the source answered, cleaned of the key, and list it in the index's `cwv` section. `key` says what became of the user's key. */
|
|
28
|
+
async function keep(store, source, origin, keyState, result) {
|
|
29
|
+
let body = result.text;
|
|
30
|
+
try { body = JSON.parse(result.text); } catch { /* a body that is not JSON is kept as text */ }
|
|
31
|
+
const record = { source, origin, key: keyState, status: result.status, ...(result.error === undefined ? { body } : { error: result.error }) };
|
|
32
|
+
const { ref, sha256 } = await store.write('cwv', `${JSON.stringify(record, null, 2)}\n`);
|
|
33
|
+
await store.append('cwv', { source, evidence_ref: ref, sha256 });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Field Core Web Vitals of one origin, stored as evidence and never judged here. With the user's own Google key the
|
|
38
|
+
* Chrome UX Report is asked first and PageSpeed Insights only when CrUX has no data for the origin; without a key (or with
|
|
39
|
+
* one that is not shaped like a Google key, which the record says) PageSpeed Insights is tried `psi_attempts` times, keyless.
|
|
40
|
+
* `fetchFn(url, { method, headers, body })` answers `{ status, text() }`: pass `transportFetch` of the run's transport, so a
|
|
41
|
+
* proxy-only network works and the host is vetted. The key travels in the `x-goog-api-key` header only: no endpoint and no URL
|
|
42
|
+
* holds it, and every stored body or error has it removed.
|
|
43
|
+
*/
|
|
44
|
+
export async function collectCwv({ origin, key = null, fetchFn, store }) {
|
|
45
|
+
if (typeof fetchFn !== 'function') throw new TypeError('collectCwv needs fetchFn');
|
|
46
|
+
const usable = typeof key === 'string' && KEY_SHAPE.test(key);
|
|
47
|
+
const keyState = key ? (usable ? 'used' : 'key_ignored_malformed') : 'absent';
|
|
48
|
+
const auth = usable ? { 'x-goog-api-key': key } : {};
|
|
49
|
+
const secret = usable ? key : null;
|
|
50
|
+
if (usable) {
|
|
51
|
+
const crux = await ask(fetchFn, endpoints().crux, { method: 'POST', headers: { 'content-type': 'application/json', ...auth },
|
|
52
|
+
body: JSON.stringify({ origin, formFactor: FORM_FACTOR }) }, secret);
|
|
53
|
+
await keep(store, 'crux', origin, keyState, crux);
|
|
54
|
+
if (!(crux.status === HTTP_NOT_FOUND && NOT_FOUND.test(crux.text))) return;
|
|
55
|
+
}
|
|
56
|
+
for (let attempt = 0; attempt < limits().psi_attempts; attempt++) {
|
|
57
|
+
const url = new URL(endpoints().psi);
|
|
58
|
+
url.searchParams.set('url', `${origin}/`);
|
|
59
|
+
const psi = await ask(fetchFn, url.href, { method: 'GET', headers: auth }, secret);
|
|
60
|
+
await keep(store, 'psi', origin, keyState, psi);
|
|
61
|
+
if (psi.status === HTTP_OK) return;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Evaluated in the page: the buffered LCP and layout-shift entries, as plain data. */
|
|
66
|
+
const READ_LAB = `new Promise(resolve => {
|
|
67
|
+
const lcp = [], shifts = [];
|
|
68
|
+
const addLcp = entry => lcp.push(entry.startTime);
|
|
69
|
+
const addShift = entry => shifts.push({ startTime: entry.startTime, value: entry.value, hadRecentInput: entry.hadRecentInput });
|
|
70
|
+
const watch = (type, add) => {
|
|
71
|
+
const observer = new PerformanceObserver(list => list.getEntries().forEach(add));
|
|
72
|
+
observer.observe({ type, buffered: true });
|
|
73
|
+
return () => observer.takeRecords().forEach(add);
|
|
74
|
+
};
|
|
75
|
+
const drain = [watch('largest-contentful-paint', addLcp), watch('layout-shift', addShift)];
|
|
76
|
+
// Buffered entries arrive in a task of their own; one more task later they have all been delivered.
|
|
77
|
+
setTimeout(() => { drain.forEach(run => run()); resolve(JSON.stringify({ lcp, shifts })); }, 0);
|
|
78
|
+
})`;
|
|
79
|
+
|
|
80
|
+
/** The largest sum of shift values over session windows; shifts right after user input do not count. */
|
|
81
|
+
function layoutShift(shifts) {
|
|
82
|
+
let best = 0, sum = 0, windowStart = null, last = null;
|
|
83
|
+
for (const { startTime, value } of shifts.filter(shift => !shift.hadRecentInput).sort((a, b) => a.startTime - b.startTime)) {
|
|
84
|
+
if (windowStart === null || startTime - last > SESSION_GAP_MS || startTime - windowStart > SESSION_CAP_MS) { sum = 0; windowStart = startTime; }
|
|
85
|
+
sum += value;
|
|
86
|
+
last = startTime;
|
|
87
|
+
best = Math.max(best, sum);
|
|
88
|
+
}
|
|
89
|
+
return best;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Lab Core Web Vitals of a page a `scopedPage` has already loaded, read with PerformanceObserver. `lcp` is the last
|
|
94
|
+
* largest-contentful-paint entry in ms (null when the page painted none), `cls` the layout shift score. The caller keeps
|
|
95
|
+
* the result in `RunState.lab_cwv`: it is shown as reference only and never becomes an observation.
|
|
96
|
+
*/
|
|
97
|
+
export async function labCwv({ page }) {
|
|
98
|
+
const { lcp, shifts } = JSON.parse(await page.evaluate(READ_LAB));
|
|
99
|
+
return { lcp: lcp.length ? lcp.at(-1) : null, cls: layoutShift(shifts) };
|
|
100
|
+
}
|