seo-intel 1.1.12 → 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,20 @@
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
+
3
18
  ## 1.1.12 (2026-03-28)
4
19
 
5
20
  ### Intelligence Ledger
package/cli.js CHANGED
@@ -3864,6 +3864,145 @@ program
3864
3864
  writeOrPrintActionOutput(output, opts.output);
3865
3865
  });
3866
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
+
3867
4006
  // ── GUIDE (Coach-style chapter map) ──────────────────────────────────────
3868
4007
  program
3869
4008
  .command('guide')
package/db/db.js CHANGED
@@ -48,6 +48,7 @@ function _insightFingerprint(type, item) {
48
48
  case 'technical_gap': raw = item.gap || ''; break;
49
49
  case 'positioning': raw = 'positioning'; break;
50
50
  case 'keyword_inventor': raw = item.phrase || ''; break;
51
+ case 'citability_gap': raw = item.url || ''; break;
51
52
  default: raw = JSON.stringify(item);
52
53
  }
53
54
  return raw.toLowerCase().replace(/[^a-z0-9\s]/g, '').replace(/\s+/g, ' ').trim();
@@ -512,6 +513,7 @@ export function pruneStaleDomains(db, project, configDomains) {
512
513
  db.prepare(`DELETE FROM page_schemas WHERE page_id IN (${placeholders})`).run(...pageIds);
513
514
  db.prepare(`DELETE FROM extractions WHERE page_id IN (${placeholders})`).run(...pageIds);
514
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 */ }
515
517
  db.prepare(`DELETE FROM pages WHERE domain_id = ?`).run(id);
516
518
  }
517
519
 
package/db/schema.sql CHANGED
@@ -176,6 +176,24 @@ CREATE TABLE IF NOT EXISTS template_samples (
176
176
  CREATE INDEX IF NOT EXISTS idx_template_groups_project ON template_groups(project);
177
177
  CREATE INDEX IF NOT EXISTS idx_template_samples_group ON template_samples(group_id);
178
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
+
179
197
  -- Indexes
180
198
  CREATE INDEX IF NOT EXISTS idx_pages_domain ON pages(domain_id);
181
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.12",
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",
@@ -19,6 +19,7 @@ import { fileURLToPath } from 'url';
19
19
  import { loadGscData } from './gsc-loader.js';
20
20
  import { isPro } from '../lib/license.js';
21
21
  import { getActiveInsights } from '../db/db.js';
22
+ import { getCitabilityScores } from '../analyses/aeo/index.js';
22
23
 
23
24
  const __dirname = dirname(fileURLToPath(import.meta.url));
24
25
 
@@ -103,6 +104,10 @@ function gatherProjectData(db, project, config) {
103
104
  // Keyword Inventor data
104
105
  const keywordsReport = getLatestKeywordsReport(project);
105
106
 
107
+ // AEO / AI Citability scores
108
+ let citabilityData = null;
109
+ try { citabilityData = getCitabilityScores(db, project); } catch { /* table may not exist yet */ }
110
+
106
111
  // Extraction status
107
112
  const extractionStatus = getExtractionStatus(db, project, config);
108
113
 
@@ -126,7 +131,7 @@ function gatherProjectData(db, project, config) {
126
131
  ctaLandscape, entityTopicMap, schemaBreakdown,
127
132
  gravityMap, contentTerrain, keywordVenn, performanceBubbles,
128
133
  headingFlow, territoryTreemap, topicClusters, linkDna, linkRadarPulse,
129
- keywordsReport, extractionStatus, gscData, domainArch, gscInsights,
134
+ keywordsReport, extractionStatus, gscData, domainArch, gscInsights, citabilityData,
130
135
  };
131
136
 
132
137
  // Rollback the owned→target merge so the actual DB is unchanged
@@ -189,7 +194,7 @@ function buildHtmlTemplate(data, opts = {}) {
189
194
  ctaLandscape, entityTopicMap, schemaBreakdown,
190
195
  gravityMap, contentTerrain, keywordVenn, performanceBubbles,
191
196
  headingFlow, territoryTreemap, topicClusters, linkDna, linkRadarPulse,
192
- keywordsReport, extractionStatus, gscData, domainArch, gscInsights,
197
+ keywordsReport, extractionStatus, gscData, domainArch, gscInsights, citabilityData,
193
198
  } = data;
194
199
 
195
200
  const totalPages = domains.reduce((sum, d) => sum + d.page_count, 0);
@@ -3143,6 +3148,9 @@ function buildHtmlTemplate(data, opts = {}) {
3143
3148
  <div class="section-divider-line"></div>
3144
3149
  </div>` : ''}
3145
3150
 
3151
+ <!-- ═══ AEO / AI CITABILITY AUDIT ═══ -->
3152
+ ${pro && citabilityData?.length ? buildAeoCard(citabilityData, escapeHtml) : ''}
3153
+
3146
3154
  <!-- ═══ LONG-TAIL OPPORTUNITIES ═══ -->
3147
3155
  ${pro && latestAnalysis?.long_tails?.length ? `
3148
3156
  <div class="card full-width" id="long-tails">
@@ -4964,6 +4972,103 @@ function buildMultiHtmlTemplate(allProjectData) {
4964
4972
  </html>`;
4965
4973
  }
4966
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
+
4967
5072
  // ─── Data Gathering Functions ────────────────────────────────────────────────
4968
5073
 
4969
5074
  function getLatestKeywordsReport(project) {