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
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import https from 'node:https';
|
|
3
|
+
import tls from 'node:tls';
|
|
4
|
+
import { isIP } from 'node:net';
|
|
5
|
+
import { performance } from 'node:perf_hooks';
|
|
6
|
+
import { unbracket, urlTarget } from './scope.mjs';
|
|
7
|
+
|
|
8
|
+
const DNS_FAILURES = new Set(['ENOTFOUND', 'EAI_AGAIN']);
|
|
9
|
+
|
|
10
|
+
/** Send one request (`body`, when there is one, is its whole payload) and read the response into `{ status, headers, rawHeaders, body }`; both transports share this, byte limit included. */
|
|
11
|
+
function exchange({ send, timeoutMs, maxBytes, body = null }) {
|
|
12
|
+
return new Promise((resolve, reject) => {
|
|
13
|
+
// Reject first: once a response has started, destroying the request ends it quietly and would resolve a truncated body.
|
|
14
|
+
const overLimit = () => { reject(new Error('response byte limit exceeded')); req.destroy(); };
|
|
15
|
+
const req = send(res => {
|
|
16
|
+
const chunks = []; let size = 0;
|
|
17
|
+
if (Number(res.headers['content-length']) > maxBytes) { overLimit(); return; }
|
|
18
|
+
res.on('data', chunk => {
|
|
19
|
+
size += chunk.length;
|
|
20
|
+
if (size > maxBytes) { overLimit(); return; }
|
|
21
|
+
chunks.push(chunk);
|
|
22
|
+
});
|
|
23
|
+
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, rawHeaders: res.rawHeaders, body: Buffer.concat(chunks) }));
|
|
24
|
+
res.on('error', reject);
|
|
25
|
+
});
|
|
26
|
+
req.setTimeout(timeoutMs, () => req.destroy(new Error('request timeout')));
|
|
27
|
+
req.on('error', reject);
|
|
28
|
+
req.end(body ?? undefined);
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Request headers; a body brings its Content-Length, and a caller's own Content-Length (any case) is dropped so it can never disagree with the bytes sent. */
|
|
33
|
+
const requestHeaders = (headers, body = null) => {
|
|
34
|
+
const merged = Object.fromEntries(Object.entries({ 'User-Agent': 'optbrew/0.1 (bounded public audit)', Accept: '*/*', 'Accept-Encoding': 'identity', ...headers })
|
|
35
|
+
.filter(([name]) => name.toLowerCase() !== 'content-length'));
|
|
36
|
+
return body === null ? merged : { ...merged, 'Content-Length': Buffer.byteLength(body) };
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** Node's lookup callback pins the already-vetted address while TLS still verifies the URL hostname. */
|
|
40
|
+
export function directTransport({ url, address, timeoutMs, maxBytes, headers = {}, signal, method = 'GET', body = null }) {
|
|
41
|
+
const client = url.protocol === 'https:' ? https : http;
|
|
42
|
+
const { host: hostname } = urlTarget(url);
|
|
43
|
+
return exchange({ timeoutMs, maxBytes, body, send: onResponse => client.request(url, {
|
|
44
|
+
method, agent: false, signal, headers: requestHeaders(headers, body),
|
|
45
|
+
lookup: (_hostname, options, callback) => options.all ?
|
|
46
|
+
callback(null, [{ address: address.address, family: address.family }]) : callback(null, address.address, address.family),
|
|
47
|
+
...(url.protocol === 'https:' ? { rejectUnauthorized: true,
|
|
48
|
+
...(!isIP(hostname) ? { servername: hostname } : {}) } : {}),
|
|
49
|
+
}, onResponse) });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Error that names the proxy by host and port only, so no credential can reach a message. */
|
|
53
|
+
function proxyError(proxy, what, code) {
|
|
54
|
+
const error = new Error(`${what} via ${proxy.host}:${proxy.port}`);
|
|
55
|
+
error.code = code;
|
|
56
|
+
return error;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Open a CONNECT tunnel to the target; resolves with the raw tunnel socket. */
|
|
60
|
+
function openTunnel(proxy, url, { timeoutMs, signal }) {
|
|
61
|
+
const { port } = urlTarget(url);
|
|
62
|
+
const authority = `${url.hostname}:${port}`;
|
|
63
|
+
return new Promise((resolve, reject) => {
|
|
64
|
+
const req = http.request({ host: proxy.host, port: proxy.port, method: 'CONNECT', path: authority, agent: false, signal,
|
|
65
|
+
headers: { Host: authority, ...(proxy.authorization ? { 'Proxy-Authorization': proxy.authorization } : {}) } });
|
|
66
|
+
req.setTimeout(timeoutMs, () => { reject(proxyError(proxy, 'proxy CONNECT timed out', 'proxy_timeout')); req.destroy(); });
|
|
67
|
+
req.on('connect', (res, socket, head) => {
|
|
68
|
+
if (res.statusCode < 200 || res.statusCode > 299) {
|
|
69
|
+
socket.destroy();
|
|
70
|
+
const hint = port === 443 ? '' : ` (a proxy may allow CONNECT to port 443 only; the target port is ${port})`;
|
|
71
|
+
reject(proxyError(proxy, `proxy CONNECT refused with status ${res.statusCode}${hint}`, 'proxy_connect_refused'));
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (head.length) socket.unshift(head);
|
|
75
|
+
resolve(socket);
|
|
76
|
+
});
|
|
77
|
+
req.on('error', error => reject(proxyError(proxy, `proxy connection failed (${error.code ?? error.message})`, 'proxy_unreachable')));
|
|
78
|
+
req.end();
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Transport that sends every request, plain http included, through a CONNECT tunnel; the proxy resolves the target name.
|
|
84
|
+
* HTTPS targets get TLS over the tunnel: the certificate is checked against the target host (`host`), and SNI
|
|
85
|
+
* (`servername`, names only) is set because Node leaves it off when a socket is supplied.
|
|
86
|
+
* The CONNECT and the request share one deadline. The returned function is tagged for `fetchScoped`, which refuses
|
|
87
|
+
* proxy address vetting on any other transport.
|
|
88
|
+
*/
|
|
89
|
+
export function proxyTransport(proxy, { connectTls = tls.connect } = {}) {
|
|
90
|
+
const transport = async ({ url, timeoutMs, maxBytes, headers = {}, signal, method = 'GET', body = null }) => {
|
|
91
|
+
const deadline = performance.now() + timeoutMs;
|
|
92
|
+
const tunnel = await openTunnel(proxy, url, { timeoutMs, signal });
|
|
93
|
+
let stream = tunnel;
|
|
94
|
+
try {
|
|
95
|
+
const { host, port } = urlTarget(url);
|
|
96
|
+
if (url.protocol === 'https:')
|
|
97
|
+
stream = connectTls({ socket: tunnel, host, rejectUnauthorized: true, ...(!isIP(host) ? { servername: host } : {}) });
|
|
98
|
+
const remainingMs = Math.ceil(deadline - performance.now());
|
|
99
|
+
if (remainingMs <= 0) throw proxyError(proxy, 'proxy tunnel timed out', 'proxy_timeout');
|
|
100
|
+
// No `agent` option: `agent: false` makes Node build an Agent that ignores createConnection and dials the name itself.
|
|
101
|
+
return await exchange({ timeoutMs: remainingMs, maxBytes, body, send: onResponse => http.request({
|
|
102
|
+
host, port, path: `${url.pathname}${url.search}`, method, signal,
|
|
103
|
+
createConnection: () => stream, headers: { Host: url.host, ...requestHeaders(headers, body) } }, onResponse) });
|
|
104
|
+
} finally { stream.destroy(); tunnel.destroy(); }
|
|
105
|
+
};
|
|
106
|
+
return Object.assign(transport, { addressVetting: 'proxy' });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** `HTTPS_PROXY` as `{ host, port, authorization }`. Only plain-HTTP proxies work (a missing scheme counts as plain HTTP); userinfo becomes a Basic header and is not kept. */
|
|
110
|
+
export function parseProxy(env) {
|
|
111
|
+
const value = (env.HTTPS_PROXY || env.https_proxy || '').trim();
|
|
112
|
+
const parts = /^(?:([a-z][a-z\d+.-]*):\/\/)?(.*)$/i.exec(value);
|
|
113
|
+
const scheme = (parts[1] ?? 'http').toLowerCase();
|
|
114
|
+
if (!value || scheme !== 'http') return null;
|
|
115
|
+
let url;
|
|
116
|
+
try { url = new URL(`${scheme}://${parts[2]}`); } catch { return null; }
|
|
117
|
+
if (!url.hostname) return null;
|
|
118
|
+
const decode = text => { try { return decodeURIComponent(text); } catch { return text; } };
|
|
119
|
+
const authorization = url.username || url.password ?
|
|
120
|
+
`Basic ${Buffer.from(`${decode(url.username)}:${decode(url.password)}`).toString('base64')}` : null;
|
|
121
|
+
const proxy = { host: unbracket(url.hostname), port: Number(url.port || 80) };
|
|
122
|
+
// Not enumerable: JSON.stringify, spread and inspect all leave the credential out; only a direct read sees it.
|
|
123
|
+
return Object.defineProperty(proxy, 'authorization', { value: authorization, enumerable: false });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** NO_PROXY entries: comma-separated, case-insensitive, `*` matches all, an entry matches the host or any subdomain; ports are ignored. */
|
|
127
|
+
function bypassesProxy(env, host) {
|
|
128
|
+
const entries = (env.NO_PROXY || env.no_proxy || '').split(',').map(entry => entry.trim().toLowerCase()).filter(Boolean);
|
|
129
|
+
const name = unbracket(host.toLowerCase());
|
|
130
|
+
return entries.some(entry => {
|
|
131
|
+
if (entry === '*') return true;
|
|
132
|
+
const bare = entry.startsWith('[') ? entry.slice(1, entry.indexOf(']')) :
|
|
133
|
+
entry.split(':').length === 2 ? entry.split(':')[0] : entry;
|
|
134
|
+
const domain = bare.replace(/^\*?\./, '');
|
|
135
|
+
return name === domain || name.endsWith(`.${domain}`);
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function dnsError(host, code, reason) {
|
|
140
|
+
const error = new Error(`DNS lookup for ${host} failed (${code}) and ${reason}`);
|
|
141
|
+
error.code = 'dns_unresolvable';
|
|
142
|
+
error.dns_code = code;
|
|
143
|
+
return error;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Direct transport when DNS works; the HTTPS proxy when DNS fails and one is set (NO_PROXY respected); a classified error otherwise. */
|
|
147
|
+
export async function selectTransport({ env, resolve, probeHost }) {
|
|
148
|
+
try {
|
|
149
|
+
await resolve(probeHost);
|
|
150
|
+
return { transport: directTransport, resolve, address_vetting: 'direct', proxy: null };
|
|
151
|
+
} catch (error) {
|
|
152
|
+
if (!DNS_FAILURES.has(error?.code)) throw error;
|
|
153
|
+
const proxy = bypassesProxy(env, probeHost) ? null : parseProxy(env);
|
|
154
|
+
if (!proxy) throw dnsError(probeHost, error.code, 'no usable HTTPS_PROXY is set');
|
|
155
|
+
return { transport: proxyTransport(proxy), resolve: async () => [], address_vetting: 'proxy', proxy };
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { publicOnlyFamily } from './config.mjs';
|
|
2
3
|
import { ContractError, validateEntity } from './contracts.mjs';
|
|
3
4
|
import { freezeProtocol } from './protocol.mjs';
|
|
4
5
|
|
|
@@ -8,7 +9,7 @@ const profiles = JSON.parse(readFileSync(new URL('../../references/profiles.json
|
|
|
8
9
|
const tolerance = 1e-9;
|
|
9
10
|
|
|
10
11
|
function candidateUnits(definition, plan, protocol) {
|
|
11
|
-
const publicOnly =
|
|
12
|
+
const publicOnly = publicOnlyFamily(definition.family_id);
|
|
12
13
|
const pageTypes = protocol.page_types.map(type => ({ ...type,
|
|
13
14
|
urls: type.urls.filter(url => (!publicOnly || url.public_indexing) &&
|
|
14
15
|
(!plan.urls || plan.urls.includes(url.normalized))) })).filter(type => type.urls.length);
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { realpathSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { googleKeyStatus, keyHome, removeGoogleKey, saveGoogleKey } from './core/keys.mjs';
|
|
5
|
+
|
|
6
|
+
const USAGE = 'usage: key.mjs <set google | status | remove google>';
|
|
7
|
+
const CTRL_C = '\u0003';
|
|
8
|
+
const CTRL_D = '\u0004';
|
|
9
|
+
const DELETE = '\u007f';
|
|
10
|
+
const BACKSPACE = '\b';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Read a secret from a terminal without echoing it: raw mode, characters are collected and never written back.
|
|
14
|
+
* Enter submits; Ctrl-C, Ctrl-D on empty input, end of input and stream errors end the read without a value.
|
|
15
|
+
*/
|
|
16
|
+
function readHidden(stdin, stdout) {
|
|
17
|
+
return new Promise((resolve, reject) => {
|
|
18
|
+
let value = '';
|
|
19
|
+
const finish = settle => {
|
|
20
|
+
stdin.off('data', onData); stdin.off('end', cancel); stdin.off('error', fail);
|
|
21
|
+
stdin.setRawMode(false);
|
|
22
|
+
stdin.pause();
|
|
23
|
+
stdout.write('\n');
|
|
24
|
+
settle();
|
|
25
|
+
};
|
|
26
|
+
const cancel = () => finish(() => reject(new Error('cancelled')));
|
|
27
|
+
const fail = error => finish(() => reject(error));
|
|
28
|
+
const onData = chunk => {
|
|
29
|
+
for (const char of String(chunk)) {
|
|
30
|
+
if (char === '\r' || char === '\n') return finish(() => resolve(value));
|
|
31
|
+
if (char === CTRL_C || (char === CTRL_D && !value)) return cancel();
|
|
32
|
+
if (char === DELETE || char === BACKSPACE) value = value.slice(0, -1);
|
|
33
|
+
else if (char >= ' ') value += char;
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
stdout.write('Google API key (input is hidden): ');
|
|
37
|
+
stdin.setRawMode(true);
|
|
38
|
+
stdin.setEncoding('utf8');
|
|
39
|
+
stdin.on('data', onData);
|
|
40
|
+
stdin.on('end', cancel);
|
|
41
|
+
stdin.on('error', fail);
|
|
42
|
+
stdin.resume();
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Read one line from a pipe or file. */
|
|
47
|
+
function readLine(stdin) {
|
|
48
|
+
return new Promise((resolve, reject) => {
|
|
49
|
+
let buffer = '';
|
|
50
|
+
const finish = () => {
|
|
51
|
+
stdin.off('data', onData); stdin.off('end', finish); stdin.off('error', reject);
|
|
52
|
+
stdin.pause();
|
|
53
|
+
resolve(buffer.split(/\r?\n/)[0]);
|
|
54
|
+
};
|
|
55
|
+
const onData = chunk => { buffer += chunk; if (buffer.includes('\n')) finish(); };
|
|
56
|
+
stdin.setEncoding('utf8');
|
|
57
|
+
stdin.on('data', onData);
|
|
58
|
+
stdin.on('end', finish);
|
|
59
|
+
stdin.on('error', reject);
|
|
60
|
+
stdin.resume();
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Return a process code: 0 success, 2 usage error, 1 failure. Messages never contain the key value. */
|
|
65
|
+
export async function runKeyCommand(argv, { stdin = process.stdin, stdout = process.stdout, stderr = process.stderr, env = process.env, home = keyHome(env) } = {}) {
|
|
66
|
+
const [action, provider, ...extra] = argv;
|
|
67
|
+
const valid = extra.length === 0 && ((['set', 'remove'].includes(action) && provider === 'google') || (action === 'status' && provider === undefined));
|
|
68
|
+
if (!valid) { stderr.write(`${USAGE}\n`); return 2; }
|
|
69
|
+
try {
|
|
70
|
+
if (action === 'status') {
|
|
71
|
+
stdout.write(`${JSON.stringify(googleKeyStatus({ env, home }))}\n`);
|
|
72
|
+
} else if (action === 'remove') {
|
|
73
|
+
stdout.write(await removeGoogleKey({ home }) ? 'google key removed\n' : 'no google key in the key file; nothing to remove\n');
|
|
74
|
+
if (googleKeyStatus({ env, home }).google.present) stderr.write('OPTBREW_GOOGLE_API_KEY is still set in the environment\n');
|
|
75
|
+
} else {
|
|
76
|
+
const key = (stdin.isTTY ? await readHidden(stdin, stdout) : await readLine(stdin)).trim();
|
|
77
|
+
if (!key) { stderr.write('no key entered; nothing saved\n'); return 2; }
|
|
78
|
+
await saveGoogleKey({ home, key });
|
|
79
|
+
stdout.write('google key saved\n');
|
|
80
|
+
}
|
|
81
|
+
return 0;
|
|
82
|
+
} catch (error) {
|
|
83
|
+
stderr.write(`${error.message}\n`);
|
|
84
|
+
return 1;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (process.argv[1] && realpathSync(path.resolve(process.argv[1])) === realpathSync(fileURLToPath(import.meta.url))) {
|
|
89
|
+
runKeyCommand(process.argv.slice(2)).then(code => { process.exitCode = code; });
|
|
90
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { lookup } from 'node:dns/promises';
|
|
3
|
+
import { realpathSync } from 'node:fs';
|
|
4
|
+
import { isIP } from 'node:net';
|
|
5
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { fetchScoped, links } from './core/collector.mjs';
|
|
9
|
+
import { findChrome as realFindChrome } from './core/chrome.mjs';
|
|
10
|
+
import { limits } from './core/config.mjs';
|
|
11
|
+
import { openStore } from './core/evidence-store.mjs';
|
|
12
|
+
import { pageFacts } from './core/facts.mjs';
|
|
13
|
+
import { decodeEntities } from './core/html.mjs';
|
|
14
|
+
import { readGoogleKey as realReadGoogleKey } from './core/keys.mjs';
|
|
15
|
+
import { probeEnvironment, runbookHarnesses } from './core/probe.mjs';
|
|
16
|
+
import { startRunbooks } from './core/runbooks.mjs';
|
|
17
|
+
import { assertUrlInScope, unbracket } from './core/scope.mjs';
|
|
18
|
+
import { selectTransport as realSelectTransport } from './core/transport.mjs';
|
|
19
|
+
|
|
20
|
+
const USAGE = 'usage: node skills/optbrew/scripts/preflight.mjs --url <URL> --out <dir> [--source-tools a,b] [--skill-root <path>] [--level beginner|brief]';
|
|
21
|
+
/** The guidance level is asked in the same start questions this file feeds, so a blocking runbook is shown at the beginner level. */
|
|
22
|
+
const START_LEVEL = 'beginner';
|
|
23
|
+
const SITEMAP_PATH = '/sitemap.xml';
|
|
24
|
+
const defaultSkillRoot = path.resolve(fileURLToPath(new URL('..', import.meta.url)));
|
|
25
|
+
|
|
26
|
+
const locCount = text => (text.match(/<loc>/gi) ?? []).length;
|
|
27
|
+
const locs = text => [...text.matchAll(/<loc>\s*([^<\s]+)\s*<\/loc>/gi)].map(match => decodeEntities(match[1]));
|
|
28
|
+
const inner = (html, pattern) => { const found = pattern.exec(html)?.[1]; return found === undefined ? null : decodeEntities(found.replace(/<[^>]+>/g, ' ')).replace(/\s+/g, ' ').trim() || null; };
|
|
29
|
+
|
|
30
|
+
/** The origins of the page's own site: itself, its apex/www twin and its http/https twin. A named port or an IP literal has no twin. */
|
|
31
|
+
function sameSiteOrigins(start) {
|
|
32
|
+
if (start.port || isIP(unbracket(start.hostname))) return [start.origin];
|
|
33
|
+
const twin = start.hostname.startsWith('www.') ? start.hostname.slice(4) : `www.${start.hostname}`;
|
|
34
|
+
return ['http:', 'https:'].flatMap(protocol => [start.hostname, twin].map(host => `${protocol}//${host}`));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** What an agent needs to propose the question list: where the page is, its title, description and first heading. */
|
|
38
|
+
function questionInput(artifact, html) {
|
|
39
|
+
const tag = /<meta\b[^>]*\bname\s*=\s*["']description["'][^>]*>/i.exec(html)?.[0];
|
|
40
|
+
const content = tag && /\bcontent\s*=\s*(?:"([^"]*)"|'([^']*)')/i.exec(tag);
|
|
41
|
+
return { url: artifact.final_url, title: inner(html, /<title\b[^>]*>([\s\S]*?)<\/title>/i), h1: inner(html, /<h1\b[^>]*>([\s\S]*?)<\/h1>/i),
|
|
42
|
+
description: content ? decodeEntities(content[1] ?? content[2]).trim() || null : null };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Facts the pages state about the business, one candidate per fact type and normalized value; the first page that states it is the source. */
|
|
46
|
+
function factCandidates(pages) {
|
|
47
|
+
const seen = new Set();
|
|
48
|
+
const candidates = [];
|
|
49
|
+
for (const { artifact, html } of pages) {
|
|
50
|
+
const facts = pageFacts(html);
|
|
51
|
+
for (const bucket of [facts.visible, facts.linked]) for (const [type, values] of Object.entries(bucket)) for (const [key, value] of values) {
|
|
52
|
+
if (seen.has(`${type}|${key}`)) continue;
|
|
53
|
+
seen.add(`${type}|${key}`);
|
|
54
|
+
candidates.push({ key: type, value, source_url: artifact.final_url });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return candidates;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The unscored read before the start questions (C17): the probe, the sitemap size, a few pages for question and fact
|
|
62
|
+
* candidates, and the start runbooks. At most `preflight_pages` requests are sent in all, redirect hops and the sitemap
|
|
63
|
+
* included. Evidence goes in its own store under `<out>/preflight/evidence`, never in the audit's evidence index, and
|
|
64
|
+
* nothing here enters the protocol or a score.
|
|
65
|
+
*/
|
|
66
|
+
export async function preflight({ url, out, sourceTools = [], skillRoot = defaultSkillRoot, level = START_LEVEL, env = process.env, home,
|
|
67
|
+
resolve = host => lookup(host, { all: true, verbatim: true }), selectTransport = realSelectTransport, findChrome = realFindChrome, readGoogleKey = realReadGoogleKey }) {
|
|
68
|
+
const start = new URL(url);
|
|
69
|
+
if (!['http:', 'https:'].includes(start.protocol)) throw new Error('url must be http or https');
|
|
70
|
+
// The first request may end on the same site under another name (apex and www, http and https); once it lands, scope narrows to where it landed.
|
|
71
|
+
const scopeOf = origins => ({ allowed: origins.map(origin => ({ origin, path_prefix: '/' })) });
|
|
72
|
+
let scope = scopeOf(sameSiteOrigins(start));
|
|
73
|
+
const dir = path.join(out, 'preflight');
|
|
74
|
+
const evidenceDir = path.join(dir, 'evidence');
|
|
75
|
+
await mkdir(evidenceDir, { recursive: true });
|
|
76
|
+
const store = openStore(evidenceDir);
|
|
77
|
+
const budget = limits().preflight_pages;
|
|
78
|
+
let used = 0;
|
|
79
|
+
const reserveRequest = () => { if (used >= budget) return false; used++; return true; };
|
|
80
|
+
let route = null;
|
|
81
|
+
|
|
82
|
+
// Where the first request was sent when that was another site (it is recorded for the run, which stops on it).
|
|
83
|
+
let leftTo = null;
|
|
84
|
+
const get = async href => {
|
|
85
|
+
try {
|
|
86
|
+
const artifact = await fetchScoped(href, { context: { scope }, ...route, reserveRequest });
|
|
87
|
+
const { ref, sha256 } = await store.write(artifact.capability === 'raw_html' ? 'html' : 'bin', artifact.body);
|
|
88
|
+
await store.append('raw', { normalized_url: artifact.normalized_url, evidence_ref: ref, content_hash: sha256 });
|
|
89
|
+
return artifact;
|
|
90
|
+
} catch (error) {
|
|
91
|
+
await store.append('failures', { normalized_url: href, error: error.code ?? 'fetch_failed' });
|
|
92
|
+
if (href === start.href && error.redirect_to) leftTo = error.redirect_to;
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
const tryGet = href => get(href).catch(() => null);
|
|
97
|
+
|
|
98
|
+
let first = null;
|
|
99
|
+
const reach = async selected => {
|
|
100
|
+
route = { transport: selected.transport, resolve: selected.resolve, addressVetting: selected.address_vetting };
|
|
101
|
+
first = await get(start.href);
|
|
102
|
+
scope = scopeOf([new URL(first.final_url).origin]);
|
|
103
|
+
};
|
|
104
|
+
const probe = await probeEnvironment({ env, url, selectTransport: input => selectTransport({ ...input, resolve }), findChrome, readGoogleKey, home, sourceTools, reach });
|
|
105
|
+
|
|
106
|
+
const pages = [];
|
|
107
|
+
const addPage = artifact => { if (artifact?.status === 200 && artifact.capability === 'raw_html') pages.push({ artifact, html: artifact.body.toString('utf8') }); };
|
|
108
|
+
let sitemapPages = null;
|
|
109
|
+
let sitemapBasis = null;
|
|
110
|
+
// Where /sitemap.xml ended up: WordPress and Yoast send it on to an index file under another name.
|
|
111
|
+
let sitemapFinal = null;
|
|
112
|
+
// The sitemap files the audit will declare: /sitemap.xml, where it ended up, and the child files of an index that were read.
|
|
113
|
+
const sitemapFiles = new Set();
|
|
114
|
+
if (first && !probe.block_reason) {
|
|
115
|
+
addPage(first);
|
|
116
|
+
const sitemapUrl = new URL(SITEMAP_PATH, new URL(first.final_url).origin).href;
|
|
117
|
+
const sitemap = await tryGet(sitemapUrl);
|
|
118
|
+
sitemapFinal = sitemap?.status === 200 ? sitemap.final_url : null;
|
|
119
|
+
sitemapFiles.add(sitemapUrl);
|
|
120
|
+
if (sitemapFinal) sitemapFiles.add(sitemapFinal);
|
|
121
|
+
if (sitemap?.status === 200) {
|
|
122
|
+
const text = sitemap.body.toString('utf8');
|
|
123
|
+
if (/<sitemapindex/i.test(text)) {
|
|
124
|
+
const children = locs(text);
|
|
125
|
+
let read = 0;
|
|
126
|
+
let counted = 0;
|
|
127
|
+
for (const child of children) {
|
|
128
|
+
if (used >= budget) break;
|
|
129
|
+
const artifact = await tryGet(child);
|
|
130
|
+
if (artifact?.status === 200) { read += locCount(artifact.body.toString('utf8')); counted++; sitemapPages = read; sitemapFiles.add(child); }
|
|
131
|
+
}
|
|
132
|
+
sitemapBasis = counted === children.length ? 'exact' : 'lower_bound';
|
|
133
|
+
} else { sitemapPages = locCount(text); sitemapBasis = 'exact'; }
|
|
134
|
+
}
|
|
135
|
+
const seen = new Set([start.href, first.normalized_url, sitemapUrl]);
|
|
136
|
+
for (const href of pages.length ? links(pages[0].html, first.final_url) : []) {
|
|
137
|
+
if (used >= budget) break;
|
|
138
|
+
let normalized;
|
|
139
|
+
try { normalized = assertUrlInScope(href, scope).href; } catch { continue; }
|
|
140
|
+
if (seen.has(normalized)) continue;
|
|
141
|
+
seen.add(normalized);
|
|
142
|
+
addPage(await tryGet(normalized));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const startJson = {
|
|
147
|
+
// Where the first request landed (after apex/www and http/https redirects): the address the audit should start from.
|
|
148
|
+
requested_url: start.href,
|
|
149
|
+
final_url: first?.final_url ?? null,
|
|
150
|
+
redirected_to: leftTo,
|
|
151
|
+
sitemap_final_url: sitemapFinal,
|
|
152
|
+
probe: { ...probe, sitemap_pages: sitemapPages },
|
|
153
|
+
sitemap_pages: sitemapPages,
|
|
154
|
+
sitemap_pages_basis: sitemapBasis,
|
|
155
|
+
sitemap_declared: sitemapFiles.size,
|
|
156
|
+
question_candidates_input: pages.map(({ artifact, html }) => questionInput(artifact, html)),
|
|
157
|
+
fact_candidates: factCandidates(pages),
|
|
158
|
+
start_runbooks: startRunbooks(probe, { level, skillRoot, harnesses: runbookHarnesses(probe.harness) }),
|
|
159
|
+
};
|
|
160
|
+
const startPath = path.join(dir, 'start.json');
|
|
161
|
+
await writeFile(startPath, `${JSON.stringify(startJson, null, 2)}\n`);
|
|
162
|
+
return { startJson, startPath };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (process.argv[1] && realpathSync(path.resolve(process.argv[1])) === realpathSync(fileURLToPath(import.meta.url))) {
|
|
166
|
+
try {
|
|
167
|
+
const args = process.argv.slice(2);
|
|
168
|
+
const option = name => { const i = args.indexOf(name); return i < 0 ? null : args[i + 1]; };
|
|
169
|
+
const url = option('--url');
|
|
170
|
+
const out = option('--out');
|
|
171
|
+
if (!url || !out || args.length % 2 || args.some((arg, i) => i % 2 === 0 && !['--url', '--out', '--source-tools', '--skill-root', '--level'].includes(arg))) throw new Error(USAGE);
|
|
172
|
+
const { startJson, startPath } = await preflight({ url, out: path.resolve(out), sourceTools: (option('--source-tools') ?? '').split(',').filter(Boolean),
|
|
173
|
+
...(option('--skill-root') ? { skillRoot: option('--skill-root') } : {}), ...(option('--level') ? { level: option('--level') } : {}) });
|
|
174
|
+
process.stdout.write(`${JSON.stringify({ start_json: startPath, block_reason: startJson.probe.block_reason })}\n`);
|
|
175
|
+
} catch (error) { process.stderr.write(`preflight failed: ${error.message}\n`); process.exitCode = 2; }
|
|
176
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { lookup } from 'node:dns/promises';
|
|
3
|
+
import { realpathSync } from 'node:fs';
|
|
4
|
+
import { readFile } from 'node:fs/promises';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { findChrome } from './core/chrome.mjs';
|
|
8
|
+
import { validateEntity } from './core/contracts.mjs';
|
|
9
|
+
import { renderChrome } from './core/render.mjs';
|
|
10
|
+
import { prepareOutput, restoreState, runStages, stages } from './core/stages.mjs';
|
|
11
|
+
import { selectTransport } from './core/transport.mjs';
|
|
12
|
+
|
|
13
|
+
const USAGE = 'usage: node skills/optbrew/scripts/run.mjs --answers <intake-answers.json> --out <dir> [--judgments <judgments.json>]';
|
|
14
|
+
const RESUME_FROM = 'judge';
|
|
15
|
+
const STATUS_DONE = 'done';
|
|
16
|
+
|
|
17
|
+
/** One render chrome per run, made when the render stage asks for it. Its owner calls `close()` in a `finally`. */
|
|
18
|
+
function chromeHolder(env) {
|
|
19
|
+
let handle = null;
|
|
20
|
+
return {
|
|
21
|
+
open: ({ context, route }) => (handle ??= renderChrome({ context, transport: route, find: () => findChrome({ env }) })),
|
|
22
|
+
close: () => handle?.close(),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A JSON file as a value; a file that is missing or not JSON is a plain sentence, not a stack trace. */
|
|
27
|
+
async function readJson(file, what) {
|
|
28
|
+
try { return JSON.parse(await readFile(file, 'utf8')); }
|
|
29
|
+
catch { throw new Error(`The ${what} file cannot be read as JSON: ${file}`); }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The model's judgments: one bare JSON array, handed to the merge as it is. */
|
|
33
|
+
function judgmentList(value) {
|
|
34
|
+
if (!Array.isArray(value)) throw new Error('The judgments file must hold one JSON array of judgments.');
|
|
35
|
+
return value;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* One audit: the first call runs context, collect, render, cwv, derive and judge, and stops at the judge queue; the second call
|
|
40
|
+
* (same `out`, now with `judgmentsPath`) rebuilds its state from the evidence in `out` and runs judge, score and report.
|
|
41
|
+
* `overrides` is for tests: `env`, `home`, `chrome` (holder), `fetchFn`, `selectTransport`, `stages` (the stage list), `localTargets`
|
|
42
|
+
* (loopback `{ host, port }` pairs a test may audit; the shipped command refuses a local address, as preflight does).
|
|
43
|
+
* Everything is written under `out`, and the render chrome is closed whatever happens.
|
|
44
|
+
*/
|
|
45
|
+
export async function run({ answersPath, out, judgmentsPath = null }, overrides = {}) {
|
|
46
|
+
const outDir = path.resolve(out);
|
|
47
|
+
const env = overrides.env ?? process.env;
|
|
48
|
+
const chrome = overrides.chrome ?? chromeHolder(env);
|
|
49
|
+
try {
|
|
50
|
+
const answers = await readJson(answersPath, 'answers');
|
|
51
|
+
try { validateEntity('IntakeAnswers', answers); }
|
|
52
|
+
catch (error) { throw new Error(`The start answers are not valid (${error.message}).`); }
|
|
53
|
+
const judgments = judgmentsPath ? judgmentList(await readJson(judgmentsPath, 'judgments')) : null;
|
|
54
|
+
const deps = { env, home: overrides.home, chrome, fetchFn: overrides.fetchFn, localTargets: overrides.localTargets,
|
|
55
|
+
selectTransport: overrides.selectTransport ?? (input => selectTransport({ ...input, resolve: host => lookup(host, { all: true, verbatim: true }) })) };
|
|
56
|
+
const resuming = await prepareOutput(outDir) === 'resume';
|
|
57
|
+
const state = resuming ? await restoreState({ out: outDir, answers }) : { answers, out: outDir, answersDir: path.dirname(path.resolve(answersPath)) };
|
|
58
|
+
const { state: end, halt } = await runStages(overrides.stages ?? stages(), { ...state, judgments }, deps, { from: resuming ? RESUME_FROM : null });
|
|
59
|
+
return halt ? { status: halt, judge_queue: path.join(outDir, 'judge-queue.json'), queue_items: end.queue.items.length } :
|
|
60
|
+
{ status: STATUS_DONE, reports: end.reports, score_input: path.join(outDir, 'score-input.json'), ...(end.lab_cwv ? { lab_cwv: end.lab_cwv } : {}) };
|
|
61
|
+
} finally { await chrome.close(); }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (process.argv[1] && realpathSync(path.resolve(process.argv[1])) === realpathSync(fileURLToPath(import.meta.url))) {
|
|
65
|
+
const chrome = chromeHolder(process.env);
|
|
66
|
+
// Ctrl-C or a terminate signal still closes the render chrome (and its profile folder) before the process leaves.
|
|
67
|
+
for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => { chrome.close().finally(() => process.exit(1)); });
|
|
68
|
+
try {
|
|
69
|
+
const args = process.argv.slice(2);
|
|
70
|
+
const option = name => { const i = args.indexOf(name); return i < 0 ? null : args[i + 1]; };
|
|
71
|
+
const answersPath = option('--answers');
|
|
72
|
+
const out = option('--out');
|
|
73
|
+
if (!answersPath || !out || args.length % 2 || args.some((arg, i) => i % 2 === 0 && !['--answers', '--out', '--judgments'].includes(arg))) throw new Error(USAGE);
|
|
74
|
+
const result = await run({ answersPath, out, judgmentsPath: option('--judgments') }, { chrome });
|
|
75
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
76
|
+
} catch (error) {
|
|
77
|
+
// The last line of stderr is one plain sentence saying why the run stopped.
|
|
78
|
+
process.stderr.write(`${String(error.message).replace(/\s*\n\s*/g, ' ')}\n`);
|
|
79
|
+
process.exitCode = 2;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -395,7 +395,7 @@
|
|
|
395
395
|
"package_path": "skills/optbrew/modules/naver-searchadvisor/scripts/audit.mjs",
|
|
396
396
|
"original_git_blob_sha": "de8c4a3430633cb2445e339d7a943d944cbf45a9",
|
|
397
397
|
"original_sha256": "3fd83141a00a9d45d4fbffa9d8ff515c7a49e7b1ea4637844e92375ea7797ff9",
|
|
398
|
-
"distributed_sha256": "
|
|
398
|
+
"distributed_sha256": "f19425a43be0d974756722d6069285317b4897720ede58a7bea8f211b6f73103",
|
|
399
399
|
"transformations": [
|
|
400
400
|
"Audit output routes policy evidence to official URL index",
|
|
401
401
|
"reviewed distribution patch naver-robots-rfc9309"
|
package/source-lock.json
CHANGED
|
@@ -395,7 +395,7 @@
|
|
|
395
395
|
"package_path": "skills/optbrew/modules/naver-searchadvisor/scripts/audit.mjs",
|
|
396
396
|
"original_git_blob_sha": "de8c4a3430633cb2445e339d7a943d944cbf45a9",
|
|
397
397
|
"original_sha256": "3fd83141a00a9d45d4fbffa9d8ff515c7a49e7b1ea4637844e92375ea7797ff9",
|
|
398
|
-
"distributed_sha256": "
|
|
398
|
+
"distributed_sha256": "f19425a43be0d974756722d6069285317b4897720ede58a7bea8f211b6f73103",
|
|
399
399
|
"transformations": [
|
|
400
400
|
"Audit output routes policy evidence to official URL index",
|
|
401
401
|
"reviewed distribution patch naver-robots-rfc9309"
|