optbrew 0.0.0-stage → 0.1.1
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 +6 -0
- package/.claude-plugin/plugin.json +6 -0
- package/.codex-plugin/plugin.json +13 -0
- package/CHANGELOG.md +20 -0
- package/LICENSE +21 -0
- package/README.ko.md +73 -0
- package/README.md +72 -2
- package/THIRD_PARTY_NOTICES.md +10 -0
- package/bin/optbrew.mjs +4 -0
- package/docs/platforms.md +31 -0
- package/docs/release.md +14 -0
- package/lib/cli.mjs +103 -0
- package/lib/doctor.mjs +44 -0
- package/lib/install.mjs +158 -0
- package/lib/paths.mjs +30 -0
- package/lib/records.mjs +44 -0
- package/lib/transaction.mjs +205 -0
- package/lib/update.mjs +181 -0
- package/package.json +39 -4
- package/plugin.json +13 -0
- package/skills/optbrew/LICENSE +21 -0
- package/skills/optbrew/SKILL.md +17 -0
- package/skills/optbrew/THIRD_PARTY_NOTICES.md +10 -0
- package/skills/optbrew/agents/openai.yaml +4 -0
- package/skills/optbrew/bundle-manifest.json +663 -0
- package/skills/optbrew/licenses/firecrawl-seo-audit.txt +15 -0
- package/skills/optbrew/licenses/magnus-seo.txt +21 -0
- package/skills/optbrew/licenses/marketing-search.txt +21 -0
- package/skills/optbrew/licenses/mblode-seo.txt +21 -0
- package/skills/optbrew/licenses/naver-searchadvisor.txt +29 -0
- package/skills/optbrew/licenses/seranking-seo.txt +21 -0
- package/skills/optbrew/modules/firecrawl-seo-audit/GUIDE.md +64 -0
- package/skills/optbrew/modules/magnus-seo/GUIDE.md +79 -0
- package/skills/optbrew/modules/magnus-seo/README.md +45 -0
- package/skills/optbrew/modules/magnus-seo/assets/audit-report-template.md +76 -0
- package/skills/optbrew/modules/magnus-seo/references/aeo-methodology.md +105 -0
- package/skills/optbrew/modules/magnus-seo/references/agent-readable-content.md +28 -0
- package/skills/optbrew/modules/magnus-seo/references/content-and-entity-architecture.md +100 -0
- package/skills/optbrew/modules/magnus-seo/references/content-strategy-seo.md +142 -0
- package/skills/optbrew/modules/magnus-seo/references/discovery-and-freshness.md +47 -0
- package/skills/optbrew/modules/magnus-seo/references/evidence-boundaries.md +75 -0
- package/skills/optbrew/modules/magnus-seo/references/ghost-metadata.md +204 -0
- package/skills/optbrew/modules/magnus-seo/references/implementation-playbook.md +114 -0
- package/skills/optbrew/modules/magnus-seo/references/measurement-and-experimentation.md +47 -0
- package/skills/optbrew/modules/magnus-seo/references/onpage-seo.md +154 -0
- package/skills/optbrew/modules/magnus-seo/references/platform-guidance.md +30 -0
- package/skills/optbrew/modules/magnus-seo/references/schema-markup.md +244 -0
- package/skills/optbrew/modules/magnus-seo/references/source-index.md +36 -0
- package/skills/optbrew/modules/magnus-seo/references/structured-data.md +56 -0
- package/skills/optbrew/modules/magnus-seo/references/technical-seo.md +99 -0
- package/skills/optbrew/modules/magnus-seo/scripts/aeo_audit.py +158 -0
- package/skills/optbrew/modules/magnus-seo/scripts/build_prompt_matrix.py +52 -0
- package/skills/optbrew/modules/magnus-seo/templates/aeo-implementation-plan.md +53 -0
- package/skills/optbrew/modules/magnus-seo/templates/citation-observation-log.md +16 -0
- package/skills/optbrew/modules/magnus-seo/templates/llms.txt.template +15 -0
- package/skills/optbrew/modules/magnus-seo/templates/question-cluster.md +27 -0
- package/skills/optbrew/modules/magnus-seo/templates/robots-ai-crawlers.txt +23 -0
- package/skills/optbrew/modules/marketing-search/skills/ai-seo/GUIDE.md +483 -0
- package/skills/optbrew/modules/marketing-search/skills/ai-seo/references/agent-readiness.md +57 -0
- package/skills/optbrew/modules/marketing-search/skills/ai-seo/references/citations-vs-recommendations.md +85 -0
- package/skills/optbrew/modules/marketing-search/skills/ai-seo/references/content-patterns.md +287 -0
- package/skills/optbrew/modules/marketing-search/skills/ai-seo/references/content-types.md +71 -0
- package/skills/optbrew/modules/marketing-search/skills/ai-seo/references/format-volatility.md +98 -0
- package/skills/optbrew/modules/marketing-search/skills/ai-seo/references/okf.md +104 -0
- package/skills/optbrew/modules/marketing-search/skills/ai-seo/references/platform-ranking-factors.md +154 -0
- package/skills/optbrew/modules/marketing-search/skills/ai-seo/references/youtube-ai-citations.md +60 -0
- package/skills/optbrew/modules/marketing-search/skills/analytics/GUIDE.md +303 -0
- package/skills/optbrew/modules/marketing-search/skills/analytics/references/event-library.md +260 -0
- package/skills/optbrew/modules/marketing-search/skills/analytics/references/ga4-implementation.md +300 -0
- package/skills/optbrew/modules/marketing-search/skills/analytics/references/gtm-implementation.md +390 -0
- package/skills/optbrew/modules/marketing-search/skills/content-strategy/GUIDE.md +432 -0
- package/skills/optbrew/modules/marketing-search/skills/content-strategy/references/content-distribution.md +83 -0
- package/skills/optbrew/modules/marketing-search/skills/content-strategy/references/headless-cms.md +194 -0
- package/skills/optbrew/modules/marketing-search/skills/programmatic-seo/GUIDE.md +231 -0
- package/skills/optbrew/modules/marketing-search/skills/programmatic-seo/references/playbooks.md +308 -0
- package/skills/optbrew/modules/marketing-search/skills/schema/GUIDE.md +172 -0
- package/skills/optbrew/modules/marketing-search/skills/schema/references/schema-examples.md +398 -0
- package/skills/optbrew/modules/marketing-search/skills/seo-audit/GUIDE.md +492 -0
- package/skills/optbrew/modules/marketing-search/skills/seo-audit/references/ai-writing-detection.md +200 -0
- package/skills/optbrew/modules/marketing-search/skills/seo-audit/references/international-seo.md +230 -0
- package/skills/optbrew/modules/marketing-search/skills/site-architecture/GUIDE.md +350 -0
- package/skills/optbrew/modules/marketing-search/skills/site-architecture/references/mermaid-templates.md +216 -0
- package/skills/optbrew/modules/marketing-search/skills/site-architecture/references/navigation-patterns.md +305 -0
- package/skills/optbrew/modules/marketing-search/skills/site-architecture/references/site-type-templates.md +293 -0
- package/skills/optbrew/modules/mblode-seo/GUIDE.md +74 -0
- package/skills/optbrew/modules/mblode-seo/references/answer-engines.md +58 -0
- package/skills/optbrew/modules/mblode-seo/references/audit.md +78 -0
- package/skills/optbrew/modules/mblode-seo/references/brief-template.md +47 -0
- package/skills/optbrew/modules/mblode-seo/references/monitoring.md +56 -0
- package/skills/optbrew/modules/mblode-seo/references/nextjs-implementation.md +65 -0
- package/skills/optbrew/modules/mblode-seo/references/research-protocol.md +37 -0
- package/skills/optbrew/modules/mblode-seo/references/sources.md +47 -0
- package/skills/optbrew/modules/mblode-seo/references/validation-evidence.md +21 -0
- package/skills/optbrew/modules/naver-searchadvisor/GUIDE.md +75 -0
- package/skills/optbrew/modules/naver-searchadvisor/NOTICE.md +59 -0
- package/skills/optbrew/modules/naver-searchadvisor/references/INDEX.md +97 -0
- package/skills/optbrew/modules/naver-searchadvisor/scripts/audit.mjs +714 -0
- package/skills/optbrew/modules/naver-searchadvisor/scripts/robots.mjs +77 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-ai-search-share-of-voice/GUIDE.md +94 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-api/GUIDE.md +186 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-api/references/api-surface-map.md +207 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-api/references/auth-and-keys.md +182 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-api/references/integration-patterns.md +283 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-api/references/rate-limits-and-credits.md +235 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-competitor-gap-analysis/GUIDE.md +117 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-content-audit/GUIDE.md +155 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-content-audit/references/cite.md +84 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-content-audit/references/core-eeat.md +147 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-content-audit/templates/verdict.md +63 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-firecrawl/GUIDE.md +141 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-firecrawl/references/preflight.md +73 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-geo/GUIDE.md +159 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-google/GUIDE.md +393 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-google/assets/templates/cwv-audit-report.md +48 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-google/assets/templates/gsc-performance-report.md +44 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-google/assets/templates/indexation-status-report.md +43 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-google/references/auth-setup.md +160 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-google/references/cross-skill-integration.md +187 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-google/references/ga4-data-api.md +184 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-google/references/indexing-api.md +109 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-google/references/keyword-planner-api.md +66 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-google/references/nlp-api.md +55 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-google/references/pagespeed-crux-api.md +204 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-google/references/rate-limits-quotas.md +75 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-google/references/search-console-api.md +156 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-google/references/supplementary-apis.md +99 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-google/references/youtube-api.md +49 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-keyword-niche/GUIDE.md +174 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-page/GUIDE.md +174 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-plan/GUIDE.md +220 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-schema/GUIDE.md +135 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-schema/references/google-rich-results.md +89 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-schema/templates/article.json +23 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-schema/templates/breadcrumb-list.json +18 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-schema/templates/faq-page.json +22 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-schema/templates/local-business.json +32 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-schema/templates/product.json +25 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-sitemap/GUIDE.md +128 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-technical-audit/GUIDE.md +235 -0
- package/skills/optbrew/modules/seranking-seo/skills/seo-technical-audit/references/severity-mapping.md +133 -0
- package/skills/optbrew/references/api.md +45 -0
- package/skills/optbrew/references/check-registry.json +28 -0
- package/skills/optbrew/references/evidence.md +9 -0
- package/skills/optbrew/references/intake.md +9 -0
- package/skills/optbrew/references/measurement.md +29 -0
- package/skills/optbrew/references/policies.md +7 -0
- package/skills/optbrew/references/profiles.json +13 -0
- package/skills/optbrew/references/remediations.json +12 -0
- package/skills/optbrew/references/report.md +25 -0
- package/skills/optbrew/references/routing.md +15 -0
- package/skills/optbrew/references/schemas/contract.schema.json +51 -0
- package/skills/optbrew/references/scoring.md +62 -0
- package/skills/optbrew/scripts/adapters/anthropic.mjs +116 -0
- package/skills/optbrew/scripts/adapters/imports.mjs +81 -0
- package/skills/optbrew/scripts/audit.mjs +145 -0
- package/skills/optbrew/scripts/cli.mjs +41 -0
- package/skills/optbrew/scripts/core/bootstrap.mjs +31 -0
- package/skills/optbrew/scripts/core/bundle.mjs +73 -0
- package/skills/optbrew/scripts/core/capabilities.mjs +19 -0
- package/skills/optbrew/scripts/core/collector.mjs +236 -0
- package/skills/optbrew/scripts/core/compare.mjs +47 -0
- package/skills/optbrew/scripts/core/contracts.mjs +144 -0
- package/skills/optbrew/scripts/core/cost-ledger.mjs +95 -0
- package/skills/optbrew/scripts/core/findings.mjs +54 -0
- package/skills/optbrew/scripts/core/metrics.mjs +97 -0
- package/skills/optbrew/scripts/core/protocol.mjs +125 -0
- package/skills/optbrew/scripts/core/report.mjs +652 -0
- package/skills/optbrew/scripts/core/scope.mjs +52 -0
- package/skills/optbrew/scripts/core/score.mjs +71 -0
- package/skills/optbrew/scripts/core/weights.mjs +139 -0
- package/skills/optbrew/source-lock.json +2686 -0
- package/source-lock.json +2686 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Report packet and evidence contract (v0.1.0)
|
|
2
|
+
|
|
3
|
+
`node skills/optbrew/scripts/cli.mjs report --input <packet.json> --output-dir <new-directory>` writes `report.json` and `report.md`. It makes no network or paid calls. It refuses to replace an existing report. A report is a local audit artifact; a local passing verification does not establish deployment, indexing, traffic, ranking, citations, or business impact.
|
|
4
|
+
|
|
5
|
+
`ReportPacket` retains the approved `run_context`, raw `baseline.input`, optional `after.input`, their `score()` results, `compare()` result, metrics, findings, changes, costs, remaining work, and source identity. `evidence_root` points to the run directory. The CLI resolves a relative root against the packet's directory. Each `evidence_index` item has `{ref,sha256,kind,run_id,source_ref?,source_url?}`. Every indexed file is reopened, checked against its SHA-256, and contained by the resolved run directory. The context file at each phase's `run_context.evidence_ref` must contain exactly that run context.
|
|
6
|
+
|
|
7
|
+
An applicability `evidence_ref` points to JSON `{run_id,target,protocol_hash,approved_by,decisions}`. Each decision is `{scope:"family",family_id,condition,reason}`, `{scope:"url",normalized_url,condition,reason}`, or `{scope:"unit",unit_key,condition,reason}`. A matching record identifies the decision and its approval context; it does not independently prove the underlying site fact. Missing or unrelated decisions are contract errors. Unknown checks remain in the denominator.
|
|
8
|
+
|
|
9
|
+
Collector phases use `audit_snapshots:[{phase:"baseline"|"after",audit_ref,context_ref}]`, one Task 5 evidence index per phase. A baseline-only packet may use legacy `audit_ref`. The audit's run/context, observation file, collection file, module output, artifact `evidence_ref` and non-null raw body hash must match that phase. A bodyless DNS/timeout artifact has `content_hash:null`, points to the collection file, and remains `unknown`; it is not compared to the collection file hash as though it were a body. Same-URL before/after observations must use their own snapshot and raw ref.
|
|
10
|
+
|
|
11
|
+
Owner export metrics replay through `normalizeImport()` from evidence JSON `{kind:"native_export",run_id,provider,rows,metadata}`. `run_context.protocol.target_identity` is a comparison-relevant, hashed field. It contains `{target,native_properties:[{provider,property,hostname?,path_prefix?,evidence_ref}],panel?}`. Each native mapping ref points to a run/target/provider/property/approval record. Known row hostname and path must fit its approved mapping. The native metadata supplies `collected_at` with offset and `last_complete_date` in the report timezone; a pair needs complete, adjacent 28-day windows, identical definition, provider definition, full sample selection, filters, panel and timezone. Missing completeness or a changed cohort yields no delta. Imported provider origin and completeness remain attributed to the owner's export.
|
|
12
|
+
|
|
13
|
+
AI panel metrics replay through `measurePanel()` from `{kind:"panel",run_id,provider_origin,panel,responses,evaluations}`. Frozen `target_identity.panel` is `{target_entity,target_domains,competitor_entities,evidence_ref}` and has a matching approved mapping file. Questions, engine, surface, model, search use, locale, region and target identity must match the frozen run. Every response has `response_ref` (indexed redacted provider response) and `extraction_ref` (indexed `{run_id,response_id,raw_ref,raw_sha256,normalized_response}` receipt). Every evaluation has `evidence_ref` for an indexed `{run_id,response_id,approved_by,evaluation}` receipt. Evaluable citations have `source_evidence_ref` whose index entry `source_url` equals the cited URL. Evaluable claims have `fact_id` and `source_evidence_ref`, and their `panel.fact_list_ref` opens an approved `{run_id,target_entity,approved_by,facts:[{fact_id,...}]}` list. Missing material is a contract error. These links preserve attribution; they do not independently authenticate a provider or prove factual truth.
|
|
14
|
+
|
|
15
|
+
The persisted `CostLedger` snapshot is indexed at `costs.evidence_ref`; unknown settlement, observed overrun and `host_policy` limitations stay visible. Do not include secrets, account tokens, personal records, or private card text in a packet or evidence directory.
|
|
16
|
+
|
|
17
|
+
Findings merge only when check, unit and normalized problem text agree. Every contributing source remains on the merged finding. A finding must match a failed or unknown check observation. The curated remediation catalog chooses only an applicable, directly relevant mechanism; official sources lead the evidence order. A remote finding has a proposed change location and validation method but remains `proposed_only`. A `ChangeRecord` receipt binds run ID, change ID, target, path, environment, after hash, status, and passing checks whose indexed changed bytes have that hash. Production also requires a deployment receipt for the same target/path/hash and an explicitly linked passing `after` observation. Every required linked change must pass; local pass plus production pending remains pending. Receipts and hashes establish this local evidence chain, not external deployment truth. No SEO gain is predicted. Hypothetical readiness changes belong in a separately marked scenario, never in `after`.
|
|
18
|
+
|
|
19
|
+
For `verification.status:"passed"`, the indexed receipt has `{run_id,change_id,target,path,environment,after_hash,status,checks:[{id,status:"passed",observed,evidence_ref,after_hash}]}`. Each check's ref opens the changed bytes. Production adds `deployed:true`, `deployment:{target,path,after_hash,evidence_ref}`, and `observation_id` / `observation_evidence_ref` in both receipt and `ChangeRecord.verification`. The deployment evidence file repeats run, target, path, hash, environment `production` and `confirmed_by`. The matching `after` observation must resolve the linked finding in a run context with the same target and `production` environment. Its indexed evidence entry also identifies `phase:"after"`, that target and `environment:"production"`; absent or different attribution leaves the finding unverified. Other comparison-condition changes may still yield `not_comparable` without invalidating a same-target verification. A pending receipt cannot combine with another passed change to finish a finding.
|
|
20
|
+
|
|
21
|
+
Collector verdicts are replayed, not trusted. For each collector-sourced `index-policy`, `document-title` and `search-crawl-policy` observation the report reopens the preserved raw bytes of the same phase and rebuilds the verdict with the code the audit used. Index directives come from the HTML `<head>` meta tags (comments and script, style, textarea and title text are ignored) and the collected `X-Robots-Tag` header. The generic `robots` directives combine with the target engine's own (`googlebot` for Google, `yeti` for Naver); a header prefixed with another crawler name does not apply, and a crawler prefix scopes only the header line that carries it. Meta directives may be separated by commas or semicolons. A robots meta tag inside `<template>` or `<noscript>` is conservatively counted as a noindex directive. A present title replays as `unknown` because uniqueness and descriptiveness need separate evidence; a missing title replays as `fail`; reviewed duplicate-title or semantic verdicts from other evaluators are not rejected for having a title. A crawl-policy verdict needs the declared `robots` auxiliary artifact, its content hash, the page's final URL and a target engine, then recomputes `pass` or `fail` from the robots bytes. Every collected response is replayed from its hashed `*.response.json` record: the collection entry must equal the record, the final URL must follow the recorded redirect chain (no redirect means the final URL is the normalized request URL), and header lines come from the record, so index every `responses[].evidence_ref` from the audit index with `source_ref:"optbrew:collector"`. A selected page must be either collected or listed in `skipped`, and an indexed collector artifact requires its baseline audit snapshot. Every observation the collector plans for a selected page (fetch, title, index and, for Naver with a disallowing declared robots file, crawl policy) must be present in the packet and in the saved observations; a missing one is an error, never a silent pass. A redirected URL binds its requested unit to the artifact first; the module output must describe that artifact's final URL (`requested_url` and `redirect_chain` stay with it). A header-only `noindex` finding uses the same HTML plus header replay as the observation and must belong to the finding's own unit.
|
|
22
|
+
|
|
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
|
+
|
|
25
|
+
Both formats show four tables: score, remediation, actual outcomes, and remaining work. Score arithmetic comes from `score()` and `compare()`. Actual outcome pairs require the same native metric definition, filters, sample selection and panel, plus two adjacent complete 28-day windows. An absent or ambiguous value stays unknown. Markdown escapes pipes, HTML and line breaks in evidence and titles. `data_class:synthetic` labels fixture reports and cannot be presented as observed production performance.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Situation routing
|
|
2
|
+
|
|
3
|
+
필요한 분기의 GUIDE만 연다. 모든 모듈 출력은 관찰 후보이며 한 URL·문제·검사는 한 번만 채점한다. 무료 경로는 허용된 공개 HTTP/HTML과 사용 가능한 호스트 도구를 사용한다. 원본 HTML, 렌더, 소유자 패널과 AI 제공자 응답의 출처를 각각 기록한다.
|
|
4
|
+
|
|
5
|
+
| 상황 | 주력 | 보완·입력 | 산출물 |
|
|
6
|
+
|---|---|---|---|
|
|
7
|
+
| 기술 차단·색인 | [mblode audit](../modules/mblode-seo/references/audit.md), [marketing SEO audit](../modules/marketing-search/skills/seo-audit/GUIDE.md) | 네이버 목표면 [Naver](../modules/naver-searchadvisor/GUIDE.md); HTTP·원본 HTML·허용된 렌더·소유자 URL 검사 | 차단·오류·unknown과 근거 |
|
|
8
|
+
| URL·템플릿 구조 | [mblode](../modules/mblode-seo/GUIDE.md), [site architecture](../modules/marketing-search/skills/site-architecture/GUIDE.md) | 대량 생성 요청일 때만 [programmatic SEO](../modules/marketing-search/skills/programmatic-seo/GUIDE.md) | 고정 URL 표본·링크·템플릿 개선안 |
|
|
9
|
+
| 페이지 수집 | [Firecrawl SEO audit](../modules/firecrawl-seo-audit/GUIDE.md) | 같은 경계와 비용을 보장하는 호스트 HTTP/브라우저 기능 | 원본·렌더 구분 및 raw 참조 |
|
|
10
|
+
| 질문·답변·엔티티 | [Magnus](../modules/magnus-seo/GUIDE.md), [AI SEO](../modules/marketing-search/skills/ai-seo/GUIDE.md) | [content strategy](../modules/marketing-search/skills/content-strategy/GUIDE.md), [schema](../modules/marketing-search/skills/schema/GUIDE.md); 고정 질문·사실 목록 | 근거 있는 답변·사실 정정안 |
|
|
11
|
+
| 네이버 노출 | [Naver](../modules/naver-searchadvisor/GUIDE.md) | mblode 기술 근거, Yeti 응답·마크업·Search Advisor 소유자 자료 | 네이버 범위의 관찰과 unknown |
|
|
12
|
+
| AI 인용·경쟁 비교 | [SE Ranking](../modules/seranking-seo/skills/seo-ai-search-share-of-voice/GUIDE.md) | 승인된 API 또는 날짜가 있는 내보내기, Magnus 질문 프로토콜 | 엔진별 인용 실측; 무료 감사의 필수 조건 아님 |
|
|
13
|
+
| 유입·전환 비교 | [mblode monitoring](../modules/mblode-seo/references/monitoring.md), [analytics](../modules/marketing-search/skills/analytics/GUIDE.md) | 같은 소유 속성·필터·완결된 28일 창 | 실제 성과와 비교 가능성 |
|
|
14
|
+
|
|
15
|
+
호스트 도구의 경계·비용 강제가 확인되지 않으면 사용하지 않는다. 직접 Anthropic v0.1.0은 [api.md](api.md)의 텍스트 전용 Messages 형태와 정확한 모델 ID 네 개만 지원한다. 다른 API와 SE Ranking 실행은 각자의 승인·상한·능력 근거가 있어야 한다. 호스트 정책만 비용을 강제하면 그 한계를 보고한다.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://optbrew.local/schemas/contract.schema.json",
|
|
4
|
+
"title": "optbrew persisted entities v1.0.0",
|
|
5
|
+
"$defs": {
|
|
6
|
+
"ApplicabilityDecision": { "type": "object", "additionalProperties": false, "required": ["condition", "reason", "evidence_ref"], "properties": { "condition": { "type": "string" }, "reason": { "type": "string" }, "evidence_ref": { "type": "string" } } },
|
|
7
|
+
"UrlRef": { "type": "object", "additionalProperties": false, "required": ["original", "normalized"], "properties": { "original": { "type": "string", "format": "uri" }, "normalized": { "type": "string", "format": "uri" }, "public_indexing": { "type": "boolean" }, "indexing_decision": { "$ref": "#/$defs/ApplicabilityDecision" } } },
|
|
8
|
+
"AuxiliaryResource": { "type": "object", "additionalProperties": false, "required": ["kind", "original", "normalized"], "properties": { "kind": { "enum": ["robots", "sitemap", "policy"] }, "original": { "type": "string", "format": "uri" }, "normalized": { "type": "string", "format": "uri" } } },
|
|
9
|
+
"PageType": { "type": "object", "required": ["id", "urls"], "properties": { "id": { "type": "string" }, "urls": { "type": "array", "items": { "$ref": "#/$defs/UrlRef" } } } },
|
|
10
|
+
"FamilyPlan": { "type": "object", "additionalProperties": false, "required": ["family_id", "applicable"], "properties": { "family_id": { "type": "string" }, "applicable": { "type": "boolean" }, "urls": { "type": "array", "items": { "type": "string" } }, "engines": { "type": "array", "items": { "type": "string" } }, "applicability_decision": { "$ref": "#/$defs/ApplicabilityDecision" } } },
|
|
11
|
+
"Question": { "type": "object", "required": ["id", "question", "category"], "properties": { "id": { "type": "string" }, "question": { "type": "string" }, "category": { "enum": ["brand", "non_brand"] }, "goal": { "type": "string" } } },
|
|
12
|
+
"Protocol": { "type": "object", "additionalProperties": false, "required": ["page_types", "engines", "families", "locale", "region", "browser", "device", "question_panel", "surfaces", "models", "search_enabled"], "properties": {
|
|
13
|
+
"page_types": { "type": "array", "items": { "$ref": "#/$defs/PageType" } }, "engines": { "type": "array", "items": { "type": "string" } }, "families": { "type": "array", "items": { "$ref": "#/$defs/FamilyPlan" } },
|
|
14
|
+
"auxiliary_resources": { "type": "array", "items": { "$ref": "#/$defs/AuxiliaryResource" } },
|
|
15
|
+
"locale": { "type": "string" }, "region": { "type": "string" }, "browser": { "type": "string" }, "device": { "type": "string" }, "question_panel": { "type": "array", "items": { "$ref": "#/$defs/Question" } }, "surfaces": { "type": "array" }, "models": { "type": "array" }, "search_enabled": { "type": ["boolean", "null"] },
|
|
16
|
+
"target": { "type": ["string", "object"] }, "environment": { "type": "string" }, "evaluation_version": { "type": "string" }, "protocol_version": { "type": "string" }, "target_identity": { "type": "object" }
|
|
17
|
+
} },
|
|
18
|
+
"RunContext": { "type": "object", "required": ["schema_version", "run_id", "target", "environment", "goals", "allowed_changes", "scope", "protocol", "evaluation_version", "protocol_version", "data_sources", "cost_decision", "evidence_ref"], "properties": {
|
|
19
|
+
"schema_version": { "const": "1.0.0" }, "run_id": { "type": "string" }, "target": { "type": ["string", "object"] }, "environment": { "type": "string" }, "goals": { "type": "array" }, "allowed_changes": { "type": "array" }, "scope": { "type": "object" }, "protocol": { "$ref": "#/$defs/Protocol" }, "evaluation_version": { "type": "string" }, "protocol_version": { "type": "string" }, "data_sources": { "type": "array" }, "cost_decision": { "type": "object" }, "evidence_ref": { "type": "string" }
|
|
20
|
+
} },
|
|
21
|
+
"CheckObservation": { "type": "object", "required": ["schema_version", "observation_id", "check_id", "family_id", "unit_key", "state", "q", "partial_basis", "reason", "evaluator_type", "source_ref", "evidence_ref"], "properties": {
|
|
22
|
+
"schema_version": { "const": "1.0.0" }, "observation_id": { "type": "string" }, "check_id": { "type": "string" }, "family_id": { "type": "string" }, "unit_key": { "type": "string" }, "state": { "enum": ["pass", "partial", "fail", "unknown", "not_applicable"] }, "q": { "type": ["number", "null"], "minimum": 0, "maximum": 1 }, "partial_basis": { "type": ["object", "null"] }, "reason": { "type": "string" }, "evaluator_type": { "enum": ["human", "host_model", "external_model", "code"] }, "source_ref": { "type": "string" }, "evidence_ref": { "type": "string" }, "applicability_decision": { "$ref": "#/$defs/ApplicabilityDecision" }, "blocker": { "type": "boolean" }, "measurement": { "type": "object" }
|
|
23
|
+
} },
|
|
24
|
+
"MetricObservation": { "type": "object", "required": ["schema_version", "metric_id", "value_state", "value", "numerator", "denominator", "definition", "filters", "window", "panel_hash", "provider_definition", "sample_selection", "source_ref", "evidence_ref"], "properties": {
|
|
25
|
+
"schema_version": { "const": "1.0.0" }, "metric_id": { "type": "string" }, "value_state": { "enum": ["measured", "unknown", "zero_or_missing", "not_applicable"] }, "value": { "type": ["number", "null"] }, "numerator": { "type": ["number", "null"] }, "denominator": { "type": ["number", "null"] }, "definition": { "type": "string" }, "filters": { "type": "object" }, "window": { "$ref": "#/$defs/MetricWindow" }, "panel_hash": { "type": ["string", "null"] }, "provider_definition": { "type": "string" }, "sample_selection": { "type": "object" }, "source_ref": { "type": "string" }, "evidence_ref": { "type": "string" }
|
|
26
|
+
} },
|
|
27
|
+
"MetricWindow": { "type": "object", "required": ["start", "end", "timezone"], "properties": { "start": { "type": "string" }, "end": { "type": "string" }, "timezone": { "type": "string" } } },
|
|
28
|
+
"ComparisonResult": { "type": "object", "required": ["status", "score_deltas", "reasons"], "properties": { "status": { "enum": ["comparable", "not_comparable", "no_after"] }, "score_deltas": { "type": "array" }, "reasons": { "type": "array", "items": { "type": "string" } } } },
|
|
29
|
+
"Finding": { "type": "object", "required": ["schema_version", "finding_id", "check_id", "unit_key", "priority", "blocker", "message", "source_ref", "evidence_ref", "remediation_ids", "status"], "properties": {
|
|
30
|
+
"schema_version": { "const": "1.0.0" }, "finding_id": { "type": "string" }, "check_id": { "type": "string" }, "unit_key": { "type": "string" }, "priority": { "type": "string" }, "blocker": { "type": "boolean" }, "message": { "type": "string" }, "source_ref": { "type": "string" }, "evidence_ref": { "type": "string" }, "remediation_ids": { "type": "array" }, "status": { "type": "string" }
|
|
31
|
+
} },
|
|
32
|
+
"ChangeRecord": { "type": "object", "required": ["schema_version", "change_id", "finding_ids", "environment", "path", "before_hash", "after_hash", "backup_ref", "verification", "evidence_ref"], "properties": {
|
|
33
|
+
"schema_version": { "const": "1.0.0" }, "change_id": { "type": "string" }, "finding_ids": { "type": "array" }, "environment": { "type": "string" }, "path": { "type": "string" }, "before_hash": { "type": "string" }, "after_hash": { "type": "string" }, "backup_ref": { "type": "string" }, "verification": { "type": "object" }, "evidence_ref": { "type": "string" }
|
|
34
|
+
} },
|
|
35
|
+
"CostLedger": { "type": "object", "required": ["schema_version", "run_id", "budgets", "reservations", "settlements", "approval_ref", "source_ref"], "properties": {
|
|
36
|
+
"schema_version": { "const": "1.0.0" }, "run_id": { "type": "string" }, "budgets": { "type": "array" }, "reservations": { "type": "array" }, "settlements": { "type": "array" }, "approval_ref": { "type": ["string", "null"] }, "source_ref": { "type": "string" }
|
|
37
|
+
} },
|
|
38
|
+
"ScoreResult": { "type": "object", "required": ["schema_version", "evaluation_version", "profile", "A", "M", "N", "score", "coverage", "missing_bounds", "band", "readiness_status", "blockers", "unknown_core", "source_ref"], "properties": {
|
|
39
|
+
"schema_version": { "const": "1.0.0" }, "evaluation_version": { "type": "string" }, "profile": { "type": "string" }, "A": { "type": "number" }, "M": { "type": "number" }, "N": { "type": "number" }, "score": { "type": ["number", "null"] }, "coverage": { "type": ["number", "null"] }, "missing_bounds": { "type": ["array", "null"] }, "band": { "type": ["string", "null"] }, "readiness_status": { "enum": ["blocked", "unverified", "provisional", "measured", "not_applicable"] }, "blockers": { "type": "array" }, "unknown_core": { "type": "array" }, "source_ref": { "type": "string" }
|
|
40
|
+
} },
|
|
41
|
+
"ScoreInput": { "type": "object", "required": ["schema_version", "run_context", "observations"], "properties": {
|
|
42
|
+
"schema_version": { "const": "1.0.0" }, "run_context": { "$ref": "#/$defs/RunContext" }, "observations": { "type": "array", "items": { "$ref": "#/$defs/CheckObservation" } }
|
|
43
|
+
} },
|
|
44
|
+
"ReportPacket": { "type": "object", "required": ["schema_version", "run_context", "baseline", "after", "comparison", "metrics", "findings", "changes", "costs", "remaining", "evidence_root", "evidence_index", "source_ref"], "properties": {
|
|
45
|
+
"schema_version": { "const": "1.0.0" }, "run_context": { "$ref": "#/$defs/RunContext" }, "baseline": { "type": "object", "required": ["input", "scores"], "properties": { "input": { "$ref": "#/$defs/ScoreInput" }, "scores": { "type": "array", "items": { "$ref": "#/$defs/ScoreResult" } } } }, "after": { "type": ["object", "null"] }, "comparison": { "$ref": "#/$defs/ComparisonResult" }, "metrics": { "type": "array", "items": { "$ref": "#/$defs/MetricObservation" } }, "findings": { "type": "array", "items": { "$ref": "#/$defs/Finding" } }, "changes": { "type": "array", "items": { "$ref": "#/$defs/ChangeRecord" } }, "costs": { "$ref": "#/$defs/CostLedger" }, "remaining": { "type": "array" }, "evidence_root": { "type": "string" }, "evidence_index": { "type": "array", "items": { "type": "object", "required": ["ref", "sha256", "kind", "run_id"], "properties": { "ref": { "type": "string" }, "sha256": { "type": "string" }, "kind": { "type": "string" }, "run_id": { "type": "string" }, "source_ref": { "type": "string" }, "source_url": { "type": "string" } } } }, "audit_ref": { "type": "string" }, "audit_snapshots": { "type": "array" }, "source_ref": { "type": "string" }, "data_class": { "enum": ["synthetic", "real", "unknown"] }, "capabilities": { "type": "object" }
|
|
46
|
+
} },
|
|
47
|
+
"InstallRecord": { "type": "object", "required": ["schema_version", "scope_id", "release_version", "bundle_hash", "source", "managed_root", "targets", "file_hashes", "previous_releases", "transaction", "source_ref"], "properties": {
|
|
48
|
+
"schema_version": { "const": "1.0.0" }, "scope_id": { "type": "string" }, "release_version": { "type": "string" }, "bundle_hash": { "type": "string" }, "source": { "type": "string" }, "managed_root": { "type": "string" }, "targets": { "type": "array" }, "file_hashes": { "type": "object" }, "previous_releases": { "type": "array" }, "transaction": { "type": "object" }, "source_ref": { "type": "string" }
|
|
49
|
+
} }
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# optbrew readiness scoring contract (evaluation 0.1.0)
|
|
2
|
+
|
|
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
|
+
|
|
5
|
+
## Fixed input
|
|
6
|
+
|
|
7
|
+
`ScoreInput` is `{schema_version:"1.0.0",run_context:RunContext,observations:CheckObservation[]}`. The JSON Schema in `schemas/contract.schema.json` names this and the nine persisted entities. `validateEntity(typeName,value)` returns the value or throws `ContractError`. `freezeProtocol(run_context)` returns `{protocol,protocol_hash}`. Its hash covers the selected URLs and page types, indexing intent, family condition codes, engines, location/language, panel and surface/model/search settings, browser/device, target, environment and separate evaluation/protocol versions. It excludes `run_id`, evidence paths, collection times and explanatory decision text. Changing a comparison condition requires re-evaluation from preserved raw data or `not_comparable`.
|
|
8
|
+
|
|
9
|
+
Every `run_context.protocol.families` entry is `{family_id,applicable,urls?,engines?,applicability_decision?}`. A `ScoreInput` must explicitly decide all 22 entries. False requires `{condition,reason,evidence_ref}` with a condition allowed for that family in `check-registry.json`; true forbids a decision. The reason and evidence reference must be nonempty. Unknown facts or missing collection data leave applicable units `unknown`; they do not remove denominator weight. `urls` and `engines`, when supplied, must equal the entire applicable selected set. To exclude one optional unit, submit an evidence-backed `not_applicable` observation for that unit. A subset in a family plan is rejected. NEO is reported only for a Naver target.
|
|
10
|
+
|
|
11
|
+
Every selected URL is a public audit target and always gets `fetch-response`. Its optional `public_indexing` defaults to true. An intentional non-index target sets `public_indexing:false` and supplies `indexing_decision:{condition:"not_public_index_target",reason,evidence_ref}`. Public-index targets automatically get `index-policy` and `discovery`; target engines also get `search-crawl-policy`. Naver as a target automatically activates `naver-markup`. Bare flags cannot turn these families off. A selected URL still gets `fetch-response` if intentionally non-indexed. An unreachable, uncollected or unclear check is `unknown`, never N/A.
|
|
12
|
+
|
|
13
|
+
`check-registry.json` fixes every family's `verdict_mode` and `na_conditions`. The family-level exclusion and unit N/A condition codes are:
|
|
14
|
+
|
|
15
|
+
| Family | Allowed condition codes |
|
|
16
|
+
|---|---|
|
|
17
|
+
| fetch-response | none |
|
|
18
|
+
| search-crawl-policy | `no_public_index_target`, `no_target_engine` (whole family only) |
|
|
19
|
+
| index-policy, discovery | `no_public_index_target` (whole family only) |
|
|
20
|
+
| render-content | `not_render_dependent` |
|
|
21
|
+
| document-title | `not_html` |
|
|
22
|
+
| canonical-consistency | `no_representative_url_policy` |
|
|
23
|
+
| internal-navigation | `not_link_based` |
|
|
24
|
+
| structured-data-truth | `markup_not_used_or_selected` |
|
|
25
|
+
| locale-links | `single_locale` |
|
|
26
|
+
| naver-markup | `naver_not_target` (whole family only), `not_html` (unit) |
|
|
27
|
+
| lcp-field, inp-field, cls-field | `outside_performance_scope` |
|
|
28
|
+
| question-coverage | `no_fixed_questions` (whole family only) |
|
|
29
|
+
| claim-support | `no_material_claims` |
|
|
30
|
+
| answer-clarity | `no_matching_questions` |
|
|
31
|
+
| original-value | `simple_contact_or_support` |
|
|
32
|
+
| entity-identity | `no_identified_entity` (whole family only) |
|
|
33
|
+
| fact-consistency | `no_evaluable_facts` |
|
|
34
|
+
| freshness-truth | `no_date_or_currentness` |
|
|
35
|
+
| source-transparency | `not_required_for_purpose` |
|
|
36
|
+
|
|
37
|
+
These codes identify a condition and point to evidence; the reference alone does not verify the evidence's truth. The collecting agent must preserve the actual evidence. A missing approved fact list for `fact-consistency`, or missing field performance data, is `unknown`, not an exclusion.
|
|
38
|
+
|
|
39
|
+
`question_panel` contains fixed `{id,question,category,goal?}` entries. Category is `brand` or `non_brand`; IDs are unique. `question-coverage` and `answer-clarity` require a nonempty panel. A partial `question-coverage` ratio uses the fixed panel length as its denominator.
|
|
40
|
+
|
|
41
|
+
Each page type has a stable ID and an array of `{original,normalized}` HTTP(S) URLs. Normalized URLs must be unique across types. Unit keys are deterministic:
|
|
42
|
+
|
|
43
|
+
| Unit scope | Key | Examples |
|
|
44
|
+
|---|---|---|
|
|
45
|
+
| URL | `family_id\|normalized_url` | `index-policy\|https://example.com/` |
|
|
46
|
+
| Engine and URL | `family_id\|engine\|normalized_url` | `search-crawl-policy\|google\|https://example.com/` |
|
|
47
|
+
| Origin | `family_id\|origin` | `lcp-field\|https://example.com` |
|
|
48
|
+
| Site | `family_id\|site` | `question-coverage\|site` |
|
|
49
|
+
|
|
50
|
+
`CheckObservation.check_id` equals its registered `family_id` in this evaluation version. Multiple modules can submit the same unit and verdict; it counts once. An unregistered unit or conflicting verdict is a contract error. For `pass` and `fail`, `q` is exactly 1 and 0. For `unknown` and `not_applicable`, `q` and `partial_basis` are null. Unit N/A also requires `applicability_decision:{condition,reason,evidence_ref}`; core policy units and fixed site/public units cannot use N/A. A confirmed noindex or target crawler block is `fail`, `q=0`, and a blocker. Missing policy evidence is `unknown`.
|
|
51
|
+
|
|
52
|
+
The 14 binary families do not accept partial; their applicable units use pass, fail or unknown. Optional units may use N/A only under the allowed condition decision above. `lcp-field`, `inp-field`, `cls-field`, `answer-clarity` and `original-value` accept `partial_basis:{kind:"threshold_half"}` with `q=0.5`. `question-coverage`, `claim-support` and `fact-consistency` accept `partial_basis:{kind:"ratio",numerator:int,denominator:positive_int}` with a matching interior ratio. `question-coverage` also requires denominator equal to the fixed panel length. A CWV verdict requires `measurement:{value,percentile:75,source_type:"field",unit}`; use `ms` for LCP/INP and `unitless` for CLS. An absent field measurement is `unknown`, not N/A.
|
|
53
|
+
|
|
54
|
+
## Weight and status calculation
|
|
55
|
+
|
|
56
|
+
`compileUnits(input,profile)` divides positive axis weight by applicable families, then engines, then fixed page types and URLs. Each nonempty page type gets equal share; URLs within a type get equal share. Site and origin units do not multiply with page count. Units marked `not_applicable` are removed and the remaining units in that family are renormalized. If a whole family has no units, it is removed from its axis. If an axis has no applicable families, the remaining positive axes are renormalized to 100. LLMO's P axis has zero weight. A weighted unit is `{key,family_id,engine,url,weight,state,q}`.
|
|
57
|
+
|
|
58
|
+
`scoreWeighted(units)` returns raw weighted totals `A` (applicable), `M` (measured) and `N` (measured credit), plus one-decimal display percentages: `score=100N/M`, `coverage=100M/A`, `missing_bounds=[100N/A,100(N+A-M)/A]`. `score` is null when `M=0`; `coverage` and `missing_bounds` are null when `A=0`. The bounds are all-missing-fail and all-missing-pass extremes, not a confidence interval. Internal comparisons use raw values with `1e-9` tolerance; display rounds only at the end.
|
|
59
|
+
|
|
60
|
+
`score(input)` returns ordered `ScoreResult[]` for SEO, optional NEO, AEO, GEO and LLMO. SEO is the default aggregate and is calculated directly, never from the mean of profile scores. Each result includes `{schema_version,evaluation_version,profile,A,M,N,score,coverage,missing_bounds,band,readiness_status,blockers,unknown_core,source_ref}`. Bands are `focus_improvement` (0–59), `major_improvement` (60–79), `additional_improvement` (80–94), and `remaining_review` (95–100). Status precedence is `not_applicable` for `A=0`, then `blocked` for confirmed access/crawl/noindex failures or an explicit severe-fact blocker, then `unverified` for unknown applicable access/index core units, then `provisional` below 80% measured coverage, else `measured`. `measured` means only that this optbrew readiness audit has enough measured coverage by its own rule.
|
|
61
|
+
|
|
62
|
+
Run a stored input without service access: `node skills/optbrew/scripts/cli.mjs score --input input.json --output score.json`. The output is the same JSON `ScoreResult[]` as the library call. The CLI validates before writing.
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import https from 'node:https';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { reserveCost, settleCost } from '../core/cost-ledger.mjs';
|
|
6
|
+
|
|
7
|
+
const ENDPOINT = new URL('https://api.anthropic.com/v1/messages');
|
|
8
|
+
const VERSION = '2023-06-01';
|
|
9
|
+
// Official model overview, checked 2026-10-14. Accepted requests reserve the full context window plus max output.
|
|
10
|
+
const MODELS = new Map(['claude-fable-5-1', 'claude-opus-5-5', 'claude-sonnet-5-5', 'claude-haiku-5-5']
|
|
11
|
+
.map(id => [id, { contextTokens: 1_000_000, maxOutputTokens: 128_000 }]));
|
|
12
|
+
|
|
13
|
+
async function nodeTransport({ headers, body, timeoutMs, signal }) {
|
|
14
|
+
return new Promise((resolve, reject) => {
|
|
15
|
+
const req = https.request(ENDPOINT, { method: 'POST', headers, signal }, res => {
|
|
16
|
+
const chunks = []; let size = 0;
|
|
17
|
+
res.on('data', chunk => {
|
|
18
|
+
size += chunk.length;
|
|
19
|
+
if (size > 5242880) { req.destroy(new Error('provider response too large')); return; }
|
|
20
|
+
chunks.push(chunk);
|
|
21
|
+
});
|
|
22
|
+
res.on('end', () => resolve({ status: res.statusCode, body: Buffer.concat(chunks).toString('utf8') }));
|
|
23
|
+
res.on('error', reject);
|
|
24
|
+
});
|
|
25
|
+
req.setTimeout(timeoutMs, () => req.destroy(new Error('provider timeout')));
|
|
26
|
+
req.on('error', reject);
|
|
27
|
+
req.end(body);
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function observation(request, error, extra = {}) {
|
|
32
|
+
return { provider: 'anthropic', surface: 'messages_api', model: request?.model ?? null,
|
|
33
|
+
search_enabled: Array.isArray(request?.tools) && request.tools.some(x => /^web_search_/.test(x.type ?? '')),
|
|
34
|
+
request_id: null, response_ref: null, usage: null, valid: false, error, evidence_ref: null, ...extra };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function actualUsage(bounds, usage) {
|
|
38
|
+
if (!usage || typeof usage !== 'object') return null;
|
|
39
|
+
const values = bounds.map(bound => {
|
|
40
|
+
let amount;
|
|
41
|
+
if (bound.unit === 'tokens' && [usage.input_tokens, usage.output_tokens].every(x => Number.isSafeInteger(x) && x >= 0))
|
|
42
|
+
amount = usage.input_tokens + usage.output_tokens +
|
|
43
|
+
(usage.cache_creation_input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0);
|
|
44
|
+
return Number.isSafeInteger(amount) && amount >= 0 ? { ...bound, amount } : null;
|
|
45
|
+
});
|
|
46
|
+
return values.every(Boolean) ? values : null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function supportedRequest(request) {
|
|
50
|
+
if (!request || typeof request !== 'object' || Array.isArray(request) ||
|
|
51
|
+
Object.keys(request).some(key => !['model', 'max_tokens', 'messages'].includes(key))) return false;
|
|
52
|
+
const model = MODELS.get(request.model);
|
|
53
|
+
return Boolean(model && Number.isInteger(request.max_tokens) && request.max_tokens > 0 &&
|
|
54
|
+
request.max_tokens <= model.maxOutputTokens && Array.isArray(request.messages) && request.messages.length > 0 &&
|
|
55
|
+
request.messages.every(message => message && typeof message === 'object' && !Array.isArray(message) &&
|
|
56
|
+
Object.keys(message).every(key => ['role', 'content'].includes(key)) &&
|
|
57
|
+
['user', 'assistant'].includes(message.role) && typeof message.content === 'string' && message.content.length > 0));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Optional paid call; every attempt has a reservation and unknown billing keeps its cap. */
|
|
61
|
+
export async function requestAnthropic({ context, request, env = process.env, transport = nodeTransport, ledgerPath }) {
|
|
62
|
+
const decision = context?.cost_decision;
|
|
63
|
+
const upperBounds = decision?.providers?.anthropic?.upper_bounds;
|
|
64
|
+
if (!decision?.approval_ref || !Array.isArray(upperBounds) || !upperBounds.length || !ledgerPath)
|
|
65
|
+
return observation(request, 'cost_decision_required');
|
|
66
|
+
if (!env?.ANTHROPIC_API_KEY) return observation(request, 'api_key_missing');
|
|
67
|
+
let body;
|
|
68
|
+
try { body = JSON.stringify(request); request = JSON.parse(body); }
|
|
69
|
+
catch { return observation(request, 'unsupported_direct_request'); }
|
|
70
|
+
if (!supportedRequest(request)) return observation(request, 'unsupported_direct_request');
|
|
71
|
+
const requiredTokens = MODELS.get(request.model).contextTokens + request.max_tokens;
|
|
72
|
+
if (upperBounds.length !== 1 || upperBounds[0].unit !== 'tokens' || !Number.isSafeInteger(upperBounds[0].amount) ||
|
|
73
|
+
upperBounds[0].amount < requiredTokens) return observation(request, 'upper_bound_unverified');
|
|
74
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
75
|
+
let reservation;
|
|
76
|
+
try { reservation = await reserveCost(ledgerPath, { provider: 'anthropic', upperBounds, approvalRef: decision.approval_ref, runId: context.run_id }); }
|
|
77
|
+
catch { return observation(request, 'cost_reservation_failed'); }
|
|
78
|
+
const controller = new AbortController();
|
|
79
|
+
const timer = setTimeout(() => controller.abort(), 20000);
|
|
80
|
+
let response;
|
|
81
|
+
try {
|
|
82
|
+
response = await Promise.race([
|
|
83
|
+
transport({ url: ENDPOINT, headers: { 'content-type': 'application/json', 'x-api-key': env.ANTHROPIC_API_KEY,
|
|
84
|
+
'anthropic-version': VERSION }, body, timeoutMs: 20000, signal: controller.signal }),
|
|
85
|
+
new Promise((_, reject) => controller.signal.addEventListener('abort', () => reject(new Error('timeout')), { once: true })),
|
|
86
|
+
]);
|
|
87
|
+
} catch {
|
|
88
|
+
await settleCost(ledgerPath, reservation.reservation_id, 'unknown');
|
|
89
|
+
return observation(request, 'transport_or_timeout_unknown_billing');
|
|
90
|
+
} finally { clearTimeout(timer); }
|
|
91
|
+
let parsed;
|
|
92
|
+
try { parsed = JSON.parse(Buffer.isBuffer(response.body) ? response.body.toString('utf8') : response.body); }
|
|
93
|
+
catch { await settleCost(ledgerPath, reservation.reservation_id, 'unknown'); return observation(request, 'malformed_provider_json_unknown_billing'); }
|
|
94
|
+
const actual = actualUsage(upperBounds, parsed?.usage);
|
|
95
|
+
const settled = await settleCost(ledgerPath, reservation.reservation_id, actual ?? 'unknown');
|
|
96
|
+
const overrun = settled.reservations.some(x => x.reservation_id === reservation.reservation_id && x.status === 'overrun');
|
|
97
|
+
if (response.status >= 200 && response.status < 300 && parsed?.type === 'message') {
|
|
98
|
+
const safeBody = JSON.stringify(parsed).replaceAll(env.ANTHROPIC_API_KEY, '[REDACTED]');
|
|
99
|
+
const evidenceDir = path.join(path.dirname(ledgerPath), 'evidence', 'provider');
|
|
100
|
+
const evidenceRef = path.join(evidenceDir, `anthropic-${createHash('sha256').update(safeBody).digest('hex')}.json`);
|
|
101
|
+
try {
|
|
102
|
+
await mkdir(evidenceDir, { recursive: true });
|
|
103
|
+
try { await writeFile(evidenceRef, `${safeBody}\n`, { flag: 'wx' }); }
|
|
104
|
+
catch (error) {
|
|
105
|
+
if (error.code !== 'EEXIST' || await readFile(evidenceRef, 'utf8') !== `${safeBody}\n`) throw error;
|
|
106
|
+
}
|
|
107
|
+
} catch { return observation(request, 'provider_evidence_persist_failed'); }
|
|
108
|
+
return observation(request, overrun ? 'observed_cost_overrun' : null, { valid: !overrun, request_id: typeof parsed.id === 'string' ? parsed.id : null,
|
|
109
|
+
response_ref: evidenceRef, usage: parsed.usage ?? null, evidence_ref: evidenceRef });
|
|
110
|
+
}
|
|
111
|
+
if (overrun) return observation(request, 'observed_cost_overrun', { usage: parsed?.usage ?? null });
|
|
112
|
+
if (![429, 500, 502, 503, 504].includes(response.status) || attempt === 1)
|
|
113
|
+
return observation(request, `provider_http_${Number(response.status) || 'error'}`);
|
|
114
|
+
}
|
|
115
|
+
return observation(request, 'attempt_limit');
|
|
116
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { validateEntity, ContractError } from '../core/contracts.mjs';
|
|
2
|
+
|
|
3
|
+
const providers = new Set(['gsc', 'ga4', 'google_ai', 'bing_ai', 'naver', 'se_ranking']);
|
|
4
|
+
const day = 86400000;
|
|
5
|
+
const datePattern = /^\d{4}-\d{2}-\d{2}$/;
|
|
6
|
+
function dateValue(value) {
|
|
7
|
+
if (typeof value !== 'string' || !datePattern.test(value)) throw new ContractError('invalid complete local date');
|
|
8
|
+
const time = Date.parse(`${value}T00:00:00Z`);
|
|
9
|
+
if (!Number.isFinite(time) || new Date(time).toISOString().slice(0, 10) !== value) throw new ContractError('invalid complete local date');
|
|
10
|
+
return time;
|
|
11
|
+
}
|
|
12
|
+
function dateString(time) { return new Date(time).toISOString().slice(0, 10); }
|
|
13
|
+
|
|
14
|
+
/** Caller supplies the last complete date in the named timezone. */
|
|
15
|
+
export function completeWindows(lastCompleteDate, timezone) {
|
|
16
|
+
const end = dateValue(lastCompleteDate);
|
|
17
|
+
try { new Intl.DateTimeFormat('en', { timeZone: timezone }); }
|
|
18
|
+
catch { throw new ContractError('invalid timezone'); }
|
|
19
|
+
if (!timezone) throw new ContractError('timezone required');
|
|
20
|
+
return { before: { start: dateString(end - 55 * day), end: dateString(end - 28 * day), timezone },
|
|
21
|
+
after: { start: dateString(end - 27 * day), end: lastCompleteDate, timezone } };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Owner export rows retain provider meaning and source filters; no cross-provider synthesis. */
|
|
25
|
+
export function normalizeImport(provider, rows, metadata) {
|
|
26
|
+
if (!providers.has(provider) || !Array.isArray(rows) || !metadata || typeof metadata !== 'object')
|
|
27
|
+
throw new ContractError('invalid provider export');
|
|
28
|
+
const required = ['timezone', 'collected_at', 'source_ref', 'evidence_ref'];
|
|
29
|
+
for (const field of required) if (typeof metadata[field] !== 'string' || !metadata[field])
|
|
30
|
+
throw new ContractError(`import metadata requires ${field}`);
|
|
31
|
+
const { window, sample_selection } = metadata;
|
|
32
|
+
if (!window || window.timezone !== metadata.timezone || dateValue(window.start) > dateValue(window.end) ||
|
|
33
|
+
!sample_selection || !['all', 'positive_only', 'unknown'].includes(sample_selection.kind))
|
|
34
|
+
throw new ContractError('invalid window or sample selection');
|
|
35
|
+
if (!Number.isFinite(Date.parse(metadata.collected_at)) || !/[zZ]|[+-]\d\d:\d\d$/.test(metadata.collected_at))
|
|
36
|
+
throw new ContractError('collected_at requires timezone');
|
|
37
|
+
if (metadata.last_complete_date !== undefined) dateValue(metadata.last_complete_date);
|
|
38
|
+
return rows.map(row => {
|
|
39
|
+
if (!row || typeof row.metric_id !== 'string' || !row.metric_id ||
|
|
40
|
+
typeof (row.provider_definition ?? metadata.provider_definition) !== 'string' ||
|
|
41
|
+
!(row.provider_definition ?? metadata.provider_definition).trim())
|
|
42
|
+
throw new ContractError('native metric definition required');
|
|
43
|
+
if (row.filters && (typeof row.filters !== 'object' || Array.isArray(row.filters)))
|
|
44
|
+
throw new ContractError('row filters must be an object');
|
|
45
|
+
for (const key of ['property', 'aggregation'])
|
|
46
|
+
if (row.filters?.[key] != null && metadata[key] != null && row.filters[key] !== metadata[key])
|
|
47
|
+
throw new ContractError(`row ${key} conflicts with export scope`);
|
|
48
|
+
if (sample_selection.kind === 'positive_only' &&
|
|
49
|
+
(['citation_response_rate', 'all_question_citation_rate', 'overall_citation_rate'].includes(row.metric_id) ||
|
|
50
|
+
row.denominator_scope === 'all_questions'))
|
|
51
|
+
throw new ContractError('positive-only sample cannot estimate all-question citation rate');
|
|
52
|
+
const ambiguousZero = provider === 'google_ai' && row.value === 0 && row.missing_mask !== false;
|
|
53
|
+
const ambiguousDisplay = provider === 'google_ai' && (['~', '-'].includes(row.display_state) || ['~', '-'].includes(row.value));
|
|
54
|
+
const ambiguousMask = row.missing_mask === true;
|
|
55
|
+
const value_state = row.value_state ?? (ambiguousZero || ambiguousDisplay || ambiguousMask ? 'zero_or_missing' :
|
|
56
|
+
row.value === null || row.value === undefined ? 'unknown' : 'measured');
|
|
57
|
+
if ((ambiguousZero || ambiguousDisplay || ambiguousMask) && value_state === 'measured')
|
|
58
|
+
throw new ContractError('Google AI export zero requires a known nonmissing mask');
|
|
59
|
+
const value = value_state === 'measured' ? row.value : null;
|
|
60
|
+
if (value_state === 'measured' && !Number.isFinite(value)) throw new ContractError('measured value must be finite');
|
|
61
|
+
const dimensions = ['property', 'hostname', 'path', 'query', 'search_type', 'device', 'country', 'aggregation'];
|
|
62
|
+
const dimension = key => row.filters?.[key] ?? metadata[key] ?? 'unknown';
|
|
63
|
+
const export_scope = Object.fromEntries(dimensions.map(key => [key, metadata[key] ?? 'unknown']));
|
|
64
|
+
const observation = { schema_version: '1.0.0', metric_id: `${provider}:${row.metric_id}`,
|
|
65
|
+
value_state, value, numerator: row.numerator ?? null, denominator: row.denominator ?? null,
|
|
66
|
+
definition: row.definition ?? row.provider_definition ?? metadata.provider_definition,
|
|
67
|
+
filters: { ...(row.filters ?? {}), provider, property: dimension('property'),
|
|
68
|
+
hostname: dimension('hostname'), search_type: dimension('search_type'),
|
|
69
|
+
device: dimension('device'), country: dimension('country'),
|
|
70
|
+
aggregation: dimension('aggregation'), collected_at: metadata.collected_at,
|
|
71
|
+
...(metadata.last_complete_date ? { last_complete_date: metadata.last_complete_date } : {}),
|
|
72
|
+
path: dimension('path'), query: dimension('query'), export_scope },
|
|
73
|
+
window: { ...window }, panel_hash: null,
|
|
74
|
+
provider_definition: row.provider_definition ?? metadata.provider_definition,
|
|
75
|
+
sample_selection: { ...sample_selection,
|
|
76
|
+
...(value_state === 'not_applicable' ? { exclusion_reason: row.not_applicable_reason } : {}) },
|
|
77
|
+
source_ref: metadata.source_ref,
|
|
78
|
+
evidence_ref: row.evidence_ref ?? metadata.evidence_ref };
|
|
79
|
+
return validateEntity('MetricObservation', observation);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { realpathSync } from 'node:fs';
|
|
5
|
+
import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { validateEntity } from './core/contracts.mjs';
|
|
9
|
+
import { collect, declaredRobotsIndex } from './core/collector.mjs';
|
|
10
|
+
import { auditCollected } from '../modules/naver-searchadvisor/scripts/audit.mjs';
|
|
11
|
+
|
|
12
|
+
const magnusScript = fileURLToPath(new URL('../modules/magnus-seo/scripts/aeo_audit.py', import.meta.url));
|
|
13
|
+
const supported = new Set(['naver-searchadvisor', 'magnus-seo']);
|
|
14
|
+
|
|
15
|
+
async function magnus(artifact) {
|
|
16
|
+
return new Promise(resolve => {
|
|
17
|
+
const child = spawn(process.platform === 'win32' ? 'python' : 'python3', [magnusScript, '--stdin', '--source-url', artifact.final_url, '--json'],
|
|
18
|
+
{ stdio: ['pipe', 'pipe', 'pipe'] });
|
|
19
|
+
const chunks = []; let bytes = 0;
|
|
20
|
+
child.stdout.on('data', chunk => { bytes += chunk.length; if (bytes > 5242880) child.kill(); else chunks.push(chunk); });
|
|
21
|
+
child.on('error', () => resolve({ status: 'unavailable', reason: 'python_unavailable' }));
|
|
22
|
+
child.on('close', code => {
|
|
23
|
+
if (code !== 0) return resolve({ status: 'unavailable', reason: 'python_audit_failed' });
|
|
24
|
+
try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))); }
|
|
25
|
+
catch { resolve({ status: 'unavailable', reason: 'python_output_invalid' }); }
|
|
26
|
+
});
|
|
27
|
+
child.stdin.on('error', () => {});
|
|
28
|
+
child.stdin.end(artifact.body);
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function observation(artifact, family, state, reason, engine = null, evidenceRef = artifact.evidence_ref) {
|
|
33
|
+
const unit_key = `${family}|${engine ? `${engine}|` : ''}${artifact.normalized_url}`;
|
|
34
|
+
const value = { schema_version: '1.0.0', observation_id: createHash('sha256').update(unit_key).digest('hex'),
|
|
35
|
+
check_id: family, family_id: family, unit_key,
|
|
36
|
+
state, q: state === 'pass' ? 1 : state === 'fail' ? 0 : null, partial_basis: null, reason,
|
|
37
|
+
evaluator_type: 'code', source_ref: 'optbrew:collector', evidence_ref: evidenceRef };
|
|
38
|
+
return validateEntity('CheckObservation', value);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Persist collector bytes and offline module output in the same run evidence directory. */
|
|
42
|
+
export async function runAudit({ context, modules, outputDir, transport, resolve }) {
|
|
43
|
+
validateEntity('RunContext', context);
|
|
44
|
+
if (!Array.isArray(modules) || modules.some(x => !supported.has(x)) || !outputDir) throw new Error('unsupported modules or output directory');
|
|
45
|
+
const evidenceDir = path.join(outputDir, 'evidence');
|
|
46
|
+
await mkdir(evidenceDir, { recursive: true });
|
|
47
|
+
try { await access(path.join(evidenceDir, 'collection.json')); throw new Error('run evidence index already exists'); }
|
|
48
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
49
|
+
const collection = await collect(context, { transport, resolve });
|
|
50
|
+
const artifacts = [];
|
|
51
|
+
const responses = [];
|
|
52
|
+
for (const artifact of collection.artifacts) {
|
|
53
|
+
const { body, ...saved } = artifact;
|
|
54
|
+
if (artifact.content_hash) {
|
|
55
|
+
const name = `${artifact.content_hash}.${artifact.capability === 'raw_html' ? 'html' : 'bin'}`;
|
|
56
|
+
const ref = path.join(evidenceDir, name);
|
|
57
|
+
try { await writeFile(ref, body, { flag: 'wx' }); }
|
|
58
|
+
catch (error) {
|
|
59
|
+
if (error.code !== 'EEXIST' || !Buffer.from(await readFile(ref)).equals(body)) throw error;
|
|
60
|
+
}
|
|
61
|
+
saved.raw_html_ref = artifact.capability === 'raw_html' ? ref : null;
|
|
62
|
+
saved.evidence_ref = ref;
|
|
63
|
+
} else saved.evidence_ref = path.join(evidenceDir, 'collection.json');
|
|
64
|
+
if (Number.isInteger(artifact.status)) {
|
|
65
|
+
// Hash the final URL, redirect chain and response header lines next to the raw body so the report replays from them.
|
|
66
|
+
const bytes = Buffer.from(`${JSON.stringify({ record_version: '1', requested_url: artifact.requested_url,
|
|
67
|
+
normalized_url: artifact.normalized_url, final_url: artifact.final_url, status: artifact.status,
|
|
68
|
+
redirect_chain: artifact.redirect_chain, header_lines: artifact.header_lines,
|
|
69
|
+
content_hash: artifact.content_hash, capability: artifact.capability }, null, 2)}\n`);
|
|
70
|
+
const content_hash = createHash('sha256').update(bytes).digest('hex');
|
|
71
|
+
const ref = path.join(evidenceDir, `${content_hash}.response.json`);
|
|
72
|
+
try { await writeFile(ref, bytes, { flag: 'wx' }); }
|
|
73
|
+
catch (error) {
|
|
74
|
+
if (error.code !== 'EEXIST' || !Buffer.from(await readFile(ref)).equals(bytes)) throw error;
|
|
75
|
+
}
|
|
76
|
+
saved.response_ref = ref;
|
|
77
|
+
responses.push({ normalized_url: artifact.normalized_url, evidence_ref: ref, content_hash });
|
|
78
|
+
}
|
|
79
|
+
artifacts.push(saved);
|
|
80
|
+
}
|
|
81
|
+
const module_outputs = Object.fromEntries(modules.map(x => [x, []]));
|
|
82
|
+
const observations = [];
|
|
83
|
+
const planned = new Set(context.protocol.families.filter(x => x.applicable).map(x => x.family_id));
|
|
84
|
+
const selectedUrls = new Set(context.protocol.page_types.flatMap(type => type.urls.map(x => x.normalized)));
|
|
85
|
+
const declaredRobots = new Set((context.protocol.auxiliary_resources ?? [])
|
|
86
|
+
.filter(resource => resource.kind === 'robots').map(resource => resource.normalized));
|
|
87
|
+
const publicUrls = new Set(context.protocol.page_types.flatMap(type => type.urls.filter(x => x.public_indexing !== false).map(x => x.normalized)));
|
|
88
|
+
for (const artifact of collection.artifacts) {
|
|
89
|
+
const saved = artifacts.find(x => x.normalized_url === artifact.normalized_url);
|
|
90
|
+
if (planned.has('fetch-response') && selectedUrls.has(artifact.normalized_url)) observations.push(observation(saved, 'fetch-response',
|
|
91
|
+
artifact.error || artifact.status < 200 || artifact.status >= 300 && artifact.status < 400 ? 'unknown' : artifact.status >= 400 ? 'fail' : 'pass',
|
|
92
|
+
artifact.error ? 'Request did not finish' : `HTTP ${artifact.status}`));
|
|
93
|
+
if (!selectedUrls.has(artifact.normalized_url) || artifact.capability !== 'raw_html' || artifact.status !== 200) continue;
|
|
94
|
+
const html = artifact.body.toString('utf8');
|
|
95
|
+
if (modules.includes('naver-searchadvisor')) {
|
|
96
|
+
const robotsIndex = declaredRobotsIndex(collection.artifacts, declaredRobots, artifact.final_url);
|
|
97
|
+
const robots = robotsIndex < 0 ? null : collection.artifacts[robotsIndex];
|
|
98
|
+
const robotsEvidenceRef = robots?.status === 200 ? artifacts[robotsIndex].evidence_ref : null;
|
|
99
|
+
const output = auditCollected({ artifact, html, engines: context.protocol.engines,
|
|
100
|
+
robotsArtifact: robots ? { ...robots, body: robots.body.toString('utf8') } : null });
|
|
101
|
+
output.robots_evidence_ref = robotsEvidenceRef;
|
|
102
|
+
module_outputs['naver-searchadvisor'].push(output);
|
|
103
|
+
if (planned.has('document-title') && selectedUrls.has(artifact.normalized_url))
|
|
104
|
+
observations.push(observation(saved, 'document-title', output.title ? 'unknown' : 'fail',
|
|
105
|
+
output.title ? 'Raw HTML has a title; uniqueness and descriptiveness are not established' : 'Raw HTML has no title'));
|
|
106
|
+
if (planned.has('index-policy') && publicUrls.has(artifact.normalized_url)) {
|
|
107
|
+
const blocked = output.index_blocked;
|
|
108
|
+
observations.push(observation(saved, 'index-policy', blocked ? 'fail' : 'pass',
|
|
109
|
+
blocked ? 'Raw response blocks indexing for the target engine' : 'Raw response has no applicable noindex directive'));
|
|
110
|
+
}
|
|
111
|
+
if (planned.has('search-crawl-policy') && publicUrls.has(artifact.normalized_url) &&
|
|
112
|
+
context.protocol.engines.includes('naver') && output.robots?.yeti_allowed === false)
|
|
113
|
+
observations.push(observation(saved, 'search-crawl-policy', 'fail', 'Collected robots.txt disallows Yeti path', 'naver', robotsEvidenceRef));
|
|
114
|
+
}
|
|
115
|
+
if (modules.includes('magnus-seo')) module_outputs['magnus-seo'].push(await magnus(artifact));
|
|
116
|
+
}
|
|
117
|
+
const result = { context_ref: context.evidence_ref, artifacts, observations, module_outputs,
|
|
118
|
+
evidence_ref: path.join(evidenceDir, 'evidence-index.json'), discovered_count: collection.discovered_count,
|
|
119
|
+
evaluated_count: collection.evaluated_count, request_count: collection.request_count, skipped: collection.skipped, limit_reached: collection.limit_reached };
|
|
120
|
+
await writeFile(path.join(evidenceDir, 'collection.json'), `${JSON.stringify({ artifacts, discovered_count: collection.discovered_count,
|
|
121
|
+
evaluated_count: collection.evaluated_count, request_count: collection.request_count, skipped: collection.skipped, limit_reached: collection.limit_reached }, null, 2)}\n`, { flag: 'wx' });
|
|
122
|
+
await writeFile(path.join(evidenceDir, 'modules.json'), `${JSON.stringify(module_outputs, null, 2)}\n`, { flag: 'wx' });
|
|
123
|
+
await writeFile(path.join(evidenceDir, 'observations.json'), `${JSON.stringify(observations, null, 2)}\n`, { flag: 'wx' });
|
|
124
|
+
await writeFile(result.evidence_ref, `${JSON.stringify({ run_id: context.run_id, context_ref: context.evidence_ref,
|
|
125
|
+
collection_ref: path.join(evidenceDir, 'collection.json'), module_output_ref: path.join(evidenceDir, 'modules.json'),
|
|
126
|
+
observations_ref: path.join(evidenceDir, 'observations.json'),
|
|
127
|
+
raw: artifacts.map(x => ({ normalized_url: x.normalized_url, evidence_ref: x.evidence_ref, content_hash: x.content_hash })),
|
|
128
|
+
responses }, null, 2)}\n`, { flag: 'wx' });
|
|
129
|
+
return result;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (process.argv[1] && realpathSync(path.resolve(process.argv[1])) === realpathSync(fileURLToPath(import.meta.url))) {
|
|
133
|
+
try {
|
|
134
|
+
const args = process.argv.slice(2);
|
|
135
|
+
const option = name => { const i = args.indexOf(name); return i < 0 ? null : args[i + 1]; };
|
|
136
|
+
const contextPath = option('--context');
|
|
137
|
+
const moduleText = option('--modules');
|
|
138
|
+
const outputDir = option('--output-dir');
|
|
139
|
+
if (!contextPath || !moduleText || !outputDir || args.length !== 6) throw new Error('usage: node skills/optbrew/scripts/audit.mjs --context <run-context.json> --modules naver-searchadvisor,magnus-seo --output-dir <run-dir>');
|
|
140
|
+
const context = JSON.parse(await readFile(contextPath, 'utf8'));
|
|
141
|
+
const result = await runAudit({ context, modules: moduleText.split(','), outputDir });
|
|
142
|
+
process.stdout.write(`${JSON.stringify({ evidence_ref: result.evidence_ref, evaluated_count: result.evaluated_count, request_count: result.request_count,
|
|
143
|
+
observations: result.observations.length })}\n`);
|
|
144
|
+
} catch (error) { process.stderr.write(`audit failed: ${error.message}\n`); process.exitCode = 2; }
|
|
145
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { readFile, realpath, mkdir, rename, writeFile, rm } from 'node:fs/promises';
|
|
2
|
+
import { realpathSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { score } from './core/score.mjs';
|
|
6
|
+
import { compare } from './core/compare.mjs';
|
|
7
|
+
import { writeReport } from './core/report.mjs';
|
|
8
|
+
|
|
9
|
+
/** Local calculation CLI. */
|
|
10
|
+
export async function runCli(argv = process.argv.slice(2)) {
|
|
11
|
+
const usage = 'usage: cli.mjs score --input <file> --output <file> | compare --before <file> --after <file> --output <file> | report --input <packet.json> --output-dir <directory>';
|
|
12
|
+
let result, destination;
|
|
13
|
+
if (argv.length === 5 && argv[0] === 'report' && argv[1] === '--input' && argv[3] === '--output-dir' && argv[2] && argv[4]) {
|
|
14
|
+
const input = path.resolve(argv[2]);
|
|
15
|
+
const packet = JSON.parse(await readFile(input, 'utf8'));
|
|
16
|
+
packet.evidence_root = path.resolve(path.dirname(input), packet.evidence_root ?? '.');
|
|
17
|
+
const relative = path.relative(await realpath(path.dirname(input)), await realpath(packet.evidence_root));
|
|
18
|
+
if (relative === '..' || relative.startsWith(`..${path.sep}`)) throw new Error('evidence root must stay under packet run directory');
|
|
19
|
+
return writeReport(packet, path.resolve(argv[4]));
|
|
20
|
+
} else if (argv.length === 5 && argv[0] === 'score' && argv[1] === '--input' && argv[3] === '--output' && argv[2] && argv[4]) {
|
|
21
|
+
result = score(JSON.parse(await readFile(argv[2], 'utf8')));
|
|
22
|
+
destination = path.resolve(argv[4]);
|
|
23
|
+
} else if (argv.length === 7 && argv[0] === 'compare' && argv[1] === '--before' && argv[3] === '--after' &&
|
|
24
|
+
argv[5] === '--output' && argv[2] && argv[4] && argv[6]) {
|
|
25
|
+
const before = JSON.parse(await readFile(argv[2], 'utf8'));
|
|
26
|
+
const after = JSON.parse(await readFile(argv[4], 'utf8'));
|
|
27
|
+
result = compare(before, after);
|
|
28
|
+
destination = path.resolve(argv[6]);
|
|
29
|
+
} else throw new Error(usage);
|
|
30
|
+
await mkdir(path.dirname(destination), { recursive: true });
|
|
31
|
+
const temp = `${destination}.${process.pid}.tmp`;
|
|
32
|
+
try {
|
|
33
|
+
await writeFile(temp, `${JSON.stringify(result, null, 2)}\n`, { flag: 'wx' });
|
|
34
|
+
await rename(temp, destination);
|
|
35
|
+
} finally { await rm(temp, { force: true }); }
|
|
36
|
+
return result;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (process.argv[1] && realpathSync(path.resolve(process.argv[1])) === realpathSync(fileURLToPath(import.meta.url))) {
|
|
40
|
+
runCli().catch(error => { process.stderr.write(`${error.name}: ${error.message}\n`); process.exitCode = 1; });
|
|
41
|
+
}
|