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 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
- // Build host chain: primary → fallback → always try localhost as last resort
77
- const configured = [
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 host of hosts) {
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
- const targetModel = process.env.OLLAMA_MODEL || 'qwen3:4b';
94
- const hasModel = models.some(m => m.startsWith(targetModel.split(':')[0]));
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 promptPath = join(__dirname, `reports/${project}-prompt-${Date.now()}.txt`);
692
- writeFileSync(promptPath, prompt, 'utf8');
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()}.txt`);
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, Date.now(), 'gemini',
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()}.txt`);
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
- writeFileSync(join(__dirname, `reports/${project}-prompt-${Date.now()}.txt`), prompt, 'utf8');
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, Date.now(), 'gemini',
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
- // page_schemas table is created by schema.sql — no migration needed (new table)
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 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
- raw TEXT -- full model response
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "seo-intel",
3
- "version": "1.1.10",
3
+ "version": "1.1.12",
4
4
  "description": "Local Ahrefs-style SEO competitor intelligence. Crawl → SQLite → cloud analysis.",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -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 = getLatestAnalysis(db, project);
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 {