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 +39 -1
- package/cli.js +222 -27
- package/db/db.js +178 -1
- package/db/schema.sql +47 -11
- package/package.json +1 -1
- package/reports/generate-html.js +180 -36
- package/server.js +21 -0
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
|
-
|
|
77
|
-
|
|
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
|
|
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
|
-
|
|
94
|
-
const hasModel = models.some(m => m.
|
|
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
|
|
692
|
-
|
|
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()}.
|
|
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,
|
|
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()}.
|
|
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
|
-
|
|
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,
|
|
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
|
-
//
|
|
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
|
|
86
|
-
project
|
|
87
|
-
generated_at
|
|
88
|
-
model
|
|
89
|
-
keyword_gaps
|
|
90
|
-
long_tails
|
|
91
|
-
quick_wins
|
|
92
|
-
new_pages
|
|
93
|
-
content_gaps
|
|
94
|
-
positioning
|
|
95
|
-
|
|
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
package/reports/generate-html.js
CHANGED
|
@@ -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 =
|
|
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 {
|