seo-intel 1.4.2 → 1.4.4

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,34 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.4.4 (2026-04-08)
4
+
5
+ ### Export Profiles
6
+ - New profile-based export: Developer, Content, and AI Pipeline profiles
7
+ - Each profile filters to actionable data only — no raw database dumps
8
+ - Developer profile: technical issues, heading problems (own site only), orphan links, schema gaps
9
+ - Content profile: keyword gaps, long-tail opportunities, citability issues, content gaps
10
+ - AI Pipeline profile: structured JSON with all actionable sections for LLM consumption
11
+ - Heading export collapsed to per-page issue summaries (missing H1, duplicate H1, skipped levels)
12
+ - Empty sections automatically skipped in exports
13
+ - Profile picker UI in dashboard sidebar with format selector (MD, JSON, CSV, ZIP)
14
+
15
+ ## 1.4.3 (2026-04-07)
16
+
17
+ ### Dashboard: Export & Download
18
+ - Per-card download buttons (Markdown, JSON, CSV) on every dashboard card
19
+ - "Download All Reports (ZIP)" in export sidebar
20
+ - New `/api/export/download` endpoint with section filtering
21
+
22
+ ### Improvements
23
+ - GSC data loader picks most recently modified folder (fixes stale folder selection)
24
+ - Report filenames use `YYYY-MM-DD` dates instead of Unix timestamps
25
+ - Setup wizard: multi-host Ollama support (`OLLAMA_HOSTS` env var)
26
+ - Skill file and Agent Guide updated with `watch`, `blog-draft`, and export features
27
+
28
+ ### Cleanup
29
+ - Removed deprecated agentic setup banner from wizard
30
+ - Consolidated Agent Guide into `skill/` directory
31
+
3
32
  ## 1.4.2 (2026-04-05)
4
33
 
5
34
  ### New Feature: Site Watch
package/cli.js CHANGED
@@ -742,9 +742,9 @@ program
742
742
  console.log(chalk.yellow('Sending to Gemini...\n'));
743
743
 
744
744
  // Save prompt for debugging (markdown for Obsidian/agent compatibility)
745
- const promptTs = Date.now();
745
+ const promptTs = new Date().toISOString().slice(0, 10);
746
746
  const promptPath = join(__dirname, `reports/${project}-prompt-${promptTs}.md`);
747
- const promptFrontmatter = `---\nproject: ${project}\ngenerated: ${new Date(promptTs).toISOString()}\ntype: analysis-prompt\nmodel: gemini\n---\n\n`;
747
+ const promptFrontmatter = `---\nproject: ${project}\ngenerated: ${new Date().toISOString()}\ntype: analysis-prompt\nmodel: gemini\n---\n\n`;
748
748
  writeFileSync(promptPath, promptFrontmatter + prompt, 'utf8');
749
749
  console.log(chalk.gray(`Prompt saved: ${promptPath}`));
750
750
 
@@ -764,13 +764,13 @@ program
764
764
  analysis = JSON.parse(jsonMatch[0]);
765
765
  } catch {
766
766
  console.error(chalk.red('Could not parse JSON from response. Saving raw output.'));
767
- const rawPath = join(__dirname, `reports/${project}-raw-${Date.now()}.md`);
767
+ const rawPath = join(__dirname, `reports/${project}-raw-${new Date().toISOString().slice(0, 10)}.md`);
768
768
  writeFileSync(rawPath, result, 'utf8');
769
769
  process.exit(1);
770
770
  }
771
771
 
772
772
  // Save structured analysis to file
773
- const outPath = join(__dirname, `reports/${project}-analysis-${Date.now()}.json`);
773
+ const outPath = join(__dirname, `reports/${project}-analysis-${new Date().toISOString().slice(0, 10)}.json`);
774
774
  writeFileSync(outPath, JSON.stringify(analysis, null, 2), 'utf8');
775
775
 
776
776
  // Save to DB (so HTML dashboard picks it up)
@@ -930,7 +930,7 @@ Respond ONLY with a single valid JSON object matching this exact schema. No expl
930
930
  data = JSON.parse(jsonMatch[0]);
931
931
  } catch {
932
932
  console.error(chalk.red('Could not parse JSON from Gemini response.'));
933
- const rawPath = join(__dirname, `reports/${project}-keywords-raw-${Date.now()}.md`);
933
+ const rawPath = join(__dirname, `reports/${project}-keywords-raw-${new Date().toISOString().slice(0, 10)}.md`);
934
934
  writeFileSync(rawPath, result, 'utf8');
935
935
  console.error(chalk.gray(`Raw output saved: ${rawPath}`));
936
936
  process.exit(1);
@@ -991,7 +991,7 @@ Respond ONLY with a single valid JSON object matching this exact schema. No expl
991
991
  }
992
992
 
993
993
  if (opts.save) {
994
- const outPath = join(__dirname, `reports/${project}-keywords-${Date.now()}.json`);
994
+ const outPath = join(__dirname, `reports/${project}-keywords-${new Date().toISOString().slice(0, 10)}.json`);
995
995
  writeFileSync(outPath, JSON.stringify(data, null, 2), 'utf8');
996
996
  console.log(chalk.bold.green(`✅ Report saved: ${outPath}\n`));
997
997
 
@@ -1759,7 +1759,7 @@ async function runAnalysis(project, db) {
1759
1759
  try {
1760
1760
  const jsonMatch = result.match(/\{[\s\S]*\}/);
1761
1761
  const analysis = JSON.parse(jsonMatch[0]);
1762
- const outPath = join(__dirname, `reports/${project}-analysis-${Date.now()}.json`);
1762
+ const outPath = join(__dirname, `reports/${project}-analysis-${new Date().toISOString().slice(0, 10)}.json`);
1763
1763
  writeFileSync(outPath, JSON.stringify(analysis, null, 2), 'utf8');
1764
1764
 
1765
1765
  // Save to DB
@@ -2410,7 +2410,7 @@ program
2410
2410
  report += `> Analyze this heading structure from ${page.domain}. What H2/H3 sub-topics are logically missing? What would a user expect to find that isn't covered? Be specific.\n\n---\n\n`;
2411
2411
  }
2412
2412
 
2413
- const outPath = join(__dirname, `reports/${project}-headings-audit-${Date.now()}.md`);
2413
+ const outPath = join(__dirname, `reports/${project}-headings-audit-${new Date().toISOString().slice(0, 10)}.md`);
2414
2414
  writeFileSync(outPath, report, 'utf8');
2415
2415
 
2416
2416
  console.log(chalk.bold.green(`\n✅ Full audit saved: ${outPath}`));
@@ -2672,7 +2672,7 @@ program
2672
2672
 
2673
2673
  // ── Save ──
2674
2674
  if (opts.save) {
2675
- const outPath = join(__dirname, `reports/${project}-entities-${Date.now()}.md`);
2675
+ const outPath = join(__dirname, `reports/${project}-entities-${new Date().toISOString().slice(0, 10)}.md`);
2676
2676
  writeFileSync(outPath, mdOutput, 'utf8');
2677
2677
  console.log(chalk.bold.green(` ✅ Entity map saved: ${outPath}\n`));
2678
2678
  }
@@ -3369,7 +3369,7 @@ program
3369
3369
 
3370
3370
  // ── Save ──
3371
3371
  if (opts.save) {
3372
- const outPath = join(__dirname, `reports/${project}-brief-${Date.now()}.md`);
3372
+ const outPath = join(__dirname, `reports/${project}-brief-${new Date().toISOString().slice(0, 10)}.md`);
3373
3373
  writeFileSync(outPath, mdOutput, 'utf8');
3374
3374
  console.log(chalk.bold.green(` ✅ Brief saved: ${outPath}\n`));
3375
3375
  }
@@ -3713,7 +3713,7 @@ program
3713
3713
  }
3714
3714
 
3715
3715
  if (opts.save) {
3716
- const outPath = join(__dirname, `reports/${project}-js-delta-${Date.now()}.md`);
3716
+ const outPath = join(__dirname, `reports/${project}-js-delta-${new Date().toISOString().slice(0, 10)}.md`);
3717
3717
  writeFileSync(outPath, mdOutput, 'utf8');
3718
3718
  console.log(chalk.bold.green(` ✅ Report saved: ${outPath}\n`));
3719
3719
  }
@@ -3859,7 +3859,7 @@ program
3859
3859
  }
3860
3860
 
3861
3861
  // Output
