seo-intel 1.1.10 → 1.1.12
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 +24 -1
- package/cli.js +83 -27
- package/db/db.js +176 -1
- package/db/schema.sql +29 -11
- package/package.json +1 -1
- package/reports/generate-html.js +73 -34
- package/server.js +21 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.1.12 (2026-03-28)
|
|
4
|
+
|
|
5
|
+
### Intelligence Ledger
|
|
6
|
+
- Analysis insights now **accumulate across runs** instead of showing only the latest
|
|
7
|
+
- New `insights` table with fingerprint-based dedup — re-running `analyze` adds new ideas without losing old ones
|
|
8
|
+
- Dashboard shows all active insights: 65 long-tails, 36 keyword gaps, 23 content gaps (vs 4 from latest-only)
|
|
9
|
+
- Done/dismiss buttons on every insight card — mark fixes as done, dismiss irrelevant suggestions
|
|
10
|
+
- `POST /api/insights/:id/status` endpoint for status toggling (active/done/dismissed)
|
|
11
|
+
- Keywords Inventor also persists to Intelligence Ledger via `keywords --save`
|
|
12
|
+
|
|
13
|
+
### Improvements
|
|
14
|
+
- Prompt and raw output files now save as `.md` with YAML frontmatter (Obsidian-compatible)
|
|
15
|
+
- Long-tail Opportunities moved to Research section where it belongs
|
|
16
|
+
- Migrated all existing prompt `.txt` files to `.md` with frontmatter
|
|
17
|
+
|
|
18
|
+
## 1.1.11 (2026-03-27)
|
|
19
|
+
|
|
20
|
+
### Fixes
|
|
21
|
+
- Extraction now preflights Ollama hosts at run start and only uses live hosts during crawl/extract
|
|
22
|
+
- Dead fallback hosts no longer poison the run or trigger noisy repeated circuit-breaker fallback spam
|
|
23
|
+
- Degraded mode messaging is clearer and only activates when no live extraction host remains
|
|
24
|
+
- Extractor timeout errors now include host/model/timeout context
|
|
25
|
+
|
|
3
26
|
## 1.1.10 (2026-03-27)
|
|
4
27
|
|
|
5
28
|
### Security
|
|
@@ -64,4 +87,4 @@
|
|
|
64
87
|
|
|
65
88
|
- Update checker, job stop API, background analyze
|
|
66
89
|
- LAN Ollama host support with fallback
|
|
67
|
-
- `html` CLI command, wizard UX improvements
|
|
90
|
+
- `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
|
package/db/db.js
CHANGED
|
@@ -24,15 +24,190 @@ 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
|
+
default: raw = JSON.stringify(item);
|
|
52
|
+
}
|
|
53
|
+
return raw.toLowerCase().replace(/[^a-z0-9\s]/g, '').replace(/\s+/g, ' ').trim();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── Migrate all historical analyses into insights ───────────────────────────
|
|
57
|
+
|
|
58
|
+
function _migrateAnalysesToInsights(db) {
|
|
59
|
+
const count = db.prepare('SELECT COUNT(*) as n FROM insights').get().n;
|
|
60
|
+
if (count > 0) return; // already migrated
|
|
61
|
+
|
|
62
|
+
const rows = db.prepare('SELECT * FROM analyses ORDER BY generated_at ASC').all();
|
|
63
|
+
if (!rows.length) return;
|
|
64
|
+
|
|
65
|
+
const safeJsonParse = (s) => { try { return JSON.parse(s); } catch { return null; } };
|
|
66
|
+
|
|
67
|
+
const upsertStmt = db.prepare(`
|
|
68
|
+
INSERT INTO insights (project, type, status, fingerprint, first_seen, last_seen, source_analysis_id, data)
|
|
69
|
+
VALUES (?, ?, 'active', ?, ?, ?, ?, ?)
|
|
70
|
+
ON CONFLICT(project, type, fingerprint) DO UPDATE SET
|
|
71
|
+
last_seen = excluded.last_seen,
|
|
72
|
+
data = excluded.data
|
|
73
|
+
`);
|
|
74
|
+
|
|
75
|
+
db.exec('BEGIN');
|
|
76
|
+
try {
|
|
77
|
+
for (const row of rows) {
|
|
78
|
+
const ts = row.generated_at;
|
|
79
|
+
const fields = [
|
|
80
|
+
['keyword_gap', safeJsonParse(row.keyword_gaps)],
|
|
81
|
+
['long_tail', safeJsonParse(row.long_tails)],
|
|
82
|
+
['quick_win', safeJsonParse(row.quick_wins)],
|
|
83
|
+
['new_page', safeJsonParse(row.new_pages)],
|
|
84
|
+
['content_gap', safeJsonParse(row.content_gaps)],
|
|
85
|
+
['technical_gap', safeJsonParse(row.technical_gaps)],
|
|
86
|
+
];
|
|
87
|
+
for (const [type, items] of fields) {
|
|
88
|
+
if (!Array.isArray(items)) continue;
|
|
89
|
+
for (const item of items) {
|
|
90
|
+
const fp = _insightFingerprint(type, item);
|
|
91
|
+
if (!fp) continue;
|
|
92
|
+
upsertStmt.run(row.project, type, fp, ts, ts, row.id, JSON.stringify(item));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// positioning is a singleton object, not an array
|
|
96
|
+
const pos = safeJsonParse(row.positioning);
|
|
97
|
+
if (pos && typeof pos === 'object' && Object.keys(pos).length) {
|
|
98
|
+
const fp = _insightFingerprint('positioning', pos);
|
|
99
|
+
upsertStmt.run(row.project, 'positioning', fp, ts, ts, row.id, JSON.stringify(pos));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
db.exec('COMMIT');
|
|
103
|
+
} catch (e) {
|
|
104
|
+
db.exec('ROLLBACK');
|
|
105
|
+
console.error('[db] insights migration failed:', e.message);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── Insight upsert (called after each analyze/keywords run) ─────────────────
|
|
110
|
+
|
|
111
|
+
export function upsertInsightsFromAnalysis(db, project, analysisId, analysis, timestamp) {
|
|
112
|
+
const upsertStmt = db.prepare(`
|
|
113
|
+
INSERT INTO insights (project, type, status, fingerprint, first_seen, last_seen, source_analysis_id, data)
|
|
114
|
+
VALUES (?, ?, 'active', ?, ?, ?, ?, ?)
|
|
115
|
+
ON CONFLICT(project, type, fingerprint) DO UPDATE SET
|
|
116
|
+
last_seen = excluded.last_seen,
|
|
117
|
+
data = excluded.data
|
|
118
|
+
`);
|
|
119
|
+
|
|
120
|
+
const ts = timestamp || Date.now();
|
|
121
|
+
db.exec('BEGIN');
|
|
122
|
+
try {
|
|
123
|
+
const fields = [
|
|
124
|
+
['keyword_gap', analysis.keyword_gaps],
|
|
125
|
+
['long_tail', analysis.long_tails],
|
|
126
|
+
['quick_win', analysis.quick_wins],
|
|
127
|
+
['new_page', analysis.new_pages],
|
|
128
|
+
['content_gap', analysis.content_gaps],
|
|
129
|
+
['technical_gap', analysis.technical_gaps],
|
|
130
|
+
];
|
|
131
|
+
for (const [type, items] of fields) {
|
|
132
|
+
if (!Array.isArray(items)) continue;
|
|
133
|
+
for (const item of items) {
|
|
134
|
+
const fp = _insightFingerprint(type, item);
|
|
135
|
+
if (!fp) continue;
|
|
136
|
+
upsertStmt.run(project, type, fp, ts, ts, analysisId, JSON.stringify(item));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (analysis.positioning && typeof analysis.positioning === 'object') {
|
|
140
|
+
const fp = _insightFingerprint('positioning', analysis.positioning);
|
|
141
|
+
upsertStmt.run(project, 'positioning', fp, ts, ts, analysisId, JSON.stringify(analysis.positioning));
|
|
142
|
+
}
|
|
143
|
+
db.exec('COMMIT');
|
|
144
|
+
} catch (e) {
|
|
145
|
+
db.exec('ROLLBACK');
|
|
146
|
+
console.error('[db] insight upsert failed:', e.message);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function upsertInsightsFromKeywords(db, project, keywordsReport) {
|
|
151
|
+
const upsertStmt = db.prepare(`
|
|
152
|
+
INSERT INTO insights (project, type, status, fingerprint, first_seen, last_seen, source_analysis_id, data)
|
|
153
|
+
VALUES (?, 'keyword_inventor', 'active', ?, ?, ?, NULL, ?)
|
|
154
|
+
ON CONFLICT(project, type, fingerprint) DO UPDATE SET
|
|
155
|
+
last_seen = excluded.last_seen,
|
|
156
|
+
data = excluded.data
|
|
157
|
+
`);
|
|
158
|
+
|
|
159
|
+
const ts = Date.now();
|
|
160
|
+
const allClusters = keywordsReport.keyword_clusters || [];
|
|
161
|
+
const allKws = allClusters.flatMap(c => (c.keywords || []).map(k => ({ ...k, cluster: c.topic })));
|
|
162
|
+
|
|
163
|
+
db.exec('BEGIN');
|
|
164
|
+
try {
|
|
165
|
+
for (const kw of allKws) {
|
|
166
|
+
const fp = _insightFingerprint('keyword_inventor', kw);
|
|
167
|
+
if (!fp) continue;
|
|
168
|
+
upsertStmt.run(project, fp, ts, ts, JSON.stringify(kw));
|
|
169
|
+
}
|
|
170
|
+
db.exec('COMMIT');
|
|
171
|
+
} catch (e) {
|
|
172
|
+
db.exec('ROLLBACK');
|
|
173
|
+
console.error('[db] keyword insight upsert failed:', e.message);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ── Read active insights (accumulated across all runs) ──────────────────────
|
|
178
|
+
|
|
179
|
+
export function getActiveInsights(db, project) {
|
|
180
|
+
const rows = db.prepare(
|
|
181
|
+
`SELECT * FROM insights WHERE project = ? AND status = 'active' ORDER BY type, last_seen DESC`
|
|
182
|
+
).all(project);
|
|
183
|
+
|
|
184
|
+
const grouped = {};
|
|
185
|
+
for (const row of rows) {
|
|
186
|
+
if (!grouped[row.type]) grouped[row.type] = [];
|
|
187
|
+
const parsed = JSON.parse(row.data);
|
|
188
|
+
parsed._insight_id = row.id;
|
|
189
|
+
parsed._first_seen = row.first_seen;
|
|
190
|
+
parsed._last_seen = row.last_seen;
|
|
191
|
+
grouped[row.type].push(parsed);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
keyword_gaps: grouped.keyword_gap || [],
|
|
196
|
+
long_tails: grouped.long_tail || [],
|
|
197
|
+
quick_wins: grouped.quick_win || [],
|
|
198
|
+
new_pages: grouped.new_page || [],
|
|
199
|
+
content_gaps: grouped.content_gap || [],
|
|
200
|
+
technical_gaps: grouped.technical_gap || [],
|
|
201
|
+
positioning: grouped.positioning?.[0] || null,
|
|
202
|
+
keyword_inventor: grouped.keyword_inventor || [],
|
|
203
|
+
generated_at: rows.length ? Math.max(...rows.map(r => r.last_seen)) : null,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function updateInsightStatus(db, id, status) {
|
|
208
|
+
db.prepare('UPDATE insights SET status = ? WHERE id = ?').run(status, id);
|
|
209
|
+
}
|
|
210
|
+
|
|
36
211
|
export function upsertDomain(db, { domain, project, role }) {
|
|
37
212
|
const now = Date.now();
|
|
38
213
|
return db.prepare(`
|
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),
|
package/package.json
CHANGED
package/reports/generate-html.js
CHANGED
|
@@ -18,6 +18,7 @@ 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';
|
|
21
22
|
|
|
22
23
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
23
24
|
|
|
@@ -69,7 +70,7 @@ function gatherProjectData(db, project, config) {
|
|
|
69
70
|
const domains = getDomainStats(db, project, config);
|
|
70
71
|
const keywords = getTopKeywords(db, project);
|
|
71
72
|
const keywordGaps = getKeywordGaps(db, project);
|
|
72
|
-
const latestAnalysis =
|
|
73
|
+
const latestAnalysis = getActiveInsights(db, project);
|
|
73
74
|
const keywordHeatmap = getKeywordHeatmapData(db, project, allDomains, latestAnalysis);
|
|
74
75
|
const technicalScores = getTechnicalScores(db, project, config);
|
|
75
76
|
const internalLinks = getInternalLinkStats(db, project);
|
|
@@ -622,6 +623,21 @@ function buildHtmlTemplate(data, opts = {}) {
|
|
|
622
623
|
.badge-target { background: rgba(232,213,163,0.15); color: var(--accent-gold); }
|
|
623
624
|
.badge-competitor { background: rgba(124,109,235,0.15); color: var(--accent-purple); }
|
|
624
625
|
|
|
626
|
+
/* ─── Insight Actions (Intelligence Ledger) ──────────────────────────── */
|
|
627
|
+
.insight-action { display: flex; gap: 4px; }
|
|
628
|
+
.insight-btn {
|
|
629
|
+
width: 22px; height: 22px; border-radius: 50%; border: 1px solid var(--border-card);
|
|
630
|
+
background: transparent; color: var(--text-muted); cursor: pointer;
|
|
631
|
+
display: flex; align-items: center; justify-content: center; font-size: 0.6rem;
|
|
632
|
+
transition: all 0.2s ease; padding: 0;
|
|
633
|
+
}
|
|
634
|
+
.insight-btn:hover { border-color: var(--accent-gold); color: var(--accent-gold); }
|
|
635
|
+
.insight-btn.btn-done:hover { border-color: var(--color-success); color: var(--color-success); }
|
|
636
|
+
.insight-btn.btn-dismiss:hover { border-color: var(--color-danger); color: var(--color-danger); }
|
|
637
|
+
tr.insight-done { opacity: 0.3; text-decoration: line-through; }
|
|
638
|
+
.new-page-card.insight-done, .positioning-card.insight-done { opacity: 0.3; }
|
|
639
|
+
.insight-age { font-size: 0.65rem; color: var(--text-muted); white-space: nowrap; }
|
|
640
|
+
|
|
625
641
|
/* ─── Heatmap Dots ───────────────────────────────────────────────────── */
|
|
626
642
|
.dot {
|
|
627
643
|
display: inline-block;
|
|
@@ -2722,13 +2738,14 @@ function buildHtmlTemplate(data, opts = {}) {
|
|
|
2722
2738
|
<h2><span class="icon"><i class="fa-solid fa-wrench"></i></span> Technical SEO Gaps</h2>
|
|
2723
2739
|
<div class="analysis-table-wrap">
|
|
2724
2740
|
<table class="analysis-table">
|
|
2725
|
-
<thead><tr><th>Gap</th><th>Competitors with it</th><th>Fix</th></tr></thead>
|
|
2741
|
+
<thead><tr><th>Gap</th><th>Competitors with it</th><th>Fix</th><th></th></tr></thead>
|
|
2726
2742
|
<tbody>
|
|
2727
2743
|
${(latestAnalysis.technical_gaps).map(tg => `
|
|
2728
|
-
<tr>
|
|
2744
|
+
<tr data-insight-id="${tg._insight_id || ''}">
|
|
2729
2745
|
<td><strong>${escapeHtml(tg.gap || '—')}</strong></td>
|
|
2730
2746
|
<td>${(tg.competitors_with_it || []).map(d => `<span class="comp-tag">${escapeHtml(d)}</span>`).join(' ') || '—'}</td>
|
|
2731
2747
|
<td>${escapeHtml(tg.fix || '—')}</td>
|
|
2748
|
+
<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
2749
|
</tr>`).join('')}
|
|
2733
2750
|
</tbody>
|
|
2734
2751
|
</table>
|
|
@@ -2749,14 +2766,15 @@ function buildHtmlTemplate(data, opts = {}) {
|
|
|
2749
2766
|
<h2><span class="icon"><i class="fa-solid fa-bolt"></i></span> Quick Wins</h2>
|
|
2750
2767
|
<div class="analysis-table-wrap">
|
|
2751
2768
|
<table class="analysis-table">
|
|
2752
|
-
<thead><tr><th>Page</th><th>Issue</th><th>Fix</th><th>Impact</th></tr></thead>
|
|
2769
|
+
<thead><tr><th>Page</th><th>Issue</th><th>Fix</th><th>Impact</th><th></th></tr></thead>
|
|
2753
2770
|
<tbody>
|
|
2754
2771
|
${(latestAnalysis.quick_wins).map(w => `
|
|
2755
|
-
<tr>
|
|
2772
|
+
<tr data-insight-id="${w._insight_id || ''}">
|
|
2756
2773
|
<td class="mono">${escapeHtml(w.page || '—')}</td>
|
|
2757
2774
|
<td>${escapeHtml(w.issue || '—')}</td>
|
|
2758
2775
|
<td>${escapeHtml(w.fix || '—')}</td>
|
|
2759
2776
|
<td><span class="badge badge-${w.impact || 'medium'}">${w.impact || '—'}</span></td>
|
|
2777
|
+
<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
2778
|
</tr>`).join('')}
|
|
2761
2779
|
</tbody>
|
|
2762
2780
|
</table>
|
|
@@ -2770,10 +2788,11 @@ function buildHtmlTemplate(data, opts = {}) {
|
|
|
2770
2788
|
<h2><span class="icon"><i class="fa-solid fa-file-circle-plus"></i></span> New Pages to Create</h2>
|
|
2771
2789
|
<div class="new-pages-grid" style="grid-template-columns: 1fr;">
|
|
2772
2790
|
${(latestAnalysis.new_pages).map(np => `
|
|
2773
|
-
<div class="new-page-card priority-${np.priority || 'medium'}">
|
|
2791
|
+
<div class="new-page-card priority-${np.priority || 'medium'}" data-insight-id="${np._insight_id || ''}">
|
|
2774
2792
|
<div class="new-page-header">
|
|
2775
2793
|
<span class="new-page-title">${escapeHtml(np.title || np.slug || 'Untitled')}</span>
|
|
2776
2794
|
<span class="badge badge-${np.priority || 'medium'}">${np.priority || '—'}</span>
|
|
2795
|
+
${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
2796
|
</div>
|
|
2778
2797
|
<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
2798
|
<div class="new-page-angle">${escapeHtml(np.content_angle || np.why || '—')}</div>
|
|
@@ -2821,11 +2840,12 @@ function buildHtmlTemplate(data, opts = {}) {
|
|
|
2821
2840
|
<h2><span class="icon"><i class="fa-solid fa-magnifying-glass-minus"></i></span> Content Gaps</h2>
|
|
2822
2841
|
<div class="insights-grid">
|
|
2823
2842
|
${(latestAnalysis.content_gaps).map(gap => `
|
|
2824
|
-
<div class="insight-card medium">
|
|
2843
|
+
<div class="insight-card medium" data-insight-id="${gap._insight_id || ''}">
|
|
2825
2844
|
<div class="insight-header">
|
|
2826
2845
|
<span class="insight-icon"><i class="fa-solid fa-clipboard" style="font-size:0.8rem;"></i></span>
|
|
2827
2846
|
<span class="insight-title">${escapeHtml(gap.topic || gap.suggested_title || 'Gap')}</span>
|
|
2828
2847
|
<span class="badge badge-medium">${gap.format || 'content'}</span>
|
|
2848
|
+
${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
2849
|
</div>
|
|
2830
2850
|
<div class="insight-desc">${escapeHtml(gap.why_it_matters || '')}</div>
|
|
2831
2851
|
${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 +2884,6 @@ function buildHtmlTemplate(data, opts = {}) {
|
|
|
2864
2884
|
</div>
|
|
2865
2885
|
` : ''}
|
|
2866
2886
|
|
|
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
2887
|
<!-- ═══ SHALLOW CHAMPIONS ═══ -->
|
|
2895
2888
|
${pro ? `
|
|
2896
2889
|
<div class="card" id="shallow-champions">
|
|
@@ -3150,6 +3143,34 @@ function buildHtmlTemplate(data, opts = {}) {
|
|
|
3150
3143
|
<div class="section-divider-line"></div>
|
|
3151
3144
|
</div>` : ''}
|
|
3152
3145
|
|
|
3146
|
+
<!-- ═══ LONG-TAIL OPPORTUNITIES ═══ -->
|
|
3147
|
+
${pro && latestAnalysis?.long_tails?.length ? `
|
|
3148
|
+
<div class="card full-width" id="long-tails">
|
|
3149
|
+
<h2><span class="icon"><i class="fa-solid fa-binoculars"></i></span> Long-tail Opportunities</h2>
|
|
3150
|
+
<div class="analysis-table-wrap">
|
|
3151
|
+
<table class="analysis-table">
|
|
3152
|
+
<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>
|
|
3153
|
+
<tbody>
|
|
3154
|
+
${(latestAnalysis.long_tails).map(lt => {
|
|
3155
|
+
const p1 = lt.placement?.[0];
|
|
3156
|
+
const p2 = lt.placement?.[1];
|
|
3157
|
+
return `
|
|
3158
|
+
<tr data-insight-id="${lt._insight_id || ''}">
|
|
3159
|
+
<td class="phrase-cell">"${escapeHtml(lt.phrase || '—')}"</td>
|
|
3160
|
+
<td>${escapeHtml(lt.intent || '—')}</td>
|
|
3161
|
+
<td><span class="type-tag">${escapeHtml(lt.page_type || '—')}</span></td>
|
|
3162
|
+
<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>
|
|
3163
|
+
<td class="placement-cell">${p2 ? `<span class="prop-tag prop-${p2.property}">${escapeHtml(p2.property)}</span>` : '—'}</td>
|
|
3164
|
+
<td><span class="badge badge-${lt.priority || 'medium'}">${lt.priority || '—'}</span></td>
|
|
3165
|
+
<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>
|
|
3166
|
+
</tr>`;
|
|
3167
|
+
}).join('')}
|
|
3168
|
+
</tbody>
|
|
3169
|
+
</table>
|
|
3170
|
+
</div>
|
|
3171
|
+
</div>
|
|
3172
|
+
` : ''}
|
|
3173
|
+
|
|
3153
3174
|
<!-- ═══ KEYWORD INVENTOR ═══ -->
|
|
3154
3175
|
${pro && keywordsReport ? (() => {
|
|
3155
3176
|
const allClusters = keywordsReport.keyword_clusters || [];
|
|
@@ -3243,6 +3264,24 @@ function buildHtmlTemplate(data, opts = {}) {
|
|
|
3243
3264
|
</div>
|
|
3244
3265
|
|
|
3245
3266
|
<div class="timestamp">Generated: ${new Date().toISOString()} | SEO Intel Dashboard v3</div>
|
|
3267
|
+
<script>
|
|
3268
|
+
async function insightAction(btn, status) {
|
|
3269
|
+
const row = btn.closest('[data-insight-id]');
|
|
3270
|
+
const id = row?.dataset?.insightId;
|
|
3271
|
+
if (!id) return;
|
|
3272
|
+
try {
|
|
3273
|
+
const res = await fetch('/api/insights/' + id + '/status', {
|
|
3274
|
+
method: 'POST',
|
|
3275
|
+
headers: {'Content-Type': 'application/json'},
|
|
3276
|
+
body: JSON.stringify({ status })
|
|
3277
|
+
});
|
|
3278
|
+
if (res.ok) {
|
|
3279
|
+
row.classList.add('insight-done');
|
|
3280
|
+
setTimeout(() => { row.style.display = 'none'; }, 600);
|
|
3281
|
+
}
|
|
3282
|
+
} catch(e) { console.warn('Insight update failed:', e); }
|
|
3283
|
+
}
|
|
3284
|
+
</script>
|
|
3246
3285
|
</div><!-- /.project-panel -->`;
|
|
3247
3286
|
|
|
3248
3287
|
// ── panelOnly mode: return just the project panel (for multi-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 {
|