seo-intel 1.1.10 → 1.2.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 CHANGED
@@ -1,5 +1,43 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.2.0 (2026-03-28)
4
+
5
+ ### AEO — AI Citability Audit (new feature)
6
+ - **New command: `seo-intel aeo <project>`** (alias: `citability`) — score every page for how well AI assistants can cite it
7
+ - Per-page citability score (0-100) computed from 6 signals: entity authority, structured claims, answer density, Q&A proximity, freshness, schema coverage
8
+ - AI Query Intent classification per page: synthesis, decision support, implementation, exploration, validation
9
+ - Tier breakdown: excellent (75+), good (55-74), needs work (35-54), poor (<35)
10
+ - Signal strength analysis — identifies your weakest citability signals site-wide
11
+ - Compares target vs competitor citability scores with delta
12
+ - Low-scoring pages automatically feed into Intelligence Ledger as `citability_gap` insights
13
+ - Dashboard: new "AI Citability Audit" card with stat bar, signal strength bars, and page score table
14
+ - Runs on existing crawl data — zero new network calls, zero Ollama required
15
+ - `--target-only` flag to skip competitor scoring
16
+ - `--save` flag to export `.md` report
17
+
18
+ ## 1.1.12 (2026-03-28)
19
+
20
+ ### Intelligence Ledger
21
+ - Analysis insights now **accumulate across runs** instead of showing only the latest
22
+ - New `insights` table with fingerprint-based dedup — re-running `analyze` adds new ideas without losing old ones
23
+ - Dashboard shows all active insights: 65 long-tails, 36 keyword gaps, 23 content gaps (vs 4 from latest-only)
24
+ - Done/dismiss buttons on every insight card — mark fixes as done, dismiss irrelevant suggestions
25
+ - `POST /api/insights/:id/status` endpoint for status toggling (active/done/dismissed)
26
+ - Keywords Inventor also persists to Intelligence Ledger via `keywords --save`
27
+
28
+ ### Improvements
29
+ - Prompt and raw output files now save as `.md` with YAML frontmatter (Obsidian-compatible)
30
+ - Long-tail Opportunities moved to Research section where it belongs
31
+ - Migrated all existing prompt `.txt` files to `.md` with frontmatter
32
+
33
+ ## 1.1.11 (2026-03-27)
34
+
35
+ ### Fixes
36
+ - Extraction now preflights Ollama hosts at run start and only uses live hosts during crawl/extract
37
+ - Dead fallback hosts no longer poison the run or trigger noisy repeated circuit-breaker fallback spam
38
+ - Degraded mode messaging is clearer and only activates when no live extraction host remains
39
+ - Extractor timeout errors now include host/model/timeout context
40
+
3
41
  ## 1.1.10 (2026-03-27)
4
42
 
5
43
  ### Security
@@ -64,4 +102,4 @@
64
102
 
65
103
  - Update checker, job stop API, background analyze
66
104
  - LAN Ollama host support with fallback
67
- - `html` CLI command, wizard UX improvements
105
+ - `html` CLI command, wizard UX improvements
package/cli.js CHANGED
@@ -38,7 +38,8 @@ import {
38
38
  insertKeywords, insertHeadings, insertLinks, insertPageSchemas,
39
39
  upsertTechnical, pruneStaleDomains,
40
40
  getCompetitorSummary, getKeywordMatrix, getHeadingStructure,
41
- getPageHash, getSchemasByProject
41
+ getPageHash, getSchemasByProject,
42
+ upsertInsightsFromAnalysis, upsertInsightsFromKeywords,
42
43
  } from './db/db.js';
43
44
  import { generateMultiDashboard } from './reports/generate-html.js';
44
45
  import { buildTechnicalActions } from './exports/technical.js';
@@ -66,44 +67,78 @@ function defaultSiteUrl(domain) {
66
67
  return `${protocol}://${host}`;
67
68
  }
68
69
 
70
+ function resolveExtractionRuntime(config) {
71
+ const primaryUrl = config?.crawl?.ollamaHost || process.env.OLLAMA_URL || 'http://localhost:11434';
72
+ const primaryModel = config?.crawl?.extractionModel || process.env.OLLAMA_MODEL || 'qwen3:4b';
73
+ const fallbackUrl = process.env.OLLAMA_FALLBACK_URL || '';
74
+ const fallbackModel = process.env.OLLAMA_FALLBACK_MODEL || primaryModel;
75
+ const localhost = 'http://localhost:11434';
76
+
77
+ const candidates = [
78
+ { host: String(primaryUrl).trim().replace(/\/+$/, ''), model: String(primaryModel).trim() || 'qwen3:4b' },
79
+ ];
80
+
81
+ if (fallbackUrl) {
82
+ candidates.push({
83
+ host: String(fallbackUrl).trim().replace(/\/+$/, ''),
84
+ model: String(fallbackModel).trim() || String(primaryModel).trim() || 'qwen3:4b',
85
+ });
86
+ }
87
+
88
+ if (!candidates.some(candidate => candidate.host === localhost)) {
89
+ candidates.push({ host: localhost, model: String(primaryModel).trim() || 'qwen3:4b' });
90
+ }
91
+
92
+ const seen = new Set();
93
+ return candidates.filter(candidate => {
94
+ if (!candidate.host) return false;
95
+ const key = `${candidate.host}::${candidate.model}`;
96
+ if (seen.has(key)) return false;
97
+ seen.add(key);
98
+ return true;
99
+ });
100
+ }
101
+
102
+ function applyExtractionRuntimeConfig(config) {
103
+ if (!config?.crawl) return;
104
+ if (config.crawl.ollamaHost) process.env.OLLAMA_URL = config.crawl.ollamaHost;
105
+ if (config.crawl.extractionModel) process.env.OLLAMA_MODEL = config.crawl.extractionModel;
106
+ }
107
+
69
108
  // ── AI AVAILABILITY PREFLIGHT ────────────────────────────────────────────
70
109
  /**
71
110
  * Check if any AI extraction backend is reachable.
72
111
  * Tries: primary Ollama → fallback Ollama → returns false.
73
112
  * Fast: 2s timeout per host, runs sequentially.
74
113
  */