3862
- const timestamp = Date.now();
3862
+ const timestamp = new Date().toISOString().slice(0, 10);
3863
3863
  const defaultPath = join(__dirname, `reports/${project}-export-${timestamp}.${format}`);
3864
3864
  const outPath = opts.output || defaultPath;
3865
3865
 
@@ -4237,7 +4237,7 @@ program
4237
4237
  for (const p of worst) {
4238
4238
  md += `- **${p.url}** — ${p.score}/100 (${p.tier})\n`;
4239
4239
  }
4240
- const outPath = join(__dirname, `reports/${project}-aeo-${Date.now()}.md`);
4240
+ const outPath = join(__dirname, `reports/${project}-aeo-${new Date().toISOString().slice(0, 10)}.md`);
4241
4241
  writeFileSync(outPath, md, 'utf8');
4242
4242
  console.log(chalk.bold.green(` ✅ Report saved: ${outPath}\n`));
4243
4243
  }
@@ -4521,7 +4521,7 @@ program
4521
4521
  const slug = opts.topic
4522
4522
  ? opts.topic.toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 50)
4523
4523
  : 'auto';
4524
- const filename = `${project}-blog-draft-${slug}-${Date.now()}.md`;
4524
+ const filename = `${project}-blog-draft-${slug}-${new Date().toISOString().slice(0, 10)}.md`;
4525
4525
  const outPath = join(__dirname, 'reports', filename);
4526
4526
  writeFileSync(outPath, draft, 'utf8');
