optbrew 0.1.2 → 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 +4 -4
- package/.claude-plugin/plugin.json +2 -1
- package/.codex-plugin/plugin.json +2 -2
- package/AGENT_INSTALL.md +94 -0
- package/CHANGELOG.md +74 -1
- package/README.ko.md +129 -105
- package/README.md +129 -105
- package/THIRD_PARTY_NOTICES.md +88 -0
- package/docs/platforms.md +45 -8
- package/docs/release.md +37 -4
- 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 +29 -9
- 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 +409 -49
- package/skills/optbrew/modules/naver-searchadvisor/scripts/audit.mjs +4 -4
- package/skills/optbrew/references/ai-crawlers.json +1 -1
- package/skills/optbrew/references/ai-crawlers.md +1 -1
- package/skills/optbrew/references/api.md +6 -4
- 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 -96
- 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 +65 -59
- 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 -161
- package/skills/optbrew/scripts/core/evidence-store.mjs +112 -0
- package/skills/optbrew/scripts/core/facts.mjs +203 -19
- 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 +357 -131
- 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,13 +60,20 @@ 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);
|
|
97
75
|
const redirect_chain = [];
|
|
76
|
+
let made = 0; // requests actually sent; an answered hop is in redirect_chain, an unanswered attempt is made - chain length
|
|
98
77
|
const controller = new AbortController();
|
|
99
78
|
const deadline = performance.now() + timeoutMs;
|
|
100
79
|
let rejectTimeout;
|
|
@@ -104,7 +83,7 @@ export async function fetchScoped(input, { context, transport = nodeTransport, r
|
|
|
104
83
|
try {
|
|
105
84
|
for (let hop = 0; hop <= maxRedirects; hop++) {
|
|
106
85
|
const addresses = await withinDeadline(addressesFor(url, resolve));
|
|
107
|
-
try { assertInScope(url.href, context.scope, addresses); }
|
|
86
|
+
try { assertInScope(url.href, context.scope, addresses, { addressVetting }); }
|
|
108
87
|
catch (error) { error.code = 'scope_rejected'; error.redirect_chain = redirect_chain; throw error; }
|
|
109
88
|
// Pick only a vetted address. The transport must use this address, not resolve again.
|
|
110
89
|
const remainingMs = Math.ceil(deadline - performance.now());
|
|
@@ -116,18 +95,23 @@ export async function fetchScoped(input, { context, transport = nodeTransport, r
|
|
|
116
95
|
error.redirect_chain = redirect_chain;
|
|
117
96
|
throw error;
|
|
118
97
|
}
|
|
119
|
-
|
|
98
|
+
made++;
|
|
99
|
+
const response = await withinDeadline(transport({ url, address: addresses[0], timeoutMs: remainingMs, maxBytes, headers, signal: controller.signal, method, body: requestBody }));
|
|
120
100
|
if (!Number.isInteger(response?.status) || response.status < 100 || response.status > 599) throw new Error('invalid transport status');
|
|
121
101
|
const body = text(response.body);
|
|
122
102
|
if (body.length > maxBytes) throw new Error('response byte limit exceeded');
|
|
123
|
-
const savedHeaders = safeHeaders(response.headers);
|
|
103
|
+
const savedHeaders = safeHeaders(response.headers, extraHeaders);
|
|
124
104
|
if (REDIRECTS.has(response.status) && savedHeaders.location) {
|
|
125
|
-
if (hop === maxRedirects)
|
|
105
|
+
if (hop === maxRedirects) {
|
|
106
|
+
redirect_chain.push({ url: url.href, status: response.status, location: '[limit]' });
|
|
107
|
+
throw new Error('redirect limit exceeded');
|
|
108
|
+
}
|
|
126
109
|
let next;
|
|
127
110
|
try { next = assertUrlInScope(new URL(savedHeaders.location, url).href, context.scope); }
|
|
128
111
|
catch {
|
|
129
112
|
const error = new Error('redirect outside scope');
|
|
130
113
|
error.code = 'redirect_outside_scope';
|
|
114
|
+
try { error.redirect_to = new URL(savedHeaders.location, url).href; } catch { /* an unparsable location names no site */ }
|
|
131
115
|
error.redirect_chain = [...redirect_chain, { url: url.href, status: response.status, location: '[blocked]' }];
|
|
132
116
|
throw error;
|
|
133
117
|
}
|
|
@@ -149,10 +133,29 @@ export async function fetchScoped(input, { context, transport = nodeTransport, r
|
|
|
149
133
|
};
|
|
150
134
|
}
|
|
151
135
|
throw new Error('redirect limit exceeded');
|
|
152
|
-
} catch (error) {
|
|
136
|
+
} catch (error) {
|
|
137
|
+
error.redirect_chain ??= [...redirect_chain];
|
|
138
|
+
error.unanswered = Math.max(0, made - error.redirect_chain.length);
|
|
139
|
+
throw error;
|
|
140
|
+
}
|
|
153
141
|
finally { clearTimeout(timer); }
|
|
154
142
|
}
|
|
155
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
|
+
|
|
156
159
|
/** Index of the declared robots.txt artifact on the same origin as the page, as the audit and the report both select it. */
|
|
157
160
|
export function declaredRobotsIndex(artifacts, declaredRobots, pageFinalUrl) {
|
|
158
161
|
return artifacts.findIndex(x => {
|
|
@@ -170,11 +173,11 @@ export function links(html, base) {
|
|
|
170
173
|
}
|
|
171
174
|
|
|
172
175
|
/** Bounded crawl of fixed protocol URLs and their in-scope links. */
|
|
173
|
-
export async function collect(context, { transport, resolve } = {}) {
|
|
176
|
+
export async function collect(context, { transport, resolve, addressVetting } = {}) {
|
|
174
177
|
const scope = context?.scope;
|
|
175
|
-
const limit =
|
|
176
|
-
const depthLimit = Math.min(scope?.max_depth ??
|
|
177
|
-
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);
|
|
178
181
|
if (![limit, depthLimit, concurrency].every(Number.isInteger) || limit < 1 || depthLimit < 0 || concurrency < 1)
|
|
179
182
|
throw new Error('invalid collection limits');
|
|
180
183
|
const initial = context.protocol.page_types.flatMap(type => type.urls.map(ref => ref.normalized));
|
|
@@ -200,39 +203,42 @@ export async function collect(context, { transport, resolve } = {}) {
|
|
|
200
203
|
if (depth > depthLimit) { skip(normalized, 'depth_limit'); return; }
|
|
201
204
|
queue.push({ url: normalized, depth, auxiliaryResource });
|
|
202
205
|
};
|
|
203
|
-
|
|
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.
|
|
204
209
|
for (const resource of auxiliary) {
|
|
205
|
-
if (seen.has(resource.normalized)) throw new Error('auxiliary resource duplicates selected URL');
|
|
206
210
|
const normalized = assertUrlInScope(resource.normalized, scope).href;
|
|
207
211
|
if (normalized !== resource.normalized || assertUrlInScope(resource.original, scope).href !== normalized)
|
|
208
212
|
throw new Error('auxiliary resource URL mismatch');
|
|
209
213
|
enqueue(normalized, 0, true);
|
|
210
214
|
}
|
|
215
|
+
initial.forEach(url => enqueue(url, 0));
|
|
211
216
|
const artifacts = []; let cursor = 0; let requestCount = 0;
|
|
212
217
|
async function worker() {
|
|
213
218
|
while (cursor < queue.length) {
|
|
214
219
|
const item = queue[cursor++];
|
|
215
220
|
if (requestCount >= limit) { skip(item.url, 'url_limit'); continue; }
|
|
216
221
|
let used = 0;
|
|
217
|
-
// The budget
|
|
218
|
-
//
|
|
222
|
+
// The budget is a hard ceiling: every request, redirect hops included, takes one slot immediately before it is sent.
|
|
223
|
+
// A chain the budget stops is a request_limit unknown whose answered hops are recorded.
|
|
219
224
|
const reserveRequest = () => {
|
|
220
|
-
if (
|
|
225
|
+
if (requestCount >= limit) return false;
|
|
221
226
|
requestCount++; used++;
|
|
222
227
|
return true;
|
|
223
228
|
};
|
|
224
229
|
let artifact;
|
|
225
|
-
|
|
226
|
-
// the limit: at most concurrency x min(10, limit) in all.
|
|
227
|
-
try { artifact = await fetchScoped(item.url, { context, transport, resolve, allowedAuxiliary: allowedDocuments, reserveRequest, maxRedirects: Math.min(10, limit) }); }
|
|
230
|
+
try { artifact = await fetchScoped(item.url, { context, transport, resolve, addressVetting, allowedAuxiliary: allowedDocuments, reserveRequest }); }
|
|
228
231
|
catch (error) {
|
|
229
232
|
const reason = ['scope_rejected', 'redirect_outside_scope', 'auxiliary_undeclared', 'request_limit'].includes(error.code) ? error.code : 'fetch_failed';
|
|
230
233
|
if (reason === 'request_limit' && !used) { skip(item.url, 'url_limit'); continue; }
|
|
231
234
|
if (reason !== 'fetch_failed') skipped.push({ url: item.url, reason });
|
|
232
|
-
//
|
|
233
|
-
|
|
235
|
+
// The requests a failed fetch made are its answered hops (kept in the hashed failure record) plus at most one
|
|
236
|
+
// attempt that got no response, so the ledger can prove them.
|
|
237
|
+
const chain = error.redirect_chain ?? [];
|
|
238
|
+
const unanswered = error.unanswered ?? 0;
|
|
239
|
+
ledger.push({ url: item.url, requests: chain.length + unanswered, outcome: 'error', skipped_reason: reason !== 'fetch_failed' ? reason : null, error: reason });
|
|
234
240
|
artifact = { requested_url: item.url, normalized_url: item.url, final_url: null, status: null,
|
|
235
|
-
headers: {}, header_lines: [], redirect_chain:
|
|
241
|
+
headers: {}, header_lines: [], redirect_chain: chain, unanswered, raw_html_ref: null, content_hash: null, capability: 'none',
|
|
236
242
|
source_ref: 'optbrew:collector', evidence_ref: null, error: reason, body: Buffer.alloc(0) };
|
|
237
243
|
}
|
|
238
244
|
if (!artifact.error) ledger.push({ url: item.url, requests: used, outcome: 'response', skipped_reason: null, error: null });
|
|
@@ -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
|
+
}
|