75
- async function checkOllamaAvailability() {
76
- // Build host chain: primary → fallback → always try localhost as last resort
77
- const configured = [
78
- process.env.OLLAMA_URL,
79
- process.env.OLLAMA_FALLBACK_URL,
80
- ].filter(Boolean);
81
- const localhost = 'http://localhost:11434';
82
- const hosts = [...new Set([...configured, localhost])];
114
+ async function checkOllamaAvailability(config) {
115
+ const candidates = resolveExtractionRuntime(config);
116
+ let sawReachableHost = false;
83
117
 
84
- for (const host of hosts) {
118
+ for (const candidate of candidates) {
85
119
  try {
86
120
  const controller = new AbortController();
87
121
  const timeout = setTimeout(() => controller.abort(), 2000);
88
- const res = await fetch(`${host}/api/tags`, { signal: controller.signal });
122
+ const res = await fetch(`${candidate.host}/api/tags`, { signal: controller.signal });
89
123
  clearTimeout(timeout);
90
124
  if (res.ok) {
91
125
  const data = await res.json();
92
126
  const models = (data.models || []).map(m => m.name);
93
- const targetModel = process.env.OLLAMA_MODEL || 'qwen3:4b';
94
- const hasModel = models.some(m => m.startsWith(targetModel.split(':')[0]));
127
+ sawReachableHost = true;
128
+ const hasModel = models.some(m => m && m.split(':')[0] === candidate.model.split(':')[0]);
95
129
  if (hasModel) {
96
130
  return true; // Ollama reachable + model available
97
131
  }
98
- // Ollama reachable but model missing — warn but allow degraded extraction
99
- console.log(chalk.yellow(` ⚠️ Ollama at ${host} is reachable but model "${targetModel}" not found`));
100
- console.log(chalk.dim(` Available models: ${models.join(', ') || 'none'}`));
101
- console.log(chalk.dim(` Run: ollama pull ${targetModel}`));
102
- return false;
103
132
  }
104
133
  } catch { /* host unreachable, try next */ }
105
134
  }
106
135
 
136
+ if (sawReachableHost) {
137
+ const primary = candidates[0];
138
+ console.log(chalk.yellow(` ⚠️ Ollama is reachable but model "${primary?.model || 'qwen3:4b'}" was not found on any live host`));
139
+ console.log(chalk.dim(` Run: ollama pull ${primary?.model || 'qwen3:4b'}`));
140
+ }
141
+
107
142
  return false;
108
143
  }
109
144
 
@@ -362,6 +397,7 @@ program
362
397
  .action(async (project, opts) => {
363
398
  const config = loadConfig(project);
364
399
  const db = getDb();
400
+ applyExtractionRuntimeConfig(config);
365
401
 
366
402
  // ── Auto-discover subdomains for target domain ──────────────────────
367
403
  if (opts.discover !== false && config.target?.domain) {
@@ -435,7 +471,7 @@ program
435
471
 
436
472
  // ── BUG-003/009: AI preflight — check Ollama availability before crawl ──
437
473
  if (opts.extract !== false) {
438
- const ollamaAvailable = await checkOllamaAvailability();
474
+ const ollamaAvailable = await checkOllamaAvailability(config);
439
475
  if (!ollamaAvailable) {
440
476
  console.log(chalk.yellow('\n ⚠️ No AI extraction available (Ollama unreachable, no API keys configured)'));
441
477
  console.log(chalk.white(' → Switching to ') + chalk.bold.green('crawl-only mode') + chalk.white(' — raw data will be collected without AI extraction'));
@@ -687,9 +723,11 @@ program
687
723
  console.log(chalk.yellow(`Prompt length: ~${Math.round(prompt.length / 4)} tokens`));
688
724
  console.log(chalk.yellow('Sending to Gemini...\n'));
689
725
 
690
- // Save prompt for debugging
691
- const promptPath = join(__dirname, `reports/${project}-prompt-${Date.now()}.txt`);
692
- writeFileSync(promptPath, prompt, 'utf8');
726
+ // Save prompt for debugging (markdown for Obsidian/agent compatibility)
727
+ const promptTs = Date.now();
728
+ const promptPath = join(__dirname, `reports/${project}-prompt-${promptTs}.md`);
729
+ const promptFrontmatter = `---\nproject: ${project}\ngenerated: ${new Date(promptTs).toISOString()}\ntype: analysis-prompt\nmodel: gemini\n---\n\n`;
730
+ writeFileSync(promptPath, promptFrontmatter + prompt, 'utf8');
693
731
  console.log(chalk.gray(`Prompt saved: ${promptPath}`));
694
732
 
695
733
  // Call Gemini via gemini CLI (reuse existing auth)
@@ -708,7 +746,7 @@ program
708
746
  analysis = JSON.parse(jsonMatch[0]);
709
747
  } catch {
710
748
  console.error(chalk.red('Could not parse JSON from response. Saving raw output.'));
711
- const rawPath = join(__dirname, `reports/${project}-raw-${Date.now()}.txt`);
749
+ const rawPath = join(__dirname, `reports/${project}-raw-${Date.now()}.md`);
712
750
  writeFileSync(rawPath, result, 'utf8');
713
751
  process.exit(1);
714
752
  }
@@ -718,11 +756,12 @@ program
718
756
  writeFileSync(outPath, JSON.stringify(analysis, null, 2), 'utf8');
719
757
 
720
758
  // Save to DB (so HTML dashboard picks it up)
759
+ const analysisTs = Date.now();
721
760
  db.prepare(`
722
761
  INSERT INTO analyses (project, generated_at, model, keyword_gaps, long_tails, quick_wins, new_pages, content_gaps, positioning, technical_gaps, raw)
723
762
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
724
763
  `).run(
725
- project, Date.now(), 'gemini',
764
+ project, analysisTs, 'gemini',
726
765
  JSON.stringify(analysis.keyword_gaps || []),
727
766
  JSON.stringify(analysis.long_tails || []),
728
767
  JSON.stringify(analysis.quick_wins || []),
@@ -733,6 +772,10 @@ program
733
772
  result,
734
773
  );
735
774
 
775
+ // Upsert individual insights (Intelligence Ledger — accumulates across runs)
776
+ const analysisRowId = db.prepare('SELECT last_insert_rowid() as id').get().id;
777
+ upsertInsightsFromAnalysis(db, project, analysisRowId, analysis, analysisTs);
778
+
736
779
  // Print summary
737
780
  printAnalysisSummary(analysis, project);
738
781
 
@@ -869,7 +912,7 @@ Respond ONLY with a single valid JSON object matching this exact schema. No expl
869
912
  data = JSON.parse(jsonMatch[0]);
870
913
  } catch {
871
914
  console.error(chalk.red('Could not parse JSON from Gemini response.'));
872
- const rawPath = join(__dirname, `reports/${project}-keywords-raw-${Date.now()}.txt`);
915
+ const rawPath = join(__dirname, `reports/${project}-keywords-raw-${Date.now()}.md`);
873
916
  writeFileSync(rawPath, result, 'utf8');
874
917
  console.error(chalk.gray(`Raw output saved: ${rawPath}`));
875
918
  process.exit(1);
@@ -933,6 +976,10 @@ Respond ONLY with a single valid JSON object matching this exact schema. No expl
933
976
  const outPath = join(__dirname, `reports/${project}-keywords-${Date.now()}.json`);
934
977
  writeFileSync(outPath, JSON.stringify(data, null, 2), 'utf8');
935
978
  console.log(chalk.bold.green(`✅ Report saved: ${outPath}\n`));
979
+
980
+ // Persist keyword inventor insights to Intelligence Ledger
981
+ const db = getDb();
982
+ upsertInsightsFromKeywords(db, project, data);
936
983
  }
937
984
  });
938
985
 
@@ -1183,6 +1230,7 @@ program
1183
1230
  }
1184
1231
 
1185
1232
  console.log(chalk.bold.cyan(`\n🔍 Cron run: crawling ${next.domain} [${next.role}] (project: ${next.project})\n`));
1233
+ applyExtractionRuntimeConfig(loadConfig(next.project));
1186
1234
 
1187
1235
  const runStart = Date.now();
1188
1236
 
@@ -1683,7 +1731,9 @@ async function runAnalysis(project, db) {
1683
1731
  headingStructure: headings, context: config.context,
1684
1732
  });
1685
1733
 
1686
- writeFileSync(join(__dirname, `reports/${project}-prompt-${Date.now()}.txt`), prompt, 'utf8');
1734
+ const promptTs2 = Date.now();
1735
+ const promptFm2 = `---\nproject: ${project}\ngenerated: ${new Date(promptTs2).toISOString()}\ntype: analysis-prompt\nmodel: gemini\n---\n\n`;
1736
+ writeFileSync(join(__dirname, `reports/${project}-prompt-${promptTs2}.md`), promptFm2 + prompt, 'utf8');
1687
1737
 
1688
1738
  const result = await callGemini(prompt);
1689
1739
  if (!result) { console.error(chalk.red('Gemini returned no response.')); process.exit(1); }
@@ -1695,11 +1745,12 @@ async function runAnalysis(project, db) {
1695
1745
  writeFileSync(outPath, JSON.stringify(analysis, null, 2), 'utf8');
1696
1746
 
1697
1747
  // Save to DB
1748
+ const analysisTs2 = Date.now();
1698
1749
  db.prepare(`
1699
1750
  INSERT INTO analyses (project, generated_at, model, keyword_gaps, long_tails, quick_wins, new_pages, content_gaps, positioning, technical_gaps, raw)
1700
1751
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1701
1752
  `).run(
1702
- project, Date.now(), 'gemini',
1753
+ project, analysisTs2, 'gemini',
1703
1754
  JSON.stringify(analysis.keyword_gaps || []),
1704
1755
  JSON.stringify(analysis.long_tails || []),
1705
1756
  JSON.stringify(analysis.quick_wins || []),
@@ -1710,6 +1761,10 @@ async function runAnalysis(project, db) {
1710
1761
  result,
1711
1762
  );
1712
1763
 
1764
+ // Upsert individual insights (Intelligence Ledger)
1765
+ const analysisRowId2 = db.prepare('SELECT last_insert_rowid() as id').get().id;
1766
+ upsertInsightsFromAnalysis(db, project, analysisRowId2, analysis, analysisTs2);
1767
+
1713
1768
  printAnalysisSummary(analysis, project);
1714
1769
  console.log(chalk.green(`\n✅ Analysis saved: ${outPath}`));
1715
1770
  } catch (err) {
@@ -1724,6 +1779,7 @@ program
1724
1779
  .description('Run AI extraction on all crawled-but-not-yet-extracted pages (requires Solo/Agency)')
1725
1780
  .action(async (project) => {
1726
1781
  if (!requirePro('extract')) return;
1782
+ applyExtractionRuntimeConfig(loadConfig(project));
1727
1783
  const db = getDb();
1728
1784
 
1729
1785
  // Query pages that have body_text stored (from crawl) but no extraction yet
@@ -3808,6 +3864,145 @@ program
3808
3864
  writeOrPrintActionOutput(output, opts.output);
3809
3865
  });
3810
3866
 
3867
+ // ── AEO / AI CITABILITY AUDIT ────────────────────────────────────────────
3868
+ program
3869
+ .command('aeo <project>')
3870
+ .alias('citability')
3871
+ .description('AI Citability Audit — score every page for how well AI assistants can cite it')
3872
+ .option('--target-only', 'Only score target domain (skip competitors)')
3873
+ .option('--save', 'Save report to reports/')
3874
+ .action(async (project, opts) => {
3875
+ if (!requirePro('aeo')) return;
3876
+ const db = getDb();
3877
+ const config = loadConfig(project);
3878
+
3879
+ printAttackHeader('🤖 AEO — AI Citability Audit', project);
3880
+
3881
+ const { runAeoAnalysis, persistAeoScores, upsertCitabilityInsights } = await import('./analyses/aeo/index.js');
3882
+
3883
+ const results = runAeoAnalysis(db, project, {
3884
+ includeCompetitors: !opts.targetOnly,
3885
+ log: (msg) => console.log(chalk.gray(msg)),
3886
+ });
3887
+
3888
+ if (!results.target.length && !results.competitors.size) {
3889
+ console.log(chalk.yellow('\n ⚠️ No pages with body_text found.'));
3890
+ console.log(chalk.gray(' Run: seo-intel crawl ' + project + ' (crawl stores body text since v1.1.6)\n'));
3891
+ return;
3892
+ }
3893
+
3894
+ // Persist scores
3895
+ persistAeoScores(db, results);
3896
+ upsertCitabilityInsights(db, project, results.target);
3897
+
3898
+ const { summary } = results;
3899
+
3900
+ // ── Summary ──
3901
+ console.log('');
3902
+ console.log(chalk.bold(' 📊 Citability Summary'));
3903
+ console.log('');
3904
+
3905
+ const scoreFmt = (s) => {
3906
+ if (s >= 75) return chalk.bold.green(s + '/100');
3907
+ if (s >= 55) return chalk.bold.yellow(s + '/100');
3908
+ if (s >= 35) return chalk.hex('#ff8c00')(s + '/100');
3909
+ return chalk.bold.red(s + '/100');
3910
+ };
3911
+
3912
+ console.log(` Target average: ${scoreFmt(summary.avgTargetScore)}`);
3913
+ if (summary.competitorPages > 0) {
3914
+ console.log(` Competitor average: ${scoreFmt(summary.avgCompetitorScore)}`);
3915
+ const delta = summary.scoreDelta;
3916
+ const deltaStr = delta > 0 ? chalk.green(`+${delta}`) : delta < 0 ? chalk.red(`${delta}`) : chalk.gray('0');
3917
+ console.log(` Delta: ${deltaStr}`);
3918
+ }
3919
+ console.log('');
3920
+
3921
+ // ── Tier breakdown ──
3922
+ const { tierCounts } = summary;
3923
+ console.log(` ${chalk.green('●')} Excellent (75+): ${tierCounts.excellent}`);
3924
+ console.log(` ${chalk.yellow('●')} Good (55-74): ${tierCounts.good}`);
3925
+ console.log(` ${chalk.hex('#ff8c00')('●')} Needs work (35-54): ${tierCounts.needs_work}`);
3926
+ console.log(` ${chalk.red('●')} Poor (<35): ${tierCounts.poor}`);
3927
+ console.log('');
3928
+
3929
+ // ── Weakest signals ──
3930
+ if (summary.weakestSignals.length) {
3931
+ console.log(chalk.bold(' 🔍 Weakest Signals (target average)'));
3932
+ console.log('');
3933
+ for (const s of summary.weakestSignals) {
3934
+ const bar = '█'.repeat(Math.round(s.avg / 5)) + chalk.gray('░'.repeat(20 - Math.round(s.avg / 5)));
3935
+ console.log(` ${s.signal.padEnd(20)} ${bar} ${s.avg}/100`);
3936
+ }
3937
+ console.log('');
3938
+ }
3939
+
3940
+ // ── Worst pages (actionable) ──
3941
+ const worst = results.target.filter(r => r.score < 55).slice(0, 10);
3942
+ if (worst.length) {
3943
+ console.log(chalk.bold.red(' ⚡ Pages Needing Work'));
3944
+ console.log('');
3945
+ for (const p of worst) {
3946
+ const path = p.url.replace(/https?:\/\/[^/]+/, '') || '/';
3947
+ const weakest = Object.entries(p.breakdown)
3948
+ .sort(([, a], [, b]) => a - b)
3949
+ .slice(0, 2)
3950
+ .map(([k]) => k.replace(/_/g, ' '));
3951
+ console.log(` ${scoreFmt(p.score)} ${chalk.bold(path.slice(0, 50))}`);
3952
+ console.log(chalk.gray(` Weak: ${weakest.join(', ')}`));
3953
+ }
3954
+ console.log('');
3955
+ }
3956
+
3957
+ // ── Best pages ──
3958
+ const best = results.target.filter(r => r.score >= 55).slice(-5).reverse();
3959
+ if (best.length) {
3960
+ console.log(chalk.bold.green(' ✨ Top Citable Pages'));
3961
+ console.log('');
3962
+ for (const p of best) {
3963
+ const path = p.url.replace(/https?:\/\/[^/]+/, '') || '/';
3964
+ console.log(` ${scoreFmt(p.score)} ${chalk.bold(path.slice(0, 50))} ${chalk.gray(p.aiIntents.join(', '))}`);
3965
+ }
3966
+ console.log('');
3967
+ }
3968
+
3969
+ // ── Actions ──
3970
+ console.log(chalk.bold.green(' 💡 Actions:'));
3971
+ if (tierCounts.poor > 0) {
3972
+ console.log(chalk.green(` 1. Fix ${tierCounts.poor} poor-scoring pages — add structured headings, Q&A format, entity depth`));
3973
+ }
3974
+ if (summary.weakestSignals.length && summary.weakestSignals[0].avg < 40) {
3975
+ console.log(chalk.green(` 2. Site-wide weakness: "${summary.weakestSignals[0].signal}" — systematically improve across all pages`));
3976
+ }
3977
+ if (summary.scoreDelta < 0) {
3978
+ console.log(chalk.green(` 3. Competitors are ${Math.abs(summary.scoreDelta)} points ahead — prioritise top-traffic pages first`));
3979
+ }
3980
+ console.log('');
3981
+
3982
+ // ── Regenerate dashboard ──
3983
+ try {
3984
+ const configs = loadAllConfigs();
3985
+ generateMultiDashboard(db, configs);
3986
+ console.log(chalk.green(' ✅ Dashboard updated with AI Citability card\n'));
3987
+ } catch (e) {
3988
+ console.log(chalk.gray(` (Dashboard not updated: ${e.message})\n`));
3989
+ }
3990
+
3991
+ // ── Save report ──
3992
+ if (opts.save) {
3993
+ let md = `---\ntype: aeo-report\nproject: ${project}\ndate: ${new Date().toISOString()}\n---\n# AEO Citability Report — ${config.target.domain}\n\n`;
3994
+ md += `## Summary\n- Target avg: ${summary.avgTargetScore}/100\n- Competitor avg: ${summary.avgCompetitorScore}/100\n- Delta: ${summary.scoreDelta}\n\n`;
3995
+ md += `## Tier Breakdown\n- Excellent: ${tierCounts.excellent}\n- Good: ${tierCounts.good}\n- Needs work: ${tierCounts.needs_work}\n- Poor: ${tierCounts.poor}\n\n`;
3996
+ md += `## Pages Needing Work\n`;
3997
+ for (const p of worst) {
3998
+ md += `- **${p.url}** — ${p.score}/100 (${p.tier})\n`;
3999
+ }
4000
+ const outPath = join(__dirname, `reports/${project}-aeo-${Date.now()}.md`);
4001
+ writeFileSync(outPath, md, 'utf8');
4002
+ console.log(chalk.bold.green(` ✅ Report saved: ${outPath}\n`));
4003
+ }
4004
+ });
4005
+
3811
4006
  // ── GUIDE (Coach-style chapter map) ──────────────────────────────────────
3812
4007
  program
3813
4008
  .command('guide')
package/db/db.js CHANGED
@@ -24,15 +24,191 @@ export function getDb(dbPath = './seo-intel.db') {
24
24
  try { _db.exec('ALTER TABLE pages ADD COLUMN title TEXT'); } catch { /* already exists */ }
25
25
  try { _db.exec('ALTER TABLE pages ADD COLUMN meta_desc TEXT'); } catch { /* already exists */ }
26
26
  try { _db.exec('ALTER TABLE pages ADD COLUMN body_text TEXT'); } catch { /* already exists */ }
27
+ try { _db.exec('ALTER TABLE analyses ADD COLUMN technical_gaps TEXT'); } catch { /* already exists */ }
27
28
 
28
29
  // Backfill first_seen_at from crawled_at for existing rows
29
30
  _db.exec('UPDATE pages SET first_seen_at = crawled_at WHERE first_seen_at IS NULL');
30
31
 
31
- // page_schemas table is created by schema.sql — no migration needed (new table)
32
+ // Migrate existing analyses insights (one-time)
33
+ _migrateAnalysesToInsights(_db);
32
34
 
33
35
  return _db;
34
36
  }
35
37
 
38
+ // ── Insight fingerprinting ──────────────────────────────────────────────────
39
+
40
+ function _insightFingerprint(type, item) {
41
+ let raw;
42
+ switch (type) {
43
+ case 'keyword_gap': raw = item.keyword || ''; break;
44
+ case 'long_tail': raw = item.phrase || ''; break;
45
+ case 'quick_win': raw = `${item.page || ''}::${item.issue || ''}`; break;
46
+ case 'new_page': raw = item.target_keyword || item.title || ''; break;
47
+ case 'content_gap': raw = item.topic || ''; break;
48
+ case 'technical_gap': raw = item.gap || ''; break;
49
+ case 'positioning': raw = 'positioning'; break;
50
+ case 'keyword_inventor': raw = item.phrase || ''; break;
51
+ case 'citability_gap': raw = item.url || ''; break;
52
+ default: raw = JSON.stringify(item);
53
+ }
54
+ return raw.toLowerCase().replace(/[^a-z0-9\s]/g, '').replace(/\s+/g, ' ').trim();
55
+ }
56
+
57
+ // ── Migrate all historical analyses into insights ───────────────────────────
58
+
59
+ function _migrateAnalysesToInsights(db) {
60
+ const count = db.prepare('SELECT COUNT(*) as n FROM insights').get().n;
61
+ if (count > 0) return; // already migrated
62
+
63
+ const rows = db.prepare('SELECT * FROM analyses ORDER BY generated_at ASC').all();
64
+ if (!rows.length) return;
65
+
66
+ const safeJsonParse = (s) => { try { return JSON.parse(s); } catch { return null; } };
67
+
68
+ const upsertStmt = db.prepare(`
69
+ INSERT INTO insights (project, type, status, fingerprint, first_seen, last_seen, source_analysis_id, data)
70
+ VALUES (?, ?, 'active', ?, ?, ?, ?, ?)
71
+ ON CONFLICT(project, type, fingerprint) DO UPDATE SET
72
+ last_seen = excluded.last_seen,
73
+ data = excluded.data
74
+ `);
75
+
76
+ db.exec('BEGIN');
77
+ try {
78
+ for (const row of rows) {
79
+ const ts = row.generated_at;
80
+ const fields = [
81
+ ['keyword_gap', safeJsonParse(row.keyword_gaps)],
82
+ ['long_tail', safeJsonParse(row.long_tails)],
83
+ ['quick_win', safeJsonParse(row.quick_wins)],
84
+ ['new_page', safeJsonParse(row.new_pages)],
85
+ ['content_gap', safeJsonParse(row.content_gaps)],
86
+ ['technical_gap', safeJsonParse(row.technical_gaps)],
87
+ ];
88
+ for (const [type, items] of fields) {
89
+ if (!Array.isArray(items)) continue;
90
+ for (const item of items) {
91
+ const fp = _insightFingerprint(type, item);
92
+ if (!fp) continue;
93
+ upsertStmt.run(row.project, type, fp, ts, ts, row.id, JSON.stringify(item));
94
+ }
95
+ }
96
+ // positioning is a singleton object, not an array
97
+ const pos = safeJsonParse(row.positioning);
98
+ if (pos && typeof pos === 'object' && Object.keys(pos).length) {
99
+ const fp = _insightFingerprint('positioning', pos);
100
+ upsertStmt.run(row.project, 'positioning', fp, ts, ts, row.id, JSON.stringify(pos));
101
+ }
102
+ }
103
+ db.exec('COMMIT');
104
+ } catch (e) {
105
+ db.exec('ROLLBACK');
106
+ console.error('[db] insights migration failed:', e.message);
107
+ }
108
+ }
109
+
110
+ // ── Insight upsert (called after each analyze/keywords run) ─────────────────
111
+
112
+ export function upsertInsightsFromAnalysis(db, project, analysisId, analysis, timestamp) {
113
+ const upsertStmt = db.prepare(`
114
+ INSERT INTO insights (project, type, status, fingerprint, first_seen, last_seen, source_analysis_id, data)
115
+ VALUES (?, ?, 'active', ?, ?, ?, ?, ?)
116
+ ON CONFLICT(project, type, fingerprint) DO UPDATE SET
117
+ last_seen = excluded.last_seen,
118
+ data = excluded.data
119
+ `);
120
+
121
+ const ts = timestamp || Date.now();
122
+ db.exec('BEGIN');
123
+ try {
124
+ const fields = [
125
+ ['keyword_gap', analysis.keyword_gaps],
126
+ ['long_tail', analysis.long_tails],
127
+ ['quick_win', analysis.quick_wins],
128
+ ['new_page', analysis.new_pages],
129
+ ['content_gap', analysis.content_gaps],
130
+ ['technical_gap', analysis.technical_gaps],
131
+ ];
132
+ for (const [type, items] of fields) {
133
+ if (!Array.isArray(items)) continue;
134
+ for (const item of items) {
135
+ const fp = _insightFingerprint(type, item);
136
+ if (!fp) continue;
137
+ upsertStmt.run(project, type, fp, ts, ts, analysisId, JSON.stringify(item));
138
+ }
139
+ }
140
+ if (analysis.positioning && typeof analysis.positioning === 'object') {
141
+ const fp = _insightFingerprint('positioning', analysis.positioning);
142
+ upsertStmt.run(project, 'positioning', fp, ts, ts, analysisId, JSON.stringify(analysis.positioning));
143
+ }
144
+ db.exec('COMMIT');
145
+ } catch (e) {
146
+ db.exec('ROLLBACK');
147
+ console.error('[db] insight upsert failed:', e.message);
148
+ }
149
+ }
150
+
151
+ export function upsertInsightsFromKeywords(db, project, keywordsReport) {
152
+ const upsertStmt = db.prepare(`
153
+ INSERT INTO insights (project, type, status, fingerprint, first_seen, last_seen, source_analysis_id, data)
154
+ VALUES (?, 'keyword_inventor', 'active', ?, ?, ?, NULL, ?)
155
+ ON CONFLICT(project, type, fingerprint) DO UPDATE SET
156
+ last_seen = excluded.last_seen,
157
+ data = excluded.data
158
+ `);
159
+
160
+ const ts = Date.now();
161
+ const allClusters = keywordsReport.keyword_clusters || [];
162
+ const allKws = allClusters.flatMap(c => (c.keywords || []).map(k => ({ ...k, cluster: c.topic })));
163
+
164
+ db.exec('BEGIN');
165
+ try {
166
+ for (const kw of allKws) {
167
+ const fp = _insightFingerprint('keyword_inventor', kw);
168
+ if (!fp) continue;
169
+ upsertStmt.run(project, fp, ts, ts, JSON.stringify(kw));
170
+ }
171
+ db.exec('COMMIT');
172
+ } catch (e) {
173
+ db.exec('ROLLBACK');
174
+ console.error('[db] keyword insight upsert failed:', e.message);
175
+ }
176
+ }
177
+
178
+ // ── Read active insights (accumulated across all runs) ──────────────────────
179
+
180
+ export function getActiveInsights(db, project) {
181
+ const rows = db.prepare(
182
+ `SELECT * FROM insights WHERE project = ? AND status = 'active' ORDER BY type, last_seen DESC`
183
+ ).all(project);
184
+
185
+ const grouped = {};
186
+ for (const row of rows) {
187
+ if (!grouped[row.type]) grouped[row.type] = [];
188
+ const parsed = JSON.parse(row.data);
189
+ parsed._insight_id = row.id;
190
+ parsed._first_seen = row.first_seen;
191
+ parsed._last_seen = row.last_seen;
192
+ grouped[row.type].push(parsed);
193
+ }
194
+
195
+ return {
196
+ keyword_gaps: grouped.keyword_gap || [],
197
+ long_tails: grouped.long_tail || [],
198
+ quick_wins: grouped.quick_win || [],
199
+ new_pages: grouped.new_page || [],
200
+ content_gaps: grouped.content_gap || [],
201
+ technical_gaps: grouped.technical_gap || [],
202
+ positioning: grouped.positioning?.[0] || null,
203
+ keyword_inventor: grouped.keyword_inventor || [],
204
+ generated_at: rows.length ? Math.max(...rows.map(r => r.last_seen)) : null,
205
+ };
206
+ }
207
+
208
+ export function updateInsightStatus(db, id, status) {
209
+ db.prepare('UPDATE insights SET status = ? WHERE id = ?').run(status, id);
210
+ }
211
+
36
212
  export function upsertDomain(db, { domain, project, role }) {
37
213
  const now = Date.now();
38
214
  return db.prepare(`
@@ -337,6 +513,7 @@ export function pruneStaleDomains(db, project, configDomains) {
337
513
  db.prepare(`DELETE FROM page_schemas WHERE page_id IN (${placeholders})`).run(...pageIds);
338
514
  db.prepare(`DELETE FROM extractions WHERE page_id IN (${placeholders})`).run(...pageIds);
339
515
  db.prepare(`DELETE FROM keywords WHERE page_id IN (${placeholders})`).run(...pageIds);
516
+ try { db.prepare(`DELETE FROM citability_scores WHERE page_id IN (${placeholders})`).run(...pageIds); } catch { /* table may not exist */ }
340
517
  db.prepare(`DELETE FROM pages WHERE domain_id = ?`).run(id);
341
518
  }
342
519
 
package/db/schema.sql CHANGED
@@ -82,19 +82,37 @@ CREATE TABLE IF NOT EXISTS technical (
82
82
  );
83
83
 
84
84
  CREATE TABLE IF NOT EXISTS analyses (
85
- id INTEGER PRIMARY KEY AUTOINCREMENT,
86
- project TEXT NOT NULL,
87
- generated_at INTEGER NOT NULL,
88
- model TEXT NOT NULL,
89
- keyword_gaps TEXT, -- JSON array
90
- long_tails TEXT, -- JSON array
91
- quick_wins TEXT, -- JSON array
92
- new_pages TEXT, -- JSON array
93
- content_gaps TEXT, -- JSON array
94
- positioning TEXT,
95
- raw TEXT -- full model response
85
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
86
+ project TEXT NOT NULL,
87
+ generated_at INTEGER NOT NULL,
88
+ model TEXT NOT NULL,
89
+ keyword_gaps TEXT, -- JSON array
90
+ long_tails TEXT, -- JSON array
91
+ quick_wins TEXT, -- JSON array
92
+ new_pages TEXT, -- JSON array
93
+ content_gaps TEXT, -- JSON array
94
+ positioning TEXT,
95
+ technical_gaps TEXT, -- JSON array
96
+ raw TEXT -- full model response
97
+ );
98
+
99
+ -- Intelligence Ledger: individual insights accumulated across analysis runs
100
+ CREATE TABLE IF NOT EXISTS insights (
101
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
102
+ project TEXT NOT NULL,
103
+ type TEXT NOT NULL, -- keyword_gap | long_tail | quick_win | new_page | content_gap | technical_gap | positioning | keyword_inventor
104
+ status TEXT NOT NULL DEFAULT 'active', -- active | done | dismissed
105
+ fingerprint TEXT NOT NULL, -- normalised dedup key
106
+ first_seen INTEGER NOT NULL, -- epoch ms
107
+ last_seen INTEGER NOT NULL, -- epoch ms
108
+ source_analysis_id INTEGER, -- FK to analyses.id (NULL for keyword_inventor)
109
+ data TEXT NOT NULL, -- JSON blob for the individual item
110
+ UNIQUE(project, type, fingerprint)
96
111
  );
97
112
 
113
+ CREATE INDEX IF NOT EXISTS idx_insights_project_status ON insights(project, status);
114
+ CREATE INDEX IF NOT EXISTS idx_insights_project_type ON insights(project, type);
115
+
98
116
  CREATE TABLE IF NOT EXISTS page_schemas (
99
117
  id INTEGER PRIMARY KEY AUTOINCREMENT,
100
118
  page_id INTEGER NOT NULL REFERENCES pages(id),
@@ -158,6 +176,24 @@ CREATE TABLE IF NOT EXISTS template_samples (
158
176
  CREATE INDEX IF NOT EXISTS idx_template_groups_project ON template_groups(project);
159
177
  CREATE INDEX IF NOT EXISTS idx_template_samples_group ON template_samples(group_id);
160
178
 
179
+ -- AEO / AI Citability scores (one per page, re-scorable)
180
+ CREATE TABLE IF NOT EXISTS citability_scores (
181
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
182
+ page_id INTEGER UNIQUE NOT NULL REFERENCES pages(id),
183
+ score INTEGER NOT NULL, -- composite 0-100
184
+ entity_authority INTEGER NOT NULL DEFAULT 0,
185
+ structured_claims INTEGER NOT NULL DEFAULT 0,
186
+ answer_density INTEGER NOT NULL DEFAULT 0,
187
+ qa_proximity INTEGER NOT NULL DEFAULT 0,
188
+ freshness INTEGER NOT NULL DEFAULT 0,
189
+ schema_coverage INTEGER NOT NULL DEFAULT 0,
190
+ ai_intents TEXT, -- JSON array: synthesis, decision_support, etc.
191
+ tier TEXT NOT NULL DEFAULT 'poor', -- excellent | good | needs_work | poor
192
+ scored_at INTEGER NOT NULL
193
+ );
194
+
195
+ CREATE INDEX IF NOT EXISTS idx_citability_page ON citability_scores(page_id);
196
+
161
197
  -- Indexes
162
198
  CREATE INDEX IF NOT EXISTS idx_pages_domain ON pages(domain_id);
163
199
  CREATE INDEX IF NOT EXISTS idx_keywords_page ON keywords(page_id);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "seo-intel",
3
- "version": "1.1.10",
3
+ "version": "1.2.0",
4
4
  "description": "Local Ahrefs-style SEO competitor intelligence. Crawl → SQLite → cloud analysis.",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -18,6 +18,8 @@ import { dirname, join } from 'path';
18
18
  import { fileURLToPath } from 'url';
19
19
  import { loadGscData } from './gsc-loader.js';
20
20
  import { isPro } from '../lib/license.js';
21
+ import { getActiveInsights } from '../db/db.js';
22
+ import { getCitabilityScores } from '../analyses/aeo/index.js';
21
23
 
22
24
  const __dirname = dirname(fileURLToPath(import.meta.url));
23
25
 
@@ -69,7 +71,7 @@ function gatherProjectData(db, project, config) {
69
71
  const domains = getDomainStats(db, project, config);
70
72
  const keywords = getTopKeywords(db, project);
71
73
  const keywordGaps = getKeywordGaps(db, project);
72
- const latestAnalysis = getLatestAnalysis(db, project);
74
+ const latestAnalysis = getActiveInsights(db, project);
73
75
  const keywordHeatmap = getKeywordHeatmapData(db, project, allDomains, latestAnalysis);
74
76
  const technicalScores = getTechnicalScores(db, project, config);
75
77
  const internalLinks = getInternalLinkStats(db, project);
@@ -102,6 +104,10 @@ function gatherProjectData(db, project, config) {
102
104
  // Keyword Inventor data
103
105
  const keywordsReport = getLatestKeywordsReport(project);
104
106
 
107
+ // AEO / AI Citability scores
108
+ let citabilityData = null;
109
+ try { citabilityData = getCitabilityScores(db, project); } catch { /* table may not exist yet */ }
110
+
105
111
  // Extraction status
106
112
  const extractionStatus = getExtractionStatus(db, project, config);
107
113
 
@@ -125,7 +131,7 @@ function gatherProjectData(db, project, config) {
125
131
  ctaLandscape, entityTopicMap, schemaBreakdown,
126
132
  gravityMap, contentTerrain, keywordVenn, performanceBubbles,
127
133
  headingFlow, territoryTreemap, topicClusters, linkDna, linkRadarPulse,
128
- keywordsReport, extractionStatus, gscData, domainArch, gscInsights,
134
+ keywordsReport, extractionStatus, gscData, domainArch, gscInsights, citabilityData,
129
135
  };
130
136
 
131
137
  // Rollback the owned→target merge so the actual DB is unchanged
@@ -188,7 +194,7 @@ function buildHtmlTemplate(data, opts = {}) {
188
194
  ctaLandscape, entityTopicMap, schemaBreakdown,
189
195
  gravityMap, contentTerrain, keywordVenn, performanceBubbles,
190
196
  headingFlow, territoryTreemap, topicClusters, linkDna, linkRadarPulse,
191
- keywordsReport, extractionStatus, gscData, domainArch, gscInsights,
197
+ keywordsReport, extractionStatus, gscData, domainArch, gscInsights, citabilityData,
192
198
  } = data;
193
199
 
194
200
  const totalPages = domains.reduce((sum, d) => sum + d.page_count, 0);
@@ -622,6 +628,21 @@ function buildHtmlTemplate(data, opts = {}) {
622
628
  .badge-target { background: rgba(232,213,163,0.15); color: var(--accent-gold); }
623
629
  .badge-competitor { background: rgba(124,109,235,0.15); color: var(--accent-purple); }
624
630
 
631
+ /* ─── Insight Actions (Intelligence Ledger) ──────────────────────────── */
632
+ .insight-action { display: flex; gap: 4px; }
633
+ .insight-btn {
634
+ width: 22px; height: 22px; border-radius: 50%; border: 1px solid var(--border-card);
635
+ background: transparent; color: var(--text-muted); cursor: pointer;
636
+ display: flex; align-items: center; justify-content: center; font-size: 0.6rem;
637
+ transition: all 0.2s ease; padding: 0;
638
+ }
639
+ .insight-btn:hover { border-color: var(--accent-gold); color: var(--accent-gold); }
640
+ .insight-btn.btn-done:hover { border-color: var(--color-success); color: var(--color-success); }
641
+ .insight-btn.btn-dismiss:hover { border-color: var(--color-danger); color: var(--color-danger); }
642
+ tr.insight-done { opacity: 0.3; text-decoration: line-through; }
643
+ .new-page-card.insight-done, .positioning-card.insight-done { opacity: 0.3; }
644
+ .insight-age { font-size: 0.65rem; color: var(--text-muted); white-space: nowrap; }
645
+
625
646
  /* ─── Heatmap Dots ───────────────────────────────────────────────────── */
626
647
  .dot {
627
648
  display: inline-block;
@@ -2722,13 +2743,14 @@ function buildHtmlTemplate(data, opts = {}) {
2722
2743
  <h2><span class="icon"><i class="fa-solid fa-wrench"></i></span> Technical SEO Gaps</h2>
2723
2744
  <div class="analysis-table-wrap">
2724
2745
  <table class="analysis-table">
2725
- <thead><tr><th>Gap</th><th>Competitors with it</th><th>Fix</th></tr></thead>
2746
+ <thead><tr><th>Gap</th><th>Competitors with it</th><th>Fix</th><th></th></tr></thead>
2726
2747
  <tbody>
2727
2748
  ${(latestAnalysis.technical_gaps).map(tg => `
2728
- <tr>
2749
+ <tr data-insight-id="${tg._insight_id || ''}">
2729
2750
  <td><strong>${escapeHtml(tg.gap || '—')}</strong></td>
2730
2751
  <td>${(tg.competitors_with_it || []).map(d => `<span class="comp-tag">${escapeHtml(d)}</span>`).join(' ') || '—'}</td>
2731
2752
  <td>${escapeHtml(tg.fix || '—')}</td>
2753
+ <td class="insight-action">${tg._insight_id ? `<button class="insight-btn btn-done" onclick="insightAction(this,'done')" title="Mark done"><i class="fa-solid fa-check"></i></button><button class="insight-btn btn-dismiss" onclick="insightAction(this,'dismissed')" title="Dismiss"><i class="fa-solid fa-xmark"></i></button>` : ''}</td>
2732
2754
  </tr>`).join('')}
2733
2755
  </tbody>
2734
2756
  </table>
@@ -2749,14 +2771,15 @@ function buildHtmlTemplate(data, opts = {}) {
2749
2771
  <h2><span class="icon"><i class="fa-solid fa-bolt"></i></span> Quick Wins</h2>
2750
2772
  <div class="analysis-table-wrap">
2751
2773
  <table class="analysis-table">
2752
- <thead><tr><th>Page</th><th>Issue</th><th>Fix</th><th>Impact</th></tr></thead>
2774
+ <thead><tr><th>Page</th><th>Issue</th><th>Fix</th><th>Impact</th><th></th></tr></thead>
2753
2775
  <tbody>
2754
2776
  ${(latestAnalysis.quick_wins).map(w => `
2755
- <tr>
2777
+ <tr data-insight-id="${w._insight_id || ''}">
2756
2778
  <td class="mono">${escapeHtml(w.page || '—')}</td>
2757
2779
  <td>${escapeHtml(w.issue || '—')}</td>
2758
2780
  <td>${escapeHtml(w.fix || '—')}</td>
2759
2781
  <td><span class="badge badge-${w.impact || 'medium'}">${w.impact || '—'}</span></td>
2782
+ <td class="insight-action">${w._insight_id ? `<button class="insight-btn btn-done" onclick="insightAction(this,'done')" title="Mark done"><i class="fa-solid fa-check"></i></button><button class="insight-btn btn-dismiss" onclick="insightAction(this,'dismissed')" title="Dismiss"><i class="fa-solid fa-xmark"></i></button>` : ''}</td>
2760
2783
  </tr>`).join('')}
2761
2784
  </tbody>
2762
2785
  </table>
@@ -2770,10 +2793,11 @@ function buildHtmlTemplate(data, opts = {}) {
2770
2793
  <h2><span class="icon"><i class="fa-solid fa-file-circle-plus"></i></span> New Pages to Create</h2>
2771
2794
  <div class="new-pages-grid" style="grid-template-columns: 1fr;">
2772
2795
  ${(latestAnalysis.new_pages).map(np => `
2773
- <div class="new-page-card priority-${np.priority || 'medium'}">
2796
+ <div class="new-page-card priority-${np.priority || 'medium'}" data-insight-id="${np._insight_id || ''}">
2774
2797
  <div class="new-page-header">
2775
2798
  <span class="new-page-title">${escapeHtml(np.title || np.slug || 'Untitled')}</span>
2776
2799
  <span class="badge badge-${np.priority || 'medium'}">${np.priority || '—'}</span>
2800
+ ${np._insight_id ? `<span class="insight-action" style="margin-left:auto;"><button class="insight-btn btn-done" onclick="insightAction(this,'done')" title="Mark done"><i class="fa-solid fa-check"></i></button><button class="insight-btn btn-dismiss" onclick="insightAction(this,'dismissed')" title="Dismiss"><i class="fa-solid fa-xmark"></i></button></span>` : ''}
2777
2801
  </div>
2778
2802
  <div class="new-page-keyword"><i class="fa-solid fa-key" style="font-size:0.7rem;opacity:0.5;margin-right:4px;"></i>${escapeHtml(np.target_keyword || '—')}</div>
2779
2803
  <div class="new-page-angle">${escapeHtml(np.content_angle || np.why || '—')}</div>
@@ -2821,11 +2845,12 @@ function buildHtmlTemplate(data, opts = {}) {
2821
2845
  <h2><span class="icon"><i class="fa-solid fa-magnifying-glass-minus"></i></span> Content Gaps</h2>
2822
2846
  <div class="insights-grid">
2823
2847
  ${(latestAnalysis.content_gaps).map(gap => `
2824
- <div class="insight-card medium">
2848
+ <div class="insight-card medium" data-insight-id="${gap._insight_id || ''}">
2825
2849
  <div class="insight-header">
2826
2850
  <span class="insight-icon"><i class="fa-solid fa-clipboard" style="font-size:0.8rem;"></i></span>
2827
2851
  <span class="insight-title">${escapeHtml(gap.topic || gap.suggested_title || 'Gap')}</span>
2828
2852
  <span class="badge badge-medium">${gap.format || 'content'}</span>
2853
+ ${gap._insight_id ? `<span class="insight-action" style="margin-left:auto;"><button class="insight-btn btn-done" onclick="insightAction(this,'done')" title="Done"><i class="fa-solid fa-check"></i></button><button class="insight-btn btn-dismiss" onclick="insightAction(this,'dismissed')" title="Dismiss"><i class="fa-solid fa-xmark"></i></button></span>` : ''}
2829
2854
  </div>
2830
2855
  <div class="insight-desc">${escapeHtml(gap.why_it_matters || '')}</div>
2831
2856
  ${gap.covered_by?.length ? `<div class="covered-by">Covered by: ${gap.covered_by.map(d => `<span class="comp-tag">${escapeHtml(d)}</span>`).join(' ')}</div>` : ''}
@@ -2864,33 +2889,6 @@ function buildHtmlTemplate(data, opts = {}) {
2864
2889
  </div>
2865
2890
  ` : ''}
2866
2891
 
2867
- <!-- ═══ LONG-TAIL OPPORTUNITIES ═══ -->
2868
- ${pro && latestAnalysis?.long_tails?.length ? `
2869
- <div class="card full-width" id="long-tails">
2870
- <h2><span class="icon"><i class="fa-solid fa-binoculars"></i></span> Long-tail Opportunities</h2>
2871
- <div class="analysis-table-wrap">
2872
- <table class="analysis-table">
2873
- <thead><tr><th>Phrase</th><th>Intent</th><th>Type</th><th>Best Placement</th><th>2nd</th><th>Priority</th></tr></thead>
2874
- <tbody>
2875
- ${(latestAnalysis.long_tails).map(lt => {
2876
- const p1 = lt.placement?.[0];
2877
- const p2 = lt.placement?.[1];
2878
- return `
2879
- <tr>
2880
- <td class="phrase-cell">"${escapeHtml(lt.phrase || '—')}"</td>
2881
- <td>${escapeHtml(lt.intent || '—')}</td>
2882
- <td><span class="type-tag">${escapeHtml(lt.page_type || '—')}</span></td>
2883
- <td class="placement-cell">${p1 ? `<span class="prop-tag prop-${p1.property}">${escapeHtml(p1.property)}</span> <span class="placement-url">${escapeHtml(p1.url || '')}</span>` : '—'}</td>
2884
- <td class="placement-cell">${p2 ? `<span class="prop-tag prop-${p2.property}">${escapeHtml(p2.property)}</span>` : '—'}</td>
2885
- <td><span class="badge badge-${lt.priority || 'medium'}">${lt.priority || '—'}</span></td>
2886
- </tr>`;
2887
- }).join('')}
2888
- </tbody>
2889
- </table>
2890
- </div>
2891
- </div>
2892
- ` : ''}
2893
-
2894
2892
  <!-- ═══ SHALLOW CHAMPIONS ═══ -->
2895
2893
  ${pro ? `
2896
2894
  <div class="card" id="shallow-champions">
@@ -3150,6 +3148,37 @@ function buildHtmlTemplate(data, opts = {}) {
3150
3148
  <div class="section-divider-line"></div>
3151
3149
  </div>` : ''}
3152
3150
 
3151
+ <!-- ═══ AEO / AI CITABILITY AUDIT ═══ -->
3152
+ ${pro && citabilityData?.length ? buildAeoCard(citabilityData, escapeHtml) : ''}
3153
+
3154
+ <!-- ═══ LONG-TAIL OPPORTUNITIES ═══ -->
3155
+ ${pro && latestAnalysis?.long_tails?.length ? `
3156
+ <div class="card full-width" id="long-tails">
3157
+ <h2><span class="icon"><i class="fa-solid fa-binoculars"></i></span> Long-tail Opportunities</h2>
3158
+ <div class="analysis-table-wrap">
3159
+ <table class="analysis-table">
3160
+ <thead><tr><th>Phrase</th><th>Intent</th><th>Type</th><th>Best Placement</th><th>2nd</th><th>Priority</th><th></th></tr></thead>
3161
+ <tbody>
3162
+ ${(latestAnalysis.long_tails).map(lt => {
3163
+ const p1 = lt.placement?.[0];
3164
+ const p2 = lt.placement?.[1];
3165
+ return `
3166
+ <tr data-insight-id="${lt._insight_id || ''}">
3167
+ <td class="phrase-cell">"${escapeHtml(lt.phrase || '—')}"</td>
3168
+ <td>${escapeHtml(lt.intent || '—')}</td>
3169
+ <td><span class="type-tag">${escapeHtml(lt.page_type || '—')}</span></td>
3170
+ <td class="placement-cell">${p1 ? `<span class="prop-tag prop-${p1.property}">${escapeHtml(p1.property)}</span> <span class="placement-url">${escapeHtml(p1.url || '')}</span>` : '—'}</td>
3171
+ <td class="placement-cell">${p2 ? `<span class="prop-tag prop-${p2.property}">${escapeHtml(p2.property)}</span>` : '—'}</td>
3172
+ <td><span class="badge badge-${lt.priority || 'medium'}">${lt.priority || '—'}</span></td>
3173
+ <td class="insight-action">${lt._insight_id ? `<button class="insight-btn btn-done" onclick="insightAction(this,'done')" title="Mark done"><i class="fa-solid fa-check"></i></button><button class="insight-btn btn-dismiss" onclick="insightAction(this,'dismissed')" title="Dismiss"><i class="fa-solid fa-xmark"></i></button>` : ''}</td>
3174
+ </tr>`;
3175
+ }).join('')}
3176
+ </tbody>
3177
+ </table>
3178
+ </div>
3179
+ </div>
3180
+ ` : ''}
3181
+
3153
3182
  <!-- ═══ KEYWORD INVENTOR ═══ -->
3154
3183
  ${pro && keywordsReport ? (() => {
3155
3184
  const allClusters = keywordsReport.keyword_clusters || [];
@@ -3243,6 +3272,24 @@ function buildHtmlTemplate(data, opts = {}) {
3243
3272
  </div>
3244
3273
 
3245
3274
  <div class="timestamp">Generated: ${new Date().toISOString()} | SEO Intel Dashboard v3</div>
3275
+ <script>
3276
+ async function insightAction(btn, status) {
3277
+ const row = btn.closest('[data-insight-id]');
3278
+ const id = row?.dataset?.insightId;
3279
+ if (!id) return;
3280
+ try {
3281
+ const res = await fetch('/api/insights/' + id + '/status', {
3282
+ method: 'POST',
3283
+ headers: {'Content-Type': 'application/json'},
3284
+ body: JSON.stringify({ status })
3285
+ });
3286
+ if (res.ok) {
3287
+ row.classList.add('insight-done');
3288
+ setTimeout(() => { row.style.display = 'none'; }, 600);
3289
+ }
3290
+ } catch(e) { console.warn('Insight update failed:', e); }
3291
+ }
3292
+ </script>
3246
3293
  </div><!-- /.project-panel -->`;
3247
3294
 
3248
3295
  // ── panelOnly mode: return just the project panel (for multi-project) ──
@@ -4925,6 +4972,103 @@ function buildMultiHtmlTemplate(allProjectData) {
4925
4972
  </html>`;
4926
4973
  }
4927
4974
 
4975
+ // ─── AEO Card Builder ────────────────────────────────────────────────────────
4976
+
4977
+ function buildAeoCard(citabilityData, escapeHtml) {
4978
+ const targetScores = citabilityData.filter(s => s.role === 'target' || s.role === 'owned');
4979
+ const compScores = citabilityData.filter(s => s.role === 'competitor');
4980
+ if (!targetScores.length) return '';
4981
+
4982
+ const avgTarget = Math.round(targetScores.reduce((a, s) => a + s.score, 0) / targetScores.length);
4983
+ const avgComp = compScores.length ? Math.round(compScores.reduce((a, s) => a + s.score, 0) / compScores.length) : null;
4984
+ const delta = avgComp !== null ? avgTarget - avgComp : null;
4985
+
4986
+ const tierCounts = { excellent: 0, good: 0, needs_work: 0, poor: 0 };
4987
+ for (const s of targetScores) tierCounts[s.tier]++;
4988
+
4989
+ const signals = ['entity_authority', 'structured_claims', 'answer_density', 'qa_proximity', 'freshness', 'schema_coverage'];
4990
+ const signalAvgs = signals.map(sig => ({
4991
+ label: sig.replace(/_/g, ' '),
4992
+ avg: Math.round(targetScores.reduce((a, s) => a + (s[sig] || 0), 0) / targetScores.length),
4993
+ }));
4994
+
4995
+ const scoreColor = (s) => s >= 75 ? '#4ade80' : s >= 55 ? '#facc15' : s >= 35 ? '#ff8c00' : '#ef4444';
4996
+
4997
+ // Page rows (worst first, limit 25)
4998
+ const pageRows = targetScores
4999
+ .sort((a, b) => a.score - b.score)
5000
+ .slice(0, 25)
5001
+ .map(s => {
5002
+ let path;
5003
+ try { path = new URL(s.url).pathname; } catch { path = s.url; }
5004
+ let intents = [];
5005
+ try { intents = JSON.parse(s.ai_intents || '[]'); } catch { /* ok */ }
5006
+ const weakest = signals
5007
+ .map(sig => ({ sig: sig.replace(/_/g, ' '), val: s[sig] || 0 }))
5008
+ .sort((a, b) => a.val - b.val)
5009
+ .slice(0, 2);
5010
+ const tierBadge = s.tier === 'excellent' ? 'high' : s.tier === 'poor' ? 'low' : 'medium';
5011
+ return `
5012
+ <tr>
5013
+ <td><span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${scoreColor(s.score)};margin-right:6px;"></span><strong>${s.score}</strong></td>
5014
+ <td class="phrase-cell" title="${escapeHtml(s.url)}">${escapeHtml(path.slice(0, 55))}</td>
5015
+ <td>${escapeHtml((s.title || '').slice(0, 40) || '—')}</td>
5016
+ <td><span class="badge badge-${tierBadge}">${s.tier.replace('_', ' ')}</span></td>
5017
+ <td style="font-size:0.75rem;color:var(--text-muted)">${intents.map(i => i.replace('_', ' ')).join(', ')}</td>
5018
+ <td style="font-size:0.75rem;color:var(--text-muted)">${weakest.map(w => w.sig).join(', ')}</td>
5019
+ </tr>`;
5020
+ }).join('');
5021
+
5022
+ const signalBars = signalAvgs.map(s => `
5023
+ <div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">
5024
+ <span style="width:130px;font-size:0.78rem;color:var(--text-secondary);text-transform:capitalize;">${s.label}</span>
5025
+ <div style="flex:1;background:var(--card-bg);border-radius:4px;height:14px;overflow:hidden;">
5026
+ <div style="width:${s.avg}%;height:100%;background:${scoreColor(s.avg)};border-radius:4px;transition:width .5s;"></div>
5027
+ </div>
5028
+ <span style="font-size:0.75rem;color:var(--text-muted);width:35px;text-align:right;">${s.avg}</span>
5029
+ </div>`).join('');
5030
+
5031
+ let compStatHtml = '';
5032
+ if (avgComp !== null) {
5033
+ compStatHtml += `<div class="ki-stat"><span class="ki-stat-number" style="color:${scoreColor(avgComp)}">${avgComp}</span><span class="ki-stat-label">Competitor Avg</span></div>`;
5034
+ }
5035
+ if (delta !== null) {
5036
+ compStatHtml += `<div class="ki-stat"><span class="ki-stat-number" style="color:${delta >= 0 ? '#4ade80' : '#ef4444'}">${delta > 0 ? '+' : ''}${delta}</span><span class="ki-stat-label">Delta</span></div>`;
5037
+ }
5038
+
5039
+ return `
5040
+ <div class="card full-width" id="aeo-citability">
5041
+ <h2><span class="icon"><i class="fa-solid fa-robot"></i></span> AI Citability Audit</h2>
5042
+ <div class="ki-stat-bar">
5043
+ <div class="ki-stat"><span class="ki-stat-number" style="color:${scoreColor(avgTarget)}">${avgTarget}</span><span class="ki-stat-label">Target Avg</span></div>
5044
+ ${compStatHtml}
5045
+ <div class="ki-stat"><span class="ki-stat-number" style="color:#4ade80">${tierCounts.excellent}</span><span class="ki-stat-label">Excellent</span></div>
5046
+ <div class="ki-stat"><span class="ki-stat-number" style="color:#facc15">${tierCounts.good}</span><span class="ki-stat-label">Good</span></div>
5047
+ <div class="ki-stat"><span class="ki-stat-number" style="color:#ff8c00">${tierCounts.needs_work}</span><span class="ki-stat-label">Needs Work</span></div>
5048
+ <div class="ki-stat"><span class="ki-stat-number" style="color:#ef4444">${tierCounts.poor}</span><span class="ki-stat-label">Poor</span></div>
5049
+ </div>
5050
+
5051
+ <div style="display:flex;gap:2rem;margin:1.5rem 0;flex-wrap:wrap;">
5052
+ <div style="flex:1;min-width:300px;">
5053
+ <h3 style="font-size:0.85rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:.06em;margin-bottom:.8rem;">
5054
+ <i class="fa-solid fa-signal" style="font-size:0.7rem;margin-right:3px;"></i> Signal Strength
5055
+ </h3>
5056
+ ${signalBars}
5057
+ </div>
5058
+ </div>
5059
+
5060
+ <h3 style="font-size:0.85rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:.06em;margin-bottom:.6rem;margin-top:1rem;">
5061
+ <i class="fa-solid fa-list-ol" style="font-size:0.7rem;margin-right:3px;"></i> Page Scores (worst first)
5062
+ </h3>
5063
+ <div class="analysis-table-wrap">
5064
+ <table class="analysis-table">
5065
+ <thead><tr><th>Score</th><th>Page</th><th>Title</th><th>Tier</th><th>AI Intent</th><th>Weakest</th></tr></thead>
5066
+ <tbody>${pageRows}</tbody>
5067
+ </table>
5068
+ </div>
5069
+ </div>`;
5070
+ }
5071
+
4928
5072
  // ─── Data Gathering Functions ────────────────────────────────────────────────
4929
5073
 
4930
5074
  function getLatestKeywordsReport(project) {
package/server.js CHANGED
@@ -530,6 +530,27 @@ async function handleRequest(req, res) {
530
530
  return;
531
531
  }
532
532
 
533
+ // ─── API: Update insight status (Intelligence Ledger) ───
534
+ const insightMatch = path.match(/^\/api\/insights\/(\d+)\/status$/);
535
+ if (req.method === 'POST' && insightMatch) {
536
+ try {
537
+ const id = parseInt(insightMatch[1]);
538
+ const body = await readBody(req);
539
+ const status = body.status;
540
+ if (!['active', 'done', 'dismissed'].includes(status)) {
541
+ json(res, 400, { error: 'Invalid status. Use: active, done, dismissed' });
542
+ return;
543
+ }
544
+ const { getDb, updateInsightStatus } = await import('./db/db.js');
545
+ const db = getDb();
546
+ updateInsightStatus(db, id, status);
547
+ json(res, 200, { success: true, id, status });
548
+ } catch (e) {
549
+ json(res, 500, { error: e.message });
550
+ }
551
+ return;
552
+ }
553
+
533
554
  // ─── API: Analyze (spawn background) ───
534
555
  if (req.method === 'POST' && path === '/api/analyze') {
535
556
  try {