@nebulacomponents/citable 1.15.1 → 1.16.0
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/CHANGELOG.md +25 -0
- package/README.md +3 -3
- package/dist/universal/.agents/skills/citable/SKILL.md +5 -1
- package/dist/universal/.agents/skills/citable/VERSION +1 -1
- package/dist/universal/.agents/skills/citable/commands/README.md +3 -1
- package/dist/universal/.agents/skills/citable/commands/governance.md +12 -0
- package/dist/universal/.agents/skills/citable/commands/measurement.md +20 -0
- package/dist/universal/.agents/skills/citable/commands/reporting.md +16 -0
- package/dist/universal/.agents/skills/citable/manifest.json +14 -11
- package/dist/universal/.agents/skills/citable/schemas/experiment.schema.json +1 -1
- package/dist/universal/.agents/skills/citable/schemas/fleet.schema.json +93 -0
- package/dist/universal/.agents/skills/citable/schemas/funnel.schema.json +116 -0
- package/dist/universal/.agents/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
- package/dist/universal/.agents/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.claude/agents/citable/manifest.json +2 -2
- package/dist/universal/.claude/skills/citable/SKILL.md +5 -1
- package/dist/universal/.claude/skills/citable/VERSION +1 -1
- package/dist/universal/.claude/skills/citable/commands/README.md +3 -1
- package/dist/universal/.claude/skills/citable/commands/governance.md +12 -0
- package/dist/universal/.claude/skills/citable/commands/measurement.md +20 -0
- package/dist/universal/.claude/skills/citable/commands/reporting.md +16 -0
- package/dist/universal/.claude/skills/citable/manifest.json +14 -11
- package/dist/universal/.claude/skills/citable/schemas/experiment.schema.json +1 -1
- package/dist/universal/.claude/skills/citable/schemas/fleet.schema.json +93 -0
- package/dist/universal/.claude/skills/citable/schemas/funnel.schema.json +116 -0
- package/dist/universal/.claude/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
- package/dist/universal/.claude/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.cursor/skills/citable/SKILL.md +5 -1
- package/dist/universal/.cursor/skills/citable/VERSION +1 -1
- package/dist/universal/.cursor/skills/citable/commands/README.md +3 -1
- package/dist/universal/.cursor/skills/citable/commands/governance.md +12 -0
- package/dist/universal/.cursor/skills/citable/commands/measurement.md +20 -0
- package/dist/universal/.cursor/skills/citable/commands/reporting.md +16 -0
- package/dist/universal/.cursor/skills/citable/manifest.json +14 -11
- package/dist/universal/.cursor/skills/citable/schemas/experiment.schema.json +1 -1
- package/dist/universal/.cursor/skills/citable/schemas/fleet.schema.json +93 -0
- package/dist/universal/.cursor/skills/citable/schemas/funnel.schema.json +116 -0
- package/dist/universal/.cursor/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
- package/dist/universal/.cursor/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.gemini/skills/citable/SKILL.md +5 -1
- package/dist/universal/.gemini/skills/citable/VERSION +1 -1
- package/dist/universal/.gemini/skills/citable/commands/README.md +3 -1
- package/dist/universal/.gemini/skills/citable/commands/governance.md +12 -0
- package/dist/universal/.gemini/skills/citable/commands/measurement.md +20 -0
- package/dist/universal/.gemini/skills/citable/commands/reporting.md +16 -0
- package/dist/universal/.gemini/skills/citable/manifest.json +14 -11
- package/dist/universal/.gemini/skills/citable/schemas/experiment.schema.json +1 -1
- package/dist/universal/.gemini/skills/citable/schemas/fleet.schema.json +93 -0
- package/dist/universal/.gemini/skills/citable/schemas/funnel.schema.json +116 -0
- package/dist/universal/.gemini/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
- package/dist/universal/.gemini/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.github/skills/citable/SKILL.md +5 -1
- package/dist/universal/.github/skills/citable/VERSION +1 -1
- package/dist/universal/.github/skills/citable/commands/README.md +3 -1
- package/dist/universal/.github/skills/citable/commands/governance.md +12 -0
- package/dist/universal/.github/skills/citable/commands/measurement.md +20 -0
- package/dist/universal/.github/skills/citable/commands/reporting.md +16 -0
- package/dist/universal/.github/skills/citable/manifest.json +14 -11
- package/dist/universal/.github/skills/citable/schemas/experiment.schema.json +1 -1
- package/dist/universal/.github/skills/citable/schemas/fleet.schema.json +93 -0
- package/dist/universal/.github/skills/citable/schemas/funnel.schema.json +116 -0
- package/dist/universal/.github/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
- package/dist/universal/.github/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.kiro/skills/citable/SKILL.md +5 -1
- package/dist/universal/.kiro/skills/citable/VERSION +1 -1
- package/dist/universal/.kiro/skills/citable/commands/README.md +3 -1
- package/dist/universal/.kiro/skills/citable/commands/governance.md +12 -0
- package/dist/universal/.kiro/skills/citable/commands/measurement.md +20 -0
- package/dist/universal/.kiro/skills/citable/commands/reporting.md +16 -0
- package/dist/universal/.kiro/skills/citable/manifest.json +14 -11
- package/dist/universal/.kiro/skills/citable/schemas/experiment.schema.json +1 -1
- package/dist/universal/.kiro/skills/citable/schemas/fleet.schema.json +93 -0
- package/dist/universal/.kiro/skills/citable/schemas/funnel.schema.json +116 -0
- package/dist/universal/.kiro/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
- package/dist/universal/.kiro/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.opencode/skills/citable/SKILL.md +5 -1
- package/dist/universal/.opencode/skills/citable/VERSION +1 -1
- package/dist/universal/.opencode/skills/citable/commands/README.md +3 -1
- package/dist/universal/.opencode/skills/citable/commands/governance.md +12 -0
- package/dist/universal/.opencode/skills/citable/commands/measurement.md +20 -0
- package/dist/universal/.opencode/skills/citable/commands/reporting.md +16 -0
- package/dist/universal/.opencode/skills/citable/manifest.json +14 -11
- package/dist/universal/.opencode/skills/citable/schemas/experiment.schema.json +1 -1
- package/dist/universal/.opencode/skills/citable/schemas/fleet.schema.json +93 -0
- package/dist/universal/.opencode/skills/citable/schemas/funnel.schema.json +116 -0
- package/dist/universal/.opencode/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
- package/dist/universal/.opencode/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.pi/agent/skills/citable/SKILL.md +5 -1
- package/dist/universal/.pi/agent/skills/citable/VERSION +1 -1
- package/dist/universal/.pi/agent/skills/citable/commands/README.md +3 -1
- package/dist/universal/.pi/agent/skills/citable/commands/governance.md +12 -0
- package/dist/universal/.pi/agent/skills/citable/commands/measurement.md +20 -0
- package/dist/universal/.pi/agent/skills/citable/commands/reporting.md +16 -0
- package/dist/universal/.pi/agent/skills/citable/manifest.json +14 -11
- package/dist/universal/.pi/agent/skills/citable/schemas/experiment.schema.json +1 -1
- package/dist/universal/.pi/agent/skills/citable/schemas/fleet.schema.json +93 -0
- package/dist/universal/.pi/agent/skills/citable/schemas/funnel.schema.json +116 -0
- package/dist/universal/.pi/agent/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
- package/dist/universal/.pi/agent/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.qoder/skills/citable/SKILL.md +5 -1
- package/dist/universal/.qoder/skills/citable/VERSION +1 -1
- package/dist/universal/.qoder/skills/citable/commands/README.md +3 -1
- package/dist/universal/.qoder/skills/citable/commands/governance.md +12 -0
- package/dist/universal/.qoder/skills/citable/commands/measurement.md +20 -0
- package/dist/universal/.qoder/skills/citable/commands/reporting.md +16 -0
- package/dist/universal/.qoder/skills/citable/manifest.json +14 -11
- package/dist/universal/.qoder/skills/citable/schemas/experiment.schema.json +1 -1
- package/dist/universal/.qoder/skills/citable/schemas/fleet.schema.json +93 -0
- package/dist/universal/.qoder/skills/citable/schemas/funnel.schema.json +116 -0
- package/dist/universal/.qoder/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
- package/dist/universal/.qoder/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.rovodev/skills/citable/SKILL.md +5 -1
- package/dist/universal/.rovodev/skills/citable/VERSION +1 -1
- package/dist/universal/.rovodev/skills/citable/commands/README.md +3 -1
- package/dist/universal/.rovodev/skills/citable/commands/governance.md +12 -0
- package/dist/universal/.rovodev/skills/citable/commands/measurement.md +20 -0
- package/dist/universal/.rovodev/skills/citable/commands/reporting.md +16 -0
- package/dist/universal/.rovodev/skills/citable/manifest.json +14 -11
- package/dist/universal/.rovodev/skills/citable/schemas/experiment.schema.json +1 -1
- package/dist/universal/.rovodev/skills/citable/schemas/fleet.schema.json +93 -0
- package/dist/universal/.rovodev/skills/citable/schemas/funnel.schema.json +116 -0
- package/dist/universal/.rovodev/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
- package/dist/universal/.rovodev/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.trae/skills/citable/SKILL.md +5 -1
- package/dist/universal/.trae/skills/citable/VERSION +1 -1
- package/dist/universal/.trae/skills/citable/commands/README.md +3 -1
- package/dist/universal/.trae/skills/citable/commands/governance.md +12 -0
- package/dist/universal/.trae/skills/citable/commands/measurement.md +20 -0
- package/dist/universal/.trae/skills/citable/commands/reporting.md +16 -0
- package/dist/universal/.trae/skills/citable/manifest.json +14 -11
- package/dist/universal/.trae/skills/citable/schemas/experiment.schema.json +1 -1
- package/dist/universal/.trae/skills/citable/schemas/fleet.schema.json +93 -0
- package/dist/universal/.trae/skills/citable/schemas/funnel.schema.json +116 -0
- package/dist/universal/.trae/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
- package/dist/universal/.trae/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.trae-cn/skills/citable/SKILL.md +5 -1
- package/dist/universal/.trae-cn/skills/citable/VERSION +1 -1
- package/dist/universal/.trae-cn/skills/citable/commands/README.md +3 -1
- package/dist/universal/.trae-cn/skills/citable/commands/governance.md +12 -0
- package/dist/universal/.trae-cn/skills/citable/commands/measurement.md +20 -0
- package/dist/universal/.trae-cn/skills/citable/commands/reporting.md +16 -0
- package/dist/universal/.trae-cn/skills/citable/manifest.json +14 -11
- package/dist/universal/.trae-cn/skills/citable/schemas/experiment.schema.json +1 -1
- package/dist/universal/.trae-cn/skills/citable/schemas/fleet.schema.json +93 -0
- package/dist/universal/.trae-cn/skills/citable/schemas/funnel.schema.json +116 -0
- package/dist/universal/.trae-cn/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
- package/dist/universal/.trae-cn/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/manifest.json +172 -136
- package/package.json +1 -1
- package/schemas/experiment.schema.json +1 -1
- package/schemas/fleet.schema.json +93 -0
- package/schemas/funnel.schema.json +116 -0
- package/schemas/mcp-transport-envelope.schema.json +244 -0
- package/schemas/observation.schema.json +1 -1
- package/skill/SKILL.md +5 -1
- package/skill/commands/README.md +3 -1
- package/skill/commands/governance.md +12 -0
- package/skill/commands/measurement.md +20 -0
- package/skill/commands/reporting.md +16 -0
- package/src/cli/index.js +383 -8
- package/src/commands/attributeImpact.js +54 -0
- package/src/commands/connect.js +3 -0
- package/src/commands/edgeRules.js +120 -0
- package/src/commands/exceptions.js +241 -0
- package/src/commands/fleet.js +104 -0
- package/src/commands/generateLlmsTxt.js +111 -0
- package/src/commands/inspectAeo.js +74 -0
- package/src/commands/inspectCro.js +82 -0
- package/src/commands/inspectGeo.js +52 -0
- package/src/commands/inspectSerp.js +317 -0
- package/src/commands/lintComponents.js +132 -0
- package/src/commands/monitor.js +138 -0
- package/src/commands/observe.js +6 -1
- package/src/commands/planExperiment.js +68 -0
- package/src/commands/probe.js +70 -0
- package/src/commands/reportConsensus.js +48 -0
- package/src/commands/testFunnel.js +107 -0
- package/src/connectors/index.js +6 -1
- package/src/connectors/indexnow.js +431 -0
- package/src/connectors/mcp/allowlist.js +107 -0
- package/src/connectors/mcp/client.js +239 -0
- package/src/connectors/mcp/diagnostics.js +214 -0
- package/src/connectors/mcp/gscServer.js +155 -0
- package/src/connectors/mcp/http.js +130 -0
- package/src/connectors/mcp/index.js +7 -0
- package/src/connectors/mcp/pilot.js +138 -0
- package/src/connectors/mcp/pilotServer.js +80 -0
- package/src/connectors/mcp/sanitizer.js +38 -0
- package/src/connectors/mcp/stdio.js +178 -0
- package/src/crawler/ipRanges.js +161 -0
- package/src/detectors/agent.js +127 -0
- package/src/detectors/ans.js +282 -0
- package/src/detectors/crawl.js +46 -0
- package/src/detectors/cro.js +777 -0
- package/src/detectors/cwv.js +38 -1
- package/src/detectors/evd.js +101 -0
- package/src/detectors/framework.js +1 -1
- package/src/detectors/geoReco.js +408 -0
- package/src/detectors/index.js +4 -2
- package/src/detectors/link.js +112 -1
- package/src/detectors/page.js +101 -0
- package/src/detectors/schemaData.js +529 -0
- package/src/detectors/tech.js +306 -0
- package/src/extractor/page.js +186 -2
- package/src/extractor/site.js +9 -2
- package/src/installer/index.js +45 -2
- package/src/monitoring/alertDelivery.js +101 -7
- package/src/observations/attribution.js +217 -0
- package/src/registries/index.js +14 -0
- package/src/reporting/consensus.js +416 -0
- package/src/reporting/executiveExport.js +132 -0
|
@@ -0,0 +1,777 @@
|
|
|
1
|
+
import { defineDetector, indexTargets, pageSubject, registryPageFor } from './framework.js';
|
|
2
|
+
|
|
3
|
+
const D = [];
|
|
4
|
+
|
|
5
|
+
D.push(defineDetector({
|
|
6
|
+
id: 'CRO-001', name: 'Declared conversion action missing visible interactive CTA', namespace: 'CRO',
|
|
7
|
+
description: 'A page registry declares a conversion_action (e.g. "request demo", "start trial"), but the rendered page DOM contains no matching interactive CTA button, link, or form.',
|
|
8
|
+
discipline: ['seo', 'aeo'], severity: 'medium', deterministic: true, requires: ['site', 'registries'],
|
|
9
|
+
impact: { conversion: 'high', representation: 'low' },
|
|
10
|
+
applicable_requirement: 'Premise 3.1: conversion action must be grounded in page interaction surfaces; CRO §1 CTA visibility',
|
|
11
|
+
remediation: 'Add a visible, interactive button, form, or link that executes the registered conversion_action.',
|
|
12
|
+
verification: 'Ensure page contains an interactive CTA element matching the declared conversion action.',
|
|
13
|
+
check(ctx) {
|
|
14
|
+
const hits = [];
|
|
15
|
+
for (const p of indexTargets(ctx)) {
|
|
16
|
+
const reg = registryPageFor(ctx, p);
|
|
17
|
+
if (!reg || !reg.conversion_action) continue;
|
|
18
|
+
const pageType = (reg.page_type || '').toLowerCase();
|
|
19
|
+
const isCommercialSurface = ['product', 'pricing', 'landing', 'commercial', 'lead_generation'].includes(pageType);
|
|
20
|
+
if (!isCommercialSurface) continue;
|
|
21
|
+
const action = reg.conversion_action.toLowerCase().trim();
|
|
22
|
+
|
|
23
|
+
const hasMatchingCta = (p.ctas || []).some((c) => {
|
|
24
|
+
const t = c.text.toLowerCase();
|
|
25
|
+
return t.includes(action) || action.includes(t) ||
|
|
26
|
+
(action.includes('demo') && t.includes('demo')) ||
|
|
27
|
+
(action.includes('trial') && t.includes('trial')) ||
|
|
28
|
+
(action.includes('contact') && t.includes('contact')) ||
|
|
29
|
+
(action.includes('sales') && t.includes('sales')) ||
|
|
30
|
+
(action.includes('sign up') && t.includes('sign up')) ||
|
|
31
|
+
(action.includes('pricing') && t.includes('pricing')) ||
|
|
32
|
+
(action.includes('download') && t.includes('download'));
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const hasMatchingForm = (p.forms || []).some((f) => {
|
|
36
|
+
return (f.action && f.action.toLowerCase().includes(action)) || f.hasSubmit;
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
if (!hasMatchingCta && !hasMatchingForm) {
|
|
40
|
+
hits.push({
|
|
41
|
+
subject: pageSubject(p),
|
|
42
|
+
summary: `Page declares conversion_action "${reg.conversion_action}" but lacks matching interactive CTA or form`,
|
|
43
|
+
evidence: [
|
|
44
|
+
`declared conversion_action: "${reg.conversion_action}"`,
|
|
45
|
+
`detected CTAs: ${(p.ctas || []).map((c) => c.text).join(', ') || 'none'}`,
|
|
46
|
+
],
|
|
47
|
+
captured: (p.ctas || []).map((c) => c.text).join(', ') || 'none',
|
|
48
|
+
expected: `interactive CTA matching "${reg.conversion_action}"`,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return hits;
|
|
53
|
+
},
|
|
54
|
+
}));
|
|
55
|
+
|
|
56
|
+
D.push(defineDetector({
|
|
57
|
+
id: 'CRO-002', name: 'Friction-heavy or defective lead capture form', namespace: 'CRO',
|
|
58
|
+
description: 'A form on the page is defective (missing submit mechanism) or contains excessive input fields (>7) on top-of-funnel conversion surfaces.',
|
|
59
|
+
discipline: ['seo', 'aeo'], severity: 'medium', deterministic: true, requires: ['site'],
|
|
60
|
+
impact: { conversion: 'medium' },
|
|
61
|
+
applicable_requirement: 'CRO §2 lead capture form accessibility and interaction friction minimization',
|
|
62
|
+
remediation: 'Ensure forms have an explicit submit button, a valid action endpoint, and keep top-of-funnel inputs to 7 or fewer fields.',
|
|
63
|
+
verification: 'Verify all forms have working submit triggers and reasonable field counts.',
|
|
64
|
+
check(ctx) {
|
|
65
|
+
const hits = [];
|
|
66
|
+
for (const p of indexTargets(ctx)) {
|
|
67
|
+
for (let i = 0; i < (p.forms || []).length; i++) {
|
|
68
|
+
const f = p.forms[i];
|
|
69
|
+
if (!f.hasSubmit) {
|
|
70
|
+
hits.push({
|
|
71
|
+
subject: pageSubject(p),
|
|
72
|
+
summary: `Form ${i + 1} has no submit button or mechanism`,
|
|
73
|
+
evidence: [`form action: ${f.action || 'none'}`, `inputs: ${f.fieldCount}`, 'missing button[type=submit] or input[type=submit]'],
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
if (f.fieldCount > 7) {
|
|
77
|
+
hits.push({
|
|
78
|
+
subject: pageSubject(p),
|
|
79
|
+
summary: `Form ${i + 1} has excessive field friction (${f.fieldCount} fields > 7 threshold)`,
|
|
80
|
+
evidence: [
|
|
81
|
+
`field count: ${f.fieldCount}`,
|
|
82
|
+
`fields: ${f.inputs.map((inp) => inp.name || inp.type).join(', ')}`,
|
|
83
|
+
],
|
|
84
|
+
captured: f.fieldCount,
|
|
85
|
+
expected: '<= 7 fields',
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return hits;
|
|
91
|
+
},
|
|
92
|
+
}));
|
|
93
|
+
|
|
94
|
+
D.push(defineDetector({
|
|
95
|
+
id: 'CRO-003', name: 'Commercial intent page lacks proximate conversion pathway', namespace: 'CRO',
|
|
96
|
+
description: 'A page with commercial or transactional intent (e.g. product, pricing, or vendor evaluation) contains zero interactive CTAs.',
|
|
97
|
+
discipline: ['seo', 'aeo'], severity: 'medium', deterministic: true, requires: ['site'],
|
|
98
|
+
impact: { conversion: 'medium' },
|
|
99
|
+
applicable_requirement: 'CRO §3 commercial intent conversion pathway alignment; SEO §1 commercial page value realization',
|
|
100
|
+
remediation: 'Add a clear primary call-to-action (e.g., demo, trial, pricing contact) to guide visitors arriving with commercial intent.',
|
|
101
|
+
verification: 'Commercial pages include at least one visible, interactive CTA.',
|
|
102
|
+
check(ctx) {
|
|
103
|
+
const hits = [];
|
|
104
|
+
for (const p of indexTargets(ctx)) {
|
|
105
|
+
const reg = registryPageFor(ctx, p);
|
|
106
|
+
const pageType = (reg?.page_type || '').toLowerCase();
|
|
107
|
+
const primaryIntent = (reg?.primary_intent || '').toLowerCase();
|
|
108
|
+
const urlLower = p.url.toLowerCase();
|
|
109
|
+
|
|
110
|
+
const isCommercial =
|
|
111
|
+
/product|pricing|commercial|vendor|solution/i.test(pageType) ||
|
|
112
|
+
/vendor evaluation|commercial|pricing|compare|trial/i.test(primaryIntent) ||
|
|
113
|
+
/\/products\/|\/pricing\/|\/solutions\//i.test(urlLower);
|
|
114
|
+
|
|
115
|
+
if (isCommercial) {
|
|
116
|
+
const ctaCount = (p.ctas || []).length;
|
|
117
|
+
if (ctaCount === 0) {
|
|
118
|
+
hits.push({
|
|
119
|
+
subject: pageSubject(p),
|
|
120
|
+
summary: `Commercial page "${p.url}" has zero interactive calls-to-action`,
|
|
121
|
+
evidence: [
|
|
122
|
+
`page_type: ${reg?.page_type || 'unregistered'}`,
|
|
123
|
+
`primary_intent: ${reg?.primary_intent || 'unregistered'}`,
|
|
124
|
+
'detected CTAs: 0',
|
|
125
|
+
],
|
|
126
|
+
captured: 0,
|
|
127
|
+
expected: '>= 1 primary CTA',
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return hits;
|
|
133
|
+
},
|
|
134
|
+
}));
|
|
135
|
+
|
|
136
|
+
D.push(defineDetector({
|
|
137
|
+
id: 'CRO-004', name: 'Defective or unverified outbound conversion target', namespace: 'CRO',
|
|
138
|
+
description: 'A primary CTA button or link points to a placeholder (#, javascript:void(0)), an unreachable internal destination (404), or a non-production staging/localhost URL.',
|
|
139
|
+
discipline: ['seo', 'aeo'], severity: 'medium', deterministic: true, requires: ['site'],
|
|
140
|
+
impact: { conversion: 'high' },
|
|
141
|
+
applicable_requirement: 'CRO §4 conversion link integrity and target availability',
|
|
142
|
+
remediation: 'Point all conversion CTAs to live, production-ready landing pages, forms, or checkout flows.',
|
|
143
|
+
verification: 'All CTA destinations resolve to valid 200 HTTP responses without dead-ends.',
|
|
144
|
+
check(ctx) {
|
|
145
|
+
const hits = [];
|
|
146
|
+
for (const p of indexTargets(ctx)) {
|
|
147
|
+
for (const c of p.ctas || []) {
|
|
148
|
+
if (!c.target) continue;
|
|
149
|
+
const target = c.target.trim();
|
|
150
|
+
if (target === '#' || /^javascript:/i.test(target)) {
|
|
151
|
+
hits.push({
|
|
152
|
+
subject: pageSubject(p),
|
|
153
|
+
summary: `CTA "${c.text}" uses placeholder target "${target}"`,
|
|
154
|
+
evidence: [`CTA text: "${c.text}"`, `target: "${target}"`],
|
|
155
|
+
});
|
|
156
|
+
} else if (/^(https?:\/\/)?(localhost|127\.0\.0\.1|staging\.|dev\.)/i.test(target)) {
|
|
157
|
+
hits.push({
|
|
158
|
+
subject: pageSubject(p),
|
|
159
|
+
summary: `CTA "${c.text}" targets non-production environment "${target}"`,
|
|
160
|
+
evidence: [`CTA text: "${c.text}"`, `target: "${target}"`],
|
|
161
|
+
});
|
|
162
|
+
} else if (target.startsWith('/') || (ctx.site.baseUrl && target.startsWith(ctx.site.baseUrl))) {
|
|
163
|
+
const normTarget = ctx.site.normalize(target);
|
|
164
|
+
const resolved = ctx.site.byUrl.get(normTarget);
|
|
165
|
+
if (resolved && resolved.status >= 400) {
|
|
166
|
+
hits.push({
|
|
167
|
+
subject: pageSubject(p),
|
|
168
|
+
summary: `CTA "${c.text}" targets internal page returning HTTP ${resolved.status}`,
|
|
169
|
+
evidence: [`CTA text: "${c.text}"`, `target: "${target}"`, `status: ${resolved.status}`],
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return hits;
|
|
176
|
+
},
|
|
177
|
+
}));
|
|
178
|
+
|
|
179
|
+
D.push(defineDetector({
|
|
180
|
+
id: 'CRO-005', name: 'Search-to-landing scent gap: primary H1 fails to corroborate title', namespace: 'CRO',
|
|
181
|
+
description: 'A commercial landing page H1 shares zero substantive keywords with the page title, creating a message-match scent gap that elevates bounce rates for search visitors.',
|
|
182
|
+
discipline: ['seo', 'aeo'], severity: 'medium', deterministic: true, requires: ['site'],
|
|
183
|
+
impact: { conversion: 'high', ranking: 'low' },
|
|
184
|
+
applicable_requirement: 'CRO §5 message-match alignment; SEO §4 title-to-H1 topic continuity',
|
|
185
|
+
remediation: 'Align the primary above-the-fold H1 to reflect the core product or service promised in the title tag.',
|
|
186
|
+
verification: 'Ensure at least one substantive keyword is shared between the page title stem and the primary H1.',
|
|
187
|
+
check(ctx) {
|
|
188
|
+
const hits = [];
|
|
189
|
+
const STOPWORDS = new Set(['the', 'and', 'for', 'with', 'from', 'your', 'that', 'this', 'what', 'how', 'best', 'more', 'about']);
|
|
190
|
+
for (const p of indexTargets(ctx)) {
|
|
191
|
+
if (p.status !== 200 || !p.title || !p.h1s || p.h1s.length === 0) continue;
|
|
192
|
+
const reg = registryPageFor(ctx, p);
|
|
193
|
+
const pageType = reg?.page_type || '';
|
|
194
|
+
if (pageType !== 'product' && pageType !== 'pricing' && pageType !== 'landing') continue;
|
|
195
|
+
|
|
196
|
+
const titleStem = p.title.split(/[|—–-]/)[0] || p.title;
|
|
197
|
+
const titleWords = titleStem.toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length >= 4 && !STOPWORDS.has(w));
|
|
198
|
+
const h1Text = p.h1s[0]?.text || '';
|
|
199
|
+
const h1Words = new Set(h1Text.toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length >= 4 && !STOPWORDS.has(w)));
|
|
200
|
+
|
|
201
|
+
const overlap = titleWords.filter((w) => h1Words.has(w));
|
|
202
|
+
if (titleWords.length >= 1 && overlap.length === 0) {
|
|
203
|
+
hits.push({
|
|
204
|
+
subject: pageSubject(p),
|
|
205
|
+
summary: `Search-to-landing scent gap: H1 "${h1Text}" shares no keywords with title stem "${titleStem.trim()}"`,
|
|
206
|
+
evidence: [
|
|
207
|
+
`title stem: "${titleStem.trim()}" (keywords: ${titleWords.join(', ')})`,
|
|
208
|
+
`primary H1: "${h1Text}" (keywords: ${[...h1Words].join(', ') || 'none'})`,
|
|
209
|
+
'visitors arriving from search experience disconnect when above-the-fold heading does not corroborate search snippet',
|
|
210
|
+
],
|
|
211
|
+
captured: { titleStem: titleStem.trim(), h1: h1Text },
|
|
212
|
+
expected: 'at least 1 shared substantive keyword between title and H1',
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return hits;
|
|
217
|
+
},
|
|
218
|
+
}));
|
|
219
|
+
|
|
220
|
+
D.push(defineDetector({
|
|
221
|
+
id: 'CRO-006', name: 'Commercial lead capture form lacks proximate trust proof or security badges', namespace: 'CRO',
|
|
222
|
+
description: 'A lead capture or transaction form collects user information without proximate trust signals (compliance certifications, encryption, guarantees, or social proof), elevating funnel abandonment.',
|
|
223
|
+
discipline: ['seo', 'aeo'], severity: 'low', deterministic: true, requires: ['site'],
|
|
224
|
+
impact: { conversion: 'medium' },
|
|
225
|
+
applicable_requirement: 'CRO §6 conversion surface trust and security proof presence',
|
|
226
|
+
remediation: 'Place security badges, compliance certifications (SOC 2, ISO, GDPR), or guarantees adjacent to the form.',
|
|
227
|
+
verification: 'Confirm pages containing multi-field forms declare at least one verifiable trust signal in the DOM.',
|
|
228
|
+
check(ctx) {
|
|
229
|
+
const hits = [];
|
|
230
|
+
for (const p of indexTargets(ctx)) {
|
|
231
|
+
if (p.status !== 200) continue;
|
|
232
|
+
const forms = p.forms || [];
|
|
233
|
+
const multiFieldForms = forms.filter((f) => (f.fieldCount || 0) >= 2);
|
|
234
|
+
if (multiFieldForms.length === 0) continue;
|
|
235
|
+
|
|
236
|
+
const trustBadges = p.trustBadges || [];
|
|
237
|
+
if (trustBadges.length === 0) {
|
|
238
|
+
hits.push({
|
|
239
|
+
subject: pageSubject(p),
|
|
240
|
+
summary: `Lead capture form (${multiFieldForms[0].fieldCount} fields) lacks proximate trust proof or compliance badges`,
|
|
241
|
+
evidence: [
|
|
242
|
+
`form action: ${multiFieldForms[0].action || 'inline'}`,
|
|
243
|
+
`field count: ${multiFieldForms[0].fieldCount}`,
|
|
244
|
+
'trust badges detected on page: 0',
|
|
245
|
+
'high friction lead capture forms without trust reassurance suffer higher abandonment rates',
|
|
246
|
+
],
|
|
247
|
+
captured: { fieldCount: multiFieldForms[0].fieldCount, trustBadgesCount: 0 },
|
|
248
|
+
expected: '>= 1 trust badge or proof signal on pages with lead capture forms',
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return hits;
|
|
253
|
+
},
|
|
254
|
+
}));
|
|
255
|
+
|
|
256
|
+
D.push(defineDetector({
|
|
257
|
+
id: 'CRO-007', name: 'Identity or contact input fields lack HTML5 autocomplete attributes', namespace: 'CRO',
|
|
258
|
+
description: 'Form input fields that collect standard personal identity or contact information (e.g. name, email, phone, organization) lack standard HTML5 autocomplete attributes, increasing typing friction and preventing browser autofill.',
|
|
259
|
+
discipline: ['seo', 'aeo'], severity: 'low', deterministic: true, requires: ['site'],
|
|
260
|
+
impact: { conversion: 'medium' },
|
|
261
|
+
applicable_requirement: 'CRO §7 form field autofill accessibility and mobile input friction minimization',
|
|
262
|
+
remediation: 'Add standard autocomplete attributes (e.g. autocomplete="email", autocomplete="given-name", autocomplete="tel", autocomplete="organization") to identity inputs.',
|
|
263
|
+
verification: 'Confirm all contact and identity inputs declare a valid standard autocomplete attribute.',
|
|
264
|
+
check(ctx) {
|
|
265
|
+
const hits = [];
|
|
266
|
+
const CONTACT_FIELD_RX = /^(email|e-mail|mail|fname|first[_-]?name|given[_-]?name|lname|last[_-]?name|family[_-]?name|phone|telephone|mobile|tel|company|organization|org)$/i;
|
|
267
|
+
for (const p of indexTargets(ctx)) {
|
|
268
|
+
if (p.status !== 200) continue;
|
|
269
|
+
for (const form of p.forms || []) {
|
|
270
|
+
for (const inp of form.inputs || []) {
|
|
271
|
+
if (inp.type === 'hidden' || inp.type === 'submit' || inp.type === 'button') continue;
|
|
272
|
+
const identifier = (inp.name || inp.id || '').toLowerCase();
|
|
273
|
+
if (CONTACT_FIELD_RX.test(identifier) && (!inp.autocomplete || inp.autocomplete === 'off')) {
|
|
274
|
+
hits.push({
|
|
275
|
+
subject: pageSubject(p),
|
|
276
|
+
summary: `Form field "${identifier}" lacks HTML5 autocomplete attribute`,
|
|
277
|
+
evidence: [
|
|
278
|
+
`field name/id: "${identifier}"`,
|
|
279
|
+
`field type: "${inp.type}"`,
|
|
280
|
+
'browser autofill increases form completion rates; missing autocomplete forces manual keyboard entry',
|
|
281
|
+
],
|
|
282
|
+
captured: { field: identifier, autocomplete: inp.autocomplete },
|
|
283
|
+
expected: `valid autocomplete attribute for "${identifier}"`,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return hits;
|
|
290
|
+
},
|
|
291
|
+
}));
|
|
292
|
+
|
|
293
|
+
D.push(defineDetector({
|
|
294
|
+
id: 'CRO-008', name: 'Form inputs rely solely on placeholder without accessible label', namespace: 'CRO',
|
|
295
|
+
description: 'Input fields on conversion forms rely solely on disappearing placeholder text without an associated <label for="...">, <label> wrapper, or aria-label, degrading cognitive accessibility and mobile error recovery.',
|
|
296
|
+
discipline: ['seo', 'aeo'], severity: 'low', deterministic: true, requires: ['site'],
|
|
297
|
+
impact: { conversion: 'medium' },
|
|
298
|
+
applicable_requirement: 'CRO §8 form label accessibility; WCAG 2.1 Success Criterion 3.3.2 Labels or Instructions',
|
|
299
|
+
remediation: 'Add explicit <label for="id"> elements or aria-label attributes for every form input instead of relying solely on placeholder text.',
|
|
300
|
+
verification: 'Confirm all form inputs have hasLabel=true in the parsed DOM.',
|
|
301
|
+
check(ctx) {
|
|
302
|
+
const hits = [];
|
|
303
|
+
for (const p of indexTargets(ctx)) {
|
|
304
|
+
if (p.status !== 200) continue;
|
|
305
|
+
for (const form of p.forms || []) {
|
|
306
|
+
for (const inp of form.inputs || []) {
|
|
307
|
+
if (inp.type === 'hidden' || inp.type === 'submit' || inp.type === 'button') continue;
|
|
308
|
+
if (!inp.hasLabel && inp.placeholder) {
|
|
309
|
+
hits.push({
|
|
310
|
+
subject: pageSubject(p),
|
|
311
|
+
summary: `Form input "${inp.name || inp.id || inp.type}" relies solely on placeholder "${inp.placeholder}" without label`,
|
|
312
|
+
evidence: [
|
|
313
|
+
`input: ${inp.name || inp.id || inp.type}`,
|
|
314
|
+
`placeholder: "${inp.placeholder}"`,
|
|
315
|
+
'placeholder text disappears upon user focus/typing, removing context and causing form completion abandonment',
|
|
316
|
+
],
|
|
317
|
+
captured: { input: inp.name || inp.id || inp.type, placeholder: inp.placeholder },
|
|
318
|
+
expected: 'associated <label> or aria-label for every input',
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return hits;
|
|
325
|
+
},
|
|
326
|
+
}));
|
|
327
|
+
|
|
328
|
+
D.push(defineDetector({
|
|
329
|
+
id: 'CRO-009', name: 'Mobile input type mismatch for email or telephone fields', namespace: 'CRO',
|
|
330
|
+
description: 'An email or telephone input uses generic type="text" instead of semantic types (type="email", type="tel") or lacks inputmode="numeric", failing to trigger the appropriate mobile virtual keyboard.',
|
|
331
|
+
discipline: ['seo', 'aeo'], severity: 'low', deterministic: true, requires: ['site'],
|
|
332
|
+
impact: { conversion: 'medium' },
|
|
333
|
+
applicable_requirement: 'CRO §9 mobile virtual keyboard optimization; HTML5 semantic input types',
|
|
334
|
+
remediation: 'Set type="email" for email inputs, type="tel" for telephone inputs, or inputmode="numeric" for numeric codes.',
|
|
335
|
+
verification: 'Confirm email inputs use type="email" and telephone inputs use type="tel" or inputmode="tel".',
|
|
336
|
+
check(ctx) {
|
|
337
|
+
const hits = [];
|
|
338
|
+
for (const p of indexTargets(ctx)) {
|
|
339
|
+
if (p.status !== 200) continue;
|
|
340
|
+
for (const form of p.forms || []) {
|
|
341
|
+
for (const inp of form.inputs || []) {
|
|
342
|
+
const id = (inp.name || inp.id || '').toLowerCase();
|
|
343
|
+
if (inp.type === 'text') {
|
|
344
|
+
if (/email|e-mail/i.test(id)) {
|
|
345
|
+
hits.push({
|
|
346
|
+
subject: pageSubject(p),
|
|
347
|
+
summary: `Email field "${id}" uses generic type="text" instead of type="email"`,
|
|
348
|
+
evidence: [
|
|
349
|
+
`field name/id: "${id}"`,
|
|
350
|
+
'type: text',
|
|
351
|
+
'mobile devices cannot display specialized email keyboard (@ and domain shortcuts)',
|
|
352
|
+
],
|
|
353
|
+
captured: { field: id, type: inp.type },
|
|
354
|
+
expected: 'type="email"',
|
|
355
|
+
});
|
|
356
|
+
} else if (/phone|telephone|mobile|tel\b/i.test(id) && !inp.inputmode) {
|
|
357
|
+
hits.push({
|
|
358
|
+
subject: pageSubject(p),
|
|
359
|
+
summary: `Phone field "${id}" uses generic type="text" without type="tel" or inputmode`,
|
|
360
|
+
evidence: [
|
|
361
|
+
`field name/id: "${id}"`,
|
|
362
|
+
'type: text',
|
|
363
|
+
'mobile devices cannot display numeric dialpad',
|
|
364
|
+
],
|
|
365
|
+
captured: { field: id, type: inp.type },
|
|
366
|
+
expected: 'type="tel" or inputmode="tel|numeric"',
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
return hits;
|
|
374
|
+
},
|
|
375
|
+
}));
|
|
376
|
+
|
|
377
|
+
D.push(defineDetector({
|
|
378
|
+
id: 'CRO-010', name: 'Choice overload: excessive competing primary CTAs in hero conversion zone', namespace: 'CRO',
|
|
379
|
+
description: 'The hero section or primary viewport of a commercial landing page presents 3 or more competing primary calls-to-action, creating choice paralysis and reducing click-through rates.',
|
|
380
|
+
discipline: ['seo', 'aeo'], severity: 'medium', deterministic: true, requires: ['site'],
|
|
381
|
+
impact: { conversion: 'high' },
|
|
382
|
+
applicable_requirement: 'CRO §10 visual hierarchy and decision friction minimization; Hick\'s Law',
|
|
383
|
+
remediation: 'Designate a single clear primary CTA in the hero section and demote secondary actions to text links or outline buttons.',
|
|
384
|
+
verification: 'Confirm hero section has fewer than 3 primary CTAs.',
|
|
385
|
+
check(ctx) {
|
|
386
|
+
const hits = [];
|
|
387
|
+
for (const p of indexTargets(ctx)) {
|
|
388
|
+
if (p.status !== 200) continue;
|
|
389
|
+
const reg = registryPageFor(ctx, p);
|
|
390
|
+
const pageType = (reg?.page_type || '').toLowerCase();
|
|
391
|
+
const isCommercial = ['product', 'pricing', 'landing', 'commercial'].includes(pageType) ||
|
|
392
|
+
/\/products\/|\/pricing\/|\/solutions\//i.test(p.url);
|
|
393
|
+
if (!isCommercial) continue;
|
|
394
|
+
|
|
395
|
+
const heroPrimaryCtas = (p.ctas || []).filter((c) => c.inHero && c.isPrimary);
|
|
396
|
+
if (heroPrimaryCtas.length >= 3) {
|
|
397
|
+
hits.push({
|
|
398
|
+
subject: pageSubject(p),
|
|
399
|
+
summary: `Hero section presents ${heroPrimaryCtas.length} competing primary CTAs (choice overload)`,
|
|
400
|
+
evidence: [
|
|
401
|
+
`competing hero CTAs: ${heroPrimaryCtas.map((c) => `"${c.text}"`).join(', ')}`,
|
|
402
|
+
'multiple high-emphasis CTAs create decision paralysis (Hick\'s Law) and lower overall conversion',
|
|
403
|
+
],
|
|
404
|
+
captured: heroPrimaryCtas.map((c) => c.text),
|
|
405
|
+
expected: '<= 2 CTAs in hero, exactly 1 primary',
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
return hits;
|
|
410
|
+
},
|
|
411
|
+
}));
|
|
412
|
+
|
|
413
|
+
D.push(defineDetector({
|
|
414
|
+
id: 'CRO-011', name: 'Buried primary conversion pathway on long-form commercial page', namespace: 'CRO',
|
|
415
|
+
description: 'A long-form commercial landing page (>1,000 words) has zero above-the-fold or early CTAs within the initial hero section and lacks a persistent navigation CTA, forcing visitors to scroll through extensive prose before reaching an action trigger.',
|
|
416
|
+
discipline: ['seo', 'aeo'], severity: 'medium', deterministic: true, requires: ['site'],
|
|
417
|
+
impact: { conversion: 'medium' },
|
|
418
|
+
applicable_requirement: 'CRO §11 commercial conversion pathway accessibility and scroll latency',
|
|
419
|
+
remediation: 'Add an early CTA in the hero section or a persistent header CTA so search visitors can convert without scrolling through the entire document.',
|
|
420
|
+
verification: 'Confirm commercial page with >1,000 words includes at least one hero or navigation CTA.',
|
|
421
|
+
check(ctx) {
|
|
422
|
+
const hits = [];
|
|
423
|
+
for (const p of indexTargets(ctx)) {
|
|
424
|
+
if (p.status !== 200 || (p.wordCount || 0) < 1000) continue;
|
|
425
|
+
const reg = registryPageFor(ctx, p);
|
|
426
|
+
const pageType = (reg?.page_type || '').toLowerCase();
|
|
427
|
+
const isCommercial = ['product', 'pricing', 'landing', 'commercial'].includes(pageType) ||
|
|
428
|
+
/\/products\/|\/pricing\/|\/solutions\//i.test(p.url);
|
|
429
|
+
if (!isCommercial) continue;
|
|
430
|
+
|
|
431
|
+
const ctas = p.ctas || [];
|
|
432
|
+
if (ctas.length === 0) continue; // CRO-003 handles 0 CTAs
|
|
433
|
+
|
|
434
|
+
const hasEarlyOrNavCta = ctas.some((c) => c.inHero || c.inNav);
|
|
435
|
+
if (!hasEarlyOrNavCta) {
|
|
436
|
+
hits.push({
|
|
437
|
+
subject: pageSubject(p),
|
|
438
|
+
summary: `Long-form commercial page (${p.wordCount} words) lacks early hero or navigation CTA`,
|
|
439
|
+
evidence: [
|
|
440
|
+
`total word count: ${p.wordCount}`,
|
|
441
|
+
`detected CTAs: ${ctas.map((c) => `"${c.text}"`).join(', ')}`,
|
|
442
|
+
'all CTAs are buried deep in body or footer; search visitors with high purchase intent face excessive scroll friction',
|
|
443
|
+
],
|
|
444
|
+
captured: { wordCount: p.wordCount, ctaCount: ctas.length },
|
|
445
|
+
expected: 'at least 1 CTA in hero or sticky navigation on >1000 word commercial page',
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
return hits;
|
|
450
|
+
},
|
|
451
|
+
}));
|
|
452
|
+
|
|
453
|
+
D.push(defineDetector({
|
|
454
|
+
id: 'CRO-012', name: 'Low-intent generic CTA microcopy on primary commercial action', namespace: 'CRO',
|
|
455
|
+
description: 'A primary CTA on a commercial landing page uses passive, low-commitment, or non-descriptive microcopy (such as "Submit", "Click Here", "Send", "Next", or "Go") rather than an action-oriented, value-delivering verb.',
|
|
456
|
+
discipline: ['seo', 'aeo'], severity: 'low', deterministic: true, requires: ['site'],
|
|
457
|
+
impact: { conversion: 'medium' },
|
|
458
|
+
applicable_requirement: 'CRO §12 CTA microcopy actionability and perceived value clarity',
|
|
459
|
+
remediation: 'Replace generic copy like "Submit" or "Click Here" with value-driven action verbs (e.g. "Start Free Trial", "Get Instant Access", "Request Demo").',
|
|
460
|
+
verification: 'Confirm primary CTAs avoid generic passive terms.',
|
|
461
|
+
check(ctx) {
|
|
462
|
+
const hits = [];
|
|
463
|
+
const GENERIC_CTA_RX = /^(submit|click here|read more|learn more|send|go|next|continue|button)$/i;
|
|
464
|
+
for (const p of indexTargets(ctx)) {
|
|
465
|
+
if (p.status !== 200) continue;
|
|
466
|
+
const reg = registryPageFor(ctx, p);
|
|
467
|
+
const pageType = (reg?.page_type || '').toLowerCase();
|
|
468
|
+
const isCommercial = ['product', 'pricing', 'landing', 'commercial'].includes(pageType) ||
|
|
469
|
+
/\/products\/|\/pricing\/|\/solutions\//i.test(p.url);
|
|
470
|
+
if (!isCommercial) continue;
|
|
471
|
+
|
|
472
|
+
for (const c of p.ctas || []) {
|
|
473
|
+
if (c.isPrimary && GENERIC_CTA_RX.test(c.text.trim())) {
|
|
474
|
+
hits.push({
|
|
475
|
+
subject: pageSubject(p),
|
|
476
|
+
summary: `Primary CTA uses low-intent generic microcopy "${c.text.trim()}"`,
|
|
477
|
+
evidence: [
|
|
478
|
+
`CTA text: "${c.text.trim()}"`,
|
|
479
|
+
'generic verbs convey friction rather than benefit, diminishing click propensity',
|
|
480
|
+
],
|
|
481
|
+
captured: c.text.trim(),
|
|
482
|
+
expected: 'benefit-oriented action verb (e.g., "Start Free Trial", "Request Demo", "Get Started")',
|
|
483
|
+
});
|
|
484
|
+
break;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
return hits;
|
|
489
|
+
},
|
|
490
|
+
}));
|
|
491
|
+
|
|
492
|
+
D.push(defineDetector({
|
|
493
|
+
id: 'CRO-013', name: 'Enclosed checkout or lead funnel leak: distraction navigation present on conversion step', namespace: 'CRO',
|
|
494
|
+
description: 'A dedicated checkout, payment, or final lead registration step retains full global navigation menus (>5 links), providing exit ramps that elevate funnel abandonment.',
|
|
495
|
+
discipline: ['seo', 'aeo'], severity: 'medium', deterministic: true, requires: ['site'],
|
|
496
|
+
impact: { conversion: 'high' },
|
|
497
|
+
applicable_requirement: 'CRO §13 enclosed checkout and distraction removal; funnel leakage prevention',
|
|
498
|
+
remediation: 'Enclose the conversion funnel by removing global navigation menus, search bars, and external outbound links on checkout and registration pages.',
|
|
499
|
+
verification: 'Confirm checkout or dedicated registration steps contain <= 5 navigation links.',
|
|
500
|
+
check(ctx) {
|
|
501
|
+
const hits = [];
|
|
502
|
+
for (const p of indexTargets(ctx)) {
|
|
503
|
+
if (p.status !== 200) continue;
|
|
504
|
+
const isCheckoutOrPay = /\/(checkout|pay|cart|signup-step2|order-summary)\b/i.test(p.url);
|
|
505
|
+
if (isCheckoutOrPay && (p.navLinksCount || 0) > 5) {
|
|
506
|
+
hits.push({
|
|
507
|
+
subject: pageSubject(p),
|
|
508
|
+
summary: `Dedicated conversion step "${p.url}" retains ${p.navLinksCount} global navigation links (distraction leak)`,
|
|
509
|
+
evidence: [
|
|
510
|
+
`navigation link count: ${p.navLinksCount}`,
|
|
511
|
+
'enclosed checkout best practices recommend removing full site navigation on final conversion steps to prevent exit leakage',
|
|
512
|
+
],
|
|
513
|
+
captured: { navLinksCount: p.navLinksCount },
|
|
514
|
+
expected: '<= 5 navigation links on dedicated checkout or payment funnel step',
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
return hits;
|
|
519
|
+
},
|
|
520
|
+
}));
|
|
521
|
+
|
|
522
|
+
D.push(defineDetector({
|
|
523
|
+
id: 'CRO-014', name: 'Unprotected post-conversion confirmation page lacks noindex directive', namespace: 'CRO',
|
|
524
|
+
description: 'A post-conversion confirmation, thank-you, or receipt page (/thank-you, /order-received, /welcome) lacks a "noindex" robots directive, exposing internal receipt URLs to organic search indexing and distorting conversion tracking.',
|
|
525
|
+
discipline: ['seo', 'aeo'], severity: 'medium', deterministic: true, requires: ['site'],
|
|
526
|
+
impact: { conversion: 'medium', ranking: 'low' },
|
|
527
|
+
applicable_requirement: 'CRO §14 conversion confirmation index protection; SEO §2 clean index hygiene',
|
|
528
|
+
remediation: 'Add <meta name="robots" content="noindex, follow"> or X-Robots-Tag: noindex to all thank-you and conversion receipt pages.',
|
|
529
|
+
verification: 'Confirm post-conversion confirmation pages have noindex=true.',
|
|
530
|
+
check(ctx) {
|
|
531
|
+
const hits = [];
|
|
532
|
+
const CONFIRM_PAGE_RX = /\/(thank-you|order-confirmed|order-received|purchase-success|signup-success|payment-complete)\b/i;
|
|
533
|
+
for (const p of ctx.site?.pages || []) {
|
|
534
|
+
if (p.status !== 200) continue;
|
|
535
|
+
if (CONFIRM_PAGE_RX.test(p.url) && !p.noindex) {
|
|
536
|
+
hits.push({
|
|
537
|
+
subject: pageSubject(p),
|
|
538
|
+
summary: `Post-conversion confirmation page "${p.url}" lacks noindex directive`,
|
|
539
|
+
evidence: [
|
|
540
|
+
`URL: ${p.url}`,
|
|
541
|
+
`noindex: ${p.noindex || false}`,
|
|
542
|
+
'organic crawling of post-conversion pages causes false-positive conversion attribution and leaks transactional endpoints',
|
|
543
|
+
],
|
|
544
|
+
captured: { url: p.url, noindex: false },
|
|
545
|
+
expected: 'meta robots "noindex" or X-Robots-Tag "noindex"',
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
return hits;
|
|
550
|
+
},
|
|
551
|
+
}));
|
|
552
|
+
|
|
553
|
+
D.push(defineDetector({
|
|
554
|
+
id: 'CRO-015', name: 'Mobile touch target size below recommended 48px threshold', namespace: 'CRO',
|
|
555
|
+
description: 'An interactive conversion CTA button or link has explicit dimensions under 44px by 44px, violating mobile accessibility and tap-target usability standards (WCAG 2.5.5) and increasing tap error rates.',
|
|
556
|
+
discipline: ['seo', 'aeo'], severity: 'low', deterministic: true, requires: ['site'],
|
|
557
|
+
impact: { conversion: 'medium' },
|
|
558
|
+
applicable_requirement: 'CRO §15 mobile touch target sizing; WCAG 2.1 Success Criterion 2.5.5 Target Size',
|
|
559
|
+
remediation: 'Ensure all primary and secondary conversion CTAs meet a minimum physical target size of 44px by 44px (recommended 48px by 48px) or declare at least 12px of padding.',
|
|
560
|
+
verification: 'Confirm CTA elements have inlineWidth >= 44 and inlineHeight >= 44 when explicit dimensions are set.',
|
|
561
|
+
check(ctx) {
|
|
562
|
+
const hits = [];
|
|
563
|
+
for (const p of indexTargets(ctx)) {
|
|
564
|
+
if (p.status !== 200) continue;
|
|
565
|
+
for (const c of p.ctas || []) {
|
|
566
|
+
if ((c.inlineWidth !== null && c.inlineWidth < 44) || (c.inlineHeight !== null && c.inlineHeight < 44)) {
|
|
567
|
+
hits.push({
|
|
568
|
+
subject: pageSubject(p),
|
|
569
|
+
summary: `CTA "${c.text}" has explicit touch target dimensions below 44px (${c.inlineWidth ?? 'auto'}x${c.inlineHeight ?? 'auto'}px)`,
|
|
570
|
+
evidence: [
|
|
571
|
+
`CTA text: "${c.text}"`,
|
|
572
|
+
`dimensions: width=${c.inlineWidth ?? 'auto'}px, height=${c.inlineHeight ?? 'auto'}px`,
|
|
573
|
+
'touch targets under 44-48px cause frequent mobile tap errors and increase conversion abandonment',
|
|
574
|
+
],
|
|
575
|
+
captured: { text: c.text, width: c.inlineWidth, height: c.inlineHeight },
|
|
576
|
+
expected: 'minimum 44x44px touch target (recommended 48x48px)',
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
return hits;
|
|
582
|
+
},
|
|
583
|
+
}));
|
|
584
|
+
|
|
585
|
+
D.push(defineDetector({
|
|
586
|
+
id: 'CRO-016', name: 'Diminutive font size on primary conversion CTA microcopy', namespace: 'CRO',
|
|
587
|
+
description: 'A primary CTA button uses diminutive font sizing (< 12px), creating readability friction and lowering tap confidence for mobile and accessibility-sensitive visitors.',
|
|
588
|
+
discipline: ['seo', 'aeo'], severity: 'low', deterministic: true, requires: ['site'],
|
|
589
|
+
impact: { conversion: 'medium' },
|
|
590
|
+
applicable_requirement: 'CRO §16 CTA microcopy legibility and perceived affordance',
|
|
591
|
+
remediation: 'Increase primary CTA font size to at least 14px (recommended 16px) to ensure immediate visual affordance and effortless readability.',
|
|
592
|
+
verification: 'Confirm primary CTA elements declare font-size >= 12px when explicit font-size is styled.',
|
|
593
|
+
check(ctx) {
|
|
594
|
+
const hits = [];
|
|
595
|
+
for (const p of indexTargets(ctx)) {
|
|
596
|
+
if (p.status !== 200) continue;
|
|
597
|
+
for (const c of p.ctas || []) {
|
|
598
|
+
if (c.isPrimary && c.inlineFontSize !== null && c.inlineFontSize < 12) {
|
|
599
|
+
hits.push({
|
|
600
|
+
subject: pageSubject(p),
|
|
601
|
+
summary: `Primary CTA "${c.text}" uses diminutive font size (${c.inlineFontSize}px < 12px)`,
|
|
602
|
+
evidence: [
|
|
603
|
+
`CTA text: "${c.text}"`,
|
|
604
|
+
`font size: ${c.inlineFontSize}px`,
|
|
605
|
+
'primary action buttons with font sizes below 12px suffer from poor legibility and reduced click-through rates',
|
|
606
|
+
],
|
|
607
|
+
captured: { text: c.text, fontSize: c.inlineFontSize },
|
|
608
|
+
expected: 'primary CTA font-size >= 14px',
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
return hits;
|
|
614
|
+
},
|
|
615
|
+
}));
|
|
616
|
+
|
|
617
|
+
D.push(defineDetector({
|
|
618
|
+
id: 'CRO-017', name: 'Multi-step conversion funnel break: intermediate step unavailable or defective', namespace: 'CRO',
|
|
619
|
+
description: 'A multi-step funnel declared in funnels.yaml contains a step whose target page cannot be located in the audited site or returns an HTTP error status (4xx/5xx).',
|
|
620
|
+
discipline: ['seo', 'aeo'], severity: 'high', deterministic: true, requires: ['site', 'registries'],
|
|
621
|
+
impact: { conversion: 'high' },
|
|
622
|
+
applicable_requirement: 'CRO §17 multi-step funnel continuity and endpoint availability',
|
|
623
|
+
remediation: 'Verify that every declared funnel step corresponds to a live, indexable page returning HTTP 200.',
|
|
624
|
+
verification: 'All declared funnel steps resolve to status 200 pages.',
|
|
625
|
+
check(ctx) {
|
|
626
|
+
const hits = [];
|
|
627
|
+
if (!ctx.registries?.funnels?.entries) return hits;
|
|
628
|
+
for (const funnel of ctx.registries.funnels.entries) {
|
|
629
|
+
if (funnel.status !== 'active') continue;
|
|
630
|
+
for (const step of funnel.steps || []) {
|
|
631
|
+
const pattern = step.url_pattern;
|
|
632
|
+
const page = ctx.site?.pages?.find((p) => p.url.includes(pattern) || (step.page_id && p.url === step.url_pattern));
|
|
633
|
+
if (!page) {
|
|
634
|
+
hits.push({
|
|
635
|
+
subject: { type: 'registry_entry', identifier: `funnels/${funnel.funnel_id}/${step.step_id}` },
|
|
636
|
+
summary: `Funnel "${funnel.name}" step "${step.name || step.step_id}" (${pattern}) not found in site`,
|
|
637
|
+
evidence: [
|
|
638
|
+
`funnel: ${funnel.funnel_id} (${funnel.name})`,
|
|
639
|
+
`step: ${step.step_id} (${pattern})`,
|
|
640
|
+
'missing funnel steps cause dead ends where visitors are unable to complete the conversion path',
|
|
641
|
+
],
|
|
642
|
+
captured: { funnel_id: funnel.funnel_id, step: step.step_id, pattern },
|
|
643
|
+
expected: `page matching "${pattern}" in audited output`,
|
|
644
|
+
});
|
|
645
|
+
} else if (page.status >= 400) {
|
|
646
|
+
hits.push({
|
|
647
|
+
subject: pageSubject(page),
|
|
648
|
+
summary: `Funnel "${funnel.name}" step "${step.name || step.step_id}" returns HTTP ${page.status}`,
|
|
649
|
+
evidence: [
|
|
650
|
+
`funnel: ${funnel.funnel_id}`,
|
|
651
|
+
`step: ${step.step_id}`,
|
|
652
|
+
`HTTP status: ${page.status}`,
|
|
653
|
+
],
|
|
654
|
+
captured: { status: page.status },
|
|
655
|
+
expected: 'status 200',
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
return hits;
|
|
661
|
+
},
|
|
662
|
+
}));
|
|
663
|
+
|
|
664
|
+
D.push(defineDetector({
|
|
665
|
+
id: 'CRO-018', name: 'Funnel progression link fails to preserve campaign attribution parameters', namespace: 'CRO',
|
|
666
|
+
description: 'An intermediate CTA link leading to the next funnel step strips or omits declared campaign query parameters (such as utm_source, utm_campaign), breaking multi-touch attribution.',
|
|
667
|
+
discipline: ['seo', 'aeo'], severity: 'low', deterministic: true, requires: ['site', 'registries'],
|
|
668
|
+
impact: { conversion: 'low', representation: 'medium' },
|
|
669
|
+
applicable_requirement: 'CRO §18 campaign parameter preservation and cross-step attribution continuity',
|
|
670
|
+
remediation: 'Ensure cross-step transition links append or forward inbound UTM and attribution query parameters.',
|
|
671
|
+
verification: 'Confirm intermediate funnel CTAs declare parameter preservation.',
|
|
672
|
+
check(ctx) {
|
|
673
|
+
const hits = [];
|
|
674
|
+
if (!ctx.registries?.funnels?.entries) return hits;
|
|
675
|
+
for (const funnel of ctx.registries.funnels.entries) {
|
|
676
|
+
if (funnel.status !== 'active') continue;
|
|
677
|
+
const steps = funnel.steps || [];
|
|
678
|
+
for (let i = 0; i < steps.length - 1; i++) {
|
|
679
|
+
const curStep = steps[i];
|
|
680
|
+
const nextStep = steps[i + 1];
|
|
681
|
+
if (!curStep.preserve_params || curStep.preserve_params.length === 0) continue;
|
|
682
|
+
|
|
683
|
+
const curPage = ctx.site?.pages?.find((p) => p.url.includes(curStep.url_pattern));
|
|
684
|
+
if (!curPage) continue;
|
|
685
|
+
|
|
686
|
+
const transitionCta = (curPage.ctas || []).find((c) => c.target && c.target.includes(nextStep.url_pattern));
|
|
687
|
+
if (transitionCta && !transitionCta.target.includes('?') && !transitionCta.target.includes('utm_')) {
|
|
688
|
+
hits.push({
|
|
689
|
+
subject: pageSubject(curPage),
|
|
690
|
+
summary: `Funnel transition CTA to "${nextStep.url_pattern}" does not forward declared params (${curStep.preserve_params.join(', ')})`,
|
|
691
|
+
evidence: [
|
|
692
|
+
`from step: ${curStep.step_id}`,
|
|
693
|
+
`to step: ${nextStep.step_id}`,
|
|
694
|
+
`CTA target: ${transitionCta.target}`,
|
|
695
|
+
`required params: ${curStep.preserve_params.join(', ')}`,
|
|
696
|
+
'hardcoded links without parameter forwarding break campaign tracking and conversion attribution',
|
|
697
|
+
],
|
|
698
|
+
captured: { target: transitionCta.target, preserve: curStep.preserve_params },
|
|
699
|
+
expected: `CTA target forwarding query params to "${nextStep.url_pattern}"`,
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
return hits;
|
|
705
|
+
},
|
|
706
|
+
}));
|
|
707
|
+
|
|
708
|
+
D.push(defineDetector({
|
|
709
|
+
id: 'CRO-019', name: 'AI Answer cited landing page lacks immediate claim corroboration or conversion pathway', namespace: 'CRO',
|
|
710
|
+
description: 'A landing page cited by an answer engine or generative AI response contains no proximate interactive conversion trigger or fails to corroborate the cited entity claim in its primary visible text.',
|
|
711
|
+
discipline: ['seo', 'aeo'], severity: 'medium', deterministic: true, requires: ['site'],
|
|
712
|
+
impact: { conversion: 'high', citation: 'medium' },
|
|
713
|
+
applicable_requirement: 'CRO §19 AI citation-to-conversion pathway alignment; AEO §4 corroboration continuity',
|
|
714
|
+
remediation: 'Place an action-oriented conversion CTA and clear statement corroborating the cited claim above the fold on AI-cited landing pages.',
|
|
715
|
+
verification: 'Confirm AI-cited pages possess both visible corroborating text and an interactive CTA.',
|
|
716
|
+
check(ctx) {
|
|
717
|
+
const hits = [];
|
|
718
|
+
const observations = ctx.observations || [];
|
|
719
|
+
const citationObs = observations.filter((o) => o.kind === 'citation' && o.data?.property_cited);
|
|
720
|
+
for (const obs of citationObs) {
|
|
721
|
+
const citedUrl = obs.data?.target_url || obs.data?.citation_url;
|
|
722
|
+
if (!citedUrl) continue;
|
|
723
|
+
const page = ctx.site?.pages?.find((p) => p.url === citedUrl || p.url.endsWith(citedUrl));
|
|
724
|
+
if (!page) continue;
|
|
725
|
+
|
|
726
|
+
const ctas = page.ctas || [];
|
|
727
|
+
if (ctas.length === 0) {
|
|
728
|
+
hits.push({
|
|
729
|
+
subject: pageSubject(page),
|
|
730
|
+
summary: `AI citation landing page "${page.url}" cited by ${obs.data.provider || 'AI engine'} lacks any conversion CTA`,
|
|
731
|
+
evidence: [
|
|
732
|
+
`provider: ${obs.data.provider || 'unknown'}`,
|
|
733
|
+
`prompt: "${obs.data.prompt_text || 'prompt'}"`,
|
|
734
|
+
'visitors arriving from AI answer citations with high intent encounter zero interactive conversion pathways',
|
|
735
|
+
],
|
|
736
|
+
captured: { citedUrl: page.url, ctasCount: 0 },
|
|
737
|
+
expected: '>= 1 visible conversion CTA on AI citation landing page',
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
return hits;
|
|
742
|
+
},
|
|
743
|
+
}));
|
|
744
|
+
|
|
745
|
+
D.push(defineDetector({
|
|
746
|
+
id: 'CRO-020', name: 'Statistical underpower risk on low-traffic A/B experiment', namespace: 'CRO',
|
|
747
|
+
description: 'An A/B experiment registered in experiments.yaml specifies a minimum observation window or sample size insufficient to detect the expected hypothesis effect at standard statistical power (80%).',
|
|
748
|
+
discipline: ['seo', 'aeo'], severity: 'low', deterministic: true, requires: ['registries'],
|
|
749
|
+
impact: { conversion: 'medium' },
|
|
750
|
+
applicable_requirement: 'CRO §20 statistical power governance; Experimentation §3 sample size adequacy',
|
|
751
|
+
remediation: 'Increase experiment evaluation duration or focus on higher-traffic pages where minimum detectable effect (MDE) can reach 80% statistical power.',
|
|
752
|
+
verification: 'Confirm active experiments have evaluation window >= 7 days and realistic MDE targets.',
|
|
753
|
+
check(ctx) {
|
|
754
|
+
const hits = [];
|
|
755
|
+
if (!ctx.registries?.experiments?.entries) return hits;
|
|
756
|
+
for (const exp of ctx.registries.experiments.entries) {
|
|
757
|
+
if (exp.status !== 'active') continue;
|
|
758
|
+
const windowStr = exp.evaluation_window || exp.minimum_observation_window || '';
|
|
759
|
+
const daysMatch = windowStr.match(/(\d+)\s*d/i);
|
|
760
|
+
if (daysMatch && Number(daysMatch[1]) < 7) {
|
|
761
|
+
hits.push({
|
|
762
|
+
subject: { type: 'registry_entry', identifier: `experiments/${exp.experiment_id}` },
|
|
763
|
+
summary: `Experiment "${exp.experiment_id}" has insufficient observation window (${daysMatch[1]} days < 7 days minimum)`,
|
|
764
|
+
evidence: [
|
|
765
|
+
`evaluation window: "${windowStr}"`,
|
|
766
|
+
'experiments running under 7 days fail to account for day-of-week seasonality and suffer high false-positive rates',
|
|
767
|
+
],
|
|
768
|
+
captured: { window: windowStr, days: Number(daysMatch[1]) },
|
|
769
|
+
expected: 'evaluation window >= 7 days (recommended >= 14 days)',
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
return hits;
|
|
774
|
+
},
|
|
775
|
+
}));
|
|
776
|
+
|
|
777
|
+
export default D;
|