seo-intel 1.1.12 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.2.1 (2026-03-28)
4
+
5
+ ### Dashboard
6
+ - Remove redundant Crawl/Extract buttons from status bar — terminal already has them
7
+ - Status bar now shows only Stop, Restart, and Stealth toggle (cleaner UI)
8
+ - Stealth toggle still controls `--stealth` flag for terminal Crawl/Extract buttons
9
+
10
+ ## 1.2.0 (2026-03-28)
11
+
12
+ ### AEO — AI Citability Audit (new feature)
13
+ - **New command: `seo-intel aeo <project>`** (alias: `citability`) — score every page for how well AI assistants can cite it
14
+ - Per-page citability score (0-100) computed from 6 signals: entity authority, structured claims, answer density, Q&A proximity, freshness, schema coverage
15
+ - AI Query Intent classification per page: synthesis, decision support, implementation, exploration, validation
16
+ - Tier breakdown: excellent (75+), good (55-74), needs work (35-54), poor (<35)
17
+ - Signal strength analysis — identifies your weakest citability signals site-wide
18
+ - Compares target vs competitor citability scores with delta
19
+ - Low-scoring pages automatically feed into Intelligence Ledger as `citability_gap` insights
20
+ - Dashboard: new "AI Citability Audit" card with stat bar, signal strength bars, and page score table
21
+ - Runs on existing crawl data — zero new network calls, zero Ollama required
22
+ - `--target-only` flag to skip competitor scoring
23
+ - `--save` flag to export `.md` report
24
+
3
25
  ## 1.1.12 (2026-03-28)
4
26
 
5
27
  ### 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.2",
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);
@@ -1955,22 +1960,6 @@ function buildHtmlTemplate(data, opts = {}) {
1955
1960
  ` : ''}
1956
1961
  </div>
1957
1962
  <div class="es-controls" id="esControls${suffix}">
1958
- ${extractionStatus.liveProgress?.status === 'running' && extractionStatus.liveProgress?.command === 'crawl'
1959
- ? `<button class="es-btn running" id="btnCrawl${suffix}" onclick="startJob('crawl','${project}')" disabled>
1960
- <i class="fa-solid fa-spinner fa-spin"></i> Crawling\u2026
1961
- </button>`
1962
- : `<button class="es-btn" id="btnCrawl${suffix}" onclick="startJob('crawl','${project}')"${extractionStatus.liveProgress?.status === 'running' ? ' disabled' : ''}>
1963
- <i class="fa-solid fa-spider"></i> Crawl
1964
- </button>`
1965
- }
1966
- ${extractionStatus.liveProgress?.status === 'running' && extractionStatus.liveProgress?.command === 'extract'
1967
- ? `<button class="es-btn running" id="btnExtract${suffix}" onclick="startJob('extract','${project}')" disabled>
1968
- <i class="fa-solid fa-spinner fa-spin"></i> Extracting\u2026
1969
- </button>`
1970
- : `<button class="es-btn" id="btnExtract${suffix}" onclick="startJob('extract','${project}')"${extractionStatus.liveProgress?.status === 'running' ? ' disabled' : ''}>
1971
- <i class="fa-solid fa-brain"></i> Extract
1972
- </button>`
1973
- }
1974
1963
  <button class="es-btn es-btn-stop${extractionStatus.liveProgress?.status === 'running' ? ' active' : ''}" id="btnStop${suffix}" onclick="stopJob()">
1975
1964
  <i class="fa-solid fa-stop"></i> Stop
1976
1965
  </button>
@@ -3143,6 +3132,9 @@ function buildHtmlTemplate(data, opts = {}) {
3143
3132
  <div class="section-divider-line"></div>
3144
3133
  </div>` : ''}
3145
3134
 
3135
+ <!-- ═══ AEO / AI CITABILITY AUDIT ═══ -->
3136
+ ${pro && citabilityData?.length ? buildAeoCard(citabilityData, escapeHtml) : ''}
3137
+
3146
3138
  <!-- ═══ LONG-TAIL OPPORTUNITIES ═══ -->
3147
3139
  ${pro && latestAnalysis?.long_tails?.length ? `
3148
3140
  <div class="card full-width" id="long-tails">
@@ -4964,6 +4956,103 @@ function buildMultiHtmlTemplate(allProjectData) {
4964
4956
  </html>`;
4965
4957
  }
4966
4958
 
