optbrew 0.0.0-stage → 0.1.2
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 +50 -0
- package/LICENSE +21 -0
- package/README.ko.md +149 -0
- package/README.md +148 -2
- package/THIRD_PARTY_NOTICES.md +10 -0
- package/bin/optbrew.mjs +4 -0
- package/docs/platforms.md +107 -0
- package/docs/release.md +14 -0
- package/lib/cli.mjs +116 -0
- package/lib/doctor.mjs +45 -0
- package/lib/install.mjs +158 -0
- package/lib/paths.mjs +30 -0
- package/lib/records.mjs +44 -0
- package/lib/reinstall.mjs +92 -0
- package/lib/transaction.mjs +205 -0
- package/lib/update.mjs +189 -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 +687 -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 +742 -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/ai-crawlers.json +21 -0
- package/skills/optbrew/references/ai-crawlers.md +24 -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 +29 -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 +124 -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 +250 -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/crawlers.mjs +11 -0
- package/skills/optbrew/scripts/core/derive.mjs +168 -0
- package/skills/optbrew/scripts/core/facts.mjs +189 -0
- package/skills/optbrew/scripts/core/findings.mjs +54 -0
- package/skills/optbrew/scripts/core/html.mjs +53 -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 +742 -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,97 @@
|
|
|
1
|
+
import { ContractError, validateEntity } from './contracts.mjs';
|
|
2
|
+
|
|
3
|
+
function ratio(id, numerator, denominator, definition, panel, selection, evaluable = true) {
|
|
4
|
+
const measured = evaluable && denominator > 0;
|
|
5
|
+
const observation = { schema_version: '1.0.0', metric_id: id,
|
|
6
|
+
value_state: measured ? 'measured' : 'unknown', value: measured ? numerator / denominator : null,
|
|
7
|
+
numerator: measured ? numerator : null, denominator: measured ? denominator : null,
|
|
8
|
+
definition, filters: { engine: panel.engine, surface: panel.surface, model: panel.model,
|
|
9
|
+
search_enabled: panel.search_enabled, locale: panel.locale, region: panel.region,
|
|
10
|
+
...(panel.collected_at ? { collected_at: panel.collected_at } : {}),
|
|
11
|
+
...(panel.last_complete_date ? { last_complete_date: panel.last_complete_date } : {}) },
|
|
12
|
+
window: panel.window, panel_hash: panel.panel_hash, provider_definition: 'optbrew fixed AI response panel v0.1.0',
|
|
13
|
+
sample_selection: selection, source_ref: panel.source_ref, evidence_ref: panel.evidence_ref };
|
|
14
|
+
return validateEntity('MetricObservation', observation);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Six distinct panel metrics; response-level and citation/claim denominators never mix. */
|
|
18
|
+
export function measurePanel(panel, responses, evaluations) {
|
|
19
|
+
if (!panel || !Array.isArray(panel.questions) || !panel.questions.length ||
|
|
20
|
+
new Set(panel.questions.map(q => q.id)).size !== panel.questions.length ||
|
|
21
|
+
!Number.isInteger(panel.repeats) || panel.repeats < 1 || !panel.panel_hash ||
|
|
22
|
+
typeof panel.search_enabled !== 'boolean' || !panel.target_entity ||
|
|
23
|
+
!Array.isArray(panel.target_domains) || !panel.target_domains.length ||
|
|
24
|
+
!Array.isArray(responses) || !Array.isArray(evaluations))
|
|
25
|
+
throw new ContractError('invalid fixed panel');
|
|
26
|
+
const planned = panel.questions.length * panel.repeats;
|
|
27
|
+
const slots = new Set(), responseIds = new Set(), valid = [];
|
|
28
|
+
for (const response of responses) {
|
|
29
|
+
const slot = `${response.question_id}\0${response.repeat}`;
|
|
30
|
+
if (!panel.questions.some(q => q.id === response.question_id) ||
|
|
31
|
+
!Number.isInteger(response.repeat) || response.repeat < 0 || response.repeat >= panel.repeats ||
|
|
32
|
+
slots.has(slot) || !response.response_id || responseIds.has(response.response_id) || typeof response.valid !== 'boolean')
|
|
33
|
+
throw new ContractError('response outside or duplicated in fixed panel');
|
|
34
|
+
slots.add(slot); responseIds.add(response.response_id);
|
|
35
|
+
if (response.valid) {
|
|
36
|
+
if (!Array.isArray(response.cited_urls) || typeof response.target_mentioned !== 'boolean' ||
|
|
37
|
+
!Array.isArray(response.brand_mentions)) throw new ContractError('valid response lacks evaluated fields');
|
|
38
|
+
for (const citedUrl of response.cited_urls) {
|
|
39
|
+
try { if (!['http:', 'https:'].includes(new URL(citedUrl).protocol)) throw new Error(); }
|
|
40
|
+
catch { throw new ContractError('valid response has invalid citation URL'); }
|
|
41
|
+
}
|
|
42
|
+
if (new Set(response.cited_urls).size !== response.cited_urls.length)
|
|
43
|
+
throw new ContractError('duplicate citation URL in response');
|
|
44
|
+
valid.push(response);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const selection = { kind: 'all', planned_responses: planned, valid_responses: valid.length,
|
|
48
|
+
unique_questions: panel.questions.length, inference: panel.questions.length < 10 ? 'exploratory' : 'descriptive' };
|
|
49
|
+
const targetDomains = panel.target_domains.map(domain => domain.toLowerCase());
|
|
50
|
+
const cited = response => (response.cited_urls ?? []).some(url => {
|
|
51
|
+
try { const host = new URL(url).hostname.toLowerCase(); return targetDomains.some(domain => host === domain || host.endsWith(`.${domain}`)); }
|
|
52
|
+
catch { return false; }
|
|
53
|
+
});
|
|
54
|
+
const citationCount = valid.filter(cited).length;
|
|
55
|
+
const mentionCount = valid.filter(r => r.target_mentioned === true).length;
|
|
56
|
+
const brands = new Set([panel.target_entity, ...(panel.competitor_entities ?? [])]);
|
|
57
|
+
const brandCount = valid.reduce((sum, r) => sum + [...new Set(r.brand_mentions)].filter(x => brands.has(x)).length, 0);
|
|
58
|
+
const targetBrandCount = valid.filter(r => r.brand_mentions.includes(panel.target_entity)).length;
|
|
59
|
+
const validIds = new Set(valid.map(r => r.response_id));
|
|
60
|
+
if (evaluations.some(e => !validIds.has(e.response_id)) ||
|
|
61
|
+
new Set(evaluations.map(e => e.response_id)).size !== evaluations.length)
|
|
62
|
+
throw new ContractError('duplicate evaluation or evaluation without valid panel response');
|
|
63
|
+
const responseById = new Map(valid.map(r => [r.response_id, r]));
|
|
64
|
+
const support = [];
|
|
65
|
+
const observedCitations = valid.reduce((sum, r) => sum + r.cited_urls.length, 0);
|
|
66
|
+
for (const evaluation of evaluations) {
|
|
67
|
+
if (evaluation.citations !== undefined && !Array.isArray(evaluation.citations))
|
|
68
|
+
throw new ContractError('citation evaluations must be an array');
|
|
69
|
+
const actual = new Set(responseById.get(evaluation.response_id).cited_urls);
|
|
70
|
+
const bound = new Set();
|
|
71
|
+
for (const citation of evaluation.citations ?? []) {
|
|
72
|
+
if (!citation || typeof citation.citation_url !== 'string' || !actual.has(citation.citation_url) ||
|
|
73
|
+
bound.has(citation.citation_url))
|
|
74
|
+
throw new ContractError('citation evaluation must bind one actual response citation');
|
|
75
|
+
bound.add(citation.citation_url);
|
|
76
|
+
if (citation.evaluable === true && citation.source_accessible === true && typeof citation.supported === 'boolean')
|
|
77
|
+
support.push(citation);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const facts = panel.fact_list_ref ? evaluations.flatMap(e => e.claims ?? []).filter(x =>
|
|
81
|
+
x.evaluable === true && typeof x.matches === 'boolean') : [];
|
|
82
|
+
return [
|
|
83
|
+
ratio('target_citation_response_rate', citationCount, valid.length, 'valid responses citing a target URL or domain / valid responses', panel, selection),
|
|
84
|
+
ratio('target_entity_mention_rate', mentionCount, valid.length, 'valid responses with evaluated target entity mention / valid responses', panel, selection),
|
|
85
|
+
ratio('citation_support_rate', support.filter(x => x.supported === true).length, support.length,
|
|
86
|
+
'supported evaluable linked citations / evaluable linked citations', panel,
|
|
87
|
+
{ ...selection, observed_citations: observedCitations, evaluation_scope: support.length,
|
|
88
|
+
unknown_citations: observedCitations - support.length,
|
|
89
|
+
...(support.length ? {} : { missing_reason: 'no_accessible_evaluable_citations' }) }, support.length > 0),
|
|
90
|
+
ratio('fact_consistency_rate', facts.filter(x => x.matches === true).length, facts.length,
|
|
91
|
+
'matching evaluable important claims / evaluable important claims', panel,
|
|
92
|
+
{ ...selection, evaluation_scope: facts.length, ...(panel.fact_list_ref ? {} : { missing_reason: 'approved_fact_list_missing' }) }, facts.length > 0),
|
|
93
|
+
ratio('observed_brand_share', targetBrandCount, brandCount,
|
|
94
|
+
'target mentions once per response / mentions for fixed competitor set', panel, selection),
|
|
95
|
+
ratio('observation_completion_rate', valid.length, planned, 'valid responses / planned responses', panel, selection),
|
|
96
|
+
];
|
|
97
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { ContractError, validateEntity, validateApplicabilityDecision } from './contracts.mjs';
|
|
4
|
+
import { assertUrlInScope } from './scope.mjs';
|
|
5
|
+
|
|
6
|
+
const registry = JSON.parse(readFileSync(new URL('../../references/check-registry.json', import.meta.url)));
|
|
7
|
+
const knownFamilies = new Map(registry.families.map(x => [x.family_id, x]));
|
|
8
|
+
|
|
9
|
+
function unique(values, label) {
|
|
10
|
+
if (values.some(x => typeof x !== 'string' || !x) || new Set(values).size !== values.length)
|
|
11
|
+
throw new ContractError(`${label}: blank or duplicate identifier`);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const compare = (a, b) => a < b ? -1 : a > b ? 1 : 0;
|
|
15
|
+
const sameSet = (a, b) => a.length === b.length && a.every(value => b.includes(value));
|
|
16
|
+
|
|
17
|
+
function stable(value) {
|
|
18
|
+
if (Array.isArray(value)) return value.map(stable);
|
|
19
|
+
if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map(key => [key, stable(value[key])]));
|
|
20
|
+
return value;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Fix comparison inputs; run_id, timestamps and evidence paths are excluded. */
|
|
24
|
+
export function freezeProtocol(context) {
|
|
25
|
+
validateEntity('RunContext', context);
|
|
26
|
+
const p = context.protocol;
|
|
27
|
+
for (const key of ['target', 'environment', 'evaluation_version', 'protocol_version'])
|
|
28
|
+
if (Object.hasOwn(p, key) && JSON.stringify(p[key]) !== JSON.stringify(context[key]))
|
|
29
|
+
throw new ContractError(`protocol ${key} differs from RunContext`);
|
|
30
|
+
unique(p.page_types.map(x => x.id), 'page_types');
|
|
31
|
+
unique(p.engines, 'engines');
|
|
32
|
+
unique(p.families.map(x => x.family_id), 'families');
|
|
33
|
+
unique(p.question_panel.map(x => x.id), 'question_panel');
|
|
34
|
+
if (p.question_panel.some(x => !x.question.trim())) throw new ContractError('question_panel: empty question');
|
|
35
|
+
const urls = p.page_types.flatMap(x => x.urls.map(u => u.normalized));
|
|
36
|
+
unique(urls, 'normalized URLs');
|
|
37
|
+
if (!urls.length) throw new ContractError('protocol requires selected public audit URLs');
|
|
38
|
+
const resources = p.auxiliary_resources ?? [];
|
|
39
|
+
unique(resources.map(x => x.normalized), 'auxiliary resources');
|
|
40
|
+
if (resources.some(x => urls.includes(x.normalized))) throw new ContractError('auxiliary resource duplicates a selected URL');
|
|
41
|
+
const requestLimit = Math.min(context.scope.max_urls ?? 50, 50);
|
|
42
|
+
if (urls.length + resources.length > requestLimit) throw new ContractError('selected pages and auxiliary resources exceed request scope');
|
|
43
|
+
for (const resource of resources) {
|
|
44
|
+
for (const url of [resource.original, resource.normalized]) {
|
|
45
|
+
try { assertUrlInScope(url, context.scope); }
|
|
46
|
+
catch { throw new ContractError(`auxiliary resource outside scope: ${url}`); }
|
|
47
|
+
}
|
|
48
|
+
if (assertUrlInScope(resource.original, context.scope).href !== resource.normalized)
|
|
49
|
+
throw new ContractError(`auxiliary resource normalized URL mismatch: ${resource.original}`);
|
|
50
|
+
}
|
|
51
|
+
for (const pageType of p.page_types) {
|
|
52
|
+
if (!pageType.urls.length) throw new ContractError(`empty page type: ${pageType.id}`);
|
|
53
|
+
for (const url of pageType.urls) {
|
|
54
|
+
for (const value of [url.original, url.normalized]) {
|
|
55
|
+
try { if (!['http:', 'https:'].includes(new URL(value).protocol)) throw new Error(); }
|
|
56
|
+
catch { throw new ContractError(`invalid HTTP URL: ${value}`); }
|
|
57
|
+
}
|
|
58
|
+
if (/\/(?:robots\.txt|sitemap[^/]*\.xml)$/i.test(decodeURIComponent(new URL(url.normalized).pathname)))
|
|
59
|
+
throw new ContractError(`${url.normalized}: robots and sitemap must be auxiliary resources`);
|
|
60
|
+
if (url.public_indexing === false) validateApplicabilityDecision(url.indexing_decision,
|
|
61
|
+
['not_public_index_target'], `${url.normalized}.indexing_decision`);
|
|
62
|
+
else if (url.indexing_decision) throw new ContractError(`${url.normalized}: indexing decision requires public_indexing=false`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const publicUrls = p.page_types.flatMap(type => type.urls.filter(url => url.public_indexing !== false).map(url => url.normalized));
|
|
66
|
+
for (const family of p.families) {
|
|
67
|
+
const definition = knownFamilies.get(family.family_id);
|
|
68
|
+
if (!definition) throw new ContractError(`unregistered family: ${family.family_id}`);
|
|
69
|
+
const requiredUrls = ['index-policy', 'search-crawl-policy', 'discovery'].includes(family.family_id) ? publicUrls : urls;
|
|
70
|
+
if (family.urls) {
|
|
71
|
+
unique(family.urls, `${family.family_id}.urls`);
|
|
72
|
+
if (definition.unit_scope === 'site' || !sameSet(family.urls, requiredUrls))
|
|
73
|
+
throw new ContractError(`${family.family_id}: use unit not_applicable decisions instead of omitting selected URLs`);
|
|
74
|
+
}
|
|
75
|
+
if (family.engines) {
|
|
76
|
+
unique(family.engines, `${family.family_id}.engines`);
|
|
77
|
+
const requiredEngines = definition.engines ?? p.engines;
|
|
78
|
+
if (definition.unit_scope !== 'engine_url' || !sameSet(family.engines, requiredEngines))
|
|
79
|
+
throw new ContractError(`${family.family_id}: target engine units cannot be omitted`);
|
|
80
|
+
}
|
|
81
|
+
const expected = family.family_id === 'fetch-response' ? true :
|
|
82
|
+
['index-policy', 'discovery'].includes(family.family_id) ? publicUrls.length > 0 :
|
|
83
|
+
family.family_id === 'search-crawl-policy' ? publicUrls.length > 0 && p.engines.length > 0 :
|
|
84
|
+
family.family_id === 'question-coverage' ? p.question_panel.length > 0 :
|
|
85
|
+
family.family_id === 'naver-markup' ? p.engines.includes('naver') : null;
|
|
86
|
+
if (expected !== null && family.applicable !== expected)
|
|
87
|
+
throw new ContractError(`${family.family_id}: applicability contradicts fixed URL/engine conditions`);
|
|
88
|
+
if (family.applicable) {
|
|
89
|
+
if (family.applicability_decision) throw new ContractError(`${family.family_id}: decision only allowed when inapplicable`);
|
|
90
|
+
} else {
|
|
91
|
+
const decision = validateApplicabilityDecision(family.applicability_decision,
|
|
92
|
+
definition.na_conditions, `${family.family_id}.applicability_decision`);
|
|
93
|
+
const automaticCondition = family.family_id === 'search-crawl-policy' ?
|
|
94
|
+
(p.engines.length ? 'no_public_index_target' : 'no_target_engine') :
|
|
95
|
+
['index-policy', 'discovery'].includes(family.family_id) ? 'no_public_index_target' :
|
|
96
|
+
family.family_id === 'question-coverage' ? 'no_fixed_questions' :
|
|
97
|
+
family.family_id === 'naver-markup' ? 'naver_not_target' : null;
|
|
98
|
+
if (automaticCondition && decision.condition !== automaticCondition)
|
|
99
|
+
throw new ContractError(`${family.family_id}: decision conflicts with fixed conditions`);
|
|
100
|
+
}
|
|
101
|
+
if (family.applicable && ['question-coverage', 'answer-clarity'].includes(family.family_id) && !p.question_panel.length)
|
|
102
|
+
throw new ContractError(`${family.family_id}: fixed question panel required`);
|
|
103
|
+
}
|
|
104
|
+
const sorted = { ...p,
|
|
105
|
+
...(p.auxiliary_resources ? { auxiliary_resources: [...resources].sort((a, b) => compare(a.normalized, b.normalized)) } : {}),
|
|
106
|
+
engines: [...p.engines].sort(),
|
|
107
|
+
families: [...p.families].sort((a, b) => compare(a.family_id, b.family_id)).map(f => ({ ...f,
|
|
108
|
+
...(f.urls ? { urls: [...f.urls].sort() } : {}),
|
|
109
|
+
...(f.engines ? { engines: [...f.engines].sort() } : {}) })),
|
|
110
|
+
page_types: [...p.page_types].sort((a, b) => compare(a.id, b.id)).map(t => ({ ...t,
|
|
111
|
+
urls: [...t.urls].sort((a, b) => compare(a.normalized, b.normalized)).map(url => ({ ...url,
|
|
112
|
+
public_indexing: url.public_indexing !== false })) })) };
|
|
113
|
+
const protocol = stable({ ...sorted, target: context.target, environment: context.environment,
|
|
114
|
+
evaluation_version: context.evaluation_version, protocol_version: context.protocol_version });
|
|
115
|
+
const hashInput = structuredClone(protocol);
|
|
116
|
+
for (const type of hashInput.page_types) for (const url of type.urls) if (url.indexing_decision)
|
|
117
|
+
url.indexing_decision = { condition: url.indexing_decision.condition };
|
|
118
|
+
for (const family of hashInput.families) if (family.applicability_decision)
|
|
119
|
+
family.applicability_decision = { condition: family.applicability_decision.condition };
|
|
120
|
+
if (hashInput.target_identity) {
|
|
121
|
+
for (const mapping of hashInput.target_identity.native_properties ?? []) delete mapping.evidence_ref;
|
|
122
|
+
if (hashInput.target_identity.panel) delete hashInput.target_identity.panel.evidence_ref;
|
|
123
|
+
}
|
|
124
|
+
return { protocol, protocol_hash: createHash('sha256').update(JSON.stringify(stable(hashInput))).digest('hex') };
|
|
125
|
+
}
|