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,95 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { dirname, basename, join } from 'node:path';
|
|
4
|
+
import { validateEntity } from './contracts.mjs';
|
|
5
|
+
|
|
6
|
+
function amountKey(item, provider) { return `${provider}\u0000${item.unit}\u0000${item.currency ?? ''}`; }
|
|
7
|
+
function validAmounts(items, allowZero = false) {
|
|
8
|
+
if (!Array.isArray(items) || !items.length) throw new Error('cost upper bounds required');
|
|
9
|
+
const seen = new Set();
|
|
10
|
+
for (const item of items) {
|
|
11
|
+
if (!['credits', 'tokens', 'search_calls', 'currency_minor'].includes(item.unit) ||
|
|
12
|
+
!Number.isSafeInteger(item.amount) || (allowZero ? item.amount < 0 : item.amount < 1) ||
|
|
13
|
+
(item.unit === 'currency_minor' ? !/^[A-Z]{3}$/.test(item.currency ?? '') : item.currency != null))
|
|
14
|
+
throw new Error('invalid cost unit or amount');
|
|
15
|
+
const key = amountKey(item, '');
|
|
16
|
+
if (seen.has(key)) throw new Error('duplicate cost unit');
|
|
17
|
+
seen.add(key);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function locked(file, action) {
|
|
22
|
+
const lock = `${file}.lock`;
|
|
23
|
+
// ponytail: one ledger lock limits throughput; split by run only if this becomes measurable.
|
|
24
|
+
let acquired = false;
|
|
25
|
+
for (let attempt = 0; attempt < 30; attempt++) {
|
|
26
|
+
try { await mkdir(lock); acquired = true; break; }
|
|
27
|
+
catch (error) {
|
|
28
|
+
if (error.code !== 'EEXIST') throw error;
|
|
29
|
+
if (attempt === 29) throw new Error('cost ledger lock exists; manual recovery required');
|
|
30
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (!acquired) throw new Error('cost ledger lock exists');
|
|
34
|
+
try {
|
|
35
|
+
const ledger = validateEntity('CostLedger', JSON.parse(await readFile(file, 'utf8')));
|
|
36
|
+
const result = await action(ledger);
|
|
37
|
+
const temporary = join(dirname(file), `.${basename(file)}.${randomUUID()}.tmp`);
|
|
38
|
+
try { await writeFile(temporary, `${JSON.stringify(ledger, null, 2)}\n`, { flag: 'wx' }); await rename(temporary, file); }
|
|
39
|
+
catch (error) { await rm(temporary, { force: true }); throw error; }
|
|
40
|
+
return result ?? ledger;
|
|
41
|
+
} finally { await rm(lock, { recursive: true }); }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function heldAmount(ledger, provider, unit) {
|
|
45
|
+
return ledger.reservations.reduce((total, reservation) => {
|
|
46
|
+
if (reservation.provider !== provider) return total;
|
|
47
|
+
const upper = reservation.upperBounds.find(x => amountKey(x, provider) === amountKey(unit, provider));
|
|
48
|
+
if (!upper) return total;
|
|
49
|
+
const settled = ledger.settlements.find(x => x.reservation_id === reservation.reservation_id && ['settled', 'overrun'].includes(x.status));
|
|
50
|
+
return total + BigInt(settled?.actual?.find(x => amountKey(x, provider) === amountKey(unit, provider))?.amount ?? upper.amount);
|
|
51
|
+
}, 0n);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Reserve a per-attempt upper bound before any paid provider request. */
|
|
55
|
+
export async function reserveCost(ledgerPath, { provider, upperBounds, approvalRef, runId }) {
|
|
56
|
+
if (typeof provider !== 'string' || !provider || typeof approvalRef !== 'string' || !approvalRef) throw new Error('provider and approval required');
|
|
57
|
+
validAmounts(upperBounds);
|
|
58
|
+
return locked(ledgerPath, ledger => {
|
|
59
|
+
if (ledger.approval_ref !== approvalRef) throw new Error('cost approval mismatch');
|
|
60
|
+
if (runId !== undefined && ledger.run_id !== runId) throw new Error('cost ledger run mismatch');
|
|
61
|
+
if (ledger.reservations.some(x => x.status === 'overrun')) throw new Error('cost spending blocked by observed overrun');
|
|
62
|
+
for (const requested of upperBounds) {
|
|
63
|
+
const budget = ledger.budgets.find(x => x.provider === provider && amountKey(x, provider) === amountKey(requested, provider));
|
|
64
|
+
if (!budget || !Number.isSafeInteger(budget.amount) || budget.amount < 0) throw new Error('cost cap missing');
|
|
65
|
+
if (heldAmount(ledger, provider, requested) + BigInt(requested.amount) > BigInt(budget.amount)) throw new Error('cost cap exceeded');
|
|
66
|
+
}
|
|
67
|
+
const reservation = { reservation_id: randomUUID(), provider, upperBounds, status: 'reserved', approval_ref: approvalRef,
|
|
68
|
+
created_at: new Date().toISOString() };
|
|
69
|
+
ledger.reservations.push(reservation);
|
|
70
|
+
return reservation;
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Unknown billing retains the bound; an observed overrun is recorded and blocks new spend. */
|
|
75
|
+
export async function settleCost(ledgerPath, reservationId, actualOrUnknown) {
|
|
76
|
+
return locked(ledgerPath, ledger => {
|
|
77
|
+
const reservation = ledger.reservations.find(x => x.reservation_id === reservationId);
|
|
78
|
+
if (!reservation || reservation.status !== 'reserved') throw new Error('reservation not open');
|
|
79
|
+
const unknown = actualOrUnknown === 'unknown';
|
|
80
|
+
let overrun = false;
|
|
81
|
+
if (!unknown) {
|
|
82
|
+
validAmounts(actualOrUnknown, true);
|
|
83
|
+
for (const actual of actualOrUnknown) {
|
|
84
|
+
const upper = reservation.upperBounds.find(x => amountKey(x, reservation.provider) === amountKey(actual, reservation.provider));
|
|
85
|
+
if (!upper) throw new Error('actual cost unit was not reserved');
|
|
86
|
+
if (actual.amount > upper.amount) overrun = true;
|
|
87
|
+
}
|
|
88
|
+
if (actualOrUnknown.length !== reservation.upperBounds.length) throw new Error('actual cost units incomplete');
|
|
89
|
+
}
|
|
90
|
+
reservation.status = unknown ? 'unknown' : overrun ? 'overrun' : 'settled';
|
|
91
|
+
ledger.settlements.push({ reservation_id: reservationId, status: reservation.status, actual: unknown ? null : actualOrUnknown,
|
|
92
|
+
settled_at: new Date().toISOString() });
|
|
93
|
+
return ledger;
|
|
94
|
+
});
|
|
95
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
|
|
3
|
+
const table = JSON.parse(readFileSync(new URL('../../references/ai-crawlers.json', import.meta.url), 'utf8'));
|
|
4
|
+
const byEngine = new Map();
|
|
5
|
+
for (const [engine, row] of Object.entries(table.engines)) {
|
|
6
|
+
byEngine.set(engine, row.agent);
|
|
7
|
+
for (const alias of row.aliases ?? []) byEngine.set(alias, row.agent);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** robots.txt token that governs a protocol engine, or null when the engine has no known search token. */
|
|
11
|
+
export const agentFor = engine => byEngine.get(String(engine).toLowerCase()) ?? null;
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { validateEntity } from './contracts.mjs';
|
|
3
|
+
import { auditCollected } from '../../modules/naver-searchadvisor/scripts/audit.mjs';
|
|
4
|
+
import { parseRobots, robotsVerdict } from '../../modules/naver-searchadvisor/scripts/robots.mjs';
|
|
5
|
+
import { anchorLinks, decodeEntities } from './html.mjs';
|
|
6
|
+
import { agentFor } from './crawlers.mjs';
|
|
7
|
+
import { declaredRobotsIndex } from './collector.mjs';
|
|
8
|
+
import { compareFacts, pageFacts } from './facts.mjs';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Every collector verdict, derived from collected responses alone. The audit writes these observations and the
|
|
12
|
+
* report replays this same function over hashed raw evidence, so a producer and a verifier cannot disagree.
|
|
13
|
+
* `artifacts` carry the collection record fields plus evidence_ref; `textOf(artifact)` returns the raw body text.
|
|
14
|
+
*/
|
|
15
|
+
/** Link and sitemap target comparison key: no fragment, and a directory index file equals its directory. */
|
|
16
|
+
function canonicalUrl(value) {
|
|
17
|
+
try {
|
|
18
|
+
const url = new URL(value); url.hash = '';
|
|
19
|
+
url.pathname = url.pathname.replace(/\/index\.(?:html?|php)$/i, '/');
|
|
20
|
+
return url.href;
|
|
21
|
+
} catch { return null; }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function deriveObservations({ context, artifacts, collection = {}, textOf }) {
|
|
25
|
+
const protocol = context.protocol;
|
|
26
|
+
const planned = new Set(protocol.families.filter(x => x.applicable).map(x => x.family_id));
|
|
27
|
+
const selected = new Map(protocol.page_types.flatMap(type => type.urls).map(url => [url.normalized, url]));
|
|
28
|
+
const declared = kind => new Set((protocol.auxiliary_resources ?? []).filter(x => x.kind === kind).map(x => x.normalized));
|
|
29
|
+
const declaredRobots = declared('robots'), declaredSitemaps = declared('sitemap');
|
|
30
|
+
const out = [];
|
|
31
|
+
const add = (family, engine, url, state, reason, evidence_ref) => {
|
|
32
|
+
const unit_key = `${family}|${engine ? `${engine}|` : ''}${url}`;
|
|
33
|
+
out.push(validateEntity('CheckObservation', { schema_version: '1.0.0',
|
|
34
|
+
observation_id: createHash('sha256').update(unit_key).digest('hex'), check_id: family, family_id: family, unit_key,
|
|
35
|
+
state, q: state === 'pass' ? 1 : state === 'fail' ? 0 : null, partial_basis: null, reason, evaluator_type: 'code',
|
|
36
|
+
source_ref: 'optbrew:collector', evidence_ref }));
|
|
37
|
+
};
|
|
38
|
+
const pageRows = [];
|
|
39
|
+
for (const artifact of artifacts) {
|
|
40
|
+
const url = artifact.normalized_url;
|
|
41
|
+
if (!selected.has(url)) continue;
|
|
42
|
+
if (planned.has('fetch-response')) add('fetch-response', null, url,
|
|
43
|
+
artifact.error || artifact.status < 200 || artifact.status >= 300 && artifact.status < 400 ? 'unknown' : artifact.status >= 400 ? 'fail' : 'pass',
|
|
44
|
+
artifact.error ? 'Request did not finish' : `HTTP ${artifact.status}`, artifact.evidence_ref);
|
|
45
|
+
if (artifact.capability !== 'raw_html' || artifact.status !== 200) continue;
|
|
46
|
+
const html = textOf(artifact);
|
|
47
|
+
const robotsIndex = declaredRobotsIndex(artifacts, declaredRobots, artifact.final_url);
|
|
48
|
+
const robots = robotsIndex < 0 ? null : artifacts[robotsIndex];
|
|
49
|
+
const info = auditCollected({ artifact, html, engines: protocol.engines,
|
|
50
|
+
robotsArtifact: robots?.status === 200 ? { ...robots, body: textOf(robots) } : null });
|
|
51
|
+
pageRows.push({ url, artifact, html, info, robots, publicIndexing: selected.get(url).public_indexing !== false });
|
|
52
|
+
}
|
|
53
|
+
const normalizeTitle = title => title.toLowerCase().replace(/\s+/g, ' ').trim();
|
|
54
|
+
const titleCounts = new Map();
|
|
55
|
+
for (const row of pageRows) if (row.info.title) {
|
|
56
|
+
const key = normalizeTitle(row.info.title);
|
|
57
|
+
titleCounts.set(key, new Set([...(titleCounts.get(key) ?? []), row.artifact.final_url]));
|
|
58
|
+
}
|
|
59
|
+
const facts = pageRows.map(row => ({ url: row.url, row, facts: pageFacts(row.html) }));
|
|
60
|
+
const comparison = compareFacts(facts);
|
|
61
|
+
for (const row of pageRows) {
|
|
62
|
+
const { url, artifact, info } = row, ref = artifact.evidence_ref;
|
|
63
|
+
if (planned.has('document-title')) {
|
|
64
|
+
const dup = info.title ? pageRows.filter(x => x !== row && x.info.title && normalizeTitle(x.info.title) === normalizeTitle(info.title) &&
|
|
65
|
+
x.artifact.final_url !== artifact.final_url) : [];
|
|
66
|
+
add('document-title', null, url, !info.title || dup.length ? 'fail' : 'unknown',
|
|
67
|
+
!info.title ? 'Raw HTML has no title' : dup.length ? `Title duplicates ${dup.map(x => x.url).join(', ')}` :
|
|
68
|
+
'Raw HTML has a title; uniqueness and descriptiveness are not established', ref);
|
|
69
|
+
}
|
|
70
|
+
if (planned.has('index-policy') && row.publicIndexing)
|
|
71
|
+
add('index-policy', null, url, info.index_blocked ? 'fail' : 'pass',
|
|
72
|
+
info.index_blocked ? 'Raw response blocks indexing for the target engine' : 'Raw response has no applicable noindex directive', ref);
|
|
73
|
+
if (planned.has('search-crawl-policy') && row.publicIndexing && row.robots?.status === 200) {
|
|
74
|
+
const parsed = parseRobots(textOf(row.robots));
|
|
75
|
+
const target = new URL(artifact.final_url);
|
|
76
|
+
for (const engine of protocol.engines) {
|
|
77
|
+
const agent = agentFor(engine);
|
|
78
|
+
if (!agent) continue;
|
|
79
|
+
const verdict = robotsVerdict(parsed, agent, target.pathname + target.search);
|
|
80
|
+
add('search-crawl-policy', engine, url, verdict.allowed ? 'pass' : 'fail',
|
|
81
|
+
verdict.allowed ? `Collected robots.txt allows ${agent} path` : `Collected robots.txt disallows ${agent} path`, row.robots.evidence_ref);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (planned.has('naver-markup') && protocol.engines.includes('naver'))
|
|
85
|
+
add('naver-markup', 'naver', url, info.markup_findings.length ? 'fail' : 'pass',
|
|
86
|
+
info.markup_findings.length ? info.markup_findings.map(x => x.message).join('; ') : 'Open Graph, description, canonical, viewport and title checks pass', ref);
|
|
87
|
+
if (planned.has('structured-data-truth') && info.jsonld_parse_errors > 0)
|
|
88
|
+
add('structured-data-truth', null, url, 'fail', `${info.jsonld_parse_errors} JSON-LD block(s) fail to parse`, ref);
|
|
89
|
+
if (planned.has('canonical-consistency')) {
|
|
90
|
+
const hops = artifact.redirect_chain?.length ?? 0;
|
|
91
|
+
let canonicalState = null, canonicalNote = '';
|
|
92
|
+
if (info.canonical) {
|
|
93
|
+
try {
|
|
94
|
+
const target = new URL(info.canonical, artifact.final_url); target.hash = '';
|
|
95
|
+
const other = artifacts.find(x => x.normalized_url === target.href || x.final_url === target.href);
|
|
96
|
+
if (target.href === artifact.final_url || target.href === artifact.normalized_url) canonicalState = 'pass';
|
|
97
|
+
else if (other && other.status >= 200 && other.status < 300) canonicalState = 'pass';
|
|
98
|
+
else if (other && Number.isInteger(other.status)) { canonicalState = 'fail'; canonicalNote = `canonical target ${target.href} returns HTTP ${other.status}`; }
|
|
99
|
+
} catch { canonicalState = 'fail'; canonicalNote = 'canonical is not a valid URL'; }
|
|
100
|
+
}
|
|
101
|
+
if (hops >= 2) add('canonical-consistency', null, url, 'fail', `Redirect chain of ${hops} hops${canonicalNote ? `; ${canonicalNote}` : ''}`, ref);
|
|
102
|
+
else if (canonicalState === 'fail') add('canonical-consistency', null, url, 'fail', canonicalNote, ref);
|
|
103
|
+
else if (canonicalState === 'pass') add('canonical-consistency', null, url, 'pass', 'Canonical target is this page or a reachable page and redirects stay within one hop', ref);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (planned.has('discovery')) {
|
|
107
|
+
const sitemapUrls = new Set();
|
|
108
|
+
const brokenSitemaps = [];
|
|
109
|
+
let sitemapIndex = false;
|
|
110
|
+
for (const artifact of artifacts) {
|
|
111
|
+
if (!declaredSitemaps.has(artifact.normalized_url)) continue;
|
|
112
|
+
if (artifact.status >= 200 && artifact.status < 300) {
|
|
113
|
+
const body = String(textOf(artifact) ?? '');
|
|
114
|
+
// Child sitemaps of an index are not fetched, so pages missing from the index prove nothing.
|
|
115
|
+
if (/<sitemapindex\b/i.test(body)) sitemapIndex = true;
|
|
116
|
+
for (const match of body.matchAll(/<loc>\s*([^<\s]+)\s*<\/loc>/gi)) sitemapUrls.add(canonicalUrl(decodeEntities(match[1])));
|
|
117
|
+
} else brokenSitemaps.push(`${artifact.normalized_url} (${artifact.status ?? artifact.error ?? 'no response'})`);
|
|
118
|
+
}
|
|
119
|
+
const htmlPages = artifacts.filter(x => x.capability === 'raw_html' && x.status === 200);
|
|
120
|
+
const linked = new Map();
|
|
121
|
+
// A page with scripts and no anchors may build its navigation in the browser: its links are unknowable.
|
|
122
|
+
const scriptOnly = new Set();
|
|
123
|
+
for (const page of htmlPages) {
|
|
124
|
+
const body = textOf(page);
|
|
125
|
+
const hrefs = anchorLinks(body, page.final_url);
|
|
126
|
+
if (!hrefs.length && /<script\b/i.test(body)) scriptOnly.add(canonicalUrl(page.normalized_url));
|
|
127
|
+
for (const href of hrefs) {
|
|
128
|
+
const target = canonicalUrl(href);
|
|
129
|
+
if (target && target !== canonicalUrl(page.final_url) && target !== canonicalUrl(page.normalized_url)) linked.set(target, (linked.get(target) ?? 0) + 1);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const complete = !collection.limit_reached && !(collection.skipped ?? []).some(x => ['depth_limit', 'url_limit', 'request_limit'].includes(x.reason));
|
|
133
|
+
for (const row of pageRows) {
|
|
134
|
+
if (!row.publicIndexing) continue;
|
|
135
|
+
const { url, artifact } = row;
|
|
136
|
+
const keys = [canonicalUrl(url), canonicalUrl(artifact.final_url)];
|
|
137
|
+
const inSitemap = keys.some(key => sitemapUrls.has(key));
|
|
138
|
+
if (inSitemap || keys.some(key => linked.has(key)))
|
|
139
|
+
add('discovery', null, url, 'pass', inSitemap ? 'Listed in a declared sitemap' : 'Linked from another collected page', artifact.evidence_ref);
|
|
140
|
+
// A page with scripts and no anchors may link any page from the browser, so while one exists an unlinked page is only
|
|
141
|
+
// an orphan when a fetched sitemap that lists other pages leaves it out. Otherwise the verdict stays unknown.
|
|
142
|
+
else if (htmlPages.length >= 2 && complete && !sitemapIndex && (!scriptOnly.size || sitemapUrls.size))
|
|
143
|
+
add('discovery', null, url, 'fail', `Not linked from any collected page and not in a sitemap${brokenSitemaps.length ? `; declared sitemap unusable: ${brokenSitemaps.join(', ')}` : ''}`, artifact.evidence_ref);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (planned.has('fact-consistency')) {
|
|
147
|
+
for (const page of facts) {
|
|
148
|
+
if (comparison.separate.has(page.url)) continue;
|
|
149
|
+
const found = comparison.deviations.get(page.url) ?? [];
|
|
150
|
+
const comparable = Object.keys(comparison.references).some(type => page.facts.visible[type].size || page.facts.linked[type].size || (type === 'phone' && page.facts.tel.phone.size));
|
|
151
|
+
if (found.length) add('fact-consistency', null, page.url, 'fail', `Cross-page fact comparison (no approved fact list): ${found.join('; ')}`, page.row.artifact.evidence_ref);
|
|
152
|
+
else if (comparable) add('fact-consistency', null, page.url, 'pass', 'Facts on this page match the cross-page reference values (no approved fact list; consistency is not accuracy)', page.row.artifact.evidence_ref);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (planned.has('entity-identity') && facts.length) {
|
|
156
|
+
const reasons = [];
|
|
157
|
+
const home = facts.find(x => new URL(x.row.artifact.final_url).pathname === '/') ?? facts[0];
|
|
158
|
+
const orgPages = facts.filter(x => x.facts.orgs.length && !comparison.separate.has(x.url));
|
|
159
|
+
const nameReference = comparison.references.name;
|
|
160
|
+
const nameDeviants = facts.filter(x => (comparison.deviations.get(x.url) ?? []).some(text => text.startsWith('name ')));
|
|
161
|
+
if (nameDeviants.length) reasons.push(`name differs from "${nameReference.label}" on ${nameDeviants.map(x => x.url).join(', ')}`);
|
|
162
|
+
for (const page of orgPages) if (page.facts.orgs.some(org => !org.sameAs)) reasons.push(`Organization JSON-LD without sameAs on ${page.url}`);
|
|
163
|
+
if (orgPages.length && !home.facts.orgs.length) reasons.push(`no Organization or LocalBusiness JSON-LD on ${home.url}`);
|
|
164
|
+
if (reasons.length) add('entity-identity', null, 'site', 'fail', `Entity identity: ${reasons.join('; ')}`, (nameDeviants[0] ?? home).row.artifact.evidence_ref);
|
|
165
|
+
else if (orgPages.length) add('entity-identity', null, 'site', 'pass', 'Organization JSON-LD with name and sameAs is present on the home page and names agree', home.row.artifact.evidence_ref);
|
|
166
|
+
}
|
|
167
|
+
return out;
|
|
168
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { decodeEntities, jsonLdBlocks, stripComments, stripInert, visibleText } from './html.mjs';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Cross-page entity fact extraction and comparison, pure functions of the raw HTML of the selected pages.
|
|
5
|
+
* Facts come only from Organization or LocalBusiness JSON-LD, tel: links and explicitly labelled contact fields.
|
|
6
|
+
* Free prose, dates and numbers elsewhere on a page are never read as facts; when a comparison is not clear the
|
|
7
|
+
* result is unknown, not a failure.
|
|
8
|
+
*/
|
|
9
|
+
const FACT_TYPES = ['name', 'phone', 'year', 'address'];
|
|
10
|
+
const ORG_TYPES = /^(?:Organization|Corporation|LocalBusiness|Store|Restaurant|CafeOrCoffeeShop|OnlineStore|NGO|[A-Za-z]*Business)$/;
|
|
11
|
+
const squash = value => String(value).toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '');
|
|
12
|
+
const BRANCH = /\b(?:branch)\b|지점|지사/i;
|
|
13
|
+
|
|
14
|
+
const ABBREVIATIONS = [[/\bstreet\b/g, 'st'], [/\broad\b/g, 'rd'], [/\bavenue\b/g, 'ave'], [/\bboulevard\b/g, 'blvd'],
|
|
15
|
+
[/\bdrive\b/g, 'dr'], [/\blane\b/g, 'ln'], [/\bsuite\b/g, 'ste'], [/\bfloor\b/g, 'fl'], [/\bbuilding\b/g, 'bldg']];
|
|
16
|
+
/**
|
|
17
|
+
* Address key: normalized tokens joined by one space. Lower case, abbreviations unified, trailing country dropped, Korean
|
|
18
|
+
* 시/도 forms unified. A postal code is dropped only in its own position (in parentheses or brackets, after a label, or as
|
|
19
|
+
* the last token after the city); a five-digit street number such as "12345 Ocean Avenue" stays part of the address.
|
|
20
|
+
*/
|
|
21
|
+
export function addressKey(value) {
|
|
22
|
+
let text = String(value).toLowerCase().replace(/[,;]+/g, ' ');
|
|
23
|
+
text = text.replace(/\b(?:south korea|republic of korea|korea|kr|usa|us|united states|대한민국|한국)\b\s*$/u, '');
|
|
24
|
+
text = text.replace(/[(\[]\s*\d{3}-?\d{2,3}(?:-\d{4})?\s*[)\]]/g, ' ');
|
|
25
|
+
text = text.replace(/(?:우편번호|postal code|post code|zip code|zip)\s*[::]?\s*\d[\d-]*/giu, ' ');
|
|
26
|
+
text = text.replace(/(?<=\p{L}\s+(?:[a-z]{2}\s+)?)(?:\d{5}(?:-\d{4})?|\d{3}-\d{3}|\d{6})\s*$/u, ' ');
|
|
27
|
+
for (const [pattern, replacement] of ABBREVIATIONS) text = text.replace(pattern, replacement);
|
|
28
|
+
text = text.replace(/(특별자치시|특별자치도|특별시|광역시)/g, '').replace(/^\s*(\p{Script=Hangul}+?)(시|도)(?=\s|$)/u, '$1');
|
|
29
|
+
return (text.match(/\p{L}+|\p{N}+/gu) ?? []).join(' ');
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Two address keys agree when they are equal or one is a run of whole tokens of the other (a street address with or
|
|
33
|
+
* without city detail). Tokens are compared exactly, so "10 main st" never matches "110 main st".
|
|
34
|
+
*/
|
|
35
|
+
function sameAddress(a, b) {
|
|
36
|
+
if (a === b) return true;
|
|
37
|
+
// The same words in another order (an English and a Korean address order) are not a difference.
|
|
38
|
+
if (a.split(' ').sort().join(' ') === b.split(' ').sort().join(' ')) return true;
|
|
39
|
+
const [short, long] = a.length <= b.length ? [a, b] : [b, a];
|
|
40
|
+
const shortTokens = short.split(' '), longTokens = long.split(' ');
|
|
41
|
+
if (shortTokens.length < 2 || !shortTokens.some(token => /^\p{L}/u.test(token))) return false;
|
|
42
|
+
for (let i = 0; i + shortTokens.length <= longTokens.length; i++)
|
|
43
|
+
if (shortTokens.every((token, k) => token === longTokens[i + k])) return true;
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function nodesOf(value, out = []) {
|
|
48
|
+
if (Array.isArray(value)) value.forEach(item => nodesOf(item, out));
|
|
49
|
+
else if (value && typeof value === 'object') {
|
|
50
|
+
out.push(value);
|
|
51
|
+
if (value['@graph']) nodesOf(value['@graph'], out);
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
const typesOf = node => [].concat(node['@type'] ?? []).map(String);
|
|
56
|
+
const addressText = address => typeof address === 'string' ? address : address && typeof address === 'object' ?
|
|
57
|
+
[address.streetAddress, address.addressLocality, address.addressRegion]
|
|
58
|
+
.filter(x => typeof x === 'string').join(', ') : '';
|
|
59
|
+
|
|
60
|
+
function phoneKey(text) {
|
|
61
|
+
const clean = String(text).replace(/\(0\)/g, '');
|
|
62
|
+
const digits = clean.replace(/\D/g, '');
|
|
63
|
+
if (digits.length < 9 || digits.length > 15) return null;
|
|
64
|
+
return /^\s*\+?\s*82/.test(clean) && clean.trim().startsWith('+') && digits.startsWith('82') ? `0${digits.slice(2)}` : digits;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Text for phone extraction: every tag, inline or not, ends the text before it, so a figure in the next element
|
|
69
|
+
* (a table cell, a span, a list item) is never read as the rest of a number.
|
|
70
|
+
*/
|
|
71
|
+
const hardText = html => decodeEntities(stripInert(html).replace(/<[^>]+>/g, '\n')).replace(/[ \t\f\v\r ]+/g, ' ');
|
|
72
|
+
// An international or parenthesised number may use spaces between its groups; a plain national number only dashes and dots.
|
|
73
|
+
const LOOSE_PHONE = /^[+(]\s?[\d()]{1,6}(?:[-. ]?\(?\d{1,6}\)?){1,5}/;
|
|
74
|
+
const STRICT_PHONE = /^\+?\d{1,4}(?:[-.]\d{1,6}){1,4}/;
|
|
75
|
+
/**
|
|
76
|
+
* The number at the start of `rest`, ending at its own last digit group. A spaced number that is followed by more
|
|
77
|
+
* digits or a bracketed digit is ambiguous, so only its dashed or dotted part is read, and a number longer than twelve
|
|
78
|
+
* digits is not read at all.
|
|
79
|
+
*/
|
|
80
|
+
function readPhone(rest) {
|
|
81
|
+
const usable = text => text && (phoneKey(text)?.length ?? 99) <= 13 ? text.trim() : null;
|
|
82
|
+
const loose = LOOSE_PHONE.exec(rest)?.[0].trimEnd();
|
|
83
|
+
if (loose && !/^[ \t]*[(\[]?\d/.test(rest.slice(loose.length)) && usable(loose)) return loose.trim();
|
|
84
|
+
return usable(STRICT_PHONE.exec(rest)?.[0]);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Facts on one page: visible labelled values and JSON-LD / tel: values, keyed by fact type. */
|
|
88
|
+
export function pageFacts(html) {
|
|
89
|
+
const live = stripComments(html);
|
|
90
|
+
const visible = Object.fromEntries(FACT_TYPES.map(type => [type, new Map()]));
|
|
91
|
+
const linked = Object.fromEntries(FACT_TYPES.map(type => [type, new Map()]));
|
|
92
|
+
// tel: links are their own evidence class: supporting evidence that never contradicts a page's labelled or JSON-LD phone.
|
|
93
|
+
const tel = { phone: new Map() };
|
|
94
|
+
const put = (bucket, type, raw, key) => { if (key) bucket[type].set(key, String(raw).trim()); };
|
|
95
|
+
const text = visibleText(live);
|
|
96
|
+
// The text after a label is read in a lookahead, so a label alone (a heading that says "phone") never consumes the next line.
|
|
97
|
+
for (const match of hardText(live).matchAll(/\b(?:Phone|Tel(?:ephone)?|Mobile)\b(?=\s*[::]?\s*([^\n]*))|(?:전화(?:번호)?|연락처|대표번호)(?=\s*[::]?\s*([^\n]*))/gi)) {
|
|
98
|
+
const number = readPhone(match[1] ?? match[2]);
|
|
99
|
+
if (number) put(visible, 'phone', number, phoneKey(number));
|
|
100
|
+
}
|
|
101
|
+
for (const match of live.matchAll(/href\s*=\s*["']tel:([^"']+)["']/gi)) {
|
|
102
|
+
const value = decodeURIComponent(match[1]);
|
|
103
|
+
put(tel, 'phone', value, phoneKey(value));
|
|
104
|
+
}
|
|
105
|
+
for (const match of text.matchAll(/\b(?:Founded|Established)\b\s*[::]\s*((?:19|20)\d{2})\b|(?:설립(?:연도)?|창립(?:연도)?)\s*[::]?\s*((?:19|20)\d{2})/gi))
|
|
106
|
+
put(visible, 'year', match[1] ?? match[2], match[1] ?? match[2]);
|
|
107
|
+
for (const match of text.matchAll(/\bAddress\b\s*[::]\s*([^\n]+?)(?=\.\s|\.?\s*$|\n)/gim)) put(visible, 'address', match[1], addressKey(match[1]));
|
|
108
|
+
for (const match of text.matchAll(/주소\s*[::]\s*([^\n.]+)/g)) put(visible, 'address', match[1], addressKey(match[1]));
|
|
109
|
+
const orgs = [];
|
|
110
|
+
for (const block of jsonLdBlocks(live)) {
|
|
111
|
+
if (!block.ok) continue;
|
|
112
|
+
for (const node of nodesOf(block.value)) {
|
|
113
|
+
if (!typesOf(node).some(type => ORG_TYPES.test(type))) continue;
|
|
114
|
+
const name = typeof node.name === 'string' ? node.name : '';
|
|
115
|
+
const org = { name, id: typeof node['@id'] === 'string' ? node['@id'] : null,
|
|
116
|
+
sameAs: [].concat(node.sameAs ?? []).some(x => typeof x === 'string' && x.trim()),
|
|
117
|
+
branch: Boolean(node.branchOf || node.parentOrganization || BRANCH.test(name)) };
|
|
118
|
+
if (name) put(linked, 'name', name, squash(name));
|
|
119
|
+
if (typeof node.telephone === 'string') put(linked, 'phone', node.telephone, phoneKey(node.telephone));
|
|
120
|
+
const address = addressText(node.address);
|
|
121
|
+
if (address) put(linked, 'address', address, addressKey(address));
|
|
122
|
+
const year = /\b((?:19|20)\d{2})\b/.exec(String(node.foundingDate ?? ''))?.[1];
|
|
123
|
+
if (year) put(linked, 'year', year, year);
|
|
124
|
+
orgs.push(org);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// A page that presents itself as a branch has its own address and phone; it is a different entity, so it is not compared.
|
|
128
|
+
const heading = /<(?:title|h1)\b[^>]*>([\s\S]*?)<\/(?:title|h1)>/gi;
|
|
129
|
+
const headings = [...live.matchAll(heading)].map(match => match[1]).join(' ');
|
|
130
|
+
return { visible, linked, tel, orgs, branch: BRANCH.test(headings) || orgs.some(org => org.branch) };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Values compared across pages for one fact type: the labelled and JSON-LD values. A tel: link joins only when the page has
|
|
135
|
+
* no labelled or JSON-LD phone, so a mobile inquiry link beside the page's landline never counts against it.
|
|
136
|
+
*/
|
|
137
|
+
function evidenceOf(page, type) {
|
|
138
|
+
const found = new Map([...page.facts.visible[type], ...page.facts.linked[type]]);
|
|
139
|
+
if (type === 'phone' && !found.size) for (const [key, raw] of page.facts.tel.phone) found.set(key, raw);
|
|
140
|
+
return found;
|
|
141
|
+
}
|
|
142
|
+
const keysOf = (page, type) => new Set(evidenceOf(page, type).keys());
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Compare facts across the selected pages. A reference value needs at least two pages and a unique top count;
|
|
146
|
+
* otherwise the type is insufficient and stays unknown. Branch pages and entities with a different JSON-LD @id are skipped.
|
|
147
|
+
*/
|
|
148
|
+
export function compareFacts(allPages) {
|
|
149
|
+
const ids = new Map();
|
|
150
|
+
for (const page of allPages) for (const id of new Set(page.facts.orgs.map(org => org.id).filter(Boolean))) ids.set(id, (ids.get(id) ?? 0) + 1);
|
|
151
|
+
const ranked = [...ids.entries()].sort((a, b) => b[1] - a[1]);
|
|
152
|
+
const mainId = ranked.length && ranked[0][1] >= 2 && (ranked.length === 1 || ranked[0][1] > ranked[1][1]) ? ranked[0][0] : null;
|
|
153
|
+
const separate = page => page.facts.branch || (mainId && page.facts.orgs.some(org => org.id) && !page.facts.orgs.some(org => org.id === mainId));
|
|
154
|
+
const pages = allPages.filter(page => !separate(page));
|
|
155
|
+
const references = {};
|
|
156
|
+
const deviations = new Map(allPages.map(page => [page.url, []]));
|
|
157
|
+
for (const type of FACT_TYPES) {
|
|
158
|
+
const counts = new Map();
|
|
159
|
+
const carrying = pages.filter(page => keysOf(page, type).size);
|
|
160
|
+
for (const page of carrying) for (const key of keysOf(page, type)) counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
161
|
+
if (type === 'address') {
|
|
162
|
+
// Merge keys that agree under address matching so abbreviations and city detail do not split the count.
|
|
163
|
+
const keys = [...counts.keys()];
|
|
164
|
+
for (const key of keys) for (const other of keys) if (key !== other && counts.has(key) && counts.has(other) && sameAddress(key, other) && key.length >= other.length) {
|
|
165
|
+
counts.set(other, counts.get(other) + counts.get(key)); counts.delete(key);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
const top = [...counts.entries()].sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1));
|
|
169
|
+
if (top.length && top[0][1] >= 2 && (top.length === 1 || top[0][1] > top[1][1])) {
|
|
170
|
+
const [modal, count] = top[0];
|
|
171
|
+
const agrees = key => type === 'address' ? sameAddress(key, modal) || [...counts.keys()].some(k => sameAddress(k, modal) && sameAddress(k, key)) : key === modal;
|
|
172
|
+
const label = pages.flatMap(page => [...evidenceOf(page, type).entries()]).find(([key]) => agrees(key))?.[1] ?? modal;
|
|
173
|
+
references[type] = { key: modal, label, pages: count };
|
|
174
|
+
for (const page of carrying) {
|
|
175
|
+
if (![...keysOf(page, type)].some(agrees)) {
|
|
176
|
+
const found = [...evidenceOf(page, type).values()].join(' / ');
|
|
177
|
+
deviations.get(page.url).push(`${type} "${found}" differs from "${label}" on ${count} pages`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
for (const page of pages) {
|
|
182
|
+
const v = [...page.facts.visible[type].keys()], l = [...page.facts.linked[type].keys()];
|
|
183
|
+
const same = type === 'address' ? v.some(a => l.some(b => sameAddress(a, b))) : v.some(key => l.includes(key));
|
|
184
|
+
if (v.length && l.length && !same && !deviations.get(page.url).some(x => x.startsWith(`${type} `)))
|
|
185
|
+
deviations.get(page.url).push(`${type} in JSON-LD "${[...page.facts.linked[type].values()].join(' / ')}" differs from visible "${[...page.facts.visible[type].values()].join(' / ')}"`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return { references, deviations, separate: new Set(allPages.filter(separate).map(page => page.url)) };
|
|
189
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { ContractError, validateEntity } from './contracts.mjs';
|
|
2
|
+
|
|
3
|
+
const rank = { P0: 0, P1: 1, P2: 2, P3: 3 };
|
|
4
|
+
const evidenceRank = { official: 0, reproduced: 1, research: 2, case: 3, hypothesis: 4 };
|
|
5
|
+
|
|
6
|
+
/** Merge the same check, unit and issue while preserving every module source. */
|
|
7
|
+
export function mergeFindings(findings) {
|
|
8
|
+
if (!Array.isArray(findings)) throw new ContractError('findings must be an array');
|
|
9
|
+
const byIssue = new Map();
|
|
10
|
+
for (const item of [...findings].sort((a, b) => String(a.finding_id).localeCompare(String(b.finding_id)))) {
|
|
11
|
+
validateEntity('Finding', item);
|
|
12
|
+
if (item.check_id !== item.unit_key.split('|')[0] || !Object.hasOwn(rank, item.priority))
|
|
13
|
+
throw new ContractError('finding check or priority invalid');
|
|
14
|
+
const key = `${item.check_id}\0${item.unit_key}\0${item.message.trim().replace(/\s+/g, ' ').toLowerCase()}`;
|
|
15
|
+
const source = { source_ref: item.source_ref, evidence_ref: item.evidence_ref };
|
|
16
|
+
if (!byIssue.has(key)) byIssue.set(key, { ...item, priority: item.blocker ? 'P0' : item.priority,
|
|
17
|
+
finding_ids: [item.finding_id], sources: [source] });
|
|
18
|
+
else {
|
|
19
|
+
const existing = byIssue.get(key);
|
|
20
|
+
if (!existing.sources.some(x => x.source_ref === source.source_ref && x.evidence_ref === source.evidence_ref))
|
|
21
|
+
existing.sources.push(source);
|
|
22
|
+
if (rank[item.priority] < rank[existing.priority]) existing.priority = item.priority;
|
|
23
|
+
existing.blocker ||= item.blocker;
|
|
24
|
+
if (existing.blocker) existing.priority = 'P0';
|
|
25
|
+
existing.finding_ids = [...new Set([...existing.finding_ids, item.finding_id])];
|
|
26
|
+
existing.remediation_ids = [...new Set([...existing.remediation_ids, ...item.remediation_ids])];
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return [...byIssue.values()].map(item => ({ ...item, sources: item.sources.sort((a, b) =>
|
|
30
|
+
a.source_ref.localeCompare(b.source_ref) || a.evidence_ref.localeCompare(b.evidence_ref)) }))
|
|
31
|
+
.sort((a, b) => rank[a.priority] - rank[b.priority] || a.unit_key.localeCompare(b.unit_key));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Select only a directly applicable mechanism, ordered by evidence class. */
|
|
35
|
+
export function selectRemediations(finding, { context, capabilities, catalog }) {
|
|
36
|
+
validateEntity('Finding', finding);
|
|
37
|
+
if (!context || !capabilities || !Array.isArray(catalog)) throw new ContractError('remediation context, capabilities and catalog required');
|
|
38
|
+
const options = catalog.filter(item => item.check_id === finding.check_id).map(item => {
|
|
39
|
+
if (!item.remediation_id || !item.source_url || !item.checked_date || !item.module_ref || !item.mechanism ||
|
|
40
|
+
!item.validation || !item.effort || !item.cost || !item.required_authority || !Object.hasOwn(evidenceRank, item.evidence_tier))
|
|
41
|
+
throw new ContractError('remediation catalog entry incomplete');
|
|
42
|
+
const applicable = (item.applies_when?.states ?? ['fail']).includes(finding.status === 'unknown' ? 'unknown' : 'fail') &&
|
|
43
|
+
(!item.applies_when?.environments || item.applies_when.environments.includes(context.environment)) &&
|
|
44
|
+
(!item.applies_when?.capability || capabilities.selected.some(x => x.capability === item.applies_when.capability));
|
|
45
|
+
return { remediation_id: item.remediation_id, check_id: finding.check_id, selected: applicable,
|
|
46
|
+
reason: applicable ? `${item.evidence_tier}: ${item.mechanism}` : 'condition or capability not met',
|
|
47
|
+
source_ref: item.source_url, source_url: item.source_url, checked_date: item.checked_date,
|
|
48
|
+
evidence_tier: item.evidence_tier, module_ref: item.module_ref, mechanism: item.mechanism,
|
|
49
|
+
validation: item.validation, effort: item.effort, cost: item.cost, required_authority: item.required_authority,
|
|
50
|
+
location_hint: item.location_hint ?? null };
|
|
51
|
+
}).sort((a, b) => evidenceRank[a.evidence_tier] - evidenceRank[b.evidence_tier]);
|
|
52
|
+
const best = options.find(x => x.selected);
|
|
53
|
+
return options.map(x => ({ ...x, selected: x === best, reason: x === best ? x.reason : 'less direct or inapplicable' }));
|
|
54
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/** Small HTML helpers shared by the collector and the verdict derivation. Pure functions of raw bytes. */
|
|
2
|
+
|
|
3
|
+
/** Drop comments and raw text elements whose content is not markup. */
|
|
4
|
+
export function stripInert(html) {
|
|
5
|
+
return String(html ?? '').replace(/<!--[\s\S]*?(?:-->|$)|<(script|style|textarea)\b[^>]*>[\s\S]*?(?:<\/\1\s*>|$)/gi, '');
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Markup without HTML comments. */
|
|
9
|
+
export const stripComments = html => String(html ?? '').replace(/<!--[\s\S]*?(?:-->|$)/g, '');
|
|
10
|
+
|
|
11
|
+
const named = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' };
|
|
12
|
+
/** Decode the character references that appear in attribute values. */
|
|
13
|
+
export const decodeEntities = value => String(value).replace(/&(?:#(\d+)|#x([0-9a-f]+)|(amp|lt|gt|quot|apos|nbsp));/gi,
|
|
14
|
+
(match, dec, hex, name) => dec ? String.fromCodePoint(Number(dec)) : hex ? String.fromCodePoint(parseInt(hex, 16)) : named[name.toLowerCase()]);
|
|
15
|
+
|
|
16
|
+
/** Absolute hrefs of anchors, resolved against the response URL. */
|
|
17
|
+
export function anchorLinks(html, base) {
|
|
18
|
+
const out = [];
|
|
19
|
+
for (const match of String(html).matchAll(/<a\b[^>]*\bhref\s*=\s*(?:"([^"]*)"|'([^']*)')/gi)) {
|
|
20
|
+
try { out.push(new URL(decodeEntities(match[1] ?? match[2]), base).href); } catch { /* malformed link */ }
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The raw href of the first rel=canonical link in live markup, or null. */
|
|
26
|
+
export function canonicalLink(html) {
|
|
27
|
+
for (const match of stripInert(html).matchAll(/<link\b([^>]*)>/gi)) {
|
|
28
|
+
const attrs = {};
|
|
29
|
+
for (const a of match[1].matchAll(/([a-zA-Z:_-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/g)) attrs[a[1].toLowerCase()] = a[2] ?? a[3] ?? a[4] ?? '';
|
|
30
|
+
if (/(^|\s)canonical(\s|$)/i.test(attrs.rel ?? '') && attrs.href) return attrs.href;
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** JSON-LD blocks with their parse result. */
|
|
36
|
+
export function jsonLdBlocks(html) {
|
|
37
|
+
// Blocks inside HTML comments are not live markup, and an empty placeholder block is not an error.
|
|
38
|
+
return [...stripComments(html).matchAll(/<script[^>]+application\/ld\+json[^>]*>([\s\S]*?)<\/script>/gi)]
|
|
39
|
+
.filter(match => match[1].trim()).map(match => {
|
|
40
|
+
try { return { ok: true, value: JSON.parse(match[1]) }; } catch { return { ok: false, value: null }; }
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const entities = { '&': '&', '<': '<', '>': '>', '"': '"', ''': "'", ' ': ' ' };
|
|
45
|
+
|
|
46
|
+
/** Visible text with block boundaries kept as line breaks. */
|
|
47
|
+
export function visibleText(html) {
|
|
48
|
+
return stripInert(html)
|
|
49
|
+
.replace(/<\/?(?:h[1-6]|p|div|li|ul|ol|br|tr|section|article|header|footer|main|nav|title)\b[^>]*>/gi, '\n')
|
|
50
|
+
.replace(/<[^>]+>/g, ' ')
|
|
51
|
+
.replace(/&(?:amp|lt|gt|quot|#39|nbsp);/g, token => entities[token])
|
|
52
|
+
.replace(/[ \t\f\v\r]+/g, ' ').replace(/ ?\n ?/g, '\n').replace(/\n{2,}/g, '\n').trim();
|
|
53
|
+
}
|