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 +22 -0
- package/cli.js +139 -0
- package/db/db.js +2 -0
- package/db/schema.sql +18 -0
- package/package.json +1 -1
- package/reports/generate-html.js +107 -18
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
package/reports/generate-html.js
CHANGED
|
@@ -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) {
|