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.
@@ -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) decides only whether a new fetch may start: a fetch starts while fewer requests than the limit have been made, and a redirect chain that has started is completed. A chain follows at most min(10, `max_urls`) redirects, so the requests beyond the limit are at most `concurrency` x min(10, `max_urls`) (20 with the defaults). A failed fetch is booked as one request. 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.
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 while the request count is under the 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`; while any collected page has scripts and no anchors, an unlinked page is `unknown` unless a fetched sitemap that lists other pages leaves it out; 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. 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 that presents itself as a branch (the word branch, 지점 or 지사 in its title or first heading, or a branch JSON-LD entity or a different `@id`) is a separate entity and is not compared. 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.1 or earlier has no request ledger or module list and lacks the families added in 0.1.2, so report replay refuses it with an "older optbrew release" error; run the audit again with 0.1.2 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 a request limit is claimed only when the requests recorded reach the limit, and that a selected page is skipped only for the request limit. 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.
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.2.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.2.0', protocol_version: '0.1.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) throw new Error('redirect limit exceeded');
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) { error.redirect_chain ??= [...redirect_chain]; throw 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 decides whether a fetch may start. A redirect chain that has started is allowed to finish (at most
218
- // maxRedirects further requests), so a chain is never cut in the middle by the limit.
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 (!used && requestCount >= limit) return false;
229
+ if (requestCount >= limit) return false;
221
230
  requestCount++; used++;
222
231
  return true;
223
232
  };
224
233
  let artifact;
225
- // A chain is at most min(10, limit) redirects long, so the requests a started chain adds beyond the limit scale with
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
- // A failed fetch has no hashed response record, so the requests it made cannot be proven later: it counts as one.
233
- ledger.push({ url: item.url, requests: 1, outcome: 'error', skipped_reason: reason !== 'fetch_failed' ? reason : null, error: reason });
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: error.redirect_chain ?? [], raw_html_ref: null, content_hash: null, capability: 'none',
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: its links are unknowable.
122
- const scriptOnly = new Set();
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)) scriptOnly.add(canonicalUrl(page.normalized_url));
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
- // A page with scripts and no anchors may link any page from the browser, so while one exists an unlinked page is only
141
- // an orphan when a fetched sitemap that lists other pages leaves it out. Otherwise the verdict stays unknown.
142
- else if (htmlPages.length >= 2 && complete && !sitemapIndex && (!scriptOnly.size || sitemapUrls.size))
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
- return usable(STRICT_PHONE.exec(rest)?.[0]);
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 page that presents itself as a branch has its own address and phone; it is a different entity, so it is not compared.
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: BRANCH.test(headings) || orgs.some(org => org.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 separate = page => page.facts.branch || (mainId && page.facts.orgs.some(org => org.id) && !page.facts.orgs.some(org => org.id === mainId));
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 decides whether a fetch may start; a redirect chain that has started finishes. A chain is at most
368
- // min(10, limit) redirects long and at most `concurrency` chains are in flight, so the overshoot is bounded by both.
369
- const concurrency = Math.min(input.run_context.scope?.concurrency ?? 2, 2);
370
- if (total > limit + concurrency * Math.min(10, limit)) fail('more requests than the limit');
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
- // A failed fetch has no hashed response record, so nothing proves how many requests it made: it is always one.
387
- // Neither the redirect chain nor any count inside collection.json can raise it, so a forger cannot pad the total.
388
- if (entry.requests !== 1) fail(`failed fetch of ${artifact.normalized_url} is not booked as exactly one request`);
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 < limit) fail('a request limit is claimed but fewer requests than the limit were made');
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 has no request ledger or module list); re-run the audit with optbrew 0.1.2 and build a new packet');
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();