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,694 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
3
|
+
import { access, mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { auditCollected } from '../../modules/naver-searchadvisor/scripts/audit.mjs';
|
|
7
|
+
import { normalizeImport, readOwnerExport } from '../adapters/imports.mjs';
|
|
8
|
+
import { scopedPage } from './chrome.mjs';
|
|
9
|
+
import { collect, declaredRobotsIndex, transportFetch } from './collector.mjs';
|
|
10
|
+
import { checkRegistry, limits, requestLimit, versions } from './config.mjs';
|
|
11
|
+
import { compare } from './compare.mjs';
|
|
12
|
+
import { validateEntity } from './contracts.mjs';
|
|
13
|
+
import { HTTP_OK, collectCwv, labCwv } from './cwv.mjs';
|
|
14
|
+
import { deriveObservations } from './derive.mjs';
|
|
15
|
+
import { lcpField, inpField, clsField } from './checks/cwv-field.mjs';
|
|
16
|
+
import { openStore } from './evidence-store.mjs';
|
|
17
|
+
import { notIndexTargets, withIndexTargets } from './index-targets.mjs';
|
|
18
|
+
import { OBSERVATION_SOURCES, buildJudgeQueue, deriveQuestionCoverage, mergeJudgments } from './judgments.mjs';
|
|
19
|
+
import { keyHome, readGoogleKey } from './keys.mjs';
|
|
20
|
+
import { MigrationRequired, assertCurrentEvidence } from './migration.mjs';
|
|
21
|
+
import { groupPages, pageBudget, readPreflight, sameSite, selectPages } from './page-select.mjs';
|
|
22
|
+
import { pageNeutral, problemsOf } from './findings.mjs';
|
|
23
|
+
import { freezeProtocol } from './protocol.mjs';
|
|
24
|
+
import { renderPages, selectRenderPages } from './render.mjs';
|
|
25
|
+
import { htmlReportWriter } from './report-page.mjs';
|
|
26
|
+
import { writeReport } from './report.mjs';
|
|
27
|
+
import { score } from './score.mjs';
|
|
28
|
+
import { assertUrlInScope, urlTarget } from './scope.mjs';
|
|
29
|
+
|
|
30
|
+
/** Fixed file names of the run documents the evidence index points at. */
|
|
31
|
+
const DOCUMENTS = { collection: 'collection.json', ledger: 'ledger.json', modules: 'modules.json', observations: 'observations.json' };
|
|
32
|
+
const magnusScript = fileURLToPath(new URL('../../modules/magnus-seo/scripts/aeo_audit.py', import.meta.url));
|
|
33
|
+
const NAVER_MODULE = 'naver-searchadvisor';
|
|
34
|
+
const MAGNUS_MODULE = 'magnus-seo';
|
|
35
|
+
/** The modules a full run uses: the Naver module is plain Node and its output is what replay checks the crawl verdicts against. */
|
|
36
|
+
const RUN_MODULES = [NAVER_MODULE];
|
|
37
|
+
const PHASE = 'baseline';
|
|
38
|
+
const HALT_JUDGMENTS = 'awaiting_judgments';
|
|
39
|
+
const JUDGE_STAGE = 'judge';
|
|
40
|
+
const QUEUE_FILE = 'judge-queue.json';
|
|
41
|
+
/** The `key` of a start-answer fact that names the business (preflight's fact candidates use the type as the key). */
|
|
42
|
+
const NAME_FACT = 'name';
|
|
43
|
+
const CONTEXT_FILE = 'context.json';
|
|
44
|
+
const INTAKE_FILE = 'intake.json';
|
|
45
|
+
const COSTS_FILE = 'costs.json';
|
|
46
|
+
const RUN_SOURCE = 'optbrew:run';
|
|
47
|
+
const OWNER_SOURCE = 'optbrew:owner-export';
|
|
48
|
+
const SCORE_INPUT_FILE = 'score-input.json';
|
|
49
|
+
// Said whenever the stored evidence cannot be trusted on the second call; the same words every time.
|
|
50
|
+
const INDEX_CHANGED = 'index changed: the evidence in this folder no longer matches what the first call stored, so nothing was collected again; run again with a new --out folder.';
|
|
51
|
+
const APPROVED_BY = 'start answers (intake-answers.json)';
|
|
52
|
+
const NAVER_ENGINE = 'naver';
|
|
53
|
+
const KOREAN_DOMAIN = '.kr';
|
|
54
|
+
const NAVER_GOAL = /naver|네이버/i;
|
|
55
|
+
// Families the start answers can switch off; every other family of the registry is planned for every run.
|
|
56
|
+
const NAVER_FAMILY = 'naver-markup';
|
|
57
|
+
const QUESTION_FAMILIES = ['question-coverage', 'answer-clarity'];
|
|
58
|
+
const NOT_APPLICABLE = {
|
|
59
|
+
[NAVER_FAMILY]: { condition: 'naver_not_target', reason: 'Naver is not among the target engines of this run' },
|
|
60
|
+
'question-coverage': { condition: 'no_fixed_questions', reason: 'The start answers fixed no question list' },
|
|
61
|
+
'answer-clarity': { condition: 'no_matching_questions', reason: 'The start answers fixed no question list to match answers against' },
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const json = value => `${JSON.stringify(value, null, 2)}\n`;
|
|
65
|
+
const sha256Of = bytes => createHash('sha256').update(bytes).digest('hex');
|
|
66
|
+
const exists = async file => access(file).then(() => true, () => false);
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* One stage of the audit: `run(state, deps)` answers `{ state, halt? }`. A stage reads what earlier stages left in `state`
|
|
70
|
+
* and writes its own result back; it names no other stage, so a stage can be added or taken out without touching the rest.
|
|
71
|
+
* `deps` = `{ selectTransport, chrome, fetchFn, env, home, store }`, all but `selectTransport` and `env` optional: `chrome.open({ context, route })`
|
|
72
|
+
* answers the run's render chrome (one per run, launched on first use) and the owner closes it; `fetchFn` replaces the field-data
|
|
73
|
+
* fetch; `store` replaces the evidence store a run would open itself.
|
|
74
|
+
*/
|
|
75
|
+
|
|
76
|
+
/** Run the stages in order from `from` (a stage name) or the first; stops at the first stage that halts. */
|
|
77
|
+
export async function runStages(list, state, deps, { from = null } = {}) {
|
|
78
|
+
const start = from === null ? 0 : list.findIndex(stage => stage.name === from);
|
|
79
|
+
if (start < 0) throw new Error(`no stage named ${from}`);
|
|
80
|
+
let current = state;
|
|
81
|
+
for (const stage of list.slice(start)) {
|
|
82
|
+
const result = await stage.run(current, deps);
|
|
83
|
+
current = result.state ?? current;
|
|
84
|
+
if (result.halt) return { state: current, halt: result.halt };
|
|
85
|
+
}
|
|
86
|
+
return { state: current, halt: null };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const isLocalTarget = (scope, url) => { const target = urlTarget(new URL(url)); return (scope.local_targets ?? []).some(x => x.host === target.host && Number(x.port) === target.port); };
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The address the audit starts from. Preflight (`<out>/preflight/start.json`) says where the first request landed; when that is
|
|
93
|
+
* the same site under another spelling, the run adopts it (and says so), because scope is the exact origin. Anywhere else is a
|
|
94
|
+
* different site: the run stops with one plain sentence.
|
|
95
|
+
*/
|
|
96
|
+
function startFrom(asked, preflight) {
|
|
97
|
+
// What the owner typed at the start read: when the answers already hold the address it landed on, the redirect is still worth saying.
|
|
98
|
+
const typed = preflight.requestedUrl ? new URL(preflight.requestedUrl) : asked;
|
|
99
|
+
const away = text => `The address ${asked.href} leads to another site (${text}), which this audit does not cover. If you own that site, start again with its address.`;
|
|
100
|
+
if (preflight.redirectedTo && !sameSite(asked, new URL(preflight.redirectedTo))) throw new Error(away(new URL(preflight.redirectedTo).origin));
|
|
101
|
+
if (!preflight.finalUrl) return { start: asked, adopted: null };
|
|
102
|
+
const landed = new URL(preflight.finalUrl);
|
|
103
|
+
if (landed.origin === asked.origin) return { start: asked, adopted: typed.origin === asked.origin ? null : { from: typed.href, to: asked.href } };
|
|
104
|
+
if (!sameSite(asked, landed)) throw new Error(away(landed.origin));
|
|
105
|
+
return { start: landed, adopted: { from: asked.href, to: landed.href } };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The answers as kept in the run folder: an export's file name and kind, never the folder it came from. */
|
|
109
|
+
const keptAnswers = answers => ({ ...answers, ...(answers.owner_exports ? { owner_exports: answers.owner_exports.map(x => ({ kind: x.kind, name: path.basename(x.path) })) } : {}) });
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The RunContext and the intake decision record that the start answers fix, with the pages the audit covers (chosen here, from
|
|
113
|
+
* the preflight evidence, before any request of the audit). Nothing is fetched; only the preflight folder is read.
|
|
114
|
+
*/
|
|
115
|
+
async function contextFrom(answers, { out, localTargets = [] }) {
|
|
116
|
+
const asked = new URL(answers.url);
|
|
117
|
+
if (!['http:', 'https:'].includes(asked.protocol)) throw new Error('url must be http or https');
|
|
118
|
+
const preflight = await readPreflight(out, asked.href);
|
|
119
|
+
const { start, adopted } = startFrom(asked, preflight);
|
|
120
|
+
const target = urlTarget(start);
|
|
121
|
+
const local = localTargets.filter(x => x.host === target.host && Number(x.port) === target.port);
|
|
122
|
+
const scope = { allowed: [{ origin: start.origin, path_prefix: '/' }], max_urls: answers.max_urls, ...(local.length ? { local_targets: local } : {}) };
|
|
123
|
+
const ref = href => ({ original: href, normalized: assertUrlInScope(href, scope).href });
|
|
124
|
+
const auxiliary = [['robots', '/robots.txt'], ['sitemap', '/sitemap.xml']].map(([kind, pathname]) => ({ kind, ...ref(new URL(pathname, start.origin).href) }));
|
|
125
|
+
const home = ref(start.href);
|
|
126
|
+
// The sitemap files the preflight read (an index, its children, the place /sitemap.xml redirected to) are declared too, so the run
|
|
127
|
+
// fetches them and a page they list counts as listed; a redirect from /sitemap.xml to its index is then followed, not refused.
|
|
128
|
+
const known = new Set(auxiliary.map(x => x.normalized));
|
|
129
|
+
for (const href of [...new Set(preflight.sitemapFiles)].slice(0, limits().preflight_pages)) {
|
|
130
|
+
let extra;
|
|
131
|
+
try { extra = ref(href); } catch { continue; }
|
|
132
|
+
if (!known.has(extra.normalized)) { known.add(extra.normalized); auxiliary.push({ kind: 'sitemap', ...extra }); }
|
|
133
|
+
}
|
|
134
|
+
// Facts the owner confirmed on the address the run started from before its redirect now live on the adopted origin.
|
|
135
|
+
const factUrls = [...new Set(answers.facts.map(fact => adopted && new URL(fact.source_url).origin === asked.origin ?
|
|
136
|
+
fact.source_url.replace(asked.origin, start.origin) : fact.source_url))];
|
|
137
|
+
const { pages, dropped, sources } = selectPages({ home: home.normalized, factUrls, preflight, scope, budget: pageBudget(requestLimit(scope), auxiliary.length),
|
|
138
|
+
auxiliary: known });
|
|
139
|
+
const pageTypes = groupPages(pages, home.normalized).map(type => ({ id: type.id, urls: type.urls.map(ref) }));
|
|
140
|
+
const wantsNaver = NAVER_GOAL.test(answers.goals.join(' ')) || (answers.owner_exports ?? []).some(x => x.kind === NAVER_ENGINE) ||
|
|
141
|
+
preflight.korean || target.host.endsWith(KOREAN_DOMAIN);
|
|
142
|
+
const engines = ['google', ...(wantsNaver ? [NAVER_ENGINE] : [])];
|
|
143
|
+
const planned = id => id === NAVER_FAMILY ? engines.includes(NAVER_ENGINE) : QUESTION_FAMILIES.includes(id) ? answers.questions.length > 0 : true;
|
|
144
|
+
const families = checkRegistry().families.map(({ family_id }) => planned(family_id) ? { family_id, applicable: true } :
|
|
145
|
+
{ family_id, applicable: false, applicability_decision: { ...NOT_APPLICABLE[family_id], evidence_ref: INTAKE_FILE } });
|
|
146
|
+
// Handing over an export of this site is the owner's approval of the property it describes: the site's own host, whole site.
|
|
147
|
+
const providers = [...new Set((answers.owner_exports ?? []).map(x => x.kind))];
|
|
148
|
+
const mapping = provider => ({ provider, property: target.host, hostname: target.host, path_prefix: '/' });
|
|
149
|
+
const targetIdentity = providers.length ? { target_identity: { target: home.normalized,
|
|
150
|
+
native_properties: providers.map(provider => ({ ...mapping(provider), target: home.normalized, evidence_ref: `owner-mapping-${provider}.json` })) } } : {};
|
|
151
|
+
const context = {
|
|
152
|
+
schema_version: versions().contract_schema, run_id: `optbrew-${randomUUID()}`, target: home.normalized,
|
|
153
|
+
environment: scope.local_targets ? 'local' : 'production', goals: answers.goals, allowed_changes: [], scope,
|
|
154
|
+
protocol: { page_types: pageTypes, engines, families, auxiliary_resources: auxiliary, locale: 'und', region: 'ZZ', browser: 'Chrome', device: 'mobile',
|
|
155
|
+
question_panel: answers.questions, surfaces: [], models: [], search_enabled: null,
|
|
156
|
+
render: { enabled: true, pages: selectRenderPages(pageTypes), max_render_requests: limits().max_render_requests }, ...targetIdentity },
|
|
157
|
+
evaluation_version: versions().evaluation, protocol_version: versions().protocol, data_sources: [], cost_decision: {},
|
|
158
|
+
evidence_ref: CONTEXT_FILE, environment_probe: answers.probe, audited: true,
|
|
159
|
+
};
|
|
160
|
+
const { protocol_hash } = freezeProtocol(context);
|
|
161
|
+
const intake = { run_id: context.run_id, target: context.target, protocol_hash, approved_by: APPROVED_BY,
|
|
162
|
+
decisions: families.filter(family => family.applicability_decision).map(family => ({ scope: 'family', family_id: family.family_id,
|
|
163
|
+
condition: family.applicability_decision.condition, reason: family.applicability_decision.reason })),
|
|
164
|
+
selection: { pages: pages.length, limit: requestLimit(scope), dropped_fact_pages: dropped, start_redirect: adopted, sources }, answers: keptAnswers(answers) };
|
|
165
|
+
const mappings = providers.map(provider => ({ file: `owner-mapping-${provider}.json`,
|
|
166
|
+
record: { run_id: context.run_id, target: context.target, ...mapping(provider), approved_by: APPROVED_BY } }));
|
|
167
|
+
return { context, intake, mappings, adopted };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const contextStage = {
|
|
171
|
+
name: 'context',
|
|
172
|
+
async run(state, deps) {
|
|
173
|
+
validateEntity('IntakeAnswers', state.answers);
|
|
174
|
+
const { context, intake, mappings, adopted } = await contextFrom(state.answers, { out: state.out, localTargets: deps.localTargets });
|
|
175
|
+
await mkdir(state.out, { recursive: true });
|
|
176
|
+
await writeFile(path.join(state.out, CONTEXT_FILE), json(context), { flag: 'wx' });
|
|
177
|
+
await writeFile(path.join(state.out, INTAKE_FILE), json(intake), { flag: 'wx' });
|
|
178
|
+
for (const { file, record } of mappings) await writeFile(path.join(state.out, file), json(record), { flag: 'wx' });
|
|
179
|
+
return { state: { ...state, context, modules: RUN_MODULES, start_redirect: adopted } };
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
/** The run's evidence store; a run opens it once, and refuses to reuse a directory that already holds a run. */
|
|
184
|
+
async function ensureStore(state, deps) {
|
|
185
|
+
if (state.store) return state.store;
|
|
186
|
+
const evidenceDir = path.join(state.out, 'evidence');
|
|
187
|
+
await mkdir(evidenceDir, { recursive: true });
|
|
188
|
+
const documentRef = name => path.join(evidenceDir, DOCUMENTS[name]);
|
|
189
|
+
const { context } = state;
|
|
190
|
+
const store = deps.store ?? openStore(evidenceDir, { run_id: context.run_id, context_ref: context.evidence_ref,
|
|
191
|
+
collection_ref: documentRef('collection'), module_output_ref: documentRef('modules'),
|
|
192
|
+
observations_ref: documentRef('observations'), ledger_ref: documentRef('ledger'), modules: state.modules });
|
|
193
|
+
for (const existing of [documentRef('collection'), store.indexRef]) if (await exists(existing)) throw new Error('run evidence index already exists');
|
|
194
|
+
state.store = store;
|
|
195
|
+
state.evidenceDir = evidenceDir;
|
|
196
|
+
return store;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** The transport the run reaches the site with, chosen once for the collect, field-data and render stages. */
|
|
200
|
+
async function routeOf(state, deps) {
|
|
201
|
+
state.route ??= await deps.selectTransport({ env: deps.env, probeHost: urlTarget(new URL(state.context.target)).host });
|
|
202
|
+
return state.route;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function magnus(artifact) {
|
|
206
|
+
return new Promise(resolve => {
|
|
207
|
+
const child = spawn(process.platform === 'win32' ? 'python' : 'python3', [magnusScript, '--stdin', '--source-url', artifact.final_url, '--json'],
|
|
208
|
+
{ stdio: ['pipe', 'pipe', 'pipe'] });
|
|
209
|
+
const chunks = []; let bytes = 0;
|
|
210
|
+
child.stdout.on('data', chunk => { bytes += chunk.length; if (bytes > limits().max_bytes) child.kill(); else chunks.push(chunk); });
|
|
211
|
+
child.on('error', () => resolve({ status: 'unavailable', reason: 'python_unavailable' }));
|
|
212
|
+
child.on('close', code => {
|
|
213
|
+
if (code !== 0) return resolve({ status: 'unavailable', reason: 'python_audit_failed' });
|
|
214
|
+
try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))); }
|
|
215
|
+
catch { resolve({ status: 'unavailable', reason: 'python_output_invalid' }); }
|
|
216
|
+
});
|
|
217
|
+
child.stdin.on('error', () => {});
|
|
218
|
+
child.stdin.end(artifact.body);
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* The owner's export files from the start answers, read through the import adapter. A file that is missing or cannot be read is
|
|
224
|
+
* recorded with its reason (`owner_exports` in the audit index) and never stops the run; the report lists it as remaining work.
|
|
225
|
+
* A readable file is kept as evidence (the bytes, and the rows read from them) so the numbers can be replayed.
|
|
226
|
+
*/
|
|
227
|
+
async function ingestOwnerExports(state, env) {
|
|
228
|
+
const { context, store } = state;
|
|
229
|
+
const entries = [];
|
|
230
|
+
for (const { kind, path: file } of state.answers?.owner_exports ?? []) {
|
|
231
|
+
try {
|
|
232
|
+
// A path is read as the owner wrote it: `~` is the home folder, and a relative path starts at the answers file's folder.
|
|
233
|
+
const where = path.resolve(state.answersDir ?? '.', file.replace(/^~(?=$|[\\/])/, keyHome(env)));
|
|
234
|
+
if ((await stat(where)).size > limits().max_bytes) throw Object.assign(new Error('too large'), { reason: 'export_file_too_large' });
|
|
235
|
+
const bytes = await readFile(where);
|
|
236
|
+
const parsed = readOwnerExport(kind, bytes);
|
|
237
|
+
const raw = await store.write('owner-export', bytes);
|
|
238
|
+
const { host } = urlTarget(new URL(context.target));
|
|
239
|
+
const ref = `owner-export-${kind}.json`;
|
|
240
|
+
const metadata = { timezone: parsed.window.timezone, collected_at: (await stat(where)).mtime.toISOString(), source_ref: OWNER_SOURCE,
|
|
241
|
+
evidence_ref: ref, window: parsed.window, sample_selection: parsed.sample_selection, property: host, hostname: host, path: '/' };
|
|
242
|
+
normalizeImport(parsed.provider, parsed.rows, metadata);
|
|
243
|
+
await writeFile(path.join(state.out, ref), json({ kind: 'native_export', run_id: context.run_id, provider: parsed.provider,
|
|
244
|
+
raw_ref: raw.ref, raw_sha256: raw.sha256, metadata, rows: parsed.rows }), { flag: 'wx' });
|
|
245
|
+
entries.push({ kind, status: 'read', ref });
|
|
246
|
+
} catch (error) {
|
|
247
|
+
const reason = error.reason ?? (error.code === 'ENOENT' ? 'export_file_missing' : error.name === 'ContractError' ? 'export_not_accepted' : 'export_file_unreadable');
|
|
248
|
+
entries.push({ kind, status: 'unusable', reason });
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if (entries.length) await store.set('owner_exports', entries);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Record the pages the site keeps out of the index in the run's context and in its intake record (whose protocol hash follows the
|
|
256
|
+
* context). Both files were written by this run's context stage, so they are overwritten here, before any verdict is derived.
|
|
257
|
+
*/
|
|
258
|
+
async function markIndexTargets(state, targets) {
|
|
259
|
+
const context = withIndexTargets(state.context, targets);
|
|
260
|
+
const { protocol_hash } = freezeProtocol(context);
|
|
261
|
+
await writeFile(path.join(state.out, CONTEXT_FILE), json(context));
|
|
262
|
+
const intake = JSON.parse(await readFile(path.join(state.out, INTAKE_FILE), 'utf8'));
|
|
263
|
+
await writeFile(path.join(state.out, INTAKE_FILE), json({ ...intake, protocol_hash, selection: { ...intake.selection, not_indexed: targets.map(({ url }) => url) } }));
|
|
264
|
+
return context;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Collect the pages, persist the collector bytes and records, and run the offline modules over them. */
|
|
268
|
+
const collectStage = {
|
|
269
|
+
name: 'collect',
|
|
270
|
+
async run(state, deps) {
|
|
271
|
+
const { context, modules } = state;
|
|
272
|
+
const store = await ensureStore(state, deps);
|
|
273
|
+
const route = await routeOf(state, deps);
|
|
274
|
+
const documentRef = name => path.join(state.evidenceDir, DOCUMENTS[name]);
|
|
275
|
+
const collection = await collect(context, { transport: route.transport, resolve: route.resolve, addressVetting: route.address_vetting });
|
|
276
|
+
const artifacts = [];
|
|
277
|
+
for (const artifact of collection.artifacts) {
|
|
278
|
+
const { body, ...saved } = artifact;
|
|
279
|
+
if (artifact.content_hash) {
|
|
280
|
+
const { ref } = await store.write(artifact.capability === 'raw_html' ? 'html' : 'bin', body);
|
|
281
|
+
saved.raw_html_ref = artifact.capability === 'raw_html' ? ref : null;
|
|
282
|
+
saved.evidence_ref = ref;
|
|
283
|
+
} else saved.evidence_ref = documentRef('collection');
|
|
284
|
+
await store.append('raw', { normalized_url: saved.normalized_url, evidence_ref: saved.evidence_ref, content_hash: saved.content_hash });
|
|
285
|
+
if (Number.isInteger(artifact.status)) {
|
|
286
|
+
// Hash the final URL, redirect chain and response header lines next to the raw body so the report replays from them.
|
|
287
|
+
const { ref, sha256 } = await store.write('response', json({ record_version: '1', requested_url: artifact.requested_url,
|
|
288
|
+
normalized_url: artifact.normalized_url, final_url: artifact.final_url, status: artifact.status,
|
|
289
|
+
redirect_chain: artifact.redirect_chain, header_lines: artifact.header_lines,
|
|
290
|
+
content_hash: artifact.content_hash, capability: artifact.capability }));
|
|
291
|
+
saved.response_ref = ref;
|
|
292
|
+
await store.append('responses', { normalized_url: artifact.normalized_url, evidence_ref: ref, content_hash: sha256 });
|
|
293
|
+
} else {
|
|
294
|
+
// A failed fetch has no response, but its answered redirect hops were real requests: hash them in a failure record
|
|
295
|
+
// so the ledger can prove the count (answered hops plus at most one unanswered attempt).
|
|
296
|
+
const { ref, sha256 } = await store.write('failure', json({ record_version: '1', kind: 'failure', requested_url: artifact.requested_url,
|
|
297
|
+
normalized_url: artifact.normalized_url, redirect_chain: artifact.redirect_chain, unanswered: artifact.unanswered ?? 0,
|
|
298
|
+
error: artifact.error }));
|
|
299
|
+
saved.failure_ref = ref;
|
|
300
|
+
await store.append('failures', { normalized_url: artifact.normalized_url, evidence_ref: ref, content_hash: sha256 });
|
|
301
|
+
}
|
|
302
|
+
artifacts.push(saved);
|
|
303
|
+
}
|
|
304
|
+
const module_outputs = Object.fromEntries(modules.map(x => [x, []]));
|
|
305
|
+
const declaredRobots = new Set((context.protocol.auxiliary_resources ?? [])
|
|
306
|
+
.filter(resource => resource.kind === 'robots').map(resource => resource.normalized));
|
|
307
|
+
const selectedUrls = new Set(context.protocol.page_types.flatMap(type => type.urls.map(x => x.normalized)));
|
|
308
|
+
for (const artifact of collection.artifacts) {
|
|
309
|
+
if (!selectedUrls.has(artifact.normalized_url) || artifact.capability !== 'raw_html' || artifact.status !== 200) continue;
|
|
310
|
+
const html = artifact.body.toString('utf8');
|
|
311
|
+
if (modules.includes(NAVER_MODULE)) {
|
|
312
|
+
const robotsIndex = declaredRobotsIndex(collection.artifacts, declaredRobots, artifact.final_url);
|
|
313
|
+
const robots = robotsIndex < 0 ? null : collection.artifacts[robotsIndex];
|
|
314
|
+
const robotsEvidenceRef = robots?.status === 200 ? artifacts[robotsIndex].evidence_ref : null;
|
|
315
|
+
const output = auditCollected({ artifact, html, engines: context.protocol.engines,
|
|
316
|
+
robotsArtifact: robots ? { ...robots, body: robots.body.toString('utf8') } : null });
|
|
317
|
+
output.robots_evidence_ref = robotsEvidenceRef;
|
|
318
|
+
module_outputs[NAVER_MODULE].push(output);
|
|
319
|
+
}
|
|
320
|
+
if (modules.includes(MAGNUS_MODULE)) module_outputs[MAGNUS_MODULE].push(await magnus(artifact));
|
|
321
|
+
}
|
|
322
|
+
const document = (name, value) => store.write(name, json(value), { name: DOCUMENTS[name] });
|
|
323
|
+
await document('collection', { artifacts, discovered_count: collection.discovered_count,
|
|
324
|
+
evaluated_count: collection.evaluated_count, request_count: collection.request_count, skipped: collection.skipped, limit_reached: collection.limit_reached });
|
|
325
|
+
await document('ledger', { entries: collection.ledger, limit: requestLimit(context.scope) });
|
|
326
|
+
await document('modules', module_outputs);
|
|
327
|
+
await ingestOwnerExports(state, deps.env);
|
|
328
|
+
// A page the site itself keeps out of the index (noindex, absent from its own sitemap) is not an index target: the run records that
|
|
329
|
+
// in its context now, before any verdict is derived, and report replay derives it again from the stored bytes (R89).
|
|
330
|
+
// A run that did not write its own context (`audit.mjs`, with a caller's context) leaves it as it was given.
|
|
331
|
+
if (state.answers) {
|
|
332
|
+
const targets = notIndexTargets({ context, textOf: artifact => artifact.body?.toString('utf8') ?? null,
|
|
333
|
+
artifacts: collection.artifacts.map((artifact, index) => ({ ...artifact, evidence_ref: artifacts[index].evidence_ref })) });
|
|
334
|
+
if (targets.length) state.context = await markIndexTargets(state, targets);
|
|
335
|
+
}
|
|
336
|
+
state.audit = { artifacts, module_outputs, discovered_count: collection.discovered_count, evaluated_count: collection.evaluated_count,
|
|
337
|
+
request_count: collection.request_count, skipped: collection.skipped, limit_reached: collection.limit_reached };
|
|
338
|
+
return { state };
|
|
339
|
+
},
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
/** Render the fixed sample pages; the result of the Chrome lookup goes into the audit index (`chrome`) for replay. */
|
|
343
|
+
const renderStage = {
|
|
344
|
+
name: 'render',
|
|
345
|
+
async run(state, deps) {
|
|
346
|
+
const { context, store } = state;
|
|
347
|
+
// Nothing to render or measure when not one selected page answered: say so plainly instead of reporting an empty audit.
|
|
348
|
+
const selected = new Set(context.protocol.page_types.flatMap(type => type.urls.map(url => url.normalized)));
|
|
349
|
+
if (!state.audit.artifacts.some(artifact => selected.has(artifact.normalized_url) && Number.isInteger(artifact.status)))
|
|
350
|
+
throw new Error('None of the selected pages could be fetched, so there is nothing to audit. Check that the address is right and that the site can be reached from this computer, then start again.');
|
|
351
|
+
const handle = deps.chrome.open({ context, route: await routeOf(state, deps) });
|
|
352
|
+
const { chrome } = await renderPages({ context, chrome: handle, store });
|
|
353
|
+
await store.set('chrome', chrome);
|
|
354
|
+
return { state: { ...state, chrome_handle: handle, chrome_result: chrome } };
|
|
355
|
+
},
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
/** Lab LCP and CLS of the first sample page: a reference for the report, never a verdict. */
|
|
359
|
+
async function measureLab(state) {
|
|
360
|
+
const [page] = state.context.protocol.render.pages;
|
|
361
|
+
const browser = page && await state.chrome_handle.get();
|
|
362
|
+
if (!browser) return null;
|
|
363
|
+
let session = null;
|
|
364
|
+
try {
|
|
365
|
+
session = await scopedPage({ chrome: browser, context: state.context, budget: state.context.protocol.render.max_render_requests, fetchStatic: state.chrome_handle.fetchStatic });
|
|
366
|
+
const loaded = await session.goto(page.normalized);
|
|
367
|
+
// The lab number of an error or blocked page says nothing about the site, and a failed measure is not worth stopping the audit for.
|
|
368
|
+
return loaded.status === HTTP_OK && !loaded.blocked_reason && !loaded.error ? await labCwv({ page: session }) : null;
|
|
369
|
+
} catch { return null; }
|
|
370
|
+
finally { await session?.close().catch(() => {}); }
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Field CWV of each public origin, from the user's own Google key when there is one. A local origin has no public field data
|
|
375
|
+
* and is not sent to Google. Without any usable field answer, and with a Chrome, the lab measure is taken and kept in
|
|
376
|
+
* `state.lab_cwv` only (D16).
|
|
377
|
+
*/
|
|
378
|
+
const cwvStage = {
|
|
379
|
+
name: 'cwv',
|
|
380
|
+
async run(state, deps) {
|
|
381
|
+
const { context, store } = state;
|
|
382
|
+
const origins = [...new Set(context.protocol.page_types.flatMap(type => type.urls.map(url => url.normalized))
|
|
383
|
+
.filter(url => !isLocalTarget(context.scope, url)).map(url => new URL(url).origin))];
|
|
384
|
+
if (origins.length) {
|
|
385
|
+
const route = await routeOf(state, deps);
|
|
386
|
+
const key = readGoogleKey({ env: deps.env, home: deps.home ?? keyHome(deps.env) });
|
|
387
|
+
const fetchFn = deps.fetchFn ?? transportFetch(route, { timeoutMs: limits().cwv_timeout_ms, maxBytes: limits().cwv_max_bytes });
|
|
388
|
+
for (const origin of origins) await collectCwv({ origin, key, fetchFn, store });
|
|
389
|
+
}
|
|
390
|
+
const stored = await readStored(state);
|
|
391
|
+
const selected = new Map(context.protocol.page_types.flatMap(type => type.urls).map(url => [url.normalized, url]));
|
|
392
|
+
const hasField = [lcpField, inpField, clsField].some(block =>
|
|
393
|
+
block.derive({ context, artifacts: state.audit.artifacts, selected, cwv: stored.cwv }).some(observation => observation.state !== 'unknown'));
|
|
394
|
+
const lab_cwv = !hasField && state.chrome_result?.found === true ? await measureLab(state) : null;
|
|
395
|
+
// Kept in the index (not as a verdict) so a resumed run still shows it; replay never reads it.
|
|
396
|
+
if (lab_cwv) await store.set('lab_cwv', lab_cwv);
|
|
397
|
+
return { state: { ...state, lab_cwv } };
|
|
398
|
+
},
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Everything the verdicts are re-derived from, read back through the store so each hash is checked: the collected pages, the
|
|
403
|
+
* render records and their DOMs, the field-data answers and the Chrome result. The derive stage and the resume both use it,
|
|
404
|
+
* so what the first call derived is what the second call re-derives.
|
|
405
|
+
*/
|
|
406
|
+
async function readStored(state) {
|
|
407
|
+
const { store } = state;
|
|
408
|
+
const index = await store.index();
|
|
409
|
+
const text = async (ref, sha) => (await store.read(ref, sha)).toString('utf8');
|
|
410
|
+
const collection = JSON.parse(await readFile(index.collection_ref, 'utf8'));
|
|
411
|
+
const bodies = new Map();
|
|
412
|
+
for (const artifact of collection.artifacts) if (artifact.content_hash) bodies.set(artifact.evidence_ref, await text(artifact.evidence_ref, artifact.content_hash));
|
|
413
|
+
for (const entry of [...index.raw.filter(x => x.content_hash), ...index.responses, ...index.failures]) await store.read(entry.evidence_ref, entry.content_hash);
|
|
414
|
+
const renders = [];
|
|
415
|
+
const doms = new Map();
|
|
416
|
+
for (const entry of index.renders) {
|
|
417
|
+
// The record carries the evidence ref it was read from, so a verdict inherited from a rendered page can cite it.
|
|
418
|
+
const record = { ...JSON.parse(await text(entry.evidence_ref, entry.sha256)), evidence_ref: entry.evidence_ref };
|
|
419
|
+
if (record.dom_ref) doms.set(record, await text(record.dom_ref, record.dom_sha256));
|
|
420
|
+
renders.push(record);
|
|
421
|
+
}
|
|
422
|
+
const cwv = [];
|
|
423
|
+
for (const entry of index.cwv) cwv.push({ ...JSON.parse(await text(entry.evidence_ref, entry.sha256)), evidence_ref: entry.evidence_ref });
|
|
424
|
+
return { index, collection, cwv, renders, input: { context: state.context, artifacts: collection.artifacts, collection,
|
|
425
|
+
textOf: artifact => artifact.content_hash ? bodies.get(artifact.evidence_ref) : null, renders, renderTextOf: record => doms.get(record) ?? '',
|
|
426
|
+
chrome: index.chrome ?? null, cwv } };
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** The hash of every run document the index does not hash itself, so a resume can tell an edited one. Names are relative to `out`. */
|
|
430
|
+
async function documentHashes(state) {
|
|
431
|
+
const names = [CONTEXT_FILE, INTAKE_FILE, ...(await readdir(state.out)).filter(name => OWNER_EXPORT_FILE.test(name) || OWNER_MAPPING_FILE.test(name)),
|
|
432
|
+
...Object.values(DOCUMENTS).map(name => path.join('evidence', name))].filter(name => !(state.ownerless && [CONTEXT_FILE, INTAKE_FILE].includes(name)));
|
|
433
|
+
const hashes = {};
|
|
434
|
+
for (const name of names) hashes[name.split(path.sep).join('/')] = sha256Of(await readFile(path.join(state.out, name)));
|
|
435
|
+
return hashes;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const deriveStage = {
|
|
439
|
+
name: 'derive',
|
|
440
|
+
async run(state) {
|
|
441
|
+
const { store } = state;
|
|
442
|
+
const observations = deriveObservations((await readStored(state)).input);
|
|
443
|
+
await store.write('observations', json(observations), { name: DOCUMENTS.observations });
|
|
444
|
+
await store.index();
|
|
445
|
+
// `audit.mjs` runs without a context or intake file of its own; only a full run records those too.
|
|
446
|
+
await store.set('documents', await documentHashes({ ...state, ownerless: !state.answers }));
|
|
447
|
+
return { state: { ...state, observations } };
|
|
448
|
+
},
|
|
449
|
+
};
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* The queue as the model reads it: the fixed question list, then one entry per page, with the page's candidate spans once and the
|
|
453
|
+
* units to judge on it. The stored queue keeps one item per unit (each repeating the page's spans); this file is only the shorter
|
|
454
|
+
* view of it. A unit that has names and facts to judge against (the site's identity) keeps them. The site's repeated lines come once, before the pages.
|
|
455
|
+
*/
|
|
456
|
+
function queueByPage(queue, panel) {
|
|
457
|
+
const pages = new Map();
|
|
458
|
+
for (const item of queue.items) {
|
|
459
|
+
// By address, not by evidence: two pages whose bytes are identical share an evidence file and are still two pages.
|
|
460
|
+
if (!pages.has(item.url)) pages.set(item.url, { url: item.url, evidence_ref: item.evidence_ref, evidence_sha256: item.evidence_sha256, spans: item.spans, ...(item.empty_page ? { empty_page: true } : {}), families: [] });
|
|
461
|
+
pages.get(item.url).families.push({ family_id: item.family_id, unit_key: item.unit_key, ...(item.identity ? { identity: item.identity } : {}) });
|
|
462
|
+
}
|
|
463
|
+
// The lines the pages repeat (menus, headers, footers) are listed once up front: a line there that answers a question (a phone number, an address, opening hours) answers it for the site.
|
|
464
|
+
return { questions: panel.map(({ id, question }) => ({ id, question })), ...(queue.site_chrome?.length ? { site_chrome: queue.site_chrome } : {}), pages: [...pages.values()] };
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** Judge: build the queue the model works from; with the model's judgments, merge them in and go on. */
|
|
468
|
+
const judgeStage = {
|
|
469
|
+
name: JUDGE_STAGE,
|
|
470
|
+
async run(state) {
|
|
471
|
+
const { store, out, context } = state;
|
|
472
|
+
const panel = context.protocol.question_panel;
|
|
473
|
+
const confirmed = state.answers?.facts ?? [];
|
|
474
|
+
const names = confirmed.filter(fact => fact.key === NAME_FACT).map(fact => fact.value);
|
|
475
|
+
const facts = confirmed.filter(fact => fact.key !== NAME_FACT).map(({ key, value }) => ({ key, value }));
|
|
476
|
+
const queue = await buildJudgeQueue({ observations: state.observations, store, context, names, facts });
|
|
477
|
+
await writeFile(path.join(out, QUEUE_FILE), json(queueByPage(queue, panel)));
|
|
478
|
+
if (state.judgments) {
|
|
479
|
+
const merged = await mergeJudgments({ observations: state.observations, judgments: state.judgments, store, questions: panel });
|
|
480
|
+
return { state: { ...state, queue, observations: deriveQuestionCoverage({ observations: merged, panel, evidence_ref: context.evidence_ref }) } };
|
|
481
|
+
}
|
|
482
|
+
if (queue.items.length) return { state: { ...state, queue }, halt: HALT_JUDGMENTS };
|
|
483
|
+
return { state: { ...state, queue } };
|
|
484
|
+
},
|
|
485
|
+
};
|
|
486
|
+
|
|
487
|
+
const scoreStage = {
|
|
488
|
+
name: 'score',
|
|
489
|
+
async run(state) {
|
|
490
|
+
const input = { schema_version: versions().contract_schema, run_context: state.context, observations: state.observations };
|
|
491
|
+
const scores = score(input);
|
|
492
|
+
// The file `cli.mjs compare` takes as a ScoreInput, so a later audit can be compared with this one.
|
|
493
|
+
await writeFile(path.join(state.out, SCORE_INPUT_FILE), json(input));
|
|
494
|
+
return { state: { ...state, input, scores } };
|
|
495
|
+
},
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
/** Where each file of the evidence folder belongs in the report packet's evidence list: its kind, and whether the collector wrote it. */
|
|
499
|
+
const EVIDENCE_KINDS = [[/\.html$/, 'raw_html', true], [/\.bin$/, 'raw_response', true], [/\.response\.json$/, 'response_record', true],
|
|
500
|
+
[/\.failure\.json$/, 'failure_record', true], [/\.render-dom$/, 'render_dom', true], [/\.render\.json$/, 'render', true],
|
|
501
|
+
[/\.cwv\.json$/, 'cwv', true], [/\.judge-queue\.json$/, 'judge-queue', false], [/\.owner-export$/, 'owner_export', false], [/^(?:collection|ledger)\.json$/, 'record', true]];
|
|
502
|
+
|
|
503
|
+
const RUN_FILE = /^(?:[0-9a-f]{64}\..+|(?:collection|ledger|modules|observations)\.json|.*\.tmp)$/;
|
|
504
|
+
const OWNER_EXPORT_FILE = /^owner-export-[a-z]+\.json$/;
|
|
505
|
+
const OWNER_MAPPING_FILE = /^owner-mapping-[a-z]+\.json$/;
|
|
506
|
+
|
|
507
|
+
async function evidenceList(state) {
|
|
508
|
+
const { context, evidenceDir } = state;
|
|
509
|
+
const entry = async (ref, kind, source_ref) => ({ ref, sha256: sha256Of(await readFile(path.resolve(state.out, ref))), kind, run_id: context.run_id,
|
|
510
|
+
...(source_ref ? { source_ref } : {}) });
|
|
511
|
+
const list = [await entry(CONTEXT_FILE, 'context'), await entry(INTAKE_FILE, 'intake'), await entry(COSTS_FILE, 'record')];
|
|
512
|
+
// The owner's export rows (a metric cites them) and the approval of the property each export describes.
|
|
513
|
+
for (const name of (await readdir(state.out)).sort()) {
|
|
514
|
+
if (OWNER_EXPORT_FILE.test(name)) list.push(await entry(name, 'native_export', OWNER_SOURCE));
|
|
515
|
+
else if (OWNER_MAPPING_FILE.test(name)) list.push(await entry(name, 'applicability'));
|
|
516
|
+
}
|
|
517
|
+
for (const name of (await readdir(evidenceDir)).sort()) {
|
|
518
|
+
const [, kind = 'record', collector = false] = EVIDENCE_KINDS.find(([pattern]) => pattern.test(name)) ?? [];
|
|
519
|
+
list.push(await entry(path.join(evidenceDir, name), kind, collector ? OBSERVATION_SOURCES.code : null));
|
|
520
|
+
}
|
|
521
|
+
return list;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* The fix list: one finding for each distinct problem, not for each page it appears on. Failed verdicts with the same check and
|
|
526
|
+
* the same message (the page's own address left out of it) are one finding that lists every page it affects in `unit_keys`; its
|
|
527
|
+
* own unit is the first of them. A verdict that states several problems (`problemsOf`) joins one group per problem; a finding
|
|
528
|
+
* that is not its first unit's first problem gets that problem's position in its id. A finding keeps the catalog key and values
|
|
529
|
+
* of its problem beside the English message, so the report can say it in Korean. A model's finding is marked with the model
|
|
530
|
+
* source and cites the page evidence the collector stored.
|
|
531
|
+
*/
|
|
532
|
+
function findingsOf(observations, evidence) {
|
|
533
|
+
const blocking = new Set(checkRegistry().families.filter(family => family.blocker_on_fail).map(family => family.family_id));
|
|
534
|
+
const core = new Set(checkRegistry().families.filter(family => family.core_policy).map(family => family.family_id));
|
|
535
|
+
const groups = new Map();
|
|
536
|
+
for (const observation of [...observations].filter(x => x.state === 'fail').sort((a, b) => a.unit_key < b.unit_key ? -1 : a.unit_key > b.unit_key ? 1 : 0)) {
|
|
537
|
+
const model = observation.evaluator_type === 'host_model';
|
|
538
|
+
const blocker = observation.blocker === true || blocking.has(observation.family_id);
|
|
539
|
+
for (const [position, problem] of problemsOf(observation).entries()) {
|
|
540
|
+
// A model's fix is told page by page: each page has its own cited text, which the owner needs to find the place.
|
|
541
|
+
const key = JSON.stringify([observation.check_id, pageNeutral(problem, observation.unit_key), model, blocker, ...(model ? [observation.unit_key] : [])]);
|
|
542
|
+
if (!groups.has(key)) groups.set(key, { observation, problem, position, model, blocker, stated: observation.problem_messages?.[position], unit_keys: [] });
|
|
543
|
+
groups.get(key).unit_keys.push(observation.unit_key);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
return [...groups.values()].map(({ observation, problem, position, model, blocker, stated, unit_keys }) => ({ schema_version: versions().contract_schema,
|
|
547
|
+
finding_id: `finding-${observation.observation_id}${position ? `-${position}` : ''}`, check_id: observation.check_id, unit_key: observation.unit_key, unit_keys,
|
|
548
|
+
priority: blocker ? 'P0' : core.has(observation.family_id) ? 'P1' : 'P2', blocker, message: problem,
|
|
549
|
+
...(stated ? { message_key: stated.message_key, message_values: stated.message_values } : {}),
|
|
550
|
+
source_ref: model ? OBSERVATION_SOURCES.host_model : evidence.find(x => x.ref === observation.evidence_ref)?.source_ref ?? observation.source_ref,
|
|
551
|
+
evidence_ref: observation.evidence_ref, remediation_ids: [], status: 'confirmed' }));
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// What the report says next to an export that could not be used.
|
|
555
|
+
const EXPORT_PROBLEMS = {
|
|
556
|
+
export_file_missing: 'No file was found at the path given',
|
|
557
|
+
export_file_unreadable: 'The file could not be opened',
|
|
558
|
+
export_file_too_large: 'The file is larger than this audit reads',
|
|
559
|
+
export_not_a_zip: 'The file is not the zip archive Search Console gives',
|
|
560
|
+
export_zip_damaged: 'The zip archive is damaged',
|
|
561
|
+
export_zip_method_unsupported: 'The zip archive uses a compression this audit does not read',
|
|
562
|
+
export_table_not_found: 'The file holds no table with clicks and impressions',
|
|
563
|
+
export_numbers_unreadable: 'The numbers in the table could not be read',
|
|
564
|
+
export_period_missing: 'The pasted text does not show the period it covers',
|
|
565
|
+
export_not_accepted: 'The numbers did not pass the import checks',
|
|
566
|
+
};
|
|
567
|
+
|
|
568
|
+
/** The metrics the owner's exports give (re-read from the stored rows, so a resumed run has them too) and a remaining-work row for each export that could not be used. */
|
|
569
|
+
async function ownerExportResults(state) {
|
|
570
|
+
const metrics = [];
|
|
571
|
+
const remaining = [];
|
|
572
|
+
// A preflight that was there but could not be used leaves the sample at the start page and the fact pages; the report says so.
|
|
573
|
+
const selection = JSON.parse(await readFile(path.join(state.out, INTAKE_FILE), 'utf8')).selection;
|
|
574
|
+
if (selection?.sources?.preflight === 'unreadable') remaining.push({ problem: 'The read of the site made before the start questions could not be used, so only the start page and the pages with confirmed facts were audited',
|
|
575
|
+
reason: 'preflight_unreadable', required_input_or_authority: 'A new read of the site', next_action: 'Run the start read again, then start the audit again in a new --out folder',
|
|
576
|
+
reobserve_when: 'on the next run that starts from a readable start read', message_key: 'follow-up.preflight-unreadable', message_values: {} });
|
|
577
|
+
for (const entry of (await state.store.index()).owner_exports ?? []) {
|
|
578
|
+
if (entry.status === 'read') {
|
|
579
|
+
const stored = JSON.parse(await readFile(path.join(state.out, entry.ref), 'utf8'));
|
|
580
|
+
metrics.push(...normalizeImport(stored.provider, stored.rows, stored.metadata));
|
|
581
|
+
} else remaining.push({ problem: `The ${entry.kind} export file could not be used: ${EXPORT_PROBLEMS[entry.reason] ?? entry.reason}`, reason: entry.reason,
|
|
582
|
+
required_input_or_authority: 'A new export file from the site owner', next_action: 'Export the report again as the runbook describes and give its path in the start answers',
|
|
583
|
+
reobserve_when: 'on the next run that includes the new file', message_key: 'follow-up.export-unusable', message_values: { kind: entry.kind, reason: entry.reason } });
|
|
584
|
+
}
|
|
585
|
+
return { metrics, remaining };
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/** Wrap the run's evidence in a replayable packet and write `report.json`, `report.md` and `report.html`; the report replays every verdict from it. */
|
|
589
|
+
const reportStage = {
|
|
590
|
+
name: 'report',
|
|
591
|
+
async run(state) {
|
|
592
|
+
const { context, input, out, store } = state;
|
|
593
|
+
const costs = { schema_version: versions().contract_schema, run_id: context.run_id, budgets: [], reservations: [], settlements: [],
|
|
594
|
+
approval_ref: null, source_ref: RUN_SOURCE, evidence_ref: COSTS_FILE };
|
|
595
|
+
// Overwritten, not created: a report stage that stopped after this write must be able to run again.
|
|
596
|
+
await writeFile(path.join(out, COSTS_FILE), json(costs));
|
|
597
|
+
const evidence_index = await evidenceList(state);
|
|
598
|
+
const { metrics, remaining } = await ownerExportResults(state);
|
|
599
|
+
const packet = { schema_version: versions().contract_schema, run_context: context, baseline: { input, scores: state.scores }, after: null,
|
|
600
|
+
comparison: compare(input, null), metrics, findings: findingsOf(state.observations, evidence_index), changes: [], costs, remaining,
|
|
601
|
+
evidence_root: out, evidence_index, audit_snapshots: [{ phase: PHASE, audit_ref: store.indexRef, context_ref: context.evidence_ref }],
|
|
602
|
+
source_ref: RUN_SOURCE, data_class: 'real', lab_cwv: state.lab_cwv ?? null, start_redirect: state.start_redirect ?? null };
|
|
603
|
+
const level = state.answers.guidance_level;
|
|
604
|
+
const renderHtml = htmlReportWriter({ level, target: context.target, synthetic: packet.data_class === 'synthetic' });
|
|
605
|
+
const { jsonPath, markdownPath, htmlPath } = await writeReport(packet, out, { renderHtml });
|
|
606
|
+
return { state: { ...state, packet, reports: { json: jsonPath, md: markdownPath, html: htmlPath } } };
|
|
607
|
+
},
|
|
608
|
+
};
|
|
609
|
+
|
|
610
|
+
/** The stages of one audit in order. A fresh list each call, so a caller may insert or drop a stage in its own copy. */
|
|
611
|
+
export function stages() {
|
|
612
|
+
return [contextStage, collectStage, renderStage, cwvStage, deriveStage, judgeStage, scoreStage, reportStage];
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// The files a first call writes in the run folder (besides the evidence folder); none of them is the owner's.
|
|
616
|
+
const FIRST_CALL_FILES = [CONTEXT_FILE, INTAKE_FILE, COSTS_FILE, QUEUE_FILE, SCORE_INPUT_FILE];
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* What to do with the run folder. A folder whose first call stored a judge queue is resumed. A folder with a finished report is
|
|
620
|
+
* refused in plain words. Anything else is a first call that stopped early (closed terminal, timeout): its own files are cleared
|
|
621
|
+
* and the run starts over; `preflight/` and the answers file are never touched.
|
|
622
|
+
*/
|
|
623
|
+
export async function prepareOutput(out) {
|
|
624
|
+
const indexFile = path.join(out, 'evidence', 'evidence-index.json');
|
|
625
|
+
let queued = false, savedObservation = null;
|
|
626
|
+
if (await exists(indexFile)) {
|
|
627
|
+
try {
|
|
628
|
+
const index = JSON.parse(await readFile(indexFile, 'utf8'));
|
|
629
|
+
queued = index.judge_queue?.length > 0;
|
|
630
|
+
// An older release's audit writes the same index keys, so only the verdicts it saved carry its version labels.
|
|
631
|
+
const saved = await readFile(path.join(out, 'evidence', path.basename(String(index.observations_ref ?? ''))), 'utf8').then(JSON.parse, () => null);
|
|
632
|
+
savedObservation = Array.isArray(saved) ? saved[0] ?? null : null;
|
|
633
|
+
} catch { return 'resume'; }
|
|
634
|
+
}
|
|
635
|
+
if (await exists(path.join(out, 'report.json'))) throw new Error('This folder already holds a finished audit. Use a new --out folder for a new one.');
|
|
636
|
+
// A folder an older optbrew started is neither resumed nor cleared: its evidence is not this release's to judge or delete.
|
|
637
|
+
const earlier = await readFile(path.join(out, CONTEXT_FILE), 'utf8').then(JSON.parse, () => null);
|
|
638
|
+
assertCurrentEvidence(earlier, 'run folder');
|
|
639
|
+
assertCurrentEvidence(savedObservation, 'run folder');
|
|
640
|
+
if (queued) return 'resume';
|
|
641
|
+
const names = await readdir(out).catch(() => []);
|
|
642
|
+
const owned = [...FIRST_CALL_FILES, 'evidence', ...names.filter(name => OWNER_EXPORT_FILE.test(name) || OWNER_MAPPING_FILE.test(name))];
|
|
643
|
+
// Only a folder this tool wrote is cleared: the files to clear must be provably the run's own, or nothing is touched.
|
|
644
|
+
if (owned.some(name => names.includes(name)) && !(await wroteByRun(out, names)))
|
|
645
|
+
throw new Error('This folder holds files this audit did not make (for example an evidence or context.json of your own). Use a new --out folder.');
|
|
646
|
+
await Promise.all(owned.map(name => rm(path.join(out, name), { recursive: true, force: true })));
|
|
647
|
+
return 'fresh';
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/** Proof that the first-call files in a folder are the run's own: its evidence index and context.json name the same run, or (before the index exists) context.json and intake.json do. */
|
|
651
|
+
async function wroteByRun(out, names) {
|
|
652
|
+
const read = async file => { try { return JSON.parse(await readFile(path.join(out, file), 'utf8')); } catch { return null; } };
|
|
653
|
+
const context = await read(CONTEXT_FILE);
|
|
654
|
+
if (!context?.run_id) return false;
|
|
655
|
+
const sameRun = (await read(INTAKE_FILE))?.run_id === context.run_id;
|
|
656
|
+
if (!names.includes('evidence')) return sameRun;
|
|
657
|
+
const index = await read(path.join('evidence', 'evidence-index.json'));
|
|
658
|
+
if (index) return index.run_id === context.run_id;
|
|
659
|
+
// A run that stopped before its first index save leaves only content-addressed blobs, its fixed documents and temp files.
|
|
660
|
+
return sameRun && (await readdir(path.join(out, 'evidence'))).every(name => RUN_FILE.test(name));
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* The state a first call left in `<out>`, rebuilt from the evidence index and the fixed context it points to; nothing else
|
|
665
|
+
* is saved between calls. Every hash is checked (the evidence the index lists, and the run documents it records), and the
|
|
666
|
+
* verdicts are derived again from the stored evidence: they must equal the recorded ones. Anything that fails stops the run with
|
|
667
|
+
* one error; nothing is collected again.
|
|
668
|
+
*/
|
|
669
|
+
export async function restoreState({ out, answers }) {
|
|
670
|
+
const evidenceDir = path.join(out, 'evidence');
|
|
671
|
+
const store = openStore(evidenceDir);
|
|
672
|
+
// Whatever fails while the stored evidence is read back or re-derived is one condition with one message.
|
|
673
|
+
// A folder an older optbrew made is the exception: it is one plain migration sentence, not a change.
|
|
674
|
+
const changed = async job => { try { return await job(); } catch (error) { throw error instanceof MigrationRequired ? error : new Error(INDEX_CHANGED); } };
|
|
675
|
+
const recorded = await changed(async () => JSON.parse(await readFile(store.indexRef, 'utf8')));
|
|
676
|
+
const { context, intake } = await changed(async () => {
|
|
677
|
+
const value = JSON.parse(await readFile(path.resolve(out, recorded.context_ref), 'utf8'));
|
|
678
|
+
assertCurrentEvidence(value, 'run folder');
|
|
679
|
+
validateEntity('RunContext', value);
|
|
680
|
+
if (recorded.run_id !== value.run_id) throw new Error('another run');
|
|
681
|
+
for (const [name, sha] of Object.entries(recorded.documents)) if (sha256Of(await readFile(path.join(out, name))) !== sha) throw new Error('edited document');
|
|
682
|
+
return { context: value, intake: JSON.parse(await readFile(path.join(out, INTAKE_FILE), 'utf8')) };
|
|
683
|
+
});
|
|
684
|
+
if (intake.answers?.url !== answers.url) throw new Error('The answers are not the ones this run was made from; use the same answers file, or a new --out folder.');
|
|
685
|
+
const state = { answers, out, evidenceDir, store, context, modules: recorded.modules, start_redirect: intake.selection?.start_redirect ?? null };
|
|
686
|
+
return changed(async () => {
|
|
687
|
+
const stored = await readStored(state);
|
|
688
|
+
const saved = JSON.parse(await readFile(recorded.observations_ref, 'utf8'));
|
|
689
|
+
const observations = deriveObservations(stored.input);
|
|
690
|
+
if (JSON.stringify(observations) !== JSON.stringify(saved)) throw new Error('verdicts differ');
|
|
691
|
+
if (!stored.index.judge_queue?.length) throw new Error('no queue');
|
|
692
|
+
return { ...state, observations, chrome_result: stored.index.chrome ?? null, lab_cwv: stored.index.lab_cwv ?? null };
|
|
693
|
+
});
|
|
694
|
+
}
|