4527
4527
  console.log(chalk.bold.green(` ✅ Draft saved: ${outPath}`));
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Minimal ZIP archive builder — zero dependencies.
3
+ * Creates valid ZIP files (uncompressed / STORE method) from an array of entries.
4
+ * Sufficient for text-based exports (MD, JSON, CSV) where deflate adds little value.
5
+ *
6
+ * Usage:
7
+ * import { createZip } from './lib/export-zip.js';
8
+ * const buf = createZip([{ name: 'report.md', content: '# Hello' }]);
9
+ * res.end(buf);
10
+ */
11
+
12
+ /**
13
+ * @param {{ name: string, content: string | Buffer }[]} entries
14
+ * @returns {Buffer} Valid ZIP archive
15
+ */
16
+ export function createZip(entries) {
17
+ const localHeaders = [];
18
+ const centralHeaders = [];
19
+ let offset = 0;
20
+
21
+ for (const entry of entries) {
22
+ const nameBytes = Buffer.from(entry.name, 'utf8');
23
+ const data = typeof entry.content === 'string' ? Buffer.from(entry.content, 'utf8') : entry.content;
24
+ const crc = crc32(data);
25
+
26
+ // Local file header (30 + nameLen + dataLen)
27
+ const local = Buffer.alloc(30 + nameBytes.length);
28
+ local.writeUInt32LE(0x04034b50, 0); // signature
29
+ local.writeUInt16LE(20, 4); // version needed (2.0)
30
+ local.writeUInt16LE(0, 6); // flags
31
+ local.writeUInt16LE(0, 8); // compression: STORE
32
+ local.writeUInt16LE(0, 10); // mod time
33
+ local.writeUInt16LE(0, 12); // mod date
34
+ local.writeUInt32LE(crc, 14); // crc-32
35
+ local.writeUInt32LE(data.length, 18); // compressed size
36
+ local.writeUInt32LE(data.length, 22); // uncompressed size
37
+ local.writeUInt16LE(nameBytes.length, 26); // filename length
38
+ local.writeUInt16LE(0, 28); // extra field length
39
+ nameBytes.copy(local, 30);
40
+
41
+ localHeaders.push(Buffer.concat([local, data]));
42
+
43
+ // Central directory header (46 + nameLen)
44
+ const central = Buffer.alloc(46 + nameBytes.length);
45
+ central.writeUInt32LE(0x02014b50, 0); // signature
46
+ central.writeUInt16LE(20, 4); // version made by
47
+ central.writeUInt16LE(20, 6); // version needed
48
+ central.writeUInt16LE(0, 8); // flags
49
+ central.writeUInt16LE(0, 10); // compression: STORE
50
+ central.writeUInt16LE(0, 12); // mod time
51
+ central.writeUInt16LE(0, 14); // mod date
52
+ central.writeUInt32LE(crc, 16); // crc-32
53
+ central.writeUInt32LE(data.length, 20); // compressed size
54
+ central.writeUInt32LE(data.length, 24); // uncompressed size
55
+ central.writeUInt16LE(nameBytes.length, 28); // filename length
56
+ central.writeUInt16LE(0, 30); // extra field length
57
+ central.writeUInt16LE(0, 32); // comment length
58
+ central.writeUInt16LE(0, 34); // disk number start
59
+ central.writeUInt16LE(0, 36); // internal attrs
60
+ central.writeUInt32LE(0, 38); // external attrs
61
+ central.writeUInt32LE(offset, 42); // local header offset
62
+ nameBytes.copy(central, 46);
63
+
64
+ centralHeaders.push(central);
65
+ offset += local.length + data.length;
66
+ }
67
+
68
+ const centralDir = Buffer.concat(centralHeaders);
69
+ const centralDirOffset = offset;
70
+
71
+ // End of central directory record (22 bytes)
72
+ const eocd = Buffer.alloc(22);
73
+ eocd.writeUInt32LE(0x06054b50, 0); // signature
74
+ eocd.writeUInt16LE(0, 4); // disk number
75
+ eocd.writeUInt16LE(0, 6); // disk with central dir
76
+ eocd.writeUInt16LE(entries.length, 8); // entries on this disk
77
+ eocd.writeUInt16LE(entries.length, 10); // total entries
78
+ eocd.writeUInt32LE(centralDir.length, 12); // central dir size
79
+ eocd.writeUInt32LE(centralDirOffset, 16); // central dir offset
80
+ eocd.writeUInt16LE(0, 20); // comment length
81
+
82
+ return Buffer.concat([...localHeaders, centralDir, eocd]);
83
+ }
84
+
85
+ // ─── CRC-32 (IEEE 802.3) ────────────────────────────────────────────────────
86
+
87
+ const CRC_TABLE = new Uint32Array(256);
88
+ for (let n = 0; n < 256; n++) {
89
+ let c = n;
90
+ for (let k = 0; k < 8; k++) {
91
+ c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
92
+ }
93
+ CRC_TABLE[n] = c;
94
+ }
95
+
96
+ function crc32(buf) {
97
+ let crc = 0xFFFFFFFF;
98
+ for (let i = 0; i < buf.length; i++) {
99
+ crc = CRC_TABLE[(crc ^ buf[i]) & 0xFF] ^ (crc >>> 8);
100
+ }
101
+ return (crc ^ 0xFFFFFFFF) >>> 0;
102
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "seo-intel",
3
- "version": "1.4.2",
3
+ "version": "1.4.4",
4
4
  "description": "Local Ahrefs-style SEO competitor intelligence. Crawl → SQLite → cloud analysis.",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -24,13 +24,19 @@ import { getWatchData } from '../analyses/watch/index.js';
24
24
 
25
25
  const __dirname = dirname(fileURLToPath(import.meta.url));
26
26
 
27
- /**
28
- * Generate HTML dashboard from database
29
- * @param {import('node:sqlite').DatabaseSync} db - SQLite database
30
- * @param {string} project - Project name (e.g., 'mysite')
31
- * @param {object} config - Project config
32
- * @returns {string} Path to generated HTML file
33
- */
27
+ /** Build per-card export dropdown HTML */
28
+ function cardExportHtml(section, project) {
29
+ return `<div class="card-export" data-project="${project}" data-section="${section}">
30
+ <button class="card-export-btn" title="Export"><i class="fa-solid fa-download"></i></button>
31
+ <div class="card-export-dropdown">
32
+ <button data-fmt="md"><i class="fa-solid fa-file-lines"></i> Markdown</button>
33
+ <button data-fmt="json"><i class="fa-solid fa-code"></i> JSON</button>
34
+ <button data-fmt="csv"><i class="fa-solid fa-table"></i> CSV</button>
35
+ <button data-fmt="zip"><i class="fa-solid fa-file-zipper"></i> ZIP (all)</button>
36
+ </div>
37
+ </div>`;
38
+ }
39
+
34
40
  /**
35
41
  * Gather all dashboard data for a single project
36
42
  */
@@ -419,6 +425,32 @@ function buildHtmlTemplate(data, opts = {}) {
419
425
  .card.full-width {
420
426
  grid-column: 1 / -1;
421
427
  }
428
+ .card { position: relative; }
429
+ .card-export {
430
+ position: absolute; top: 10px; right: 10px; z-index: 5;
431
+ }
432
+ .card-export-btn {
433
+ background: var(--bg-elevated); border: 1px solid var(--border-subtle);
434
+ color: var(--text-muted); cursor: pointer; border-radius: var(--radius);
435
+ width: 28px; height: 28px; display: flex; align-items: center; justify-content: center;
436
+ font-size: 0.7rem; transition: all .15s;
437
+ }
438
+ .card-export-btn:hover { color: var(--accent-gold); border-color: var(--accent-gold); }
439
+ .card-export-dropdown {
440
+ display: none; position: absolute; right: 0; top: 32px;
441
+ background: var(--bg-card); border: 1px solid var(--border-card);
442
+ border-radius: var(--radius); min-width: 150px; padding: 4px 0;
443
+ box-shadow: 0 8px 24px rgba(0,0,0,.4);
444
+ }
445
+ .card-export.open .card-export-dropdown { display: block; }
446
+ .card-export-dropdown button {
447
+ display: flex; align-items: center; gap: 8px; width: 100%;
448
+ background: none; border: none; color: var(--text-secondary);
449
+ padding: 7px 14px; font-size: 0.72rem; cursor: pointer; text-align: left;
450
+ font-family: var(--font-body);
451
+ }
452
+ .card-export-dropdown button:hover { background: var(--bg-elevated); color: var(--text-primary); }
453
+ .card-export-dropdown button i { width: 14px; text-align: center; color: var(--text-muted); font-size: 0.65rem; }
422
454
 
423
455
  /* ─── Extraction Status Bar ─────────────────────────────────────────── */
424
456
  .extraction-status {
@@ -1493,6 +1525,20 @@ function buildHtmlTemplate(data, opts = {}) {
1493
1525
  .export-btn:hover { border-color: var(--accent-gold); color: var(--accent-gold); }
1494
1526
  .export-btn i { margin-right: 5px; font-size: 0.6rem; }
1495
1527
  .export-btn.active { border-color: var(--accent-gold); color: var(--accent-gold); background: rgba(232,213,163,0.06); }
1528
+ .profile-export-picker { position: relative; }
1529
+ .profile-export-trigger { display: flex; align-items: center; width: 100%; }
1530
+ .profile-export-menu {
1531
+ display: none;
1532
+ position: absolute;
1533
+ bottom: calc(100% + 4px);
1534
+ left: 0; right: 0;
1535
+ background: #1a1a1a;
1536
+ border: 1px solid var(--accent-gold);
1537
+ border-radius: var(--radius);
1538
+ padding: 10px;
1539
+ z-index: 50;
1540
+ box-shadow: 0 8px 24px rgba(0,0,0,0.5);
1541
+ }
1496
1542
  .draft-dropdown { position: relative; }
1497
1543
  .draft-trigger { display: flex; align-items: center; width: 100%; }
1498
1544
  .draft-menu {
@@ -1798,7 +1844,7 @@ function buildHtmlTemplate(data, opts = {}) {
1798
1844
  </div>`}
1799
1845
 
1800
1846
  <!-- SITE WATCH -->
1801
- ${watchData?.current ? buildWatchCard(watchData, escapeHtml) : ''}
1847
+ ${watchData?.current ? buildWatchCard(watchData, escapeHtml, project) : ''}
1802
1848
 
1803
1849
  <!-- PAGE INVENTORY -->
1804
1850
  <div class="card" style="margin-bottom:16px;">
@@ -2157,6 +2203,29 @@ function buildHtmlTemplate(data, opts = {}) {
2157
2203
  </div>
2158
2204
  <button class="export-btn" data-export-cmd="aeo" data-export-project="${project}"><i class="fa-solid fa-robot"></i> AI Citability Audit</button>
2159
2205
  </div>
2206
+ <div class="export-sidebar-header" style="margin-top:12px;">
2207
+ <i class="fa-solid fa-download"></i> Download
2208
+ </div>
2209
+ <div class="export-sidebar-btns">
2210
+ <div class="profile-export-picker" id="profilePicker${suffix}">
2211
+ <button class="export-btn profile-export-trigger"><i class="fa-solid fa-download"></i> Export Report <i class="fa-solid fa-chevron-down" style="font-size:0.55rem;margin-left:auto;opacity:0.5;"></i></button>
2212
+ <div class="profile-export-menu">
2213
+ <div class="draft-menu-section">Profile</div>
2214
+ <label class="draft-option"><input type="radio" name="exportProfile${suffix}" value="dev" /> <i class="fa-solid fa-wrench"></i> Developer <span style="font-size:0.5rem;opacity:0.45;margin-left:2px;">technical fixes, schema gaps</span></label>
2215
+ <label class="draft-option"><input type="radio" name="exportProfile${suffix}" value="content" checked /> <i class="fa-solid fa-pen-fancy"></i> Content <span style="font-size:0.5rem;opacity:0.45;margin-left:2px;">keyword gaps, opportunities</span></label>
2216
+ <label class="draft-option"><input type="radio" name="exportProfile${suffix}" value="ai-pipeline" /> <i class="fa-solid fa-robot"></i> AI Pipeline <span style="font-size:0.5rem;opacity:0.45;margin-left:2px;">structured JSON for LLMs</span></label>
2217
+ <div class="draft-menu-section" style="margin-top:8px;">Format</div>
2218
+ <div style="display:flex;gap:6px;flex-wrap:wrap;">
2219
+ <label class="draft-option" style="flex:1;min-width:60px;"><input type="radio" name="exportFmt${suffix}" value="md" checked /> <i class="fa-solid fa-file-lines"></i> MD</label>
2220
+ <label class="draft-option" style="flex:1;min-width:60px;"><input type="radio" name="exportFmt${suffix}" value="json" /> <i class="fa-solid fa-code"></i> JSON</label>
2221
+ <label class="draft-option" style="flex:1;min-width:60px;"><input type="radio" name="exportFmt${suffix}" value="csv" /> <i class="fa-solid fa-table"></i> CSV</label>
2222
+ <label class="draft-option" style="flex:1;min-width:60px;"><input type="radio" name="exportFmt${suffix}" value="zip" /> <i class="fa-solid fa-file-zipper"></i> ZIP</label>
2223
+ </div>
2224
+ <button class="draft-generate-btn profile-download-btn" data-project="${project}" style="margin-top:10px;"><i class="fa-solid fa-download"></i> Download</button>
2225
+ </div>
2226
+ </div>
2227
+ <button class="export-btn download-all-btn" data-project="${project}" style="font-size:0.58rem;opacity:0.6;"><i class="fa-solid fa-file-zipper"></i> Raw Full Export (ZIP)</button>
2228
+ </div>
2160
2229
  <div style="position:relative;">
2161
2230
  <div id="exportSaveStatus${suffix}" style="display:none;padding:4px 10px;font-size:.6rem;color:var(--color-success);background:rgba(80,200,120,0.06);border-bottom:1px solid rgba(80,200,120,0.15);font-family:'SF Mono',monospace;">
2162
2231
  <i class="fa-solid fa-check" style="margin-right:4px;"></i><span></span>
@@ -2516,6 +2585,81 @@ function buildHtmlTemplate(data, opts = {}) {
2516
2585
  });
2517
2586
  }
2518
2587
 
2588
+ // ── Card export dropdowns (global — run once, capture phase) ──
2589
+ if (!window._cardExportBound) {
2590
+ window._cardExportBound = true;
2591
+ document.addEventListener('click', function(e) {
2592
+ var btn = e.target.closest('.card-export-btn');
2593
+ var fmtBtn = e.target.closest('[data-fmt]');
2594
+ if (btn) {
2595
+ var wrap = btn.closest('.card-export');
2596
+ if (wrap) {
2597
+ e.stopImmediatePropagation();
2598
+ var wasOpen = wrap.classList.contains('open');
2599
+ document.querySelectorAll('.card-export.open').forEach(function(el) { el.classList.remove('open'); });
2600
+ if (!wasOpen) wrap.classList.add('open');
2601
+ return;
2602
+ }
2603
+ }
2604
+ if (fmtBtn) {
2605
+ var wrap2 = fmtBtn.closest('.card-export');
2606
+ if (wrap2) {
2607
+ e.stopImmediatePropagation();
2608
+ wrap2.classList.remove('open');
2609
+ var sec = wrap2.getAttribute('data-section');
2610
+ var proj2 = wrap2.getAttribute('data-project');
2611
+ var fmt = fmtBtn.getAttribute('data-fmt');
2612
+ if (window.location.protocol.startsWith('http')) {
2613
+ window.location = '/api/export/download?project=' + encodeURIComponent(proj2) + '&section=' + encodeURIComponent(sec) + '&format=' + encodeURIComponent(fmt);
2614
+ }
2615
+ return;
2616
+ }
2617
+ }
2618
+ var dlBtn = e.target.closest('.download-all-btn');
2619
+ if (dlBtn) {
2620
+ var proj3 = dlBtn.getAttribute('data-project');
2621
+ if (window.location.protocol.startsWith('http')) {
2622
+ window.location = '/api/export/download?project=' + encodeURIComponent(proj3) + '&section=all&format=zip';
2623
+ }
2624
+ return;
2625
+ }
2626
+ // Profile export picker toggle
2627
+ var profTrigger = e.target.closest('.profile-export-trigger');
2628
+ if (profTrigger) {
2629
+ var picker = profTrigger.closest('.profile-export-picker');
2630
+ if (picker) {
2631
+ e.stopImmediatePropagation();
2632
+ var menu = picker.querySelector('.profile-export-menu');
2633
+ var wasVis = menu.style.display === 'block';
2634
+ document.querySelectorAll('.profile-export-menu').forEach(function(m) { m.style.display = 'none'; });
2635
+ menu.style.display = wasVis ? 'none' : 'block';
2636
+ return;
2637
+ }
2638
+ }
2639
+ // Profile download button
2640
+ var profDl = e.target.closest('.profile-download-btn');
2641
+ if (profDl) {
2642
+ var picker2 = profDl.closest('.profile-export-picker');
2643
+ if (picker2) {
2644
+ e.stopImmediatePropagation();
2645
+ var projP = profDl.getAttribute('data-project');
2646
+ var profVal = picker2.querySelector('input[name^="exportProfile"]:checked');
2647
+ var fmtVal = picker2.querySelector('input[name^="exportFmt"]:checked');
2648
+ var prof = profVal ? profVal.value : 'content';
2649
+ var fmt2 = fmtVal ? fmtVal.value : 'md';
2650
+ picker2.querySelector('.profile-export-menu').style.display = 'none';
2651
+ if (window.location.protocol.startsWith('http')) {
2652
+ window.location = '/api/export/download?project=' + encodeURIComponent(projP) + '&profile=' + encodeURIComponent(prof) + '&format=' + encodeURIComponent(fmt2);
2653
+ }
2654
+ return;
2655
+ }
2656
+ }
2657
+ // Outside click — close all open dropdowns
2658
+ document.querySelectorAll('.card-export.open').forEach(function(el) { el.classList.remove('open'); });
2659
+ document.querySelectorAll('.profile-export-menu').forEach(function(m) { m.style.display = 'none'; });
2660
+ }, true);
2661
+ }
2662
+
2519
2663
  // Input enter
2520
2664
  input.addEventListener('keydown', function(e) {
2521
2665
  if (e.key !== 'Enter') return;
@@ -2779,7 +2923,7 @@ function buildHtmlTemplate(data, opts = {}) {
2779
2923
  })() : ''}
2780
2924
 
2781
2925
  <!-- ═══ SITE WATCH ═══ -->
2782
- ${watchData?.current ? buildWatchCard(watchData, escapeHtml) : ''}
2926
+ ${watchData?.current ? buildWatchCard(watchData, escapeHtml, project) : ''}
2783
2927
 
2784
2928
  <div class="section-divider">
2785
2929
  <div class="section-divider-line right"></div>
@@ -2835,6 +2979,7 @@ function buildHtmlTemplate(data, opts = {}) {
2835
2979
 
2836
2980
  <!-- ═══ HEADING DEPTH FLOW ═══ -->
2837
2981
  <div class="card full-width" id="heading-flow">
2982
+ ${cardExportHtml('headings', project)}
2838
2983
  <h2><span class="icon"><i class="fa-solid fa-water"></i></span> Heading Depth Flow</h2>
2839
2984
  <canvas id="headingFlowCanvas${suffix}" width="1100" height="320"></canvas>
2840
2985
  </div>
@@ -2862,6 +3007,7 @@ function buildHtmlTemplate(data, opts = {}) {
2862
3007
  <!-- ═══ ENTITY TOPIC MAP ═══ -->
2863
3008
  ${pro && entityTopicMap.hasData ? `
2864
3009
  <div class="card full-width" id="entity-map">
3010
+ ${cardExportHtml('insights', project)}
2865
3011
  <h2><span class="icon"><i class="fa-solid fa-map"></i></span> Entity Topic Map</h2>
2866
3012
  <div class="entity-map-grid">
2867
3013
  ${Object.entries(entityTopicMap.domainEntities).map(([domain, data]) => `
@@ -2886,6 +3032,7 @@ function buildHtmlTemplate(data, opts = {}) {
2886
3032
  <!-- ═══ KEYWORD BATTLEGROUND ═══ -->
2887
3033
  ${pro ? `
2888
3034
  <div class="card full-width" id="keyword-heatmap">
3035
+ ${cardExportHtml('keywords', project)}
2889
3036
  <h2><span class="icon"><i class="fa-solid fa-shield-halved"></i></span> Keyword Battleground</h2>
2890
3037
  ${keywordHeatmap.keywords.length ? `
2891
3038
  <div class="table-wrapper">
@@ -2930,6 +3077,7 @@ function buildHtmlTemplate(data, opts = {}) {
2930
3077
 
2931
3078
  <!-- ═══ TECHNICAL SEO SCORECARD ═══ -->
2932
3079
  <div class="card full-width" id="technical-seo">
3080
+ ${cardExportHtml('technical', project)}
2933
3081
  <h2><span class="icon"><i class="fa-solid fa-gear"></i></span> Technical SEO Scorecard</h2>
2934
3082
  <div class="scorecard-grid">
2935
3083
  ${technicalScores.map(ts => {
@@ -2998,6 +3146,7 @@ function buildHtmlTemplate(data, opts = {}) {
2998
3146
  <!-- ═══ TECHNICAL SEO GAPS ═══ -->
2999
3147
  ${pro && latestAnalysis?.technical_gaps?.length ? `
3000
3148
  <div class="card full-width" id="technical-gaps">
3149
+ ${cardExportHtml('technical', project)}
3001
3150
  <h2><span class="icon"><i class="fa-solid fa-wrench"></i></span> Technical SEO Gaps</h2>
3002
3151
  <div class="analysis-table-wrap">
3003
3152
  <table class="analysis-table">
@@ -3026,6 +3175,7 @@ function buildHtmlTemplate(data, opts = {}) {
3026
3175
  <!-- ═══ QUICK WINS ═══ -->
3027
3176
  ${pro && latestAnalysis?.quick_wins?.length ? `
3028
3177
  <div class="card" id="quick-wins">
3178
+ ${cardExportHtml('insights', project)}
3029
3179
  <h2><span class="icon"><i class="fa-solid fa-bolt"></i></span> Quick Wins</h2>
3030
3180
  <div class="analysis-table-wrap">
3031
3181
  <table class="analysis-table">
@@ -3048,6 +3198,7 @@ function buildHtmlTemplate(data, opts = {}) {
3048
3198
  <!-- ═══ NEW PAGES TO CREATE ═══ -->
3049
3199
  ${pro && latestAnalysis?.new_pages?.length ? `
3050
3200
  <div class="card" id="new-pages">
3201
+ ${cardExportHtml('pages', project)}
3051
3202
  <h2><span class="icon"><i class="fa-solid fa-file-circle-plus"></i></span> New Pages to Create</h2>
3052
3203
  <div class="new-pages-grid" style="grid-template-columns: 1fr;">
3053
3204
  ${(latestAnalysis.new_pages).map(np => `
@@ -3100,6 +3251,7 @@ function buildHtmlTemplate(data, opts = {}) {
3100
3251
  <!-- ═══ CONTENT GAPS ═══ -->
3101
3252
  ${pro && latestAnalysis?.content_gaps?.length ? `
3102
3253
  <div class="card full-width" id="content-gaps">
3254
+ ${cardExportHtml('insights', project)}
3103
3255
  <h2><span class="icon"><i class="fa-solid fa-magnifying-glass-minus"></i></span> Content Gaps</h2>
3104
3256
  <div class="insights-grid">
3105
3257
  ${(latestAnalysis.content_gaps).map(gap => `
@@ -3308,6 +3460,7 @@ function buildHtmlTemplate(data, opts = {}) {
3308
3460
  <!-- ═══ SCHEMA TYPE BREAKDOWN ═══ -->
3309
3461
  ${schemaBreakdown.hasData ? `
3310
3462
  <div class="card" id="schema-breakdown">
3463
+ ${cardExportHtml('schemas', project)}
3311
3464
  <h2><span class="icon"><i class="fa-solid fa-code"></i></span> Schema Markup Breakdown</h2>
3312
3465
  <div class="table-wrapper">
3313
3466
  <table>
@@ -3334,6 +3487,7 @@ function buildHtmlTemplate(data, opts = {}) {
3334
3487
  <!-- ═══ TOP KEYWORDS ═══ -->
3335
3488
  ${pro ? `
3336
3489
  <div class="card" id="top-keywords">
3490
+ ${cardExportHtml('keywords', project)}
3337
3491
  <h2><span class="icon"><i class="fa-solid fa-key"></i></span> Top Keywords (${targetDomain})</h2>
3338
3492
  ${keywords.length ? `
3339
3493
  <div class="table-wrapper" style="max-height: 400px; overflow-y: auto;">
@@ -3369,6 +3523,7 @@ function buildHtmlTemplate(data, opts = {}) {
3369
3523
 
3370
3524
  <!-- ═══ INTERNAL LINK ANALYSIS ═══ -->
3371
3525
  <div class="card" id="internal-links">
3526
+ ${cardExportHtml('links', project)}
3372
3527
  <h2><span class="icon"><i class="fa-solid fa-link"></i></span> Internal Link Analysis</h2>
3373
3528
  <div class="stat-row">
3374
3529
  <div class="stat-box">
@@ -3407,7 +3562,7 @@ function buildHtmlTemplate(data, opts = {}) {
3407
3562
  </div>` : ''}
3408
3563
 
3409
3564
  <!-- ═══ AEO / AI CITABILITY AUDIT ═══ -->
3410
- ${pro && citabilityData?.length ? buildAeoCard(citabilityData, escapeHtml) : ''}
3565
+ ${pro && citabilityData?.length ? buildAeoCard(citabilityData, escapeHtml, project) : ''}
3411
3566
 
3412
3567
  <!-- ═══ LONG-TAIL OPPORTUNITIES ═══ -->
3413
3568
  ${pro && latestAnalysis?.long_tails?.length ? `
@@ -5232,7 +5387,7 @@ function buildMultiHtmlTemplate(allProjectData) {
5232
5387
 
5233
5388
  // ─── AEO Card Builder ────────────────────────────────────────────────────────
5234
5389
 
5235
- function buildAeoCard(citabilityData, escapeHtml) {
5390
+ function buildAeoCard(citabilityData, escapeHtml, project) {
5236
5391
  const targetScores = citabilityData.filter(s => s.role === 'target' || s.role === 'owned');
5237
5392
  const compScores = citabilityData.filter(s => s.role === 'competitor');
5238
5393
  if (!targetScores.length) return '';
@@ -5296,6 +5451,7 @@ function buildAeoCard(citabilityData, escapeHtml) {
5296
5451
 
5297
5452
  return `
5298
5453
  <div class="card full-width" id="aeo-citability">
5454
+ ${cardExportHtml('aeo', project)}
5299
5455
  <h2><span class="icon"><i class="fa-solid fa-robot"></i></span> AI Citability Audit</h2>
5300
5456
  <div class="ki-stat-bar">
5301
5457
  <div class="ki-stat"><span class="ki-stat-number" style="color:${scoreColor(avgTarget)}">${avgTarget}</span><span class="ki-stat-label">Target Avg</span></div>
@@ -6886,7 +7042,7 @@ function getGscInsights(gscData, db, project) {
6886
7042
  // SITE WATCH CARD
6887
7043
  // ═══════════════════════════════════════════════════════════════════════════
6888
7044
 
6889
- function buildWatchCard(watchData, escapeHtml) {
7045
+ function buildWatchCard(watchData, escapeHtml, project) {
6890
7046
  const { current, previous, events, trend } = watchData;
6891
7047
  const score = current.health_score ?? 0;
6892
7048
  const scoreColor = score >= 80 ? 'var(--color-success)' : score >= 60 ? 'var(--color-warning)' : 'var(--color-danger)';
@@ -6942,6 +7098,7 @@ function buildWatchCard(watchData, escapeHtml) {
6942
7098
 
6943
7099
  return `
6944
7100
  <div class="card" style="margin-bottom:16px;">
7101
+ ${cardExportHtml('watch', project)}
6945
7102
  <h2><span class="icon"><i class="fa-solid fa-eye"></i></span> Site Watch</h2>
6946
7103
 
6947
7104
  <!-- Health Score + Summary -->
package/server.js CHANGED
@@ -96,6 +96,8 @@ const MIME = {
96
96
  '.png': 'image/png',
97
97
  '.svg': 'image/svg+xml',
98
98
  '.md': 'text/markdown; charset=utf-8',
99
+ '.csv': 'text/csv; charset=utf-8',
100
+ '.zip': 'application/zip',
99
101
  };
100
102
 
101
103
  // ── Read progress with PID liveness check (mirrors cli.js) ──
@@ -449,7 +451,7 @@ async function handleRequest(req, res) {
449
451
 
450
452
  try {
451
453
  const data = JSON.parse(rawJson);
452
- const stamp = Date.now();
454
+ const stamp = new Date().toISOString().slice(0, 10);
453
455
  const baseName = `${project}-actions-${stamp}`;
454
456
  writeFileSync(join(REPORTS_DIR, `${baseName}.json`), JSON.stringify(data, null, 2), 'utf8');
455
457
  writeFileSync(join(REPORTS_DIR, `${baseName}.md`), buildActionsMarkdown(data), 'utf8');
@@ -586,6 +588,507 @@ async function handleRequest(req, res) {
586
588
  return;
587
589
  }
588
590
 
591
+ // ─── API: Universal Export Download ───
592
+ if (req.method === 'GET' && path === '/api/export/download') {
593
+ try {
594
+ const project = url.searchParams.get('project');
595
+ const section = url.searchParams.get('section') || 'all';
596
+ const format = url.searchParams.get('format') || 'json';
597
+ const profile = url.searchParams.get('profile'); // dev | content | ai-pipeline
598
+
599
+ if (!project) { json(res, 400, { error: 'Missing project' }); return; }
600
+
601
+ const { getDb } = await import('./db/db.js');
602
+ const db = getDb(join(__dirname, 'seo-intel.db'));
603
+ const configPath = join(__dirname, 'config', `${project}.json`);
604
+ const config = existsSync(configPath) ? JSON.parse(readFileSync(configPath, 'utf8')) : null;
605
+
606
+ const dateStr = new Date().toISOString().slice(0, 10);
607
+ const { createZip } = await import('./lib/export-zip.js');
608
+
609
+ const SECTIONS = ['aeo', 'insights', 'technical', 'keywords', 'pages', 'watch', 'schemas', 'headings', 'links'];
610
+
611
+ // ── Profile definitions: which sections + which insight types matter ──
612
+ const PROFILES = {
613
+ dev: {
614
+ sections: ['technical', 'schemas', 'links', 'headings', 'watch', 'insights'],
615
+ insightTypes: ['technical_gap', 'quick_win', 'site_watch'],
616
+ label: 'Developer',
617
+ },
618
+ content: {
619
+ sections: ['insights', 'keywords', 'aeo'],
620
+ insightTypes: ['keyword_gap', 'long_tail', 'content_gap', 'new_page', 'keyword_inventor', 'citability_gap', 'positioning'],
621
+ label: 'Content',
622
+ },
623
+ 'ai-pipeline': {
624
+ sections: ['insights', 'aeo', 'technical', 'keywords', 'watch'],
625
+ insightTypes: null, // all types
626
+ label: 'AI Pipeline',
627
+ },
628
+ };
629
+
630
+ function querySection(sec) {
631
+ switch (sec) {
632
+ case 'aeo': {
633
+ try {
634
+ return db.prepare(`
635
+ SELECT cs.score, cs.entity_authority, cs.structured_claims, cs.answer_density,
636
+ cs.qa_proximity, cs.freshness, cs.schema_coverage, cs.tier, cs.ai_intents,
637
+ p.url, p.title, p.word_count, d.domain, d.role
638
+ FROM citability_scores cs
639
+ JOIN pages p ON p.id = cs.page_id
640
+ JOIN domains d ON d.id = p.domain_id
641
+ WHERE d.project = ?
642
+ ORDER BY d.role ASC, cs.score ASC
643
+ `).all(project);
644
+ } catch { return []; }
645
+ }
646
+ case 'insights': {
647
+ try {
648
+ const rows = db.prepare(
649
+ `SELECT * FROM insights WHERE project = ? AND status = 'active' ORDER BY type, last_seen DESC`
650
+ ).all(project);
651
+ return rows.map(r => {
652
+ try { return { ...JSON.parse(r.data), _type: r.type, _id: r.id, _first_seen: r.first_seen, _last_seen: r.last_seen }; }
653
+ catch { return { _type: r.type, _id: r.id, raw: r.data }; }
654
+ });
655
+ } catch { return []; }
656
+ }
657
+ case 'technical': {
658
+ try {
659
+ return db.prepare(`
660
+ SELECT p.url, p.status_code, p.word_count, p.load_ms, p.is_indexable, p.click_depth,
661
+ t.has_canonical, t.has_og_tags, t.has_schema, t.has_robots, t.is_mobile_ok,
662
+ d.domain, d.role
663
+ FROM pages p
664
+ JOIN domains d ON d.id = p.domain_id
665
+ LEFT JOIN technical t ON t.page_id = p.id
666
+ WHERE d.project = ?
667
+ ORDER BY d.domain, p.url
668
+ `).all(project);
669
+ } catch { return []; }
670
+ }
671
+ case 'keywords': {
672
+ try {
673
+ return db.prepare(`
674
+ SELECT k.keyword, d.domain, d.role, k.location, COUNT(*) as freq
675
+ FROM keywords k
676
+ JOIN pages p ON p.id = k.page_id
677
+ JOIN domains d ON d.id = p.domain_id
678
+ WHERE d.project = ?
679
+ GROUP BY k.keyword, d.domain
680
+ ORDER BY freq DESC
681
+ `).all(project);
682
+ } catch { return []; }
683
+ }
684
+ case 'pages': {
685
+ try {
686
+ return db.prepare(`
687
+ SELECT p.url, p.status_code, p.word_count, p.load_ms, p.is_indexable, p.click_depth,
688
+ p.title, p.meta_desc, p.published_date, p.modified_date,
689
+ p.crawled_at, p.first_seen_at, d.domain, d.role
690
+ FROM pages p
691
+ JOIN domains d ON d.id = p.domain_id
692
+ WHERE d.project = ?
693
+ ORDER BY d.domain, p.url
694
+ `).all(project);
695
+ } catch { return []; }
696
+ }
697
+ case 'watch': {
698
+ try {
699
+ const snap = db.prepare('SELECT * FROM watch_snapshots WHERE project = ? ORDER BY created_at DESC LIMIT 1').get(project);
700
+ if (!snap) return [];
701
+ const events = db.prepare('SELECT * FROM watch_events WHERE snapshot_id = ? ORDER BY severity, event_type').all(snap.id);
702
+ const pages = db.prepare('SELECT * FROM watch_page_states WHERE snapshot_id = ?').all(snap.id);
703
+ return { snapshot: snap, events, pages };
704
+ } catch { return []; }
705
+ }
706
+ case 'schemas': {
707
+ try {
708
+ return db.prepare(`
709
+ SELECT d.domain, d.role, p.url, ps.schema_type, ps.name, ps.description,
710
+ ps.rating, ps.rating_count, ps.price, ps.currency, ps.author,
711
+ ps.date_published, ps.date_modified
712
+ FROM page_schemas ps
713
+ JOIN pages p ON p.id = ps.page_id
714
+ JOIN domains d ON d.id = p.domain_id
715
+ WHERE d.project = ?
716
+ ORDER BY d.domain, ps.schema_type
717
+ `).all(project);
718
+ } catch { return []; }
719
+ }
720
+ case 'headings': {
721
+ try {
722
+ return db.prepare(`
723
+ SELECT d.domain, d.role, p.url, h.level, h.text
724
+ FROM headings h
725
+ JOIN pages p ON p.id = h.page_id
726
+ JOIN domains d ON d.id = p.domain_id
727
+ WHERE d.project = ?
728
+ ORDER BY d.domain, p.url, h.level
729
+ `).all(project);
730
+ } catch { return []; }
731
+ }
732
+ case 'links': {
733
+ try {
734
+ return db.prepare(`
735
+ SELECT l.source_page_id, l.target_url, l.anchor_text, l.is_internal,
736
+ p.url as source_url, d.domain, d.role
737
+ FROM links l
738
+ JOIN pages p ON p.id = l.source_page_id
739
+ JOIN domains d ON d.id = p.domain_id
740
+ WHERE d.project = ?
741
+ ORDER BY d.domain, p.url
742
+ `).all(project);
743
+ } catch { return []; }
744
+ }
745
+ default: return [];
746
+ }
747
+ }
748
+
749
+ // ── Profile-aware filtering: strip raw dumps, keep actionable items ──
750
+ function filterForProfile(sec, data, prof) {
751
+ if (!prof || !data) return data;
752
+ const p = PROFILES[prof];
753
+ if (!p) return data;
754
+
755
+ switch (sec) {
756
+ case 'insights': {
757
+ if (!Array.isArray(data)) return data;
758
+ return p.insightTypes ? data.filter(r => p.insightTypes.includes(r._type)) : data;
759
+ }
760
+ case 'technical': {
761
+ if (!Array.isArray(data) || prof === 'ai-pipeline') return data;
762
+ // Dev profile: only pages with issues
763
+ return data.filter(r =>
764
+ r.status_code >= 400 || !r.has_canonical || !r.has_og_tags ||
765
+ !r.has_schema || !r.has_robots || !r.is_mobile_ok ||
766
+ (r.load_ms && r.load_ms > 3000) || (r.word_count != null && r.word_count < 100)
767
+ );
768
+ }
769
+ case 'headings': {
770
+ if (!Array.isArray(data)) return data;
771
+ // Own site only — group by page, return per-page issue summary
772
+ const ownOnly = data.filter(r => r.role === 'target' || r.role === 'owned');
773
+ const byPage = {};
774
+ for (const r of ownOnly) (byPage[r.url] ||= []).push(r);
775
+ const issues = [];
776
+ for (const [url, headings] of Object.entries(byPage)) {
777
+ const h1s = headings.filter(h => h.level === 1);
778
+ const levels = headings.map(h => h.level);
779
+ const problems = [];
780
+ if (h1s.length === 0) problems.push('missing H1');
781
+ else if (h1s.length > 1) problems.push(`${h1s.length}× H1`);
782
+ // Check for skipped levels (e.g. H1→H3 skips H2)
783
+ const unique = [...new Set(levels)].sort((a, b) => a - b);
784
+ for (let i = 1; i < unique.length; i++) {
785
+ if (unique[i] - unique[i - 1] > 1) {
786
+ problems.push(`skips H${unique[i - 1]}→H${unique[i]}`);
787
+ }
788
+ }
789
+ if (problems.length) {
790
+ const sequence = levels.map(l => `H${l}`).join(' → ');
791
+ issues.push({ url, domain: headings[0].domain, issues: problems.join(', '), sequence, heading_count: headings.length });
792
+ }
793
+ }
794
+ return issues;
795
+ }
796
+ case 'links': {
797
+ if (!Array.isArray(data)) return data;
798
+ // Only orphan pages (pages that are never a target) and broken anchors
799
+ const targetUrls = new Set(data.filter(l => l.is_internal).map(l => l.target_url));
800
+ const sourceUrls = new Set(data.map(l => l.source_url));
801
+ // Pages that link out but are never linked TO = orphan
802
+ const orphans = new Set([...sourceUrls].filter(u => !targetUrls.has(u)));
803
+ return data.filter(r => orphans.has(r.source_url) || !r.anchor_text);
804
+ }
805
+ case 'schemas': {
806
+ if (!Array.isArray(data) || prof !== 'dev') return data;
807
+ // Dev: pages missing schema are more useful — but we only have pages WITH schema here
808
+ // So return all (schema gaps come from technical section's has_schema=false)
809
+ return data;
810
+ }
811
+ case 'aeo': {
812
+ if (!Array.isArray(data)) return data;
813
+ if (prof === 'content') {
814
+ // Content: only low-scoring pages (needs improvement)
815
+ return data.filter(r => r.score < 60);
816
+ }
817
+ return data;
818
+ }
819
+ case 'keywords': {
820
+ if (!Array.isArray(data)) return data;
821
+ if (prof === 'content') {
822
+ // Content: only competitor-dominated keywords (role != target/owned)
823
+ const byKw = {};
824
+ for (const r of data) { (byKw[r.keyword] ||= []).push(r); }
825
+ const gapKws = new Set();
826
+ for (const [kw, rows] of Object.entries(byKw)) {
827
+ const hasTarget = rows.some(r => r.role === 'target' || r.role === 'owned');
828
+ const hasCompetitor = rows.some(r => r.role === 'competitor');
829
+ if (!hasTarget && hasCompetitor) gapKws.add(kw);
830
+ }
831
+ return data.filter(r => gapKws.has(r.keyword));
832
+ }
833
+ return data;
834
+ }
835
+ case 'watch': {
836
+ // Keep only errors + warnings, drop notices
837
+ if (data && data.events) {
838
+ return { ...data, events: data.events.filter(e => e.severity === 'error' || e.severity === 'warning') };
839
+ }
840
+ return data;
841
+ }
842
+ default: return data;
843
+ }
844
+ }
845
+
846
+ function toCSV(rows) {
847
+ if (!rows || (Array.isArray(rows) && !rows.length)) return '';
848
+ const arr = Array.isArray(rows) ? rows : (rows.events || rows.pages || []);
849
+ if (!arr.length) return '';
850
+ const keys = Object.keys(arr[0]);
851
+ const escape = (v) => {
852
+ if (v == null) return '';
853
+ const s = String(v);
854
+ return s.includes(',') || s.includes('"') || s.includes('\n') ? `"${s.replace(/"/g, '""')}"` : s;
855
+ };
856
+ return [keys.join(','), ...arr.map(r => keys.map(k => escape(r[k])).join(','))].join('\n');
857
+ }
858
+
859
+ function toMarkdown(sec, data, proj) {
860
+ const date = new Date().toISOString().slice(0, 10);
861
+ const header = `# SEO Intel — ${sec.charAt(0).toUpperCase() + sec.slice(1)} Export\n\n- Project: ${proj}\n- Date: ${date}\n\n`;
862
+ if (!data || (Array.isArray(data) && !data.length)) return header + '_No data available._\n';
863
+
864
+ switch (sec) {
865
+ case 'aeo': {
866
+ const targetRows = data.filter(r => r.role === 'target' || r.role === 'owned');
867
+ const avg = targetRows.length ? Math.round(targetRows.reduce((a, r) => a + r.score, 0) / targetRows.length) : 0;
868
+ let md = header + `## Summary\n\n- Pages scored: ${data.length}\n- Target average: ${avg}/100\n\n`;
869
+ md += '## Page Scores\n\n| Score | Tier | URL | Title | Weakest Signals |\n|-------|------|-----|-------|-----------------|\n';
870
+ for (const r of data) {
871
+ const signals = ['entity_authority', 'structured_claims', 'answer_density', 'qa_proximity', 'freshness', 'schema_coverage'];
872
+ const weakest = signals.sort((a, b) => (r[a] || 0) - (r[b] || 0)).slice(0, 2).map(s => s.replace(/_/g, ' ')).join(', ');
873
+ md += `| ${r.score} | ${r.tier} | ${r.url} | ${(r.title || '').slice(0, 50)} | ${weakest} |\n`;
874
+ }
875
+ return md;
876
+ }
877
+ case 'insights': {
878
+ let md = header + `## Active Insights (${data.length})\n\n`;
879
+ const grouped = {};
880
+ for (const r of data) { (grouped[r._type] ||= []).push(r); }
881
+ for (const [type, items] of Object.entries(grouped)) {
882
+ md += `### ${type.replace(/_/g, ' ')} (${items.length})\n\n`;
883
+ for (const item of items) {
884
+ const desc = item.phrase || item.keyword || item.title || item.page || item.message || JSON.stringify(item).slice(0, 120);
885
+ md += `- ${desc}\n`;
886
+ }
887
+ md += '\n';
888
+ }
889
+ return md;
890
+ }
891
+ case 'technical': {
892
+ let md = header + '## Technical Audit\n\n| URL | Status | Words | Load ms | Canonical | OG | Schema | Robots | Mobile |\n|-----|--------|-------|---------|-----------|-----|--------|--------|--------|\n';
893
+ for (const r of data) {
894
+ md += `| ${r.url} | ${r.status_code} | ${r.word_count || 0} | ${r.load_ms || 0} | ${r.has_canonical ? 'Y' : 'N'} | ${r.has_og_tags ? 'Y' : 'N'} | ${r.has_schema ? 'Y' : 'N'} | ${r.has_robots ? 'Y' : 'N'} | ${r.is_mobile_ok ? 'Y' : 'N'} |\n`;
895
+ }
896
+ return md;
897
+ }
898
+ case 'keywords': {
899
+ let md = header + '## Keyword Matrix\n\n| Keyword | Domain | Role | Location | Frequency |\n|---------|--------|------|----------|-----------|\n';
900
+ for (const r of data.slice(0, 500)) {
901
+ md += `| ${r.keyword} | ${r.domain} | ${r.role} | ${r.location || ''} | ${r.freq} |\n`;
902
+ }
903
+ if (data.length > 500) md += `\n_...and ${data.length - 500} more rows._\n`;
904
+ return md;
905
+ }
906
+ case 'pages': {
907
+ let md = header + '## Crawled Pages\n\n| URL | Status | Words | Title | Domain | Role |\n|-----|--------|-------|-------|--------|------|\n';
908
+ for (const r of data) {
909
+ md += `| ${r.url} | ${r.status_code} | ${r.word_count || 0} | ${(r.title || '').slice(0, 50)} | ${r.domain} | ${r.role} |\n`;
910
+ }
911
+ return md;
912
+ }
913
+ case 'watch': {
914
+ const snap = data.snapshot || {};
915
+ const events = data.events || [];
916
+ let md = header + `## Site Watch Snapshot\n\n- Health score: ${snap.health_score ?? 'N/A'}\n- Pages: ${snap.total_pages || 0}\n- Errors: ${snap.errors_count || 0} | Warnings: ${snap.warnings_count || 0} | Notices: ${snap.notices_count || 0}\n\n`;
917
+ if (events.length) {
918
+ md += '## Events\n\n| Type | Severity | URL | Details |\n|------|----------|-----|---------|\n';
919
+ for (const e of events) {
920
+ md += `| ${e.event_type} | ${e.severity} | ${e.url} | ${(e.details || '').slice(0, 80)} |\n`;
921
+ }
922
+ }
923
+ return md;
924
+ }
925
+ case 'schemas': {
926
+ let md = header + '## Schema Markup\n\n| Domain | URL | Type | Name | Rating | Price |\n|--------|-----|------|------|--------|-------|\n';
927
+ for (const r of data) {
928
+ md += `| ${r.domain} | ${r.url} | ${r.schema_type} | ${(r.name || '').slice(0, 40)} | ${r.rating || ''} | ${r.price ? r.currency + r.price : ''} |\n`;
929
+ }
930
+ return md;
931
+ }
932
+ case 'headings': {
933
+ let md = header + '## Heading Structure\n\n| Domain | URL | Level | Text |\n|--------|-----|-------|------|\n';
934
+ for (const r of data.slice(0, 1000)) {
935
+ md += `| ${r.domain} | ${r.url} | H${r.level} | ${(r.text || '').slice(0, 80)} |\n`;
936
+ }
937
+ if (data.length > 1000) md += `\n_...and ${data.length - 1000} more rows._\n`;
938
+ return md;
939
+ }
940
+ case 'links': {
941
+ let md = header + '## Internal Links\n\n| Source | Target | Anchor |\n|--------|--------|--------|\n';
942
+ for (const r of data.filter(l => l.is_internal).slice(0, 1000)) {
943
+ md += `| ${r.source_url} | ${r.target_url} | ${(r.anchor_text || '').slice(0, 50)} |\n`;
944
+ }
945
+ if (data.length > 1000) md += `\n_...and more rows._\n`;
946
+ return md;
947
+ }
948
+ default: {
949
+ return header + '```json\n' + JSON.stringify(data, null, 2).slice(0, 10000) + '\n```\n';
950
+ }
951
+ }
952
+ }
953
+
954
+ // ── Resolve sections: profile overrides section=all ──
955
+ const validProfiles = Object.keys(PROFILES);
956
+ if (profile && !validProfiles.includes(profile)) {
957
+ json(res, 400, { error: `Invalid profile. Allowed: ${validProfiles.join(', ')}` });
958
+ return;
959
+ }
960
+ const resolvedSections = profile
961
+ ? PROFILES[profile].sections
962
+ : (section === 'all' ? SECTIONS : [section]);
963
+
964
+ if (!profile && section !== 'all' && !SECTIONS.includes(section)) {
965
+ json(res, 400, { error: `Invalid section. Allowed: ${SECTIONS.join(', ')}, all` });
966
+ return;
967
+ }
968
+
969
+ // Helper: query + filter for profile
970
+ function getData(sec) {
971
+ const raw = querySection(sec);
972
+ return profile ? filterForProfile(sec, raw, profile) : raw;
973
+ }
974
+
975
+ function isEmpty(data) {
976
+ if (!data) return true;
977
+ if (Array.isArray(data)) return data.length === 0;
978
+ if (data.events) return data.events.length === 0;
979
+ return false;
980
+ }
981
+
982
+ const profileTag = profile ? `-${profile}` : '';
983
+ const profileLabel = profile ? PROFILES[profile].label : '';
984
+
985
+ if (format === 'zip') {
986
+ const entries = [];
987
+ for (const sec of resolvedSections) {
988
+ const data = getData(sec);
989
+ if (isEmpty(data)) continue; // skip empty sections
990
+ const baseName = `${project}${profileTag}-${sec}-${dateStr}`;
991
+ entries.push({ name: `${baseName}.json`, content: JSON.stringify(data, null, 2) });
992
+ entries.push({ name: `${baseName}.md`, content: toMarkdown(sec, data, project) });
993
+ const csv = toCSV(data);
994
+ if (csv) entries.push({ name: `${baseName}.csv`, content: csv });
995
+ }
996
+ if (!entries.length) {
997
+ json(res, 200, { message: 'No actionable data to export.' });
998
+ return;
999
+ }
1000
+ const zipBuf = createZip(entries);
1001
+ const zipName = profile
1002
+ ? `${project}-${profile}-export-${dateStr}.zip`
1003
+ : (section === 'all' ? `${project}-full-export-${dateStr}.zip` : `${project}-${section}-${dateStr}.zip`);
1004
+ res.writeHead(200, {
1005
+ 'Content-Type': 'application/zip',
1006
+ 'Content-Disposition': `attachment; filename="${zipName}"`,
1007
+ 'Content-Length': zipBuf.length,
1008
+ });
1009
+ res.end(zipBuf);
1010
+ } else if (format === 'json') {
1011
+ if (profile) {
1012
+ // Profile JSON: merged object with all profile sections
1013
+ const result = { profile: profileLabel, project, date: dateStr, sections: {} };
1014
+ for (const sec of resolvedSections) {
1015
+ const data = getData(sec);
1016
+ if (!isEmpty(data)) result.sections[sec] = data;
1017
+ }
1018
+ const fileName = `${project}-${profile}-${dateStr}.json`;
1019
+ res.writeHead(200, {
1020
+ 'Content-Type': 'application/json',
1021
+ 'Content-Disposition': `attachment; filename="${fileName}"`,
1022
+ });
1023
+ res.end(JSON.stringify(result, null, 2));
1024
+ } else {
1025
+ const data = getData(resolvedSections[0]);
1026
+ const fileName = `${project}-${resolvedSections[0]}-${dateStr}.json`;
1027
+ res.writeHead(200, {
1028
+ 'Content-Type': 'application/json',
1029
+ 'Content-Disposition': `attachment; filename="${fileName}"`,
1030
+ });
1031
+ res.end(JSON.stringify(data, null, 2));
1032
+ }
1033
+ } else if (format === 'csv') {
1034
+ if (profile) {
1035
+ // Profile CSV: concatenate sections with headers
1036
+ let csv = '';
1037
+ for (const sec of resolvedSections) {
1038
+ const data = getData(sec);
1039
+ const secCsv = toCSV(data);
1040
+ if (secCsv) csv += `# ${sec}\n${secCsv}\n\n`;
1041
+ }
1042
+ const fileName = `${project}-${profile}-${dateStr}.csv`;
1043
+ res.writeHead(200, {
1044
+ 'Content-Type': 'text/csv; charset=utf-8',
1045
+ 'Content-Disposition': `attachment; filename="${fileName}"`,
1046
+ });
1047
+ res.end(csv || 'No actionable data.');
1048
+ } else {
1049
+ const data = getData(resolvedSections[0]);
1050
+ const fileName = `${project}-${resolvedSections[0]}-${dateStr}.csv`;
1051
+ res.writeHead(200, {
1052
+ 'Content-Type': 'text/csv; charset=utf-8',
1053
+ 'Content-Disposition': `attachment; filename="${fileName}"`,
1054
+ });
1055
+ res.end(toCSV(data));
1056
+ }
1057
+ } else if (format === 'md') {
1058
+ if (profile) {
1059
+ // Profile Markdown: combined report
1060
+ let md = `# SEO Intel — ${profileLabel} Report\n\n- Project: ${project}\n- Date: ${dateStr}\n- Profile: ${profileLabel}\n\n`;
1061
+ for (const sec of resolvedSections) {
1062
+ const data = getData(sec);
1063
+ if (isEmpty(data)) continue;
1064
+ md += toMarkdown(sec, data, project).replace(/^# .+\n\n- Project:.+\n- Date:.+\n\n/, ''); // strip per-section header
1065
+ }
1066
+ if (md.split('\n').length < 8) md += '_No actionable data found. Run crawl + extract + analyze first._\n';
1067
+ const fileName = `${project}-${profile}-${dateStr}.md`;
1068
+ res.writeHead(200, {
1069
+ 'Content-Type': 'text/markdown; charset=utf-8',
1070
+ 'Content-Disposition': `attachment; filename="${fileName}"`,
1071
+ });
1072
+ res.end(md);
1073
+ } else {
1074
+ const data = getData(resolvedSections[0]);
1075
+ const fileName = `${project}-${resolvedSections[0]}-${dateStr}.md`;
1076
+ res.writeHead(200, {
1077
+ 'Content-Type': 'text/markdown; charset=utf-8',
1078
+ 'Content-Disposition': `attachment; filename="${fileName}"`,
1079
+ });
1080
+ res.end(toMarkdown(resolvedSections[0], data, project));
1081
+ }
1082
+ } else {
1083
+ json(res, 400, { error: 'Invalid format. Allowed: json, csv, md, zip' });
1084
+ }
1085
+ } catch (e) {
1086
+ console.error('[export/download]', e);
1087
+ json(res, 500, { error: e.message });
1088
+ }
1089
+ return;
1090
+ }
1091
+
589
1092
  // ─── API: SSE Terminal — stream command output ───
590
1093
  if (req.method === 'GET' && path === '/api/terminal') {
591
1094
  const params = url.searchParams;
@@ -410,8 +410,17 @@ async function handleEnv(req, res) {
410
410
  return;
411
411
  }
412
412
 
413
- const result = updateEnvForSetup(keys);
414
- jsonResponse(res, { success: true, path: result.path });
413
+ // saveModelsModule sends raw env var names (OLLAMA_MODEL, ANALYSIS_PROVIDER, etc.)
414
+ // while updateEnvForSetup expects camelCase. Write raw env vars directly.
415
+ for (const [key, value] of Object.entries(keys)) {
416
+ if (/^[A-Z_]+$/.test(key) && value) {
417
+ writeEnvKey(key, String(value));
418
+ process.env[key] = String(value);
419
+ }
420
+ }
421
+
422
+ const envPath = join(ROOT, '.env');
423
+ jsonResponse(res, { success: true, path: envPath });
415
424
  } catch (err) {
416
425
  jsonResponse(res, { error: err.message }, 500);
417
426
  }
package/setup/wizard.html CHANGED
@@ -3152,6 +3152,10 @@ input::placeholder {
3152
3152
  if (state.selectedAnalysisProvider && state.selectedAnalysisModel) {
3153
3153
  payload.ANALYSIS_PROVIDER = state.selectedAnalysisProvider;
3154
3154
  payload.ANALYSIS_MODEL = state.selectedAnalysisModel;
3155
+ } else if (state.selectedAnalysis) {
3156
+ // Local Ollama analysis model — provider/model not set separately
3157
+ payload.ANALYSIS_PROVIDER = 'ollama';
3158
+ payload.ANALYSIS_MODEL = state.selectedAnalysis;
3155
3159
  }
3156
3160
 
3157
3161
  const cloudInput = document.getElementById('cloudApiKeyInput');