optbrew 0.1.2 → 0.1.3
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 +92 -0
- package/CHANGELOG.md +22 -1
- package/README.ko.md +105 -106
- package/README.md +105 -106
- package/docs/platforms.md +44 -7
- package/docs/release.md +3 -2
- package/package.json +26 -8
- package/plugin.json +2 -2
- package/skills/optbrew/SKILL.md +1 -1
- package/skills/optbrew/bundle-manifest.json +17 -17
- package/skills/optbrew/references/ai-crawlers.json +1 -1
- package/skills/optbrew/references/ai-crawlers.md +1 -1
- package/skills/optbrew/references/api.md +2 -2
- package/skills/optbrew/references/report.md +1 -1
- package/skills/optbrew/references/scoring.md +1 -1
- package/skills/optbrew/scripts/audit.mjs +17 -1
- package/skills/optbrew/scripts/core/bundle.mjs +1 -1
- package/skills/optbrew/scripts/core/collector.mjs +21 -11
- package/skills/optbrew/scripts/core/derive.mjs +31 -6
- package/skills/optbrew/scripts/core/facts.mjs +30 -4
- package/skills/optbrew/scripts/core/report.mjs +33 -12
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"engines": {
|
|
6
6
|
"google": { "agent": "Googlebot", "role": "search", "source": "https://developers.google.com/search/docs/crawling-indexing/google-common-crawlers" },
|
|
7
7
|
"google_ai": { "agent": "Googlebot", "role": "search", "source": "https://developers.google.com/search/docs/appearance/ai-features" },
|
|
8
|
-
"naver": { "agent": "Yeti", "role": "search", "source": "https://searchadvisor.naver.com/guide/seo-basic-robots" },
|
|
8
|
+
"naver": { "agent": "Yeti", "role": "search", "source": "https://searchadvisor.naver.com/guide/seo-basic-robots", "verified": "2026-10-15", "evidence": "The page (HTTP 200) uses `User-agent: Yeti` in its robots.txt examples; page text sha256 ff8ddc85c5dd42e0d1133abae6928fb9b42f67f35d1b2c89b40378009571ed49" },
|
|
9
9
|
"chatgpt": { "agent": "OAI-SearchBot", "role": "search", "aliases": ["openai"], "source": "https://developers.openai.com/api/docs/bots" },
|
|
10
10
|
"claude": { "agent": "Claude-SearchBot", "role": "search", "aliases": ["anthropic"], "source": "https://support.claude.com/en/articles/8896518-does-anthropic-crawl-data-from-the-web-and-how-can-site-owners-block-the-crawler" },
|
|
11
11
|
"perplexity": { "agent": "PerplexityBot", "role": "search", "source": "https://docs.perplexity.ai/guides/bots" }
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
| Protocol engine | robots.txt token | Role | Vendor statement (summary) |
|
|
6
6
|
| --- | --- | --- | --- |
|
|
7
7
|
| `google`, `google_ai` | Googlebot | search | Google's AI features need no extra technical requirement: a page must be indexed and snippet-eligible, and "robots.txt directives for Googlebot is the control for site owners" ([AI features](https://developers.google.com/search/docs/appearance/ai-features)). |
|
|
8
|
-
| `naver` | Yeti | search | Naver Search Advisor robots guidance. |
|
|
8
|
+
| `naver` | Yeti | search | Naver Search Advisor robots guidance ([robots.txt guide](https://searchadvisor.naver.com/guide/seo-basic-robots)): its robots.txt examples use `User-agent: Yeti`. Read again on 2026-10-15 (HTTP 200, page text sha256 `ff8ddc85c5dd42e0d1133abae6928fb9b42f67f35d1b2c89b40378009571ed49`). |
|
|
9
9
|
| `chatgpt` (alias `openai`) | OAI-SearchBot | search | Sites opted out of OAI-SearchBot are not shown in ChatGPT search answers ([OpenAI bots](https://developers.openai.com/api/docs/bots)). |
|
|
10
10
|
| `claude` (alias `anthropic`) | Claude-SearchBot | search | Blocking stops indexing for Claude search and may reduce visibility ([Anthropic](https://support.claude.com/en/articles/8896518-does-anthropic-crawl-data-from-the-web-and-how-can-site-owners-block-the-crawler)). |
|
|
11
11
|
| `perplexity` | PerplexityBot | search | Surfaces and links sites in Perplexity results and is not used for foundation-model training ([Perplexity](https://docs.perplexity.ai/guides/bots)). |
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Collection and optional API boundary
|
|
2
2
|
|
|
3
|
-
The optbrew audit command is `node skills/optbrew/scripts/audit.mjs --context <run-context.json> --modules naver-searchadvisor,magnus-seo --output-dir <run-dir>`. It writes raw responses, one hashed `<sha256>.response.json` record per response (final URL, redirect chain and every collected header line, repeated lines kept separate), `collection.json`, `modules.json`, `observations.json` and `evidence-index.json` under `<run-dir>/evidence/`. The evidence index binds the run ID, context reference, raw response hashes, response record hashes (`responses`) and module/observation paths for later scoring and reporting. Reusing an output directory with an existing collection index is rejected. The audit never posts to a search engine or changes the site.
|
|
3
|
+
The optbrew audit command is `node skills/optbrew/scripts/audit.mjs --context <run-context.json> --modules naver-searchadvisor,magnus-seo --output-dir <run-dir>`. It writes raw responses, one hashed `<sha256>.response.json` record per response (final URL, redirect chain and every collected header line, repeated lines kept separate), one hashed `<sha256>.failure.json` record per failed fetch (answered redirect hops and whether one more attempt got no response), `collection.json`, `modules.json`, `observations.json` and `evidence-index.json` under `<run-dir>/evidence/`. The evidence index binds the run ID, context reference, raw response hashes, response record hashes (`responses`), failure record hashes (`failures`) and module/observation paths for later scoring and reporting. Reusing an output directory with an existing collection index is rejected. The audit never posts to a search engine or changes the site.
|
|
4
4
|
|
|
5
|
-
`RunContext.scope.allowed` is a nonempty list of `{ "origin": "https://example.org", "path_prefix": "/owned/" }`. An origin fixes scheme, host and effective port. The path prefix is segment bounded. `scope.local_targets` may list explicit `{ "host": "localhost", "port": 8765 }` pairs for a declared local target. Without this pair, loopback, private, link-local, ULA and mapped IPv4 addresses are blocked. The default ceilings are 50 URLs, depth 3, concurrency 2, 20 seconds, 5 MiB and 10 redirects. Lower limits may be set with `max_urls`, `max_depth` and `concurrency`. The request limit (`max_urls`, at most 50)
|
|
5
|
+
`RunContext.scope.allowed` is a nonempty list of `{ "origin": "https://example.org", "path_prefix": "/owned/" }`. An origin fixes scheme, host and effective port. The path prefix is segment bounded. `scope.local_targets` may list explicit `{ "host": "localhost", "port": 8765 }` pairs for a declared local target. Without this pair, loopback, private, link-local, ULA and mapped IPv4 addresses are blocked. The default ceilings are 50 URLs, depth 3, concurrency 2, 20 seconds, 5 MiB and 10 redirects. Lower limits may be set with `max_urls`, `max_depth` and `concurrency`. The request limit (`max_urls`, at most 50) is a hard ceiling for pages and auxiliary resources together: every request, each redirect hop included, takes one slot immediately before it is sent, and the total never exceeds the limit. A redirect chain the limit stops leaves its URL as a `request_limit` unknown, and the hops it already received stay recorded. A failed fetch counts the hops it received plus at most one attempt that got no response; both are written to a hashed `<sha256>.failure.json` record so report replay can prove the count. The 20-second deadline covers DNS, redirects, connection and response together. Every redirect is checked against scope and fresh DNS; the Node transport connects to the vetted address and preserves TLS hostname verification. A host MCP or bulk crawler is selected only if it can guarantee the same boundary and cost controls.
|
|
6
6
|
|
|
7
7
|
The v0.1.0 direct Anthropic Messages adapter accepts only text-only `{model,max_tokens,messages}` requests. Each message has only `{role,content}` with string content. It accepts these exact model IDs: `claude-fable-5-1`, `claude-opus-5-5`, `claude-sonnet-5-5`, `claude-haiku-5-5`. Anthropic's [model overview](https://platform.claude.com/docs/en/models/overview), checked 2026-10-14, lists a 1,000,000-token context window and 128,000 maximum output tokens for each. Every attempt must reserve at least `1,000,000 + request.max_tokens` in the `tokens` unit. The adapter requires exactly one token upper bound, a matching ledger budget and approval reference, and an `ANTHROPIC_API_KEY` environment variable. It rejects unknown models, extra fields, tools, web search, non-text input, currency-only bounds and smaller token reservations before any provider request. The official [token-counting guide](https://platform.claude.com/docs/en/build-with-claude/token-counting), checked 2026-10-14, calls count results estimates that may differ from billed input usage; they are not accepted as hard upper bounds.
|
|
8
8
|
|
|
@@ -22,7 +22,7 @@ Collector verdicts are replayed, not trusted. For each collector-sourced `index-
|
|
|
22
22
|
|
|
23
23
|
`verified_local` uses the production binding: the change and its receipt name an `observation_id` and `observation_evidence_ref` for a passing after observation that the collector produced (so the audit replay covers it); that raw evidence entry is labelled `phase:"after"`, `environment:"local"` and the run target, and the after context is `local` for the same target. A byte-hash receipt or an agent-written observation alone leaves the finding `modified_unverified`. For `verified_production` the finding carries `resolution` (observation ID, evaluator type, source) and the Status cell reads `resolved by <evaluator_type> observation from <source_ref>`, so a model-attested resolution is visibly distinct from a collector-verified one; the production gating rules are unchanged. Native path mappings match whole path segments: the exported path starts with a single `/` (protocol-relative paths and absolute URLs on another host are refused) and equals the approved `path_prefix` or continues after a `/`.
|
|
24
24
|
|
|
25
|
-
Collector verdicts for the families below come from one derivation, `scripts/core/derive.mjs`, which the audit runs on the live responses and the report runs again on the hashed raw evidence. Every derived observation must be in the packet and in the saved observations, and each packet observation must match its derivation in state, credit and evidence reference; an extra or missing collector observation is an error. The families: `fetch-response`; `index-policy`; `document-title` (missing or duplicated across the selected pages fails; a unique title stays `unknown`); `search-crawl-policy` for every engine with a token in `ai-crawlers.json`, judged on the declared robots file; `canonical-consistency` (a redirect chain of two or more hops, or a canonical target that returns an error, fails; a self-referencing or reachable canonical within one hop passes; the collector fetches in-scope canonical targets
|
|
25
|
+
Collector verdicts for the families below come from one derivation, `scripts/core/derive.mjs`, which the audit runs on the live responses and the report runs again on the hashed raw evidence. Every derived observation must be in the packet and in the saved observations, and each packet observation must match its derivation in state, credit and evidence reference; an extra or missing collector observation is an error. The families: `fetch-response`; `index-policy`; `document-title` (missing or duplicated across the selected pages fails; a unique title stays `unknown`); `search-crawl-policy` for every engine with a token in `ai-crawlers.json`, judged on the declared robots file; `canonical-consistency` (a redirect chain of two or more hops, or a canonical target that returns an error, fails; a self-referencing or reachable canonical within one hop passes; the collector fetches in-scope canonical targets within the request limit); `discovery` (a selected public page that no collected page links to and no reachable declared sitemap lists fails when the crawl was complete and at least two pages were collected; a sitemap index or an incomplete crawl leaves it `unknown`; an unlinked page is `unknown` when another collected page's own bytes (inline scripts included) name its path as a whole path, or when a script-only page loads a same-origin script file that was not collected, unless a fetched sitemap that lists other pages leaves it out (an inline script that builds the URL from pieces can still give a false orphan); entity-encoded hrefs are decoded and `index.html` equals its directory); `structured-data-truth` (a live JSON-LD block that does not parse fails; blocks inside HTML comments and empty placeholder blocks are ignored; parsing alone never passes); `naver-markup` (Open Graph, description, canonical, viewport, title repetition, CSS or JS blocked for Yeti); and, for runs that enable them, `fact-consistency` and `entity-identity`. Fact consistency compares name, address, phone and founding year across the selected pages. Values come only from Organization or LocalBusiness JSON-LD, `tel:` links (counted only on a page with no labelled or JSON-LD phone, so a mobile inquiry link never contradicts the landline) and explicitly labelled fields (`Phone:`, `Tel`, `Address:`, `Founded:` and Korean equivalents); prose, dates and numbers elsewhere are never read as facts, and a fax or registration number is not a phone. A national number written with spaces only (02 555 0142) is read in a label, and so are Korean nationwide representative numbers (15xx, 16xx or 18xx plus four digits). Addresses are compared after unifying street abbreviations, dropping postal codes and a trailing country, and treating a street address with or without city detail as the same. A page is a separate entity and is not compared only on structural evidence: JSON-LD `branchOf`, `parentOrganization` or a different `@id`; a branch-list URL path (`/branch/`, `/branches/`, `/locations/`, `/stores/`, `/지점/`); or the word branch, 지점 or 지사 in its title or first heading together with an address of its own that differs from the main entity's. A title word alone does not hide a wrong phone. A reference value needs at least two pages and a unique majority; a page that disagrees, or whose JSON-LD disagrees with its own visible text, fails; a tie or a single page stays `unknown`. This is consistency between pages, not accuracy against an approved fact list. `entity-identity` (site unit) fails on conflicting names, Organization JSON-LD without `sameAs`, or no Organization JSON-LD on the home page while other pages have it. The `robots.txt` Sitemap line, og:image size, hreflang and JavaScript-only navigation are not scripted. Audit evidence from 0.1.2 or earlier has no hashed failure records and lacks the 0.1.3 discovery and fact observations, so report replay refuses it with an "older optbrew release" error; run the audit again with 0.1.3 and build a new packet. Every audit also writes `ledger.json`: one entry per queued or rejected URL with its request count. Index it with `source_ref:"optbrew:collector"`. Replay checks that the skipped list, `limit_reached`, `request_count` and `evaluated_count` equal the ledger, that each response entry matches a hashed response record (requests = redirect hops + 1), that each failed fetch matches a hashed failure record (`*.failure.json`, listed in `failures`; requests = answered redirect hops + at most one attempt that got no response), that a URL appears in only one entry, that a failed fetch or a request-limit skip belongs to a URL with provenance (selected, auxiliary, or linked from a collected page), that the total never exceeds the request limit and a limit is claimed only when the recorded requests use all of it, and that a selected page is skipped only for the request limit. Index every `*.failure.json` file with `source_ref:"optbrew:collector"`, as for `*.response.json`. The audit index also lists the modules that ran; every listed module must have its output in `modules.json`. All derived families above are computed from the collector evidence whether or not a module ran.
|
|
26
26
|
|
|
27
27
|
The report opens with a Headline per profile. At least 80% measured coverage and a verdict on every core access and indexing check print the score with its coverage; below 80% the headline is `insufficient coverage`, and with no verdict on a core check it is `insufficient core checks`, with the measured-subset score and the missing-data bounds, and `json.headline[]` carries `status` (`sufficient`, `insufficient_coverage`, `insufficient_core`), `coverage_exact`, `headline_score` (null when withheld), `measured_subset_score`, `coverage` and `missing_bounds`.
|
|
28
28
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# optbrew readiness scoring contract (evaluation 0.
|
|
1
|
+
# optbrew readiness scoring contract (evaluation 0.3.0)
|
|
2
2
|
|
|
3
3
|
This is a readiness audit. It does not predict indexing, rank, AI citations, accuracy, or revenue. `check-registry.json` holds the 22 unique families. `profiles.json` holds the five optbrew axis weight sets. Those weights, the middle-band CWV `q=0.5`, the score bands and the 80% coverage threshold are **optbrew rules**, not search engine weights or official sufficiency tests. `source_refs` names match the official references in `docs/superpowers/specs/2026-10-13-optbrew-sources.md` in the source repository.
|
|
4
4
|
|
|
@@ -41,6 +41,7 @@ export async function runAudit({ context, modules, outputDir, transport, resolve
|
|
|
41
41
|
const collection = await collect(context, { transport, resolve });
|
|
42
42
|
const artifacts = [];
|
|
43
43
|
const responses = [];
|
|
44
|
+
const failures = [];
|
|
44
45
|
for (const artifact of collection.artifacts) {
|
|
45
46
|
const { body, ...saved } = artifact;
|
|
46
47
|
if (artifact.content_hash) {
|
|
@@ -68,6 +69,21 @@ export async function runAudit({ context, modules, outputDir, transport, resolve
|
|
|
68
69
|
saved.response_ref = ref;
|
|
69
70
|
responses.push({ normalized_url: artifact.normalized_url, evidence_ref: ref, content_hash });
|
|
70
71
|
}
|
|
72
|
+
else {
|
|
73
|
+
// A failed fetch has no response, but its answered redirect hops were real requests: hash them in a failure record
|
|
74
|
+
// so the ledger can prove the count (answered hops plus at most one unanswered attempt).
|
|
75
|
+
const bytes = Buffer.from(`${JSON.stringify({ record_version: '1', kind: 'failure', requested_url: artifact.requested_url,
|
|
76
|
+
normalized_url: artifact.normalized_url, redirect_chain: artifact.redirect_chain, unanswered: artifact.unanswered ?? 0,
|
|
77
|
+
error: artifact.error }, null, 2)}\n`);
|
|
78
|
+
const content_hash = createHash('sha256').update(bytes).digest('hex');
|
|
79
|
+
const ref = path.join(evidenceDir, `${content_hash}.failure.json`);
|
|
80
|
+
try { await writeFile(ref, bytes, { flag: 'wx' }); }
|
|
81
|
+
catch (error) {
|
|
82
|
+
if (error.code !== 'EEXIST' || !Buffer.from(await readFile(ref)).equals(bytes)) throw error;
|
|
83
|
+
}
|
|
84
|
+
saved.failure_ref = ref;
|
|
85
|
+
failures.push({ normalized_url: artifact.normalized_url, evidence_ref: ref, content_hash });
|
|
86
|
+
}
|
|
71
87
|
artifacts.push(saved);
|
|
72
88
|
}
|
|
73
89
|
const module_outputs = Object.fromEntries(modules.map(x => [x, []]));
|
|
@@ -104,7 +120,7 @@ export async function runAudit({ context, modules, outputDir, transport, resolve
|
|
|
104
120
|
collection_ref: path.join(evidenceDir, 'collection.json'), module_output_ref: path.join(evidenceDir, 'modules.json'),
|
|
105
121
|
observations_ref: path.join(evidenceDir, 'observations.json'), ledger_ref: path.join(evidenceDir, 'ledger.json'), modules,
|
|
106
122
|
raw: artifacts.map(x => ({ normalized_url: x.normalized_url, evidence_ref: x.evidence_ref, content_hash: x.content_hash })),
|
|
107
|
-
responses }, null, 2)}\n`, { flag: 'wx' });
|
|
123
|
+
responses, failures }, null, 2)}\n`, { flag: 'wx' });
|
|
108
124
|
return result;
|
|
109
125
|
}
|
|
110
126
|
|
|
@@ -42,7 +42,7 @@ export async function createBundleManifest({ bundleRoot, releaseVersion }) {
|
|
|
42
42
|
const evaluation_sources = selected(evaluationSourcePaths);
|
|
43
43
|
const protocol_sources = selected(protocolSourcePaths);
|
|
44
44
|
const identity = evaluation_sources.length && protocol_sources.length ? {
|
|
45
|
-
evaluation_version: '0.
|
|
45
|
+
evaluation_version: '0.3.0', protocol_version: '0.1.0',
|
|
46
46
|
evaluation_sources, evaluation_hash: digest(JSON.stringify(evaluation_sources)),
|
|
47
47
|
protocol_sources, protocol_hash: digest(JSON.stringify(protocol_sources)) } : {};
|
|
48
48
|
return { schema_version: '1.0.0', release_version: releaseVersion,
|
|
@@ -95,6 +95,7 @@ export async function fetchScoped(input, { context, transport = nodeTransport, r
|
|
|
95
95
|
if (allowedAuxiliary) undeclaredAuxiliary(url, allowedAuxiliary);
|
|
96
96
|
const requested_url = String(input);
|
|
97
97
|
const redirect_chain = [];
|
|
98
|
+
let made = 0; // requests actually sent; an answered hop is in redirect_chain, an unanswered attempt is made - chain length
|
|
98
99
|
const controller = new AbortController();
|
|
99
100
|
const deadline = performance.now() + timeoutMs;
|
|
100
101
|
let rejectTimeout;
|
|
@@ -116,13 +117,17 @@ export async function fetchScoped(input, { context, transport = nodeTransport, r
|
|
|
116
117
|
error.redirect_chain = redirect_chain;
|
|
117
118
|
throw error;
|
|
118
119
|
}
|
|
120
|
+
made++;
|
|
119
121
|
const response = await withinDeadline(transport({ url, address: addresses[0], timeoutMs: remainingMs, maxBytes, headers, signal: controller.signal }));
|
|
120
122
|
if (!Number.isInteger(response?.status) || response.status < 100 || response.status > 599) throw new Error('invalid transport status');
|
|
121
123
|
const body = text(response.body);
|
|
122
124
|
if (body.length > maxBytes) throw new Error('response byte limit exceeded');
|
|
123
125
|
const savedHeaders = safeHeaders(response.headers);
|
|
124
126
|
if (REDIRECTS.has(response.status) && savedHeaders.location) {
|
|
125
|
-
if (hop === maxRedirects)
|
|
127
|
+
if (hop === maxRedirects) {
|
|
128
|
+
redirect_chain.push({ url: url.href, status: response.status, location: '[limit]' });
|
|
129
|
+
throw new Error('redirect limit exceeded');
|
|
130
|
+
}
|
|
126
131
|
let next;
|
|
127
132
|
try { next = assertUrlInScope(new URL(savedHeaders.location, url).href, context.scope); }
|
|
128
133
|
catch {
|
|
@@ -149,7 +154,11 @@ export async function fetchScoped(input, { context, transport = nodeTransport, r
|
|
|
149
154
|
};
|
|
150
155
|
}
|
|
151
156
|
throw new Error('redirect limit exceeded');
|
|
152
|
-
} catch (error) {
|
|
157
|
+
} catch (error) {
|
|
158
|
+
error.redirect_chain ??= [...redirect_chain];
|
|
159
|
+
error.unanswered = Math.max(0, made - error.redirect_chain.length);
|
|
160
|
+
throw error;
|
|
161
|
+
}
|
|
153
162
|
finally { clearTimeout(timer); }
|
|
154
163
|
}
|
|
155
164
|
|
|
@@ -214,25 +223,26 @@ export async function collect(context, { transport, resolve } = {}) {
|
|
|
214
223
|
const item = queue[cursor++];
|
|
215
224
|
if (requestCount >= limit) { skip(item.url, 'url_limit'); continue; }
|
|
216
225
|
let used = 0;
|
|
217
|
-
// The budget
|
|
218
|
-
//
|
|
226
|
+
// The budget is a hard ceiling: every request, redirect hops included, takes one slot immediately before it is sent.
|
|
227
|
+
// A chain the budget stops is a request_limit unknown whose answered hops are recorded.
|
|
219
228
|
const reserveRequest = () => {
|
|
220
|
-
if (
|
|
229
|
+
if (requestCount >= limit) return false;
|
|
221
230
|
requestCount++; used++;
|
|
222
231
|
return true;
|
|
223
232
|
};
|
|
224
233
|
let artifact;
|
|
225
|
-
|
|
226
|
-
// the limit: at most concurrency x min(10, limit) in all.
|
|
227
|
-
try { artifact = await fetchScoped(item.url, { context, transport, resolve, allowedAuxiliary: allowedDocuments, reserveRequest, maxRedirects: Math.min(10, limit) }); }
|
|
234
|
+
try { artifact = await fetchScoped(item.url, { context, transport, resolve, allowedAuxiliary: allowedDocuments, reserveRequest }); }
|
|
228
235
|
catch (error) {
|
|
229
236
|
const reason = ['scope_rejected', 'redirect_outside_scope', 'auxiliary_undeclared', 'request_limit'].includes(error.code) ? error.code : 'fetch_failed';
|
|
230
237
|
if (reason === 'request_limit' && !used) { skip(item.url, 'url_limit'); continue; }
|
|
231
238
|
if (reason !== 'fetch_failed') skipped.push({ url: item.url, reason });
|
|
232
|
-
//
|
|
233
|
-
|
|
239
|
+
// The requests a failed fetch made are its answered hops (kept in the hashed failure record) plus at most one
|
|
240
|
+
// attempt that got no response, so the ledger can prove them.
|
|
241
|
+
const chain = error.redirect_chain ?? [];
|
|
242
|
+
const unanswered = error.unanswered ?? 0;
|
|
243
|
+
ledger.push({ url: item.url, requests: chain.length + unanswered, outcome: 'error', skipped_reason: reason !== 'fetch_failed' ? reason : null, error: reason });
|
|
234
244
|
artifact = { requested_url: item.url, normalized_url: item.url, final_url: null, status: null,
|
|
235
|
-
headers: {}, header_lines: [], redirect_chain:
|
|
245
|
+
headers: {}, header_lines: [], redirect_chain: chain, unanswered, raw_html_ref: null, content_hash: null, capability: 'none',
|
|
236
246
|
source_ref: 'optbrew:collector', evidence_ref: null, error: reason, body: Buffer.alloc(0) };
|
|
237
247
|
}
|
|
238
248
|
if (!artifact.error) ledger.push({ url: item.url, requests: used, outcome: 'response', skipped_reason: null, error: null });
|
|
@@ -103,6 +103,18 @@ export function deriveObservations({ context, artifacts, collection = {}, textOf
|
|
|
103
103
|
else if (canonicalState === 'pass') add('canonical-consistency', null, url, 'pass', 'Canonical target is this page or a reachable page and redirects stay within one hop', ref);
|
|
104
104
|
}
|
|
105
105
|
}
|
|
106
|
+
/** True when any other collected page's raw bytes name this page's path (whole path segment, not a longer path). */
|
|
107
|
+
const pathMentioned = ({ url, artifact }, bodies) => {
|
|
108
|
+
const own = canonicalUrl(url);
|
|
109
|
+
for (const target of new Set([url, artifact.final_url])) {
|
|
110
|
+
let path;
|
|
111
|
+
try { path = decodeURIComponent(new URL(target).pathname); } catch { continue; }
|
|
112
|
+
if (path === '/') continue;
|
|
113
|
+
const pattern = new RegExp(path.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(?![A-Za-z0-9_-])');
|
|
114
|
+
for (const [page, body] of bodies) if (page !== own && pattern.test(body)) return true;
|
|
115
|
+
}
|
|
116
|
+
return false;
|
|
117
|
+
};
|
|
106
118
|
if (planned.has('discovery')) {
|
|
107
119
|
const sitemapUrls = new Set();
|
|
108
120
|
const brokenSitemaps = [];
|
|
@@ -118,12 +130,24 @@ export function deriveObservations({ context, artifacts, collection = {}, textOf
|
|
|
118
130
|
}
|
|
119
131
|
const htmlPages = artifacts.filter(x => x.capability === 'raw_html' && x.status === 200);
|
|
120
132
|
const linked = new Map();
|
|
121
|
-
// A page with scripts and no anchors may build its navigation in the browser
|
|
122
|
-
|
|
133
|
+
// A page with scripts and no anchors may build its navigation in the browser. That only hides an orphan when the
|
|
134
|
+
// script can be read: an inline script is searched for the path below, and a same-origin script file that was not
|
|
135
|
+
// collected cannot be read at all.
|
|
136
|
+
const collected = new Set(artifacts.flatMap(x => [x.normalized_url, x.final_url].filter(Boolean).map(canonicalUrl)));
|
|
137
|
+
let uncollectedScript = false;
|
|
138
|
+
const bodies = new Map();
|
|
123
139
|
for (const page of htmlPages) {
|
|
124
140
|
const body = textOf(page);
|
|
141
|
+
bodies.set(canonicalUrl(page.normalized_url), body);
|
|
125
142
|
const hrefs = anchorLinks(body, page.final_url);
|
|
126
|
-
if (!hrefs.length && /<script\b/i.test(body))
|
|
143
|
+
if (!hrefs.length && /<script\b/i.test(body)) {
|
|
144
|
+
for (const match of body.matchAll(/<script\b[^>]*\bsrc\s*=\s*(?:"([^"]*)"|'([^']*)')/gi)) {
|
|
145
|
+
try {
|
|
146
|
+
const source = new URL(decodeEntities(match[1] ?? match[2]), page.final_url);
|
|
147
|
+
if (source.origin === new URL(page.final_url).origin && !collected.has(canonicalUrl(source.href))) uncollectedScript = true;
|
|
148
|
+
} catch { /* malformed script URL */ }
|
|
149
|
+
}
|
|
150
|
+
}
|
|
127
151
|
for (const href of hrefs) {
|
|
128
152
|
const target = canonicalUrl(href);
|
|
129
153
|
if (target && target !== canonicalUrl(page.final_url) && target !== canonicalUrl(page.normalized_url)) linked.set(target, (linked.get(target) ?? 0) + 1);
|
|
@@ -137,9 +161,10 @@ export function deriveObservations({ context, artifacts, collection = {}, textOf
|
|
|
137
161
|
const inSitemap = keys.some(key => sitemapUrls.has(key));
|
|
138
162
|
if (inSitemap || keys.some(key => linked.has(key)))
|
|
139
163
|
add('discovery', null, url, 'pass', inSitemap ? 'Listed in a declared sitemap' : 'Linked from another collected page', artifact.evidence_ref);
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
|
|
164
|
+
// An unlinked page is unknown when a collected page's own bytes (inline scripts included) name its path, or when a
|
|
165
|
+
// script file that could build the link was not collected, unless a fetched sitemap that lists other pages leaves
|
|
166
|
+
// it out. Anything else is an orphan.
|
|
167
|
+
else if (htmlPages.length >= 2 && complete && !sitemapIndex && (sitemapUrls.size || !(uncollectedScript || pathMentioned(row, bodies))))
|
|
143
168
|
add('discovery', null, url, 'fail', `Not linked from any collected page and not in a sitemap${brokenSitemaps.length ? `; declared sitemap unusable: ${brokenSitemaps.join(', ')}` : ''}`, artifact.evidence_ref);
|
|
144
169
|
}
|
|
145
170
|
}
|
|
@@ -10,6 +10,12 @@ const FACT_TYPES = ['name', 'phone', 'year', 'address'];
|
|
|
10
10
|
const ORG_TYPES = /^(?:Organization|Corporation|LocalBusiness|Store|Restaurant|CafeOrCoffeeShop|OnlineStore|NGO|[A-Za-z]*Business)$/;
|
|
11
11
|
const squash = value => String(value).toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '');
|
|
12
12
|
const BRANCH = /\b(?:branch)\b|지점|지사/i;
|
|
13
|
+
// URL path segments that list branches: /branch/, /branches/, /locations/, /stores/, /지점/.
|
|
14
|
+
const BRANCH_PATH = /^(?:branch|branches|locations|stores|지점)$/i;
|
|
15
|
+
const branchPath = url => {
|
|
16
|
+
try { return new URL(url, 'http://page.invalid').pathname.split('/').some(part => BRANCH_PATH.test(decodeURIComponent(part))); }
|
|
17
|
+
catch { return false; }
|
|
18
|
+
};
|
|
13
19
|
|
|
14
20
|
const ABBREVIATIONS = [[/\bstreet\b/g, 'st'], [/\broad\b/g, 'rd'], [/\bavenue\b/g, 'ave'], [/\bboulevard\b/g, 'blvd'],
|
|
15
21
|
[/\bdrive\b/g, 'dr'], [/\blane\b/g, 'ln'], [/\bsuite\b/g, 'ste'], [/\bfloor\b/g, 'fl'], [/\bbuilding\b/g, 'bldg']];
|
|
@@ -60,6 +66,8 @@ const addressText = address => typeof address === 'string' ? address : address &
|
|
|
60
66
|
function phoneKey(text) {
|
|
61
67
|
const clean = String(text).replace(/\(0\)/g, '');
|
|
62
68
|
const digits = clean.replace(/\D/g, '');
|
|
69
|
+
// Korean nationwide representative numbers (15xx, 16xx, 18xx plus four digits) have eight digits.
|
|
70
|
+
if (/^1[568]\d{6}$/.test(digits)) return digits;
|
|
63
71
|
if (digits.length < 9 || digits.length > 15) return null;
|
|
64
72
|
return /^\s*\+?\s*82/.test(clean) && clean.trim().startsWith('+') && digits.startsWith('82') ? `0${digits.slice(2)}` : digits;
|
|
65
73
|
}
|
|
@@ -72,6 +80,10 @@ const hardText = html => decodeEntities(stripInert(html).replace(/<[^>]+>/g, '\n
|
|
|
72
80
|
// An international or parenthesised number may use spaces between its groups; a plain national number only dashes and dots.
|
|
73
81
|
const LOOSE_PHONE = /^[+(]\s?[\d()]{1,6}(?:[-. ]?\(?\d{1,6}\)?){1,5}/;
|
|
74
82
|
const STRICT_PHONE = /^\+?\d{1,4}(?:[-.]\d{1,6}){1,4}/;
|
|
83
|
+
// In a label, a national number may use spaces only (02 555 0142, 010 1234 5678), and a nationwide representative number
|
|
84
|
+
// is 15xx, 16xx or 18xx plus four digits. Neither is read in prose.
|
|
85
|
+
const SPACED_NATIONAL_PHONE = /^0\d{1,2}(?: \d{3,4}){2}(?!\d)/;
|
|
86
|
+
const REPRESENTATIVE_PHONE = /^1[568]\d{2}[- ]\d{4}(?!\d)/;
|
|
75
87
|
/**
|
|
76
88
|
* The number at the start of `rest`, ending at its own last digit group. A spaced number that is followed by more
|
|
77
89
|
* digits or a bracketed digit is ambiguous, so only its dashed or dotted part is read, and a number longer than twelve
|
|
@@ -81,7 +93,14 @@ function readPhone(rest) {
|
|
|
81
93
|
const usable = text => text && (phoneKey(text)?.length ?? 99) <= 13 ? text.trim() : null;
|
|
82
94
|
const loose = LOOSE_PHONE.exec(rest)?.[0].trimEnd();
|
|
83
95
|
if (loose && !/^[ \t]*[(\[]?\d/.test(rest.slice(loose.length)) && usable(loose)) return loose.trim();
|
|
84
|
-
|
|
96
|
+
const strict = usable(STRICT_PHONE.exec(rest)?.[0]);
|
|
97
|
+
if (strict) return strict;
|
|
98
|
+
// Digits straight after a spaced number are ambiguous (02 555 0142 42 seats); a bracket after it is not (hours).
|
|
99
|
+
for (const pattern of [SPACED_NATIONAL_PHONE, REPRESENTATIVE_PHONE]) {
|
|
100
|
+
const match = pattern.exec(rest)?.[0];
|
|
101
|
+
if (match && !/^[ \t]*\d/.test(rest.slice(match.length)) && usable(match)) return match.trim();
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
85
104
|
}
|
|
86
105
|
|
|
87
106
|
/** Facts on one page: visible labelled values and JSON-LD / tel: values, keyed by fact type. */
|
|
@@ -124,10 +143,11 @@ export function pageFacts(html) {
|
|
|
124
143
|
orgs.push(org);
|
|
125
144
|
}
|
|
126
145
|
}
|
|
127
|
-
// A
|
|
146
|
+
// A branch word in the title or first heading is only a hint (titleBranch). A page is a separate entity when its
|
|
147
|
+
// JSON-LD says so (branch), when its URL is a branch list, or when the hint comes with an address of its own.
|
|
128
148
|
const heading = /<(?:title|h1)\b[^>]*>([\s\S]*?)<\/(?:title|h1)>/gi;
|
|
129
149
|
const headings = [...live.matchAll(heading)].map(match => match[1]).join(' ');
|
|
130
|
-
return { visible, linked, tel, orgs, branch:
|
|
150
|
+
return { visible, linked, tel, orgs, branch: orgs.some(org => org.branch), titleBranch: BRANCH.test(headings) };
|
|
131
151
|
}
|
|
132
152
|
|
|
133
153
|
/**
|
|
@@ -150,7 +170,13 @@ export function compareFacts(allPages) {
|
|
|
150
170
|
for (const page of allPages) for (const id of new Set(page.facts.orgs.map(org => org.id).filter(Boolean))) ids.set(id, (ids.get(id) ?? 0) + 1);
|
|
151
171
|
const ranked = [...ids.entries()].sort((a, b) => b[1] - a[1]);
|
|
152
172
|
const mainId = ranked.length && ranked[0][1] >= 2 && (ranked.length === 1 || ranked[0][1] > ranked[1][1]) ? ranked[0][0] : null;
|
|
153
|
-
const
|
|
173
|
+
const structural = page => page.facts.branch || branchPath(page.url) ||
|
|
174
|
+
(mainId && page.facts.orgs.some(org => org.id) && !page.facts.orgs.some(org => org.id === mainId));
|
|
175
|
+
// The main entity's addresses come from the pages with neither structure nor a branch word in the title.
|
|
176
|
+
const mainAddresses = [...new Set(allPages.filter(page => !structural(page) && !page.facts.titleBranch).flatMap(page => [...keysOf(page, 'address')]))];
|
|
177
|
+
// A branch word in the title counts only with a labelled address that is not the main entity's.
|
|
178
|
+
const ownAddress = page => { const own = keysOf(page, 'address'); return own.size > 0 && mainAddresses.length > 0 && ![...own].some(key => mainAddresses.some(main => sameAddress(key, main))); };
|
|
179
|
+
const separate = page => structural(page) || (page.facts.titleBranch && ownAddress(page));
|
|
154
180
|
const pages = allPages.filter(page => !separate(page));
|
|
155
181
|
const references = {};
|
|
156
182
|
const deviations = new Map(allPages.map(page => [page.url, []]));
|
|
@@ -178,12 +178,32 @@ const pathWithin = (value, prefix, host) => {
|
|
|
178
178
|
return wanted === '/' || actual === wanted || actual.startsWith(wanted.endsWith('/') ? wanted : `${wanted}/`);
|
|
179
179
|
};
|
|
180
180
|
|
|
181
|
+
/**
|
|
182
|
+
* A failed fetch has no response, but its answered redirect hops are real requests. They are replayed from the hashed
|
|
183
|
+
* failure record, which also says whether one more attempt got no response.
|
|
184
|
+
*/
|
|
185
|
+
function boundFailure(artifact, audit, requireRef) {
|
|
186
|
+
const entry = (audit.failures ?? []).find(x => x.normalized_url === artifact.normalized_url);
|
|
187
|
+
if (!artifact.failure_ref || !entry || entry.evidence_ref !== artifact.failure_ref) throw new ContractError('failure record missing for failed fetch');
|
|
188
|
+
const raw = requireRef(artifact.failure_ref, 'optbrew:collector');
|
|
189
|
+
if (raw.sha256 !== entry.content_hash) throw new ContractError('failure record hash differs from the audit index');
|
|
190
|
+
const record = JSON.parse(raw.text);
|
|
191
|
+
// At most ten redirects are followed, and the eleventh answer (a refused redirect) is the last hop that can be recorded.
|
|
192
|
+
if (record.kind !== 'failure' || ![0, 1].includes(record.unanswered) || !Array.isArray(record.redirect_chain) || record.redirect_chain.length > 11 ||
|
|
193
|
+
record.redirect_chain.some((hop, index) => typeof hop?.url !== 'string' || !(hop.status >= 300 && hop.status <= 399) ||
|
|
194
|
+
hop.url !== (index ? record.redirect_chain[index - 1].location : record.normalized_url)))
|
|
195
|
+
throw new ContractError('failure record is malformed');
|
|
196
|
+
for (const key of ['requested_url', 'normalized_url', 'redirect_chain', 'unanswered', 'error'])
|
|
197
|
+
if (!same(record[key], artifact[key])) throw new ContractError(`failure record differs from collection record: ${key}`);
|
|
198
|
+
return { ...artifact, ...record };
|
|
199
|
+
}
|
|
200
|
+
|
|
181
201
|
/**
|
|
182
202
|
* Replay a collected response from its hashed record: final URL, redirect chain and header lines come from the
|
|
183
203
|
* record, and the collection entry must agree with it. Bodyless failures have no response to bind.
|
|
184
204
|
*/
|
|
185
205
|
function boundArtifact(artifact, audit, requireRef) {
|
|
186
|
-
if (!Number.isInteger(artifact.status)) return artifact;
|
|
206
|
+
if (!Number.isInteger(artifact.status)) return boundFailure(artifact, audit, requireRef);
|
|
187
207
|
const entry = (audit.responses ?? []).find(x => x.normalized_url === artifact.normalized_url);
|
|
188
208
|
if (!artifact.response_ref || !entry || entry.evidence_ref !== artifact.response_ref)
|
|
189
209
|
throw new ContractError('response record missing for collected artifact');
|
|
@@ -364,15 +384,16 @@ function checkLedger(input, audit, collection, artifacts, requireRef) {
|
|
|
364
384
|
const limit = Math.min(input.run_context.scope?.max_urls ?? 50, 50);
|
|
365
385
|
if (ledger.limit !== limit) fail('request limit differs from the run scope');
|
|
366
386
|
const total = entries.reduce((sum, entry) => sum + entry.requests, 0);
|
|
367
|
-
// The limit
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
387
|
+
// The limit is a hard ceiling: every request, redirect hops included, takes one slot before it is sent.
|
|
388
|
+
if (total > limit) fail('more requests than the limit');
|
|
389
|
+
// One URL, one entry. Skips for out-of-scope and undeclared auxiliary links are recorded per link and may repeat.
|
|
390
|
+
const identities = entries.filter(x => !(x.outcome === 'skipped' && ['outside_scope', 'auxiliary_undeclared'].includes(x.skipped_reason))).map(x => x.url);
|
|
391
|
+
if (new Set(identities).size !== identities.length) fail('duplicate ledger url: a URL appears in more than one entry');
|
|
371
392
|
const responded = artifacts.filter(x => Number.isInteger(x.status));
|
|
372
393
|
const failed = artifacts.filter(x => !Number.isInteger(x.status));
|
|
373
394
|
const byUrl = outcome => new Map(entries.filter(x => x.outcome === outcome).map(x => [x.url, x]));
|
|
374
395
|
const responses = byUrl('response'), errors = byUrl('error');
|
|
375
|
-
if (responses.size !== responded.length || audit.responses.length !== responded.length || errors.size !== failed.length)
|
|
396
|
+
if (responses.size !== responded.length || audit.responses.length !== responded.length || errors.size !== failed.length || audit.failures.length !== failed.length)
|
|
376
397
|
fail('collected artifacts differ from the ledger or the response records');
|
|
377
398
|
for (const artifact of responded) {
|
|
378
399
|
const entry = responses.get(artifact.normalized_url);
|
|
@@ -383,16 +404,16 @@ function checkLedger(input, audit, collection, artifacts, requireRef) {
|
|
|
383
404
|
for (const artifact of failed) {
|
|
384
405
|
const entry = errors.get(artifact.normalized_url);
|
|
385
406
|
if (!entry) fail(`failed fetch of ${artifact.normalized_url} is not recorded`);
|
|
386
|
-
//
|
|
387
|
-
//
|
|
388
|
-
if (entry.requests !==
|
|
407
|
+
// The requests of a failed fetch are the answered hops of its hashed failure record plus at most one attempt that got
|
|
408
|
+
// no response; nothing in collection.json can raise them.
|
|
409
|
+
if (entry.requests !== artifact.redirect_chain.length + artifact.unanswered) fail(`failed fetch of ${artifact.normalized_url} does not match its hashed failure record`);
|
|
389
410
|
}
|
|
390
411
|
const key = x => `${x.url}|${x.reason ?? x.skipped_reason}`;
|
|
391
412
|
const ledgerSkips = entries.filter(x => x.skipped_reason).map(key).sort();
|
|
392
413
|
if (JSON.stringify((collection.skipped ?? []).map(key).sort()) !== JSON.stringify(ledgerSkips)) fail('skipped list differs from the ledger');
|
|
393
414
|
const limited = entries.some(x => ['url_limit', 'request_limit'].includes(x.skipped_reason));
|
|
394
415
|
if (collection.limit_reached !== limited) fail('limit_reached differs from the ledger');
|
|
395
|
-
if (limited && total
|
|
416
|
+
if (limited && total !== limit) fail('a request limit is claimed but the recorded requests do not use the whole limit');
|
|
396
417
|
if (collection.request_count !== total || collection.evaluated_count !== entries.filter(x => x.requests > 0).length) fail('request or evaluated count differs from the ledger');
|
|
397
418
|
const selected = new Set(input.run_context.protocol.page_types.flatMap(type => type.urls.map(x => x.normalized)));
|
|
398
419
|
if (entries.some(x => selected.has(x.url) && x.skipped_reason && x.skipped_reason !== 'url_limit' && x.outcome === 'skipped')) fail('a selected page is skipped for a reason other than the request limit');
|
|
@@ -433,8 +454,8 @@ function checkAuditPhase(input, phase, snapshot, requireRef) {
|
|
|
433
454
|
const savedObservations = JSON.parse(requireRef(audit.observations_ref).text);
|
|
434
455
|
if (!Array.isArray(collection.artifacts) || !Array.isArray(audit.raw) || !Array.isArray(savedObservations))
|
|
435
456
|
throw new ContractError('audit snapshot files incomplete');
|
|
436
|
-
if (!Array.isArray(audit.modules) || !audit.ledger_ref || !Array.isArray(audit.responses))
|
|
437
|
-
throw new ContractError('audit evidence was produced by an older optbrew release (it
|
|
457
|
+
if (!Array.isArray(audit.modules) || !audit.ledger_ref || !Array.isArray(audit.responses) || !Array.isArray(audit.failures))
|
|
458
|
+
throw new ContractError('audit evidence was produced by an older optbrew release (it lacks the request ledger, module list or failure records); re-run the audit with optbrew 0.1.3 and build a new packet');
|
|
438
459
|
for (const name of audit.modules) if (!Array.isArray(moduleOutputs[name])) throw new ContractError(`planned module output missing: ${name}`);
|
|
439
460
|
const naverPlanned = audit.modules.includes('naver-searchadvisor');
|
|
440
461
|
const rawRefs = new Set();
|