@nebulacomponents/citable 1.17.0 → 1.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (164) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/dist/universal/.agents/skills/citable/SKILL.md +41 -1
  3. package/dist/universal/.agents/skills/citable/VERSION +1 -1
  4. package/dist/universal/.agents/skills/citable/agents/citable-remediator.md +43 -0
  5. package/dist/universal/.agents/skills/citable/agents/citable-sow-architect.md +43 -0
  6. package/dist/universal/.agents/skills/citable/commands/README.md +13 -0
  7. package/dist/universal/.agents/skills/citable/commands/remediation.md +55 -0
  8. package/dist/universal/.agents/skills/citable/commands/reporting.md +28 -0
  9. package/dist/universal/.agents/skills/citable/commands/sow.md +95 -0
  10. package/dist/universal/.agents/skills/citable/manifest.json +12 -7
  11. package/dist/universal/.agents/skills/citable/schemas/sow.schema.json +409 -0
  12. package/dist/universal/.claude/agents/citable/citable-remediator.md +43 -0
  13. package/dist/universal/.claude/agents/citable/citable-sow-architect.md +43 -0
  14. package/dist/universal/.claude/agents/citable/manifest.json +6 -4
  15. package/dist/universal/.claude/skills/citable/SKILL.md +41 -1
  16. package/dist/universal/.claude/skills/citable/VERSION +1 -1
  17. package/dist/universal/.claude/skills/citable/agents/citable-remediator.md +43 -0
  18. package/dist/universal/.claude/skills/citable/agents/citable-sow-architect.md +43 -0
  19. package/dist/universal/.claude/skills/citable/commands/README.md +13 -0
  20. package/dist/universal/.claude/skills/citable/commands/remediation.md +55 -0
  21. package/dist/universal/.claude/skills/citable/commands/reporting.md +28 -0
  22. package/dist/universal/.claude/skills/citable/commands/sow.md +95 -0
  23. package/dist/universal/.claude/skills/citable/manifest.json +16 -9
  24. package/dist/universal/.claude/skills/citable/schemas/sow.schema.json +409 -0
  25. package/dist/universal/.cursor/skills/citable/SKILL.md +41 -1
  26. package/dist/universal/.cursor/skills/citable/VERSION +1 -1
  27. package/dist/universal/.cursor/skills/citable/agents/citable-remediator.md +43 -0
  28. package/dist/universal/.cursor/skills/citable/agents/citable-sow-architect.md +43 -0
  29. package/dist/universal/.cursor/skills/citable/commands/README.md +13 -0
  30. package/dist/universal/.cursor/skills/citable/commands/remediation.md +55 -0
  31. package/dist/universal/.cursor/skills/citable/commands/reporting.md +28 -0
  32. package/dist/universal/.cursor/skills/citable/commands/sow.md +95 -0
  33. package/dist/universal/.cursor/skills/citable/manifest.json +12 -7
  34. package/dist/universal/.cursor/skills/citable/schemas/sow.schema.json +409 -0
  35. package/dist/universal/.gemini/skills/citable/SKILL.md +41 -1
  36. package/dist/universal/.gemini/skills/citable/VERSION +1 -1
  37. package/dist/universal/.gemini/skills/citable/agents/citable-remediator.md +43 -0
  38. package/dist/universal/.gemini/skills/citable/agents/citable-sow-architect.md +43 -0
  39. package/dist/universal/.gemini/skills/citable/commands/README.md +13 -0
  40. package/dist/universal/.gemini/skills/citable/commands/remediation.md +55 -0
  41. package/dist/universal/.gemini/skills/citable/commands/reporting.md +28 -0
  42. package/dist/universal/.gemini/skills/citable/commands/sow.md +95 -0
  43. package/dist/universal/.gemini/skills/citable/manifest.json +12 -7
  44. package/dist/universal/.gemini/skills/citable/schemas/sow.schema.json +409 -0
  45. package/dist/universal/.github/skills/citable/SKILL.md +41 -1
  46. package/dist/universal/.github/skills/citable/VERSION +1 -1
  47. package/dist/universal/.github/skills/citable/agents/citable-remediator.md +43 -0
  48. package/dist/universal/.github/skills/citable/agents/citable-sow-architect.md +43 -0
  49. package/dist/universal/.github/skills/citable/commands/README.md +13 -0
  50. package/dist/universal/.github/skills/citable/commands/remediation.md +55 -0
  51. package/dist/universal/.github/skills/citable/commands/reporting.md +28 -0
  52. package/dist/universal/.github/skills/citable/commands/sow.md +95 -0
  53. package/dist/universal/.github/skills/citable/manifest.json +12 -7
  54. package/dist/universal/.github/skills/citable/schemas/sow.schema.json +409 -0
  55. package/dist/universal/.kiro/skills/citable/SKILL.md +41 -1
  56. package/dist/universal/.kiro/skills/citable/VERSION +1 -1
  57. package/dist/universal/.kiro/skills/citable/agents/citable-remediator.md +43 -0
  58. package/dist/universal/.kiro/skills/citable/agents/citable-sow-architect.md +43 -0
  59. package/dist/universal/.kiro/skills/citable/commands/README.md +13 -0
  60. package/dist/universal/.kiro/skills/citable/commands/remediation.md +55 -0
  61. package/dist/universal/.kiro/skills/citable/commands/reporting.md +28 -0
  62. package/dist/universal/.kiro/skills/citable/commands/sow.md +95 -0
  63. package/dist/universal/.kiro/skills/citable/manifest.json +12 -7
  64. package/dist/universal/.kiro/skills/citable/schemas/sow.schema.json +409 -0
  65. package/dist/universal/.opencode/skills/citable/SKILL.md +41 -1
  66. package/dist/universal/.opencode/skills/citable/VERSION +1 -1
  67. package/dist/universal/.opencode/skills/citable/agents/citable-remediator.md +43 -0
  68. package/dist/universal/.opencode/skills/citable/agents/citable-sow-architect.md +43 -0
  69. package/dist/universal/.opencode/skills/citable/commands/README.md +13 -0
  70. package/dist/universal/.opencode/skills/citable/commands/remediation.md +55 -0
  71. package/dist/universal/.opencode/skills/citable/commands/reporting.md +28 -0
  72. package/dist/universal/.opencode/skills/citable/commands/sow.md +95 -0
  73. package/dist/universal/.opencode/skills/citable/manifest.json +12 -7
  74. package/dist/universal/.opencode/skills/citable/schemas/sow.schema.json +409 -0
  75. package/dist/universal/.pi/agent/skills/citable/SKILL.md +41 -1
  76. package/dist/universal/.pi/agent/skills/citable/VERSION +1 -1
  77. package/dist/universal/.pi/agent/skills/citable/agents/citable-remediator.md +43 -0
  78. package/dist/universal/.pi/agent/skills/citable/agents/citable-sow-architect.md +43 -0
  79. package/dist/universal/.pi/agent/skills/citable/commands/README.md +13 -0
  80. package/dist/universal/.pi/agent/skills/citable/commands/remediation.md +55 -0
  81. package/dist/universal/.pi/agent/skills/citable/commands/reporting.md +28 -0
  82. package/dist/universal/.pi/agent/skills/citable/commands/sow.md +95 -0
  83. package/dist/universal/.pi/agent/skills/citable/manifest.json +12 -7
  84. package/dist/universal/.pi/agent/skills/citable/schemas/sow.schema.json +409 -0
  85. package/dist/universal/.qoder/skills/citable/SKILL.md +41 -1
  86. package/dist/universal/.qoder/skills/citable/VERSION +1 -1
  87. package/dist/universal/.qoder/skills/citable/agents/citable-remediator.md +43 -0
  88. package/dist/universal/.qoder/skills/citable/agents/citable-sow-architect.md +43 -0
  89. package/dist/universal/.qoder/skills/citable/commands/README.md +13 -0
  90. package/dist/universal/.qoder/skills/citable/commands/remediation.md +55 -0
  91. package/dist/universal/.qoder/skills/citable/commands/reporting.md +28 -0
  92. package/dist/universal/.qoder/skills/citable/commands/sow.md +95 -0
  93. package/dist/universal/.qoder/skills/citable/manifest.json +12 -7
  94. package/dist/universal/.qoder/skills/citable/schemas/sow.schema.json +409 -0
  95. package/dist/universal/.rovodev/skills/citable/SKILL.md +41 -1
  96. package/dist/universal/.rovodev/skills/citable/VERSION +1 -1
  97. package/dist/universal/.rovodev/skills/citable/agents/citable-remediator.md +43 -0
  98. package/dist/universal/.rovodev/skills/citable/agents/citable-sow-architect.md +43 -0
  99. package/dist/universal/.rovodev/skills/citable/commands/README.md +13 -0
  100. package/dist/universal/.rovodev/skills/citable/commands/remediation.md +55 -0
  101. package/dist/universal/.rovodev/skills/citable/commands/reporting.md +28 -0
  102. package/dist/universal/.rovodev/skills/citable/commands/sow.md +95 -0
  103. package/dist/universal/.rovodev/skills/citable/manifest.json +12 -7
  104. package/dist/universal/.rovodev/skills/citable/schemas/sow.schema.json +409 -0
  105. package/dist/universal/.trae/skills/citable/SKILL.md +41 -1
  106. package/dist/universal/.trae/skills/citable/VERSION +1 -1
  107. package/dist/universal/.trae/skills/citable/agents/citable-remediator.md +43 -0
  108. package/dist/universal/.trae/skills/citable/agents/citable-sow-architect.md +43 -0
  109. package/dist/universal/.trae/skills/citable/commands/README.md +13 -0
  110. package/dist/universal/.trae/skills/citable/commands/remediation.md +55 -0
  111. package/dist/universal/.trae/skills/citable/commands/reporting.md +28 -0
  112. package/dist/universal/.trae/skills/citable/commands/sow.md +95 -0
  113. package/dist/universal/.trae/skills/citable/manifest.json +12 -7
  114. package/dist/universal/.trae/skills/citable/schemas/sow.schema.json +409 -0
  115. package/dist/universal/.trae-cn/skills/citable/SKILL.md +41 -1
  116. package/dist/universal/.trae-cn/skills/citable/VERSION +1 -1
  117. package/dist/universal/.trae-cn/skills/citable/agents/citable-remediator.md +43 -0
  118. package/dist/universal/.trae-cn/skills/citable/agents/citable-sow-architect.md +43 -0
  119. package/dist/universal/.trae-cn/skills/citable/commands/README.md +13 -0
  120. package/dist/universal/.trae-cn/skills/citable/commands/remediation.md +55 -0
  121. package/dist/universal/.trae-cn/skills/citable/commands/reporting.md +28 -0
  122. package/dist/universal/.trae-cn/skills/citable/commands/sow.md +95 -0
  123. package/dist/universal/.trae-cn/skills/citable/manifest.json +12 -7
  124. package/dist/universal/.trae-cn/skills/citable/schemas/sow.schema.json +409 -0
  125. package/dist/universal/manifest.json +150 -88
  126. package/package.json +1 -1
  127. package/schemas/sow.schema.json +409 -0
  128. package/skill/SKILL.md +41 -1
  129. package/skill/agents/citable-remediator.md +43 -0
  130. package/skill/agents/citable-sow-architect.md +43 -0
  131. package/skill/commands/README.md +13 -0
  132. package/skill/commands/remediation.md +55 -0
  133. package/skill/commands/reporting.md +28 -0
  134. package/skill/commands/sow.md +95 -0
  135. package/src/analysis/behavioral.js +126 -0
  136. package/src/analysis/croAudit.js +302 -0
  137. package/src/analysis/croRoadmap.js +121 -0
  138. package/src/analysis/eeat.js +300 -0
  139. package/src/analysis/funnelAnalysis.js +171 -0
  140. package/src/analysis/iceMatrix.js +205 -0
  141. package/src/analysis/offpage.js +228 -0
  142. package/src/analysis/readiness.js +253 -0
  143. package/src/analysis/schemaRecommendations.js +173 -0
  144. package/src/analysis/strategicRoadmap.js +148 -0
  145. package/src/cli/index.js +149 -3
  146. package/src/commands/answerEngineReadiness.js +48 -0
  147. package/src/commands/auditBacklinks.js +79 -0
  148. package/src/commands/croBacklog.js +182 -0
  149. package/src/commands/croSuite.js +156 -0
  150. package/src/commands/executive.js +6 -0
  151. package/src/commands/executiveCro.js +30 -0
  152. package/src/commands/executiveSearch.js +28 -0
  153. package/src/commands/inspectCro.js +7 -0
  154. package/src/commands/inspectEeat.js +53 -0
  155. package/src/commands/prioritize.js +42 -6
  156. package/src/commands/roadmapCmd.js +69 -0
  157. package/src/commands/schemaCmd.js +10 -2
  158. package/src/commands/sowCmd.js +84 -0
  159. package/src/commands/sweep.js +357 -0
  160. package/src/reporting/executiveCroReport.js +731 -0
  161. package/src/reporting/executiveExport.js +62 -0
  162. package/src/reporting/executiveSearchReport.js +704 -0
  163. package/src/sow/admissibilityGate.js +364 -0
  164. package/src/sow/generateSow.js +1178 -0
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Impact / Effort / Confidence (ICE) scoring matrix for Citable findings and initiatives.
3
+ *
4
+ * All factors are explicitly scored on a transparent 1-10 scale.
5
+ * Formula: ICE Score = (Impact × Confidence) / Effort
6
+ * Quadrants:
7
+ * - Quick Wins: High Impact (>=6), Low Effort (<=4), High Confidence (>=6)
8
+ * - Strategic Bets: High Impact (>=6), High Effort (>=5)
9
+ * - Low-Hanging Fruit: Low Impact (<6), Low Effort (<=4)
10
+ * - Deprioritize: Low Impact (<6), High Effort (>=5) or Low Confidence (<5)
11
+ */
12
+
13
+ const SEVERITY_IMPACT_MAP = {
14
+ critical: 10,
15
+ high: 8,
16
+ medium: 5,
17
+ low: 2,
18
+ informational: 1,
19
+ experimental: 2,
20
+ };
21
+
22
+ const DETECTOR_EFFORT_MAP = {
23
+ TECH: 4,
24
+ CRAWL: 3,
25
+ CWV: 5,
26
+ ARCH: 6,
27
+ PAGE: 3,
28
+ ANS: 4,
29
+ GEO: 5,
30
+ RECO: 6,
31
+ SCHEMA: 3,
32
+ LINK: 4,
33
+ ENTITY: 4,
34
+ CLAIM: 5,
35
+ EVD: 6,
36
+ SEC: 4,
37
+ MOBILE: 3,
38
+ CRO: 4,
39
+ };
40
+
41
+ const CONFIDENCE_MAP = {
42
+ deterministic: 10,
43
+ verified: 9,
44
+ validated: 8,
45
+ proxy: 6,
46
+ modeled: 6,
47
+ anecdote: 4,
48
+ assumption: 3,
49
+ };
50
+
51
+ /**
52
+ * Score an audit finding for the ICE matrix
53
+ */
54
+ export function scoreFinding(finding) {
55
+ const severity = finding.classification?.severity || 'medium';
56
+ const impact = SEVERITY_IMPACT_MAP[severity] ?? 5;
57
+
58
+ const ns = (finding.detector_id || '').split('-')[0];
59
+ let effort = DETECTOR_EFFORT_MAP[ns] ?? 4;
60
+ if (finding.remediation?.review_required) effort += 2;
61
+ effort = Math.min(10, Math.max(1, effort));
62
+
63
+ let confidence = 8;
64
+ if (finding.classification?.deterministic) {
65
+ confidence = 10;
66
+ } else if (finding.fact_status === 'observed_telemetry') {
67
+ confidence = 9;
68
+ } else if (finding.fact_status === 'modeled_rubric_evaluation') {
69
+ confidence = 6;
70
+ }
71
+
72
+ const iceScore = Math.round(((impact * confidence) / effort) * 10) / 10;
73
+
74
+ let quadrant = 'low_hanging_fruit';
75
+ if (impact >= 6 && effort <= 4 && confidence >= 6) {
76
+ quadrant = 'quick_wins';
77
+ } else if (impact >= 6 && effort >= 5) {
78
+ quadrant = 'strategic_bets';
79
+ } else if (impact < 6 && effort <= 4) {
80
+ quadrant = 'low_hanging_fruit';
81
+ } else {
82
+ quadrant = 'deprioritize';
83
+ }
84
+
85
+ return {
86
+ id: finding.detector_id || finding.finding_id,
87
+ finding_id: finding.finding_id,
88
+ detector_id: finding.detector_id,
89
+ title: finding.observation?.summary || finding.name || finding.detector_id,
90
+ subject: finding.subject?.identifier || finding.subject?.url || 'global',
91
+ severity,
92
+ impact,
93
+ effort,
94
+ confidence,
95
+ ice_score: iceScore,
96
+ quadrant,
97
+ };
98
+ }
99
+
100
+ /**
101
+ * Score an initiative from initiatives.yaml for the ICE matrix
102
+ */
103
+ export function scoreInitiative(initiative) {
104
+ const SCALE = { none: 0, low: 2, medium: 5, high: 8, critical: 10, validated: 9, transformative: 10, trivial: 1, very_high: 9, negligible: 1 };
105
+
106
+ const demand = SCALE[initiative.customer_demand] ?? 5;
107
+ const rev = SCALE[initiative.revenue_potential] ?? 5;
108
+ const diff = SCALE[initiative.strategic_differentiation] ?? 5;
109
+ const impact = Math.min(10, Math.max(1, Math.round((demand + rev + diff) / 3)));
110
+
111
+ const engCost = SCALE[initiative.engineering_cost] ?? 5;
112
+ const opCost = SCALE[initiative.operating_cost || 'none'] ?? 0;
113
+ const effort = Math.min(10, Math.max(1, Math.round(engCost + opCost * 0.3)));
114
+
115
+ const confidence = CONFIDENCE_MAP[initiative.evidence_strength] ?? 5;
116
+ const iceScore = Math.round(((impact * confidence) / effort) * 10) / 10;
117
+
118
+ let quadrant = 'low_hanging_fruit';
119
+ if (impact >= 6 && effort <= 4 && confidence >= 6) {
120
+ quadrant = 'quick_wins';
121
+ } else if (impact >= 6 && effort >= 5) {
122
+ quadrant = 'strategic_bets';
123
+ } else if (impact < 6 && effort <= 4) {
124
+ quadrant = 'low_hanging_fruit';
125
+ } else {
126
+ quadrant = 'deprioritize';
127
+ }
128
+
129
+ return {
130
+ id: initiative.initiative_id,
131
+ title: initiative.title,
132
+ owner: initiative.owner || 'unassigned',
133
+ status: initiative.status,
134
+ impact,
135
+ effort,
136
+ confidence,
137
+ ice_score: iceScore,
138
+ quadrant,
139
+ };
140
+ }
141
+
142
+ /**
143
+ * Build the full ICE Scoring Matrix from a list of findings or initiatives
144
+ */
145
+ export function buildIceMatrix(items = [], { type = 'findings' } = {}) {
146
+ const scored = items.map((item) => (type === 'initiatives' ? scoreInitiative(item) : scoreFinding(item)));
147
+ scored.sort((a, b) => b.ice_score - a.ice_score || b.impact - a.impact);
148
+
149
+ const quadrants = {
150
+ quick_wins: scored.filter((i) => i.quadrant === 'quick_wins'),
151
+ strategic_bets: scored.filter((i) => i.quadrant === 'strategic_bets'),
152
+ low_hanging_fruit: scored.filter((i) => i.quadrant === 'low_hanging_fruit'),
153
+ deprioritize: scored.filter((i) => i.quadrant === 'deprioritize'),
154
+ };
155
+
156
+ return {
157
+ fact_status: 'transparent_prioritization_matrix',
158
+ scoring_method: 'ICE: (Impact [1-10] × Confidence [1-10]) / Effort [1-10]',
159
+ total_items: scored.length,
160
+ summary: {
161
+ quick_wins_count: quadrants.quick_wins.length,
162
+ strategic_bets_count: quadrants.strategic_bets.length,
163
+ low_hanging_fruit_count: quadrants.low_hanging_fruit.length,
164
+ deprioritize_count: quadrants.deprioritize.length,
165
+ },
166
+ quadrants,
167
+ ranked_items: scored,
168
+ };
169
+ }
170
+
171
+ /**
172
+ * Format ICE Matrix as human-readable terminal table / markdown
173
+ */
174
+ export function formatIceMatrixOutput(matrix) {
175
+ const lines = [
176
+ `Impact / Effort / Confidence (ICE) Prioritization Matrix`,
177
+ `=======================================================`,
178
+ `Methodology: ${matrix.scoring_method}`,
179
+ `Total Items Scored: ${matrix.total_items}`,
180
+ ``,
181
+ `QUADRANT SUMMARY:`,
182
+ ` Quadrant I [Quick Wins]: ${matrix.summary.quick_wins_count} item(s) (High Impact, Low Effort, High Confidence)`,
183
+ ` Quadrant II [Strategic Bets]: ${matrix.summary.strategic_bets_count} item(s) (High Impact, High Effort)`,
184
+ ` Quadrant III [Low-Hanging Fruit]: ${matrix.summary.low_hanging_fruit_count} item(s) (Low Impact, Low Effort)`,
185
+ ` Quadrant IV [Deprioritize]: ${matrix.summary.deprioritize_count} item(s) (Low Impact or Low Confidence)`,
186
+ ``,
187
+ `RANKED ACTION MATRIX:`,
188
+ ` ${'Rank'.padEnd(6)} | ${'ID'.padEnd(16)} | ${'Impact'.padEnd(8)} | ${'Effort'.padEnd(8)} | ${'Conf'.padEnd(6)} | ${'ICE Score'.padEnd(10)} | Quadrant`,
189
+ ` ${'-'.repeat(80)}`,
190
+ ];
191
+
192
+ matrix.ranked_items.forEach((item, idx) => {
193
+ const rank = `#${idx + 1}`.padEnd(6);
194
+ const id = (item.id || '').slice(0, 16).padEnd(16);
195
+ const impact = `${item.impact}/10`.padEnd(8);
196
+ const effort = `${item.effort}/10`.padEnd(8);
197
+ const conf = `${item.confidence}/10`.padEnd(6);
198
+ const score = `${item.ice_score}`.padEnd(10);
199
+ const q = item.quadrant.toUpperCase();
200
+ lines.push(` ${rank} | ${id} | ${impact} | ${effort} | ${conf} | ${score} | ${q}`);
201
+ lines.push(` ${item.title.slice(0, 70)}`);
202
+ });
203
+
204
+ return lines.join('\n');
205
+ }
@@ -0,0 +1,228 @@
1
+ import { URL } from 'node:url';
2
+
3
+ const SPAM_TLDS = new Set([
4
+ 'top', 'work', 'gdn', 'click', 'monster', 'buzz', 'cfd', 'sbs',
5
+ 'rest', 'cam', 'fit', 'surf', 'stream', 'tk', 'ml', 'ga', 'cf',
6
+ ]);
7
+
8
+ const COMMERCIAL_SPAM_KEYWORDS = /\b(casino|viagra|cialis|payday\s*loans?|replica|cheap\s*essay|free\s*download|warez|crack|hack|gambling|betting|slots|crypto\s*airdrop)\b/i;
9
+
10
+ function extractHostname(urlString) {
11
+ try {
12
+ return new URL(urlString).hostname.toLowerCase();
13
+ } catch {
14
+ return null;
15
+ }
16
+ }
17
+
18
+ function extractRootDomain(hostname) {
19
+ if (!hostname) return null;
20
+ const parts = hostname.split('.');
21
+ if (parts.length <= 2) return hostname;
22
+ return parts.slice(-2).join('.');
23
+ }
24
+
25
+ function extractTld(hostname) {
26
+ if (!hostname) return null;
27
+ const parts = hostname.split('.');
28
+ return parts.at(-1) || null;
29
+ }
30
+
31
+ /**
32
+ * Audit an inbound backlink profile for authority distribution and toxic domain risks.
33
+ */
34
+ export function auditBacklinkProfile(backlinks, { targetDomain = null } = {}) {
35
+ const links = Array.isArray(backlinks) ? backlinks : (backlinks?.backlinks || []);
36
+ const normalizedTarget = targetDomain ? targetDomain.toLowerCase().replace(/^www\./, '') : null;
37
+
38
+ const referringDomains = new Map();
39
+ const cClassClusters = new Map();
40
+ let dofollowCount = 0;
41
+ let nofollowCount = 0;
42
+ let ugcCount = 0;
43
+ let sponsoredCount = 0;
44
+ let homepageLinksCount = 0;
45
+ let deepLinksCount = 0;
46
+
47
+ const anchorTypes = {
48
+ branded: 0,
49
+ naked_url: 0,
50
+ generic: 0,
51
+ exact_match_commercial: 0,
52
+ other: 0,
53
+ };
54
+
55
+ const toxicDomains = [];
56
+ const GENERIC_ANCHORS = /^(?:click here|here|website|link|read more|learn more|source|this site|visit|more info)$/i;
57
+
58
+ // 1. Process each backlink
59
+ for (const link of links) {
60
+ const srcHost = extractHostname(link.source_url);
61
+ if (!srcHost) continue;
62
+ const rootDomain = extractRootDomain(srcHost) || srcHost;
63
+ const tld = extractTld(srcHost);
64
+ const anchor = (link.anchor_text || '').trim();
65
+ const rel = (link.rel || '').toLowerCase();
66
+ const isNofollow = rel.includes('nofollow');
67
+ const isUgc = rel.includes('ugc');
68
+ const isSponsored = rel.includes('sponsored');
69
+
70
+ if (isNofollow) nofollowCount++;
71
+ else if (isUgc) ugcCount++;
72
+ else if (isSponsored) sponsoredCount++;
73
+ else dofollowCount++;
74
+
75
+ // Deep link vs homepage
76
+ const targetPath = link.target_url ? (() => { try { return new URL(link.target_url).pathname; } catch { return '/'; } })() : '/';
77
+ if (targetPath === '/' || targetPath === '') homepageLinksCount++;
78
+ else deepLinksCount++;
79
+
80
+ // Anchor categorization
81
+ if (normalizedTarget && (anchor.toLowerCase().includes(normalizedTarget) || normalizedTarget.includes(anchor.toLowerCase()))) {
82
+ anchorTypes.branded++;
83
+ } else if (/^https?:\/\//i.test(anchor) || anchor.includes('www.') || anchor.includes('.com') || anchor.includes('.org')) {
84
+ anchorTypes.naked_url++;
85
+ } else if (GENERIC_ANCHORS.test(anchor)) {
86
+ anchorTypes.generic++;
87
+ } else if (COMMERCIAL_SPAM_KEYWORDS.test(anchor) || /\b(buy|best|cheap|discount|order)\b/i.test(anchor)) {
88
+ anchorTypes.exact_match_commercial++;
89
+ } else {
90
+ anchorTypes.other++;
91
+ }
92
+
93
+ // C-class IP clustering
94
+ if (link.source_ip) {
95
+ const parts = link.source_ip.split('.');
96
+ if (parts.length === 4) {
97
+ const cClass = parts.slice(0, 3).join('.');
98
+ const cluster = cClassClusters.get(cClass) || new Set();
99
+ cluster.add(rootDomain);
100
+ cClassClusters.set(cClass, cluster);
101
+ }
102
+ }
103
+
104
+ // Domain tracking
105
+ const domainData = referringDomains.get(rootDomain) || {
106
+ domain: rootDomain,
107
+ sample_source_url: link.source_url,
108
+ total_links: 0,
109
+ anchors: [],
110
+ tld,
111
+ ip: link.source_ip || null,
112
+ dofollow: false,
113
+ };
114
+ domainData.total_links++;
115
+ if (!isNofollow && !isSponsored) domainData.dofollow = true;
116
+ if (anchor && !domainData.anchors.includes(anchor)) domainData.anchors.push(anchor);
117
+ referringDomains.set(rootDomain, domainData);
118
+ }
119
+
120
+ // 2. Toxic domain risk analysis per domain
121
+ for (const [domain, data] of referringDomains.entries()) {
122
+ const reasons = [];
123
+ let riskTier = 'clean';
124
+
125
+ // Spam TLD rule
126
+ if (data.tld && SPAM_TLDS.has(data.tld)) {
127
+ reasons.push(`Known high-abuse spam TLD (.${data.tld})`);
128
+ riskTier = 'high';
129
+ }
130
+
131
+ // Spam anchor rule
132
+ const hasSpamAnchor = data.anchors.some((a) => COMMERCIAL_SPAM_KEYWORDS.test(a));
133
+ if (hasSpamAnchor) {
134
+ reasons.push('Anchor text matches known high-risk commercial spam patterns');
135
+ riskTier = 'critical';
136
+ }
137
+
138
+ // PBN IP clustering rule (3+ distinct domains on the exact same /24 C-block)
139
+ if (data.ip) {
140
+ const parts = data.ip.split('.');
141
+ if (parts.length === 4) {
142
+ const cClass = parts.slice(0, 3).join('.');
143
+ const cluster = cClassClusters.get(cClass);
144
+ if (cluster && cluster.size >= 3) {
145
+ reasons.push(`Part of hosting cluster on ${cClass}.0/24 with ${cluster.size} domains (PBN footprint)`);
146
+ riskTier = 'critical';
147
+ }
148
+ }
149
+ }
150
+
151
+ // Excessive sitewide link density without nofollow
152
+ if (data.total_links > 50 && data.dofollow && (data.tld && SPAM_TLDS.has(data.tld))) {
153
+ reasons.push(`Sitewide link farm pattern (${data.total_links} links without rel="nofollow")`);
154
+ riskTier = 'critical';
155
+ }
156
+
157
+ if (riskTier !== 'clean') {
158
+ toxicDomains.push({
159
+ domain,
160
+ risk_tier: riskTier,
161
+ reasons,
162
+ sample_url: data.sample_source_url,
163
+ anchors: data.anchors.slice(0, 5),
164
+ total_links: data.total_links,
165
+ });
166
+ }
167
+ }
168
+
169
+ // Sort toxic domains by severity (critical first)
170
+ toxicDomains.sort((a, b) => (a.risk_tier === 'critical' ? -1 : 1));
171
+
172
+ const totalLinks = links.length;
173
+ const totalDomains = referringDomains.size;
174
+ const commercialAnchorPct = totalLinks > 0 ? Math.round((anchorTypes.exact_match_commercial / totalLinks) * 100) : 0;
175
+ const brandedAnchorPct = totalLinks > 0 ? Math.round((anchorTypes.branded / totalLinks) * 100) : 0;
176
+ const deepLinkPct = totalLinks > 0 ? Math.round((deepLinksCount / totalLinks) * 100) : 0;
177
+
178
+ // Authority health assessment
179
+ let profileHealth = 'natural';
180
+ if (toxicDomains.some((d) => d.risk_tier === 'critical') || commercialAnchorPct > 30) {
181
+ profileHealth = 'high_risk_footprint';
182
+ } else if (toxicDomains.length > 0 || commercialAnchorPct > 15) {
183
+ profileHealth = 'moderate_risk';
184
+ }
185
+
186
+ // Generate GSC-compatible disavow lines
187
+ const disavowLines = [
188
+ `# Citable Google Disavow Candidate Export`,
189
+ `# Generated at: ${new Date().toISOString()}`,
190
+ `# Flagged domains: ${toxicDomains.length} (critical: ${toxicDomains.filter((d) => d.risk_tier === 'critical').length})`,
191
+ ``,
192
+ ];
193
+ for (const td of toxicDomains) {
194
+ disavowLines.push(`# Risk: ${td.risk_tier.toUpperCase()} — ${td.reasons.join('; ')}`);
195
+ disavowLines.push(`domain:${td.domain}`);
196
+ }
197
+
198
+ return {
199
+ fact_status: 'observable_risk_indicators',
200
+ target_domain: targetDomain,
201
+ profile_health: profileHealth,
202
+ summary: {
203
+ total_backlinks: totalLinks,
204
+ total_referring_domains: totalDomains,
205
+ toxic_domains_count: toxicDomains.length,
206
+ critical_risk_domains: toxicDomains.filter((d) => d.risk_tier === 'critical').length,
207
+ high_risk_domains: toxicDomains.filter((d) => d.risk_tier === 'high').length,
208
+ dofollow_count: dofollowCount,
209
+ nofollow_count: nofollowCount,
210
+ ugc_count: ugcCount,
211
+ sponsored_count: sponsoredCount,
212
+ deep_link_ratio_pct: deepLinkPct,
213
+ },
214
+ anchor_profile: {
215
+ branded_pct: brandedAnchorPct,
216
+ commercial_exact_match_pct: commercialAnchorPct,
217
+ naked_url_count: anchorTypes.naked_url,
218
+ generic_count: anchorTypes.generic,
219
+ over_optimization_risk: commercialAnchorPct > 25 ? 'elevated' : 'normal',
220
+ },
221
+ toxic_domains: toxicDomains,
222
+ disavow_export: disavowLines.join('\n'),
223
+ limitations: [
224
+ 'Search engines do not disclose algorithmic penalty thresholds; toxic classifications reflect observable risk patterns.',
225
+ 'Manual verification is required before submitting disavow files to Google Search Console.',
226
+ ],
227
+ };
228
+ }
@@ -0,0 +1,253 @@
1
+ import { parse as parseHtml } from 'node-html-parser';
2
+ import { registryPageFor, safePath } from '../detectors/framework.js';
3
+
4
+ const QUESTION_RX = /^(?:how|what|why|when|where|who|can|is|does|which|should)\b|\?$/i;
5
+ const COPULA_RX = /\b(?:is|are|refers\s+to|denotes|represents|means|provides|enables)\b/i;
6
+
7
+ /**
8
+ * Evaluates a page's extraction, retrieval, and citation readiness across
9
+ * Perplexity, Bing Copilot, and ChatGPT / SearchGPT.
10
+ */
11
+ export function evaluateAnswerEngineReadiness(page, ctx = {}) {
12
+ const html = page.html || '';
13
+ const root = parseHtml(html);
14
+ const text = (page.text || root.textContent || '').replace(/\s+/g, ' ').trim();
15
+ const headings = page.headings || [];
16
+ const paragraphs = page.paragraphs || root.querySelectorAll('p').map((p) => p.text.trim()).filter(Boolean);
17
+ const jsonLd = page.jsonLd || [];
18
+ const tablesCount = (page.tables ?? root.querySelectorAll('table').length);
19
+ const listsCount = (page.orderedLists ?? root.querySelectorAll('ol').length) + root.querySelectorAll('ul').length;
20
+ const wordCount = page.rawVisibleWordCount || page.wordCount || text.split(/\s+/).filter(Boolean).length;
21
+
22
+ // Crawler directives check (from robots Directives or headers)
23
+ const robotsDirectives = Array.from(page.robotsDirectives || []);
24
+ const isNoindex = robotsDirectives.some((d) => d.includes('noindex'));
25
+
26
+ // -----------------------------------------------------------------
27
+ // 1. PERPLEXITY READINESS
28
+ // -----------------------------------------------------------------
29
+ const perplexityChecks = [];
30
+ let perplexityScore = 0;
31
+
32
+ // Check 1: Crawler policy (PerplexityBot)
33
+ const perplexityBotBlocked = robotsDirectives.some((d) => d.includes('perplexitybot: noindex') || d.includes('perplexitybot: none'));
34
+ if (!perplexityBotBlocked && !isNoindex) {
35
+ perplexityScore += 25;
36
+ perplexityChecks.push({ name: 'Crawler Policy', passed: true, note: 'PerplexityBot allowed' });
37
+ } else {
38
+ perplexityChecks.push({ name: 'Crawler Policy', passed: false, note: 'Blocked by robots directive' });
39
+ }
40
+
41
+ // Check 2: Direct Definitional Lead (Copula in first paragraph)
42
+ const firstParagraph = paragraphs[0] || '';
43
+ const hasCopularLead = COPULA_RX.test(firstParagraph.slice(0, 160));
44
+ if (hasCopularLead) {
45
+ perplexityScore += 25;
46
+ perplexityChecks.push({ name: 'Definitional Lead', passed: true, note: 'Concise copular definition in opening passage' });
47
+ } else {
48
+ perplexityChecks.push({ name: 'Definitional Lead', passed: false, note: 'Opening passage lacks concise definitional copula ("X is Y")' });
49
+ }
50
+
51
+ // Check 3: Structured question headings
52
+ const questionHeadings = headings.filter((h) => QUESTION_RX.test(h.text.trim()));
53
+ if (questionHeadings.length >= 2) {
54
+ perplexityScore += 25;
55
+ perplexityChecks.push({ name: 'Question Density', passed: true, note: `${questionHeadings.length} interrogative headings found` });
56
+ } else if (questionHeadings.length === 1) {
57
+ perplexityScore += 15;
58
+ perplexityChecks.push({ name: 'Question Density', passed: true, note: '1 interrogative heading found' });
59
+ } else {
60
+ perplexityChecks.push({ name: 'Question Density', passed: false, note: 'No interrogative headings found (Q&A format optimizes extraction)' });
61
+ }
62
+
63
+ // Check 4: Structured data & tables for citation synthesis
64
+ if (tablesCount >= 1 || listsCount >= 2) {
65
+ perplexityScore += 25;
66
+ perplexityChecks.push({ name: 'Tabular / List Data', passed: true, note: `${tablesCount} table(s), ${listsCount} list(s) for direct citation` });
67
+ } else {
68
+ perplexityScore += 10;
69
+ perplexityChecks.push({ name: 'Tabular / List Data', passed: false, note: 'Limited tables or structured lists for bulleted answers' });
70
+ }
71
+
72
+ // -----------------------------------------------------------------
73
+ // 2. BING COPILOT READINESS
74
+ // -----------------------------------------------------------------
75
+ const bingChecks = [];
76
+ let bingScore = 0;
77
+
78
+ // Check 1: Bingbot allowed
79
+ const bingbotBlocked = robotsDirectives.some((d) => d.includes('bingbot: noindex'));
80
+ if (!bingbotBlocked && !isNoindex) {
81
+ bingScore += 25;
82
+ bingChecks.push({ name: 'Crawler Policy', passed: true, note: 'Bingbot allowed' });
83
+ } else {
84
+ bingChecks.push({ name: 'Crawler Policy', passed: false, note: 'Bingbot blocked' });
85
+ }
86
+
87
+ // Check 2: IndexNow protocol support / fast-indexing
88
+ const hasIndexNow = Boolean(
89
+ ctx.registries?.connections?.entries?.some((c) => c.provider === 'indexnow') ||
90
+ root.querySelector('link[rel="indexnow"]') ||
91
+ ctx.config?.indexnow
92
+ );
93
+ if (hasIndexNow) {
94
+ bingScore += 25;
95
+ bingChecks.push({ name: 'IndexNow Protocol', passed: true, note: 'IndexNow instant discovery integration detected' });
96
+ } else {
97
+ bingScore += 10;
98
+ bingChecks.push({ name: 'IndexNow Protocol', passed: false, note: 'IndexNow not configured; submission relies on standard crawl schedule' });
99
+ }
100
+
101
+ // Check 3: Schema.org JSON-LD completeness
102
+ const validSchemaBlocks = jsonLd.flatMap((j) => j.blocks || []).filter((b) => b['@type']);
103
+ const schemaTypes = validSchemaBlocks.map((b) => [].concat(b['@type']).join(',')).join('; ');
104
+ if (validSchemaBlocks.length >= 2) {
105
+ bingScore += 25;
106
+ bingChecks.push({ name: 'Structured Data', passed: true, note: `Rich schema graphs (${schemaTypes})` });
107
+ } else if (validSchemaBlocks.length === 1) {
108
+ bingScore += 15;
109
+ bingChecks.push({ name: 'Structured Data', passed: true, note: `Basic schema present (${schemaTypes})` });
110
+ } else {
111
+ bingChecks.push({ name: 'Structured Data', passed: false, note: 'No valid JSON-LD schema detected; Bing Copilot cards hindered' });
112
+ }
113
+
114
+ // Check 4: Deep page internal linking and canonical clarity
115
+ const canonicals = page.canonicals || [];
116
+ if (canonicals.length === 1) {
117
+ bingScore += 25;
118
+ bingChecks.push({ name: 'Canonical Authority', passed: true, note: `Canonical unambiguously specified (${canonicals[0]})` });
119
+ } else {
120
+ bingChecks.push({ name: 'Canonical Authority', passed: false, note: canonicals.length === 0 ? 'Missing canonical URL' : 'Multiple contradictory canonical tags' });
121
+ }
122
+
123
+ // -----------------------------------------------------------------
124
+ // 3. CHATGPT / SEARCHGPT READINESS
125
+ // -----------------------------------------------------------------
126
+ const chatGptChecks = [];
127
+ let chatGptScore = 0;
128
+
129
+ // Check 1: OAI-SearchBot / ChatGPT-User allowed
130
+ const oaiBlocked = robotsDirectives.some((d) => d.includes('oai-searchbot: noindex') || d.includes('chatgpt-user: noindex'));
131
+ if (!oaiBlocked && !isNoindex) {
132
+ chatGptScore += 25;
133
+ chatGptScore = Math.min(100, chatGptScore);
134
+ chatGptChecks.push({ name: 'SearchBot Policy', passed: true, note: 'OAI-SearchBot and ChatGPT-User allowed for search retrieval' });
135
+ } else {
136
+ chatGptChecks.push({ name: 'SearchBot Policy', passed: false, note: 'OAI-SearchBot / ChatGPT-User blocked by robots directives' });
137
+ }
138
+
139
+ // Check 2: RAG Chunkability (H2/H3 headers under 350 words per chunk)
140
+ const estimatedChunks = Math.max(1, Math.ceil(wordCount / 300));
141
+ const wordsPerHeading = headings.length > 0 ? Math.round(wordCount / headings.length) : wordCount;
142
+ if (headings.length >= 2 && wordsPerHeading >= 80 && wordsPerHeading <= 400) {
143
+ chatGptScore += 25;
144
+ chatGptChecks.push({ name: 'RAG Chunkability', passed: true, note: `Optimal chunk sizes (${wordsPerHeading} words/heading across ${headings.length} headings)` });
145
+ } else if (headings.length >= 1) {
146
+ chatGptScore += 15;
147
+ chatGptChecks.push({ name: 'RAG Chunkability', passed: true, note: `Sub-optimal chunking (${wordsPerHeading} words/heading)` });
148
+ } else {
149
+ chatGptChecks.push({ name: 'RAG Chunkability', passed: false, note: 'Monolithic content without H2/H3 hierarchy risks retrieval severance' });
150
+ }
151
+
152
+ // Check 3: Direct answer lead in first 100 words of section
153
+ const SUMMARY_RX = /\b(?:executive\s+summary|key\s+takeaways?|summary|overview|quick\s+facts|highlights?)\b/i;
154
+ const hasSummary = headings.some((h) => SUMMARY_RX.test(h.text));
155
+ if (hasSummary || hasCopularLead) {
156
+ chatGptScore += 25;
157
+ chatGptChecks.push({ name: 'Direct Answer Synthesis', passed: true, note: hasSummary ? 'Executive summary / key takeaways section present' : 'Immediate answer thesis in opening paragraph' });
158
+ } else {
159
+ chatGptChecks.push({ name: 'Direct Answer Synthesis', passed: false, note: 'Missing executive summary or lead key takeaways section' });
160
+ }
161
+
162
+ // Check 4: Quantitative claim grounding
163
+ const hasQuantitativeData = /(?:\b\d+(?:\.\d+)?%|\b\d+\s*ms\b|\b\d+\s*s\b|\$\d+(?:,\d{3})*)/.test(text);
164
+ if (hasQuantitativeData && wordCount >= 300) {
165
+ chatGptScore += 25;
166
+ chatGptChecks.push({ name: 'Claim Grounding', passed: true, note: 'Specific numerical metrics and quantitative facts present' });
167
+ } else if (wordCount >= 300) {
168
+ chatGptScore += 15;
169
+ chatGptChecks.push({ name: 'Claim Grounding', passed: false, note: 'Qualitative assertions without empirical statistics' });
170
+ } else {
171
+ chatGptChecks.push({ name: 'Claim Grounding', passed: false, note: 'Thin content lacks verifiable data' });
172
+ }
173
+
174
+ // Posture per engine
175
+ const posture = (s) => (s >= 80 ? 'optimal' : s >= 50 ? 'needs_optimization' : 'obstructed');
176
+ const perplexityStatus = posture(perplexityScore);
177
+ const bingStatus = posture(bingScore);
178
+ const chatGptStatus = posture(chatGptScore);
179
+
180
+ const compositeScore = Math.round((perplexityScore + bingScore + chatGptScore) / 3);
181
+
182
+ // Cross-engine prioritized recommendations
183
+ const recommendations = [];
184
+ if (!hasCopularLead) {
185
+ recommendations.push({
186
+ priority: 'high',
187
+ engine: 'all',
188
+ action: 'Add a concise definitional sentence ("X is Y") within the first 140 characters of the page',
189
+ });
190
+ }
191
+ if (!hasSummary) {
192
+ recommendations.push({
193
+ priority: 'medium',
194
+ engine: 'chatgpt',
195
+ action: 'Include a "Key Takeaways" or "Executive Summary" section at the top of long-form content',
196
+ });
197
+ }
198
+ if (questionHeadings.length < 2) {
199
+ recommendations.push({
200
+ priority: 'medium',
201
+ engine: 'perplexity',
202
+ action: 'Rephrase section headings as explicit user questions (e.g. "How does X work?", "What are the benefits of Y?")',
203
+ });
204
+ }
205
+ if (validSchemaBlocks.length < 2) {
206
+ recommendations.push({
207
+ priority: 'medium',
208
+ engine: 'bing_copilot',
209
+ action: 'Deploy structured JSON-LD schema (FAQPage, Article, or Product) to power Copilot card extraction',
210
+ });
211
+ }
212
+ if (!hasIndexNow) {
213
+ recommendations.push({
214
+ priority: 'low',
215
+ engine: 'bing_copilot',
216
+ action: 'Configure IndexNow protocol to notify Bing immediately of content updates',
217
+ });
218
+ }
219
+
220
+ return {
221
+ url: page.url,
222
+ fact_status: 'deterministic_engine_readiness_audit',
223
+ composite_readiness_score: compositeScore,
224
+ engines: {
225
+ perplexity: {
226
+ score: perplexityScore,
227
+ status: perplexityStatus,
228
+ primary_bot: 'PerplexityBot',
229
+ checks: perplexityChecks,
230
+ },
231
+ bing_copilot: {
232
+ score: bingScore,
233
+ status: bingStatus,
234
+ primary_bot: 'Bingbot',
235
+ checks: bingChecks,
236
+ },
237
+ chatgpt: {
238
+ score: chatGptScore,
239
+ status: chatGptStatus,
240
+ primary_bot: 'OAI-SearchBot',
241
+ checks: chatGptChecks,
242
+ },
243
+ },
244
+ comparison_matrix: [
245
+ { capability: 'Direct Definitional Lead', perplexity: hasCopularLead ? 'SUPPORTED' : 'MISSING', bing_copilot: hasCopularLead ? 'SUPPORTED' : 'OPTIONAL', chatgpt: hasCopularLead ? 'SUPPORTED' : 'MISSING' },
246
+ { capability: 'Q&A Heading Structure', perplexity: questionHeadings.length >= 2 ? 'HIGH' : 'LOW', bing_copilot: questionHeadings.length >= 2 ? 'SUPPORTED' : 'OPTIONAL', chatgpt: questionHeadings.length >= 2 ? 'OPTIMAL' : 'MODERATE' },
247
+ { capability: 'Structured Data (JSON-LD)', perplexity: validSchemaBlocks.length ? 'PARSED' : 'FALLBACK', bing_copilot: validSchemaBlocks.length ? 'REQUIRED_CARD' : 'OBSTRUCTED', chatgpt: validSchemaBlocks.length ? 'GROUNDED' : 'PLAIN_TEXT' },
248
+ { capability: 'RAG Section Chunkability', perplexity: wordsPerHeading <= 400 ? 'OPTIMAL' : 'LONG', bing_copilot: 'STANDARD', chatgpt: wordsPerHeading <= 400 ? 'OPTIMAL' : 'SEVERED' },
249
+ { capability: 'Crawler Directives', perplexity: perplexityBotBlocked ? 'BLOCKED' : 'ALLOWED', bing_copilot: bingbotBlocked ? 'BLOCKED' : 'ALLOWED', chatgpt: oaiBlocked ? 'BLOCKED' : 'ALLOWED' },
250
+ ],
251
+ recommendations,
252
+ };
253
+ }