4959
+ // ─── AEO Card Builder ────────────────────────────────────────────────────────
4960
+
4961
+ function buildAeoCard(citabilityData, escapeHtml) {
4962
+ const targetScores = citabilityData.filter(s => s.role === 'target' || s.role === 'owned');
4963
+ const compScores = citabilityData.filter(s => s.role === 'competitor');
4964
+ if (!targetScores.length) return '';
4965
+
4966
+ const avgTarget = Math.round(targetScores.reduce((a, s) => a + s.score, 0) / targetScores.length);
4967
+ const avgComp = compScores.length ? Math.round(compScores.reduce((a, s) => a + s.score, 0) / compScores.length) : null;
4968
+ const delta = avgComp !== null ? avgTarget - avgComp : null;
4969
+
4970
+ const tierCounts = { excellent: 0, good: 0, needs_work: 0, poor: 0 };
4971
+ for (const s of targetScores) tierCounts[s.tier]++;
4972
+
4973
+ const signals = ['entity_authority', 'structured_claims', 'answer_density', 'qa_proximity', 'freshness', 'schema_coverage'];
4974
+ const signalAvgs = signals.map(sig => ({
4975
+ label: sig.replace(/_/g, ' '),
4976
+ avg: Math.round(targetScores.reduce((a, s) => a + (s[sig] || 0), 0) / targetScores.length),
4977
+ }));
4978
+
4979
+ const scoreColor = (s) => s >= 75 ? '#4ade80' : s >= 55 ? '#facc15' : s >= 35 ? '#ff8c00' : '#ef4444';
4980
+
4981
+ // Page rows (worst first, limit 25)
4982
+ const pageRows = targetScores
4983
+ .sort((a, b) => a.score - b.score)
4984
+ .slice(0, 25)
4985
+ .map(s => {
4986
+ let path;
4987
+ try { path = new URL(s.url).pathname; } catch { path = s.url; }
4988
+ let intents = [];
4989
+ try { intents = JSON.parse(s.ai_intents || '[]'); } catch { /* ok */ }
4990
+ const weakest = signals
4991
+ .map(sig => ({ sig: sig.replace(/_/g, ' '), val: s[sig] || 0 }))
4992
+ .sort((a, b) => a.val - b.val)
4993
+ .slice(0, 2);
4994
+ const tierBadge = s.tier === 'excellent' ? 'high' : s.tier === 'poor' ? 'low' : 'medium';
4995
+ return `
4996
+ <tr>
4997
+ <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>
4998
+ <td class="phrase-cell" title="${escapeHtml(s.url)}">${escapeHtml(path.slice(0, 55))}</td>
4999
+ <td>${escapeHtml((s.title || '').slice(0, 40) || '—')}</td>
5000
+ <td><span class="badge badge-${tierBadge}">${s.tier.replace('_', ' ')}</span></td>
5001
+ <td style="font-size:0.75rem;color:var(--text-muted)">${intents.map(i => i.replace('_', ' ')).join(', ')}</td>
5002
+ <td style="font-size:0.75rem;color:var(--text-muted)">${weakest.map(w => w.sig).join(', ')}</td>
5003
+ </tr>`;
5004
+ }).join('');
5005
+
5006
+ const signalBars = signalAvgs.map(s => `
5007
+ <div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">
5008
+ <span style="width:130px;font-size:0.78rem;color:var(--text-secondary);text-transform:capitalize;">${s.label}</span>
5009
+ <div style="flex:1;background:var(--card-bg);border-radius:4px;height:14px;overflow:hidden;">
5010
+ <div style="width:${s.avg}%;height:100%;background:${scoreColor(s.avg)};border-radius:4px;transition:width .5s;"></div>
5011
+ </div>
5012
+ <span style="font-size:0.75rem;color:var(--text-muted);width:35px;text-align:right;">${s.avg}</span>
5013
+ </div>`).join('');
5014
+
5015
+ let compStatHtml = '';
5016
+ if (avgComp !== null) {
5017
+ 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>`;
5018
+ }
5019
+ if (delta !== null) {
5020
+ 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>`;
5021
+ }
5022
+
5023
+ return `
5024
+ <div class="card full-width" id="aeo-citability">
5025
+ <h2><span class="icon"><i class="fa-solid fa-robot"></i></span> AI Citability Audit</h2>
5026
+ <div class="ki-stat-bar">
5027
+ <div class="ki-stat"><span class="ki-stat-number" style="color:${scoreColor(avgTarget)}">${avgTarget}</span><span class="ki-stat-label">Target Avg</span></div>
5028
+ ${compStatHtml}
5029
+ <div class="ki-stat"><span class="ki-stat-number" style="color:#4ade80">${tierCounts.excellent}</span><span class="ki-stat-label">Excellent</span></div>
5030
+ <div class="ki-stat"><span class="ki-stat-number" style="color:#facc15">${tierCounts.good}</span><span class="ki-stat-label">Good</span></div>
5031
+ <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>
5032
+ <div class="ki-stat"><span class="ki-stat-number" style="color:#ef4444">${tierCounts.poor}</span><span class="ki-stat-label">Poor</span></div>
5033
+ </div>
5034
+
5035
+ <div style="display:flex;gap:2rem;margin:1.5rem 0;flex-wrap:wrap;">
5036
+ <div style="flex:1;min-width:300px;">
5037
+ <h3 style="font-size:0.85rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:.06em;margin-bottom:.8rem;">
5038
+ <i class="fa-solid fa-signal" style="font-size:0.7rem;margin-right:3px;"></i> Signal Strength
5039
+ </h3>
5040
+ ${signalBars}
5041
+ </div>
5042
+ </div>
5043
+
5044
+ <h3 style="font-size:0.85rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:.06em;margin-bottom:.6rem;margin-top:1rem;">
5045
+ <i class="fa-solid fa-list-ol" style="font-size:0.7rem;margin-right:3px;"></i> Page Scores (worst first)
5046
+ </h3>
5047
+ <div class="analysis-table-wrap">
5048
+ <table class="analysis-table">
5049
+ <thead><tr><th>Score</th><th>Page</th><th>Title</th><th>Tier</th><th>AI Intent</th><th>Weakest</th></tr></thead>
5050
+ <tbody>${pageRows}</tbody>
5051
+ </table>
5052
+ </div>
5053
+ </div>`;
5054
+ }
5055
+
4967
5056
  // ─── Data Gathering Functions ────────────────────────────────────────────────
4968
5057
 
4969
5058
  function getLatestKeywordsReport(project) {