seo-intel 1.5.2 → 1.5.21

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.
@@ -33,6 +33,7 @@ function cardExportHtml(section, project) {
33
33
  <button data-fmt="json"><i class="fa-solid fa-code"></i> JSON</button>
34
34
  <button data-fmt="csv"><i class="fa-solid fa-table"></i> CSV</button>
35
35
  <button data-fmt="zip"><i class="fa-solid fa-file-zipper"></i> ZIP (all)</button>
36
+ <label style="display:flex;align-items:center;gap:5px;padding:4px 10px;font-size:0.6rem;color:var(--accent-gold);cursor:pointer;border-top:1px solid var(--border-subtle);margin-top:2px;padding-top:6px;"><input type="checkbox" class="card-ai-toggle" style="accent-color:var(--accent-gold);" /> <i class="fa-solid fa-wand-magic-sparkles"></i> AI Smart</label>
36
37
  </div>
37
38
  </div>`;
38
39
  }
@@ -104,7 +105,7 @@ export function gatherProjectData(db, project, config) {
104
105
  const performanceBubbles = getPerformanceBubbleData(db, project);
105
106
  const headingFlow = getHeadingFlowData(db, project, config);
106
107
  const territoryTreemap = getTerritoryTreemapData(db, project, config);
107
- const topicClusters = getTopicClusterData(project); // from topic-cluster-mapper output
108
+ const topicClusters = getTopicClusterData(db, project); // auto-generates from DB if no file exists
108
109
  const linkDna = getLinkDnaData(db, project, config);
109
110
  const linkRadarPulse = getLinkRadarPulseData(db, project, config);
110
111
 
@@ -1648,15 +1649,16 @@ function buildHtmlTemplate(data, opts = {}) {
1648
1649
  cursor: pointer;
1649
1650
  }
1650
1651
  .export-viewer {
1651
- flex: 1;
1652
- padding: 12px;
1652
+ padding: 12px 16px;
1653
1653
  font-family: 'SF Mono', 'Fira Code', monospace;
1654
1654
  font-size: 0.66rem;
1655
1655
  line-height: 1.7;
1656
1656
  color: var(--text-muted);
1657
1657
  overflow-y: auto;
1658
- max-height: 400px;
1658
+ min-height: 60px;
1659
+ max-height: 600px;
1659
1660
  }
1661
+ .export-viewer:empty, .export-viewer:has(> div:only-child) { max-height: 80px; }
1660
1662
  .export-viewer h1, .export-viewer h2, .export-viewer h3 { color: var(--text-primary); margin: 12px 0 6px; font-family: var(--font-display); font-size: 0.8rem; }
1661
1663
  .export-viewer h2 { font-size: 0.75rem; }
1662
1664
  .export-viewer h3 { font-size: 0.7rem; }
@@ -1673,6 +1675,62 @@ function buildHtmlTemplate(data, opts = {}) {
1673
1675
 
1674
1676
  /* Action exports integrated into terminal panel — CSS cleaned up */
1675
1677
 
1678
+ /* ── AI Smart Export Modal ── */
1679
+ .ai-export-overlay {
1680
+ position: fixed; inset: 0; z-index: 9999;
1681
+ background: rgba(0,0,0,0.85); backdrop-filter: blur(12px);
1682
+ display: flex; align-items: center; justify-content: center;
1683
+ opacity: 0; pointer-events: none; transition: opacity 0.4s ease;
1684
+ }
1685
+ .ai-export-overlay.active { opacity: 1; pointer-events: all; }
1686
+ .ai-export-card {
1687
+ position: relative; z-index: 2;
1688
+ background: rgba(18,18,18,0.85); border: 1px solid rgba(212,175,55,0.2);
1689
+ border-radius: 16px; padding: 32px 40px 28px; text-align: center;
1690
+ box-shadow: 0 0 60px rgba(212,175,55,0.08), 0 24px 48px rgba(0,0,0,0.5);
1691
+ min-width: 340px; max-width: 420px;
1692
+ }
1693
+ .ai-export-card h3 {
1694
+ font-family: var(--font-display); font-size: 1.1rem; color: var(--accent-gold);
1695
+ margin: 0 0 4px; letter-spacing: -0.02em;
1696
+ }
1697
+ .ai-export-card .ai-subtitle {
1698
+ font-size: 0.68rem; color: var(--text-muted); margin-bottom: 24px;
1699
+ }
1700
+ .ai-export-status {
1701
+ font-size: 0.72rem; color: var(--text-secondary); margin-bottom: 16px;
1702
+ min-height: 1.2em;
1703
+ }
1704
+ .ai-export-status i { color: var(--accent-gold); margin-right: 6px; }
1705
+ .ai-progress-track {
1706
+ width: 100%; height: 4px; background: rgba(255,255,255,0.06);
1707
+ border-radius: 4px; overflow: hidden; margin-bottom: 20px;
1708
+ position: relative;
1709
+ }
1710
+ .ai-progress-bar {
1711
+ height: 100%; width: 0%; border-radius: 4px;
1712
+ background: linear-gradient(90deg, var(--accent-gold), #f5c842, var(--accent-gold));
1713
+ background-size: 200% 100%;
1714
+ animation: ai-shimmer 1.5s ease infinite;
1715
+ transition: width 0.6s cubic-bezier(0.4, 0, 0.2, 1);
1716
+ }
1717
+ @keyframes ai-shimmer {
1718
+ 0% { background-position: 200% 0; }
1719
+ 100% { background-position: -200% 0; }
1720
+ }
1721
+ .ai-progress-pct {
1722
+ font-size: 0.6rem; color: var(--text-muted); font-family: 'SF Mono', monospace;
1723
+ margin-top: -14px; margin-bottom: 12px; text-align: right;
1724
+ }
1725
+ .ai-export-cancel {
1726
+ font-size: 0.62rem; color: var(--text-muted); background: none; border: 1px solid var(--border-subtle);
1727
+ padding: 4px 14px; border-radius: var(--radius); cursor: pointer; transition: all 0.2s;
1728
+ }
1729
+ .ai-export-cancel:hover { color: var(--text-primary); border-color: var(--text-muted); }
1730
+ #aiSwarmCanvas {
1731
+ position: absolute; inset: 0; z-index: 1; pointer-events: none; border-radius: 0;
1732
+ }
1733
+
1676
1734
  </style>
1677
1735
  </head>`;
1678
1736
 
@@ -2194,7 +2252,8 @@ function buildHtmlTemplate(data, opts = {}) {
2194
2252
  <div class="draft-menu" id="draftMenu${suffix}">
2195
2253
  <div class="draft-menu-section">Type</div>
2196
2254
  <label class="draft-option"><input type="radio" name="draftType${suffix}" value="blog" checked /> <i class="fa-solid fa-blog"></i> Blog Post</label>
2197
- <label class="draft-option"><input type="radio" name="draftType${suffix}" value="docs" disabled /> <i class="fa-solid fa-book"></i> Documentation <span style="font-size:0.55rem;opacity:0.4;margin-left:4px;">soon</span></label>
2255
+ <label class="draft-option"><input type="radio" name="draftType${suffix}" value="docs" /> <i class="fa-solid fa-book"></i> Documentation</label>
2256
+ <label class="draft-option"><input type="radio" name="draftType${suffix}" value="social" /> <i class="fa-solid fa-share-nodes"></i> Social Media</label>
2198
2257
  <div class="draft-menu-section" style="margin-top:8px;">Topic <span style="font-size:0.55rem;opacity:0.4;">(optional)</span></div>
2199
2258
  <input type="text" id="draftTopic${suffix}" class="draft-topic-input" placeholder="e.g. solana rpc, site speed..." />
2200
2259
  <div class="draft-menu-section" style="margin-top:8px;">Language</div>
@@ -2221,23 +2280,13 @@ function buildHtmlTemplate(data, opts = {}) {
2221
2280
  <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
2281
  <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
2282
  </div>
2283
+ <label class="draft-option" style="margin-top:8px;border-color:var(--accent-gold);background:rgba(212,175,55,0.04);"><input type="checkbox" name="aiExport${suffix}" value="1" /> <i class="fa-solid fa-wand-magic-sparkles" style="color:var(--accent-gold);"></i> AI Smart Export</label>
2284
+ <div style="font-size:0.55rem;color:var(--text-muted);padding:2px 4px;">Fills gaps, adds priorities & action tips via AI</div>
2224
2285
  <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
2286
  </div>
2226
2287
  </div>
2227
2288
  <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
2289
  </div>
2229
- <div style="position:relative;">
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;">
2231
- <i class="fa-solid fa-check" style="margin-right:4px;"></i><span></span>
2232
- </div>
2233
- <button id="exportExpand${suffix}" class="export-expand-btn" title="Expand viewer"><i class="fa-solid fa-expand"></i></button>
2234
- <div id="exportViewer${suffix}" class="export-viewer">
2235
- <div style="color:#444;padding:20px 0;text-align:center;">
2236
- <i class="fa-solid fa-file-export" style="font-size:1.2rem;margin-bottom:8px;display:block;"></i>
2237
- Click an export to generate an<br/>implementation-ready action brief.
2238
- </div>
2239
- </div>
2240
- </div>
2241
2290
  ` : `
2242
2291
  <div style="padding:20px 14px;text-align:center;">
2243
2292
  <i class="fa-solid fa-lock" style="font-size:1rem;color:var(--accent-gold);margin-bottom:8px;display:block;"></i>
@@ -2247,6 +2296,22 @@ function buildHtmlTemplate(data, opts = {}) {
2247
2296
  `}
2248
2297
  </div>
2249
2298
  </div>
2299
+ ${pro ? `
2300
+ <div class="viewer-row" style="max-width:var(--max-width);margin:0 auto;">
2301
+ <div style="position:relative;background:#0e0e0e;border:1px solid var(--border-card);border-radius:0 0 var(--radius) var(--radius);border-top:none;">
2302
+ <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;">
2303
+ <i class="fa-solid fa-check" style="margin-right:4px;"></i><span></span>
2304
+ </div>
2305
+ <button id="exportExpand${suffix}" class="export-expand-btn" title="Expand viewer"><i class="fa-solid fa-expand"></i></button>
2306
+ <div id="exportViewer${suffix}" class="export-viewer">
2307
+ <div style="color:#444;padding:20px 0;text-align:center;">
2308
+ <i class="fa-solid fa-file-export" style="font-size:1.2rem;margin-bottom:8px;display:block;"></i>
2309
+ Click an export or generate a draft — output appears here.
2310
+ </div>
2311
+ </div>
2312
+ </div>
2313
+ </div>
2314
+ ` : ''}
2250
2315
 
2251
2316
  <script>
2252
2317
  (function() {
@@ -2423,6 +2488,7 @@ function buildHtmlTemplate(data, opts = {}) {
2423
2488
  if (msg.type === 'stdout') mdContent += msg.data + '\\n';
2424
2489
  else if (msg.type === 'stderr') mdContent += msg.data + '\\n';
2425
2490
  else if (msg.type === 'exit') {
2491
+ var exitCode = msg.data && msg.data.code;
2426
2492
  running = false;
2427
2493
  status.textContent = 'done';
2428
2494
  status.style.color = 'var(--color-success)';
@@ -2445,11 +2511,17 @@ function buildHtmlTemplate(data, opts = {}) {
2445
2511
  }
2446
2512
  // Show save status
2447
2513
  var saveEl = document.getElementById('exportSaveStatus' + suffix);
2448
- if (saveEl && code === 0) {
2449
- var slugName = cmd === 'suggest-usecases' ? 'suggestions' : (scope || 'all');
2514
+ if (saveEl && exitCode === 0) {
2450
2515
  var dateStr = new Date().toISOString().slice(0, 10);
2516
+ var savedName;
2517
+ if (cmd === 'aeo') {
2518
+ savedName = proj + '-aeo-' + dateStr + '.md';
2519
+ } else {
2520
+ var slugName = cmd === 'suggest-usecases' ? 'suggestions' : (scope || 'all');
2521
+ savedName = proj + '-' + slugName + '-' + dateStr + '.md';
2522
+ }
2451
2523
  saveEl.style.display = 'block';
2452
- saveEl.querySelector('span').textContent = 'Saved → reports/' + proj + '-' + slugName + '-' + dateStr + '.md';
2524
+ saveEl.querySelector('span').textContent = 'Saved → reports/' + savedName;
2453
2525
  }
2454
2526
  }
2455
2527
  } catch (_) {}
@@ -2464,20 +2536,22 @@ function buildHtmlTemplate(data, opts = {}) {
2464
2536
  });
2465
2537
  });
2466
2538
 
2467
- // Draft dropdown
2539
+ // Draft dropdown — use capture phase to match card-export handler
2468
2540
  var draftTrigger = document.getElementById('draftTrigger' + suffix);
2469
2541
  var draftMenu = document.getElementById('draftMenu' + suffix);
2470
2542
  var draftGenerate = document.getElementById('draftGenerate' + suffix);
2471
2543
  if (draftTrigger && draftMenu) {
2472
2544
  draftTrigger.addEventListener('click', function(e) {
2473
- e.stopPropagation();
2545
+ e.stopImmediatePropagation();
2546
+ // Close other menus
2547
+ document.querySelectorAll('.draft-menu.open').forEach(function(m) { if (m !== draftMenu) m.classList.remove('open'); });
2548
+ document.querySelectorAll('.profile-export-menu').forEach(function(m) { m.style.display = 'none'; });
2474
2549
  draftMenu.classList.toggle('open');
2475
- });
2476
- document.addEventListener('click', function(e) {
2477
- if (!draftMenu.contains(e.target) && e.target !== draftTrigger) {
2478
- draftMenu.classList.remove('open');
2479
- }
2480
- });
2550
+ }, true);
2551
+ // Clicks inside the menu should not close it
2552
+ draftMenu.addEventListener('click', function(e) {
2553
+ e.stopImmediatePropagation();
2554
+ }, true);
2481
2555
  }
2482
2556
  if (draftGenerate) {
2483
2557
  draftGenerate.addEventListener('click', function() {
@@ -2490,29 +2564,28 @@ function buildHtmlTemplate(data, opts = {}) {
2490
2564
  var lang = langEl ? langEl.value : 'en';
2491
2565
  var topic = topicEl ? topicEl.value.trim() : '';
2492
2566
 
2493
- if (draftType !== 'blog') return; // docs not yet supported
2494
-
2495
2567
  draftMenu.classList.remove('open');
2496
2568
 
2497
- // Run blog-draft via terminal SSE
2498
- var extra = { lang: lang };
2499
- if (topic) extra.topic = topic;
2500
-
2569
+ // Run blog-draft via terminal SSE — type is passed so prompt builder can adapt
2501
2570
  var params = new URLSearchParams({ command: 'blog-draft' });
2502
2571
  params.set('project', proj);
2503
2572
  params.set('lang', lang);
2573
+ params.set('type', draftType);
2504
2574
  params.set('save', '1');
2505
2575
  if (topic) params.set('topic', topic);
2506
2576
 
2577
+ var typeLabels = { blog: 'blog post', docs: 'documentation', social: 'social media post' };
2578
+ var typeLabel = typeLabels[draftType] || draftType;
2579
+
2507
2580
  if (!isServed) {
2508
- var cmd = 'seo-intel blog-draft ' + proj + (topic ? ' --topic "' + topic + '"' : '') + ' --lang ' + lang + ' --save';
2581
+ var cmd = 'seo-intel blog-draft ' + proj + (topic ? ' --topic "' + topic + '"' : '') + ' --lang ' + lang + ' --type ' + draftType + ' --save';
2509
2582
  if (exportViewer) {
2510
2583
  exportViewer.innerHTML = '<div style="color:var(--color-danger);padding:12px;">Not connected. Run in terminal:<br/><code style="color:var(--accent-gold);">' + cmd + '</code></div>';
2511
2584
  }
2512
2585
  return;
2513
2586
  }
2514
2587
 
2515
- if (exportViewer) exportViewer.innerHTML = '<div style="color:var(--text-muted);padding:20px;text-align:center;"><i class="fa-solid fa-wand-magic-sparkles fa-spin" style="margin-right:6px;color:var(--accent-gold);"></i>Generating AEO draft...</div>';
2588
+ if (exportViewer) exportViewer.innerHTML = '<div style="color:var(--text-muted);padding:20px;text-align:center;"><i class="fa-solid fa-wand-magic-sparkles fa-spin" style="margin-right:6px;color:var(--accent-gold);"></i>Generating ' + typeLabel + ' draft...</div>';
2516
2589
 
2517
2590
  var mdContent = '';
2518
2591
  var es = new EventSource('/api/terminal?' + params.toString());
@@ -2593,6 +2666,16 @@ function buildHtmlTemplate(data, opts = {}) {
2593
2666
  document.addEventListener('click', function(e) {
2594
2667
  var btn = e.target.closest('.card-export-btn');
2595
2668
  var fmtBtn = e.target.closest('[data-fmt]');
2669
+ // Clicks on AI toggle checkbox or its label inside card-export — don't close dropdown
2670
+ var aiLabel = e.target.closest('label');
2671
+ if (aiLabel && aiLabel.querySelector('.card-ai-toggle')) {
2672
+ e.stopImmediatePropagation();
2673
+ return;
2674
+ }
2675
+ if (e.target.classList && e.target.classList.contains('card-ai-toggle')) {
2676
+ e.stopImmediatePropagation();
2677
+ return;
2678
+ }
2596
2679
  if (btn) {
2597
2680
  var wrap = btn.closest('.card-export');
2598
2681
  if (wrap) {
@@ -2611,8 +2694,14 @@ function buildHtmlTemplate(data, opts = {}) {
2611
2694
  var sec = wrap2.getAttribute('data-section');
2612
2695
  var proj2 = wrap2.getAttribute('data-project');
2613
2696
  var fmt = fmtBtn.getAttribute('data-fmt');
2697
+ var aiToggle = wrap2.querySelector('.card-ai-toggle');
2698
+ var useAi = aiToggle && aiToggle.checked;
2699
+ var exportUrl = '/api/export/download?project=' + encodeURIComponent(proj2) + '&section=' + encodeURIComponent(sec) + '&format=' + encodeURIComponent(fmt) + (useAi ? '&ai=true' : '');
2614
2700
  if (window.location.protocol.startsWith('http')) {
2615
- window.location = '/api/export/download?project=' + encodeURIComponent(proj2) + '&section=' + encodeURIComponent(sec) + '&format=' + encodeURIComponent(fmt);
2701
+ if (useAi) {
2702
+ var loaderUrl = '/ai-loader?url=' + encodeURIComponent(exportUrl);
2703
+ window.open(loaderUrl, 'ai-export', 'width=600,height=480,menubar=no,toolbar=no,status=no');
2704
+ } else { window.location = exportUrl; }
2616
2705
  }
2617
2706
  return;
2618
2707
  }
@@ -2647,13 +2736,24 @@ function buildHtmlTemplate(data, opts = {}) {
2647
2736
  var projP = profDl.getAttribute('data-project');
2648
2737
  var fmtVal = picker2.querySelector('input[name^="exportFmt"]:checked');
2649
2738
  var fmt2 = fmtVal ? fmtVal.value : 'md';
2739
+ var aiCheck = picker2.querySelector('input[name^="aiExport"]');
2740
+ var useAi2 = aiCheck && aiCheck.checked;
2650
2741
  picker2.querySelector('.profile-export-menu').style.display = 'none';
2742
+ var exportUrl2 = '/api/export/download?project=' + encodeURIComponent(projP) + '&format=' + encodeURIComponent(fmt2) + (useAi2 ? '&ai=true' : '');
2651
2743
  if (window.location.protocol.startsWith('http')) {
2652
- window.location = '/api/export/download?project=' + encodeURIComponent(projP) + '&format=' + encodeURIComponent(fmt2);
2744
+ if (useAi2) {
2745
+ var loaderUrl2 = '/ai-loader?url=' + encodeURIComponent(exportUrl2);
2746
+ window.open(loaderUrl2, 'ai-export', 'width=600,height=480,menubar=no,toolbar=no,status=no');
2747
+ } else { window.location = exportUrl2; }
2653
2748
  }
2654
2749
  return;
2655
2750
  }
2656
2751
  }
2752
+ // Clicks inside an open profile-export-menu (radio buttons etc) — don't close
2753
+ if (e.target.closest('.profile-export-menu')) {
2754
+ e.stopImmediatePropagation();
2755
+ return;
2756
+ }
2657
2757
  // Outside click — close all open dropdowns
2658
2758
  document.querySelectorAll('.card-export.open').forEach(function(el) { el.classList.remove('open'); });
2659
2759
  document.querySelectorAll('.profile-export-menu').forEach(function(m) { m.style.display = 'none'; });
@@ -4750,8 +4850,214 @@ function buildHtmlTemplate(data, opts = {}) {
4750
4850
 
4751
4851
  </script>`;
4752
4852
 
4853
+ // ── AI Smart Export Modal ──
4854
+ const aiModalHtml = `
4855
+ <div class="ai-export-overlay" id="aiExportOverlay">
4856
+ <div id="aiSwarmCanvas"></div>
4857
+ <div class="ai-export-card">
4858
+ <h3><i class="fa-solid fa-wand-magic-sparkles"></i> AI Smart Export</h3>
4859
+ <p class="ai-subtitle">Enriching your report with AI intelligence</p>
4860
+ <div class="ai-export-status" id="aiExportStatus"><i class="fa-solid fa-brain fa-beat-fade"></i> Initializing...</div>
4861
+ <div class="ai-progress-track"><div class="ai-progress-bar" id="aiProgressBar"></div></div>
4862
+ <div class="ai-progress-pct" id="aiProgressPct">0%</div>
4863
+ <button class="ai-export-cancel" id="aiExportCancel">Cancel</button>
4864
+ </div>
4865
+ </div>
4866
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
4867
+ <script>
4868
+ (function(){
4869
+ // ── Swarm animation (compact, gold-themed) ──
4870
+ var overlay = document.getElementById('aiExportOverlay');
4871
+ var swarmEl = document.getElementById('aiSwarmCanvas');
4872
+ var swarmInited = false, swarmRaf = null, swarmRenderer, swarmScene, swarmCam;
4873
+
4874
+ function initSwarm() {
4875
+ if (swarmInited) return;
4876
+ swarmInited = true;
4877
+ var N = 300, sc = new THREE.Scene();
4878
+ sc.fog = new THREE.FogExp2(0x000000, 0.004);
4879
+ var cam = new THREE.PerspectiveCamera(60, swarmEl.clientWidth / Math.max(swarmEl.clientHeight, 1), 1, 800);
4880
+ cam.position.set(0, 0, 200);
4881
+ var r = new THREE.WebGLRenderer({ antialias: true, alpha: true });
4882
+ r.setSize(swarmEl.clientWidth, swarmEl.clientHeight);
4883
+ r.setPixelRatio(Math.min(devicePixelRatio, 1.5));
4884
+ r.setClearColor(0x000000, 0);
4885
+ swarmEl.appendChild(r.domElement);
4886
+ swarmRenderer = r; swarmScene = sc; swarmCam = cam;
4887
+
4888
+ // Dot texture
4889
+ var cv = document.createElement('canvas'); cv.width = cv.height = 64;
4890
+ var cx = cv.getContext('2d'), grd = cx.createRadialGradient(32,32,0,32,32,32);
4891
+ grd.addColorStop(0, 'rgba(255,255,255,1)');
4892
+ grd.addColorStop(0.3, 'rgba(212,175,55,0.9)');
4893
+ grd.addColorStop(1, 'rgba(212,175,55,0)');
4894
+ cx.fillStyle = grd; cx.fillRect(0,0,64,64);
4895
+ var tex = new THREE.CanvasTexture(cv);
4896
+
4897
+ // Particles — galaxy spiral
4898
+ var pos = new Float32Array(N*3), col = new Float32Array(N*3), szArr = new Float32Array(N);
4899
+ for (var i = 0; i < N; i++) {
4900
+ var t = i/N, ao = (i%4)*(Math.PI/2), rd = Math.pow(t,0.5)*100, a = t*Math.PI*5 + ao;
4901
+ pos[i*3] = Math.cos(a)*rd; pos[i*3+1] = (Math.random()-0.5)*12*(1-t); pos[i*3+2] = Math.sin(a)*rd;
4902
+ var isGold = Math.random() > 0.7;
4903
+ if (isGold) { col[i*3]=0.83; col[i*3+1]=0.69; col[i*3+2]=0.22; szArr[i]=3; }
4904
+ else { col[i*3]=0.38; col[i*3+1]=0.51; col[i*3+2]=0.96; szArr[i]=1.5; }
4905
+ }
4906
+ var geo = new THREE.BufferGeometry();
4907
+ geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
4908
+ geo.setAttribute('color', new THREE.BufferAttribute(col, 3));
4909
+ geo.setAttribute('size', new THREE.BufferAttribute(szArr, 1));
4910
+
4911
+ var vs = 'attribute float size; attribute vec3 color; varying vec3 vColor; void main(){ vColor=color; vec4 mv=modelViewMatrix*vec4(position,1.0); gl_PointSize=size*2.0*(200.0/-mv.z); gl_Position=projectionMatrix*mv; }';
4912
+ var fs = 'uniform sampler2D pointTexture; varying vec3 vColor; void main(){ vec4 tc=texture2D(pointTexture,gl_PointCoord); if(tc.a<0.1) discard; gl_FragColor=vec4(vColor*1.8,1.0)*tc; }';
4913
+ var mat = new THREE.ShaderMaterial({ uniforms:{ pointTexture:{value:tex} }, vertexShader:vs, fragmentShader:fs, blending:THREE.AdditiveBlending, depthTest:false, transparent:true });
4914
+ var pts = new THREE.Points(geo, mat);
4915
+ sc.add(pts);
4916
+
4917
+ // Connection lines
4918
+ var maxD = 28*28, lPos = new Float32Array(N*36), lGeo = new THREE.BufferGeometry();
4919
+ lGeo.setAttribute('position', new THREE.BufferAttribute(lPos, 3));
4920
+ var lMat = new THREE.LineBasicMaterial({ color: 0xd4af37, transparent:true, opacity:0.08, blending:THREE.AdditiveBlending });
4921
+ var lines = new THREE.LineSegments(lGeo, lMat); sc.add(lines);
4922
+ var vi=0, cnt=0;
4923
+ for (var i=0; i<N && cnt<N*4; i++) {
4924
+ for (var j=i+1; j<N && cnt<N*4; j++) {
4925
+ var dx=pos[i*3]-pos[j*3], dy=pos[i*3+1]-pos[j*3+1], dz=pos[i*3+2]-pos[j*3+2];
4926
+ if (dx*dx+dy*dy+dz*dz < maxD) {
4927
+ lPos[vi++]=pos[i*3]; lPos[vi++]=pos[i*3+1]; lPos[vi++]=pos[i*3+2];
4928
+ lPos[vi++]=pos[j*3]; lPos[vi++]=pos[j*3+1]; lPos[vi++]=pos[j*3+2];
4929
+ cnt++;
4930
+ }
4931
+ }
4932
+ }
4933
+ lGeo.setDrawRange(0, cnt*2); lGeo.attributes.position.needsUpdate = true;
4934
+
4935
+ function anim() {
4936
+ swarmRaf = requestAnimationFrame(anim);
4937
+ sc.rotation.y += 0.003; sc.rotation.x += 0.001;
4938
+ r.render(sc, cam);
4939
+ }
4940
+ anim();
4941
+ }
4942
+
4943
+ function stopSwarm() { if (swarmRaf) cancelAnimationFrame(swarmRaf); swarmRaf = null; }
4944
+ function startSwarm() { initSwarm(); if (!swarmRaf) { var sc=swarmScene, cam=swarmCam, r=swarmRenderer; (function a(){ swarmRaf=requestAnimationFrame(a); sc.rotation.y+=0.003; sc.rotation.x+=0.001; r.render(sc,cam); })(); } }
4945
+
4946
+ // Resize handler
4947
+ window.addEventListener('resize', function() {
4948
+ if (swarmRenderer && overlay.classList.contains('active')) {
4949
+ swarmCam.aspect = swarmEl.clientWidth / Math.max(swarmEl.clientHeight, 1);
4950
+ swarmCam.updateProjectionMatrix();
4951
+ swarmRenderer.setSize(swarmEl.clientWidth, swarmEl.clientHeight);
4952
+ }
4953
+ });
4954
+
4955
+ // ── Progress animation ──
4956
+ var STATUS_STEPS = [
4957
+ { at: 0, icon: 'fa-brain fa-beat-fade', text: 'Analyzing report structure...' },
4958
+ { at: 12, icon: 'fa-table-cells fa-fade', text: 'Filling empty table cells...' },
4959
+ { at: 30, icon: 'fa-diagram-project fa-beat-fade', text: 'Mapping keyword clusters...' },
4960
+ { at: 50, icon: 'fa-ranking-star fa-fade', text: 'Scoring priorities...' },
4961
+ { at: 70, icon: 'fa-list-check fa-beat-fade', text: 'Building action plan...' },
4962
+ { at: 88, icon: 'fa-file-export fa-fade', text: 'Finalizing export...' },
4963
+ ];
4964
+
4965
+ var progressTimer = null, currentProgress = 0, abortCtrl = null;
4966
+
4967
+ function animateProgress(targetPct, durationMs) {
4968
+ var start = currentProgress, startTime = Date.now();
4969
+ clearInterval(progressTimer);
4970
+ progressTimer = setInterval(function() {
4971
+ var elapsed = Date.now() - startTime;
4972
+ var t = Math.min(elapsed / durationMs, 1);
4973
+ // Ease-out cubic
4974
+ var eased = 1 - Math.pow(1 - t, 3);
4975
+ currentProgress = start + (targetPct - start) * eased;
4976
+ updateProgressUI(currentProgress);
4977
+ if (t >= 1) clearInterval(progressTimer);
4978
+ }, 50);
4979
+ }
4980
+
4981
+ function updateProgressUI(pct) {
4982
+ var bar = document.getElementById('aiProgressBar');
4983
+ var pctEl = document.getElementById('aiProgressPct');
4984
+ var statusEl = document.getElementById('aiExportStatus');
4985
+ if (bar) bar.style.width = pct + '%';
4986
+ if (pctEl) pctEl.textContent = Math.round(pct) + '%';
4987
+ // Update status text
4988
+ var step = STATUS_STEPS[0];
4989
+ for (var i = STATUS_STEPS.length - 1; i >= 0; i--) {
4990
+ if (pct >= STATUS_STEPS[i].at) { step = STATUS_STEPS[i]; break; }
4991
+ }
4992
+ if (statusEl) statusEl.innerHTML = '<i class="fa-solid ' + step.icon + '"></i> ' + step.text;
4993
+ }
4994
+
4995
+ function showAiModal() {
4996
+ overlay.classList.add('active');
4997
+ currentProgress = 0;
4998
+ updateProgressUI(0);
4999
+ startSwarm();
5000
+ // Animate to 92% over ~60s (slowing down toward end)
5001
+ animateProgress(25, 8000);
5002
+ setTimeout(function() { animateProgress(55, 15000); }, 8000);
5003
+ setTimeout(function() { animateProgress(78, 15000); }, 23000);
5004
+ setTimeout(function() { animateProgress(92, 25000); }, 38000);
5005
+ }
5006
+
5007
+ function hideAiModal() {
5008
+ clearInterval(progressTimer);
5009
+ stopSwarm();
5010
+ overlay.classList.remove('active');
5011
+ }
5012
+
5013
+ function finishAiModal() {
5014
+ clearInterval(progressTimer);
5015
+ currentProgress = 100;
5016
+ updateProgressUI(100);
5017
+ var statusEl = document.getElementById('aiExportStatus');
5018
+ if (statusEl) statusEl.innerHTML = '<i class="fa-solid fa-check" style="color:#50c878;"></i> Export ready!';
5019
+ setTimeout(hideAiModal, 800);
5020
+ }
5021
+
5022
+ // Cancel button
5023
+ document.getElementById('aiExportCancel').addEventListener('click', function() {
5024
+ if (abortCtrl) abortCtrl.abort();
5025
+ hideAiModal();
5026
+ });
5027
+
5028
+ // ── Intercept AI export downloads ──
5029
+ window._triggerAiExport = function(url) {
5030
+ abortCtrl = new AbortController();
5031
+ showAiModal();
5032
+ fetch(url, { signal: abortCtrl.signal })
5033
+ .then(function(resp) {
5034
+ if (!resp.ok) throw new Error('Export failed: ' + resp.status);
5035
+ var cd = resp.headers.get('content-disposition') || '';
5036
+ var m = cd.match(/filename="?([^"]+)"?/);
5037
+ var filename = m ? m[1] : 'export.md';
5038
+ return resp.blob().then(function(blob) { return { blob: blob, filename: filename }; });
5039
+ })
5040
+ .then(function(result) {
5041
+ finishAiModal();
5042
+ setTimeout(function() {
5043
+ var a = document.createElement('a');
5044
+ a.href = URL.createObjectURL(result.blob);
5045
+ a.download = result.filename;
5046
+ a.click();
5047
+ URL.revokeObjectURL(a.href);
5048
+ }, 900);
5049
+ })
5050
+ .catch(function(err) {
5051
+ if (err.name === 'AbortError') return;
5052
+ hideAiModal();
5053
+ alert('AI Smart Export failed: ' + err.message);
5054
+ });
5055
+ };
5056
+ })();
5057
+ </script>`;
5058
+
4753
5059
  // ── Compose full HTML ──
4754
- return headHtml + '\n<body>\n' + panelHtml + '\n' + scriptHtml + '\n</body>\n</html>';
5060
+ return headHtml + '\n<body>\n' + panelHtml + '\n' + scriptHtml + '\n' + aiModalHtml + '\n</body>\n</html>';
4755
5061
  }
4756
5062
 
4757
5063
  // ─── Multi-Project Dashboard Builder ──────────────────────────────────────────
@@ -6581,8 +6887,8 @@ function getTerritoryTreemapData(db, project, config) {
6581
6887
  });
6582
6888
  }
6583
6889
 
6584
- function getTopicClusterData(project) {
6585
- // Load from topic-cluster-mapper.js output — try project-specific file first, then generic
6890
+ function getTopicClusterData(db, project) {
6891
+ // Try pre-generated file first (from topic-cluster-mapper.js)
6586
6892
  const candidates = [
6587
6893
  join(__dirname, `topic-clusters-${project}.json`),
6588
6894
  join(__dirname, 'topic-clusters.json'),
@@ -6592,7 +6898,6 @@ function getTopicClusterData(project) {
6592
6898
  try {
6593
6899
  if (existsSync(path)) {
6594
6900
  const raw = JSON.parse(readFileSync(path, 'utf8'));
6595
- // Verify this data is for the right project (if it has a project field)
6596
6901
  if (raw.project && raw.project !== project) continue;
6597
6902
  const data = raw.dashboard_data || null;
6598
6903
  if (data) console.log(` 📊 Topic clusters loaded: ${data.length} clusters from ${path.split('/').pop()}`);
@@ -6603,8 +6908,149 @@ function getTopicClusterData(project) {
6603
6908
  }
6604
6909
  }
6605
6910
 
6606
- console.log(` ⚠️ No topic-clusters file found for project: ${project}`);
6607
- return null;
6911
+ // No file — auto-generate from DB (pure text scoring, no LLM)
6912
+ return generateTopicClustersFromDb(db, project);
6913
+ }
6914
+
6915
+ function generateTopicClustersFromDb(db, project) {
6916
+ const CLUSTERS = [
6917
+ { id: 'rpc', label: 'RPC & Node Infrastructure', seeds: ['rpc', 'node', 'endpoint', 'rpc node', 'rpc endpoint', 'json-rpc', 'jsonrpc', 'websocket', 'wss', 'connection', 'latency', 'uptime', 'reliability'] },
6918
+ { id: 'dex', label: 'DEX & Swap', seeds: ['dex', 'swap', 'quote', 'amm', 'liquidity', 'pool', 'routing', 'slippage', 'token swap', 'dex api', 'swap api', 'jupiter', 'raydium', 'orca', 'gasless'] },
6919
+ { id: 'data', label: 'Blockchain Data & Analytics', seeds: ['data', 'analytics', 'historical', 'indexer', 'index', 'stream', 'webhook', 'transaction data', 'on-chain', 'onchain', 'real-time', 'realtime', 'archive'] },
6920
+ { id: 'validator', label: 'Validator & Staking', seeds: ['validator', 'stake', 'staking', 'delegation', 'epoch', 'rewards', 'apy', 'sol staking', 'validator node'] },
6921
+ { id: 'api', label: 'API & Developer Tools', seeds: ['api', 'sdk', 'developer', 'documentation', 'quickstart', 'tutorial', 'integration', 'library', 'typescript', 'python', 'rust'] },
6922
+ { id: 'trading', label: 'Trading & DeFi', seeds: ['trading', 'defi', 'bot', 'arbitrage', 'mev', 'sniper', 'frontrun', 'backrun', 'sandwich', 'jito', 'bundle', 'profit'] },
6923
+ { id: 'pricing', label: 'Pricing & Plans', seeds: ['pricing', 'price', 'plan', 'tier', 'free', 'pro', 'enterprise', 'cost', 'credit', 'rate limit', 'quota'] },
6924
+ { id: 'solana_ecosystem', label: 'Solana Ecosystem', seeds: ['solana', 'spl', 'token', 'program', 'account', 'transaction', 'block', 'slot', 'lamport', 'nft', 'metaplex'] },
6925
+ { id: 'infrastructure', label: 'Infrastructure & Ops', seeds: ['infrastructure', 'bare metal', 'server', 'devops', 'monitoring', 'alerting', 'dashboard', 'status', 'sla', 'downtime'] },
6926
+ { id: 'education', label: 'Education & Learning', seeds: ['learn', 'guide', 'tutorial', 'how to', 'getting started', 'beginner', 'explained', 'what is', 'introduction', 'course'] },
6927
+ { id: 'ai', label: 'AI & Agents', seeds: ['ai', 'agent', 'skill', 'llm', 'gpt', 'claude', 'langchain', 'autonomous', 'agentic', 'artificial intelligence', 'machine learning'] },
6928
+ { id: 'comparison', label: 'Comparisons & Alternatives', seeds: ['vs', 'versus', 'alternative', 'compare', 'comparison', 'better than', 'switch', 'migrate'] },
6929
+ ];
6930
+
6931
+ const WEIGHTS = { title: 5, h1: 4, h2: 3, meta: 2, body: 1 };
6932
+
6933
+ // Check if project has crawl data
6934
+ const pageCount = db.prepare(`
6935
+ SELECT COUNT(*) as cnt FROM pages p
6936
+ JOIN domains d ON p.domain_id = d.id
6937
+ WHERE d.project = ? AND p.status_code = 200
6938
+ `).get(project)?.cnt || 0;
6939
+
6940
+ if (pageCount === 0) return null;
6941
+
6942
+ // Load pages
6943
+ const pages = db.prepare(`
6944
+ SELECT p.id, p.url, p.word_count, p.click_depth,
6945
+ d.domain, d.role,
6946
+ e.title, e.meta_desc, e.h1
6947
+ FROM pages p
6948
+ JOIN domains d ON p.domain_id = d.id
6949
+ LEFT JOIN extractions e ON e.page_id = p.id
6950
+ WHERE d.project = ? AND p.status_code = 200
6951
+ ORDER BY d.domain, p.click_depth
6952
+ `).all(project);
6953
+
6954
+ // Load keywords per page
6955
+ const keywordsByPage = new Map();
6956
+ const allKeywords = db.prepare(`
6957
+ SELECT k.page_id, k.keyword, k.location
6958
+ FROM keywords k
6959
+ JOIN pages p ON k.page_id = p.id
6960
+ JOIN domains d ON p.domain_id = d.id
6961
+ WHERE d.project = ?
6962
+ `).all(project);
6963
+ for (const row of allKeywords) {
6964
+ if (!keywordsByPage.has(row.page_id)) keywordsByPage.set(row.page_id, []);
6965
+ keywordsByPage.get(row.page_id).push(row);
6966
+ }
6967
+
6968
+ // Load headings per page
6969
+ const headingsByPage = new Map();
6970
+ const allHeadings = db.prepare(`
6971
+ SELECT h.page_id, h.text
6972
+ FROM headings h
6973
+ JOIN pages p ON h.page_id = p.id
6974
+ JOIN domains d ON p.domain_id = d.id
6975
+ WHERE d.project = ? AND h.level <= 3
6976
+ `).all(project);
6977
+ for (const row of allHeadings) {
6978
+ if (!headingsByPage.has(row.page_id)) headingsByPage.set(row.page_id, []);
6979
+ headingsByPage.get(row.page_id).push(row.text);
6980
+ }
6981
+
6982
+ // Score each page
6983
+ const clusterStats = {};
6984
+ for (const c of CLUSTERS) {
6985
+ clusterStats[c.id] = { label: c.label, pages: [], byDomain: {}, targetPages: [], competitorPages: [] };
6986
+ }
6987
+
6988
+ for (const page of pages) {
6989
+ const keywords = keywordsByPage.get(page.id) || [];
6990
+ const headings = headingsByPage.get(page.id) || [];
6991
+ const scores = {};
6992
+ for (const c of CLUSTERS) scores[c.id] = 0;
6993
+
6994
+ // Score from keywords
6995
+ for (const { keyword, location } of keywords) {
6996
+ const kw = keyword.toLowerCase().trim();
6997
+ const w = WEIGHTS[location] || 1;
6998
+ for (const c of CLUSTERS) {
6999
+ for (const seed of c.seeds) {
7000
+ if (kw.includes(seed) || seed.includes(kw)) { scores[c.id] += w; break; }
7001
+ }
7002
+ }
7003
+ }
7004
+
7005
+ // Score from headings
7006
+ for (const text of headings) {
7007
+ const t = text.toLowerCase();
7008
+ for (const c of CLUSTERS) {
7009
+ for (const seed of c.seeds) {
7010
+ if (t.includes(seed)) { scores[c.id] += 3; break; }
7011
+ }
7012
+ }
7013
+ }
7014
+
7015
+ // Assign to primary cluster
7016
+ const primary = Object.entries(scores).sort((a, b) => b[1] - a[1]).filter(([, s]) => s > 0)[0];
7017
+ if (primary) {
7018
+ const cs = clusterStats[primary[0]];
7019
+ cs.pages.push(page);
7020
+ if (!cs.byDomain[page.domain]) cs.byDomain[page.domain] = 0;
7021
+ cs.byDomain[page.domain]++;
7022
+ if (page.role === 'target') cs.targetPages.push(page);
7023
+ else cs.competitorPages.push(page);
7024
+ }
7025
+ }
7026
+
7027
+ // Build dashboard_data format
7028
+ const dashboardData = CLUSTERS.map(cluster => {
7029
+ const cs = clusterStats[cluster.id];
7030
+ const domainCounts = cs.byDomain;
7031
+ const dominant = Object.entries(domainCounts).sort((a, b) => b[1] - a[1])[0];
7032
+ const wcs = cs.pages.map(p => p.word_count || 0).filter(Boolean);
7033
+ const avgWc = wcs.length ? Math.round(wcs.reduce((a, b) => a + b, 0) / wcs.length) : 0;
7034
+ return {
7035
+ cluster: cluster.label,
7036
+ cluster_id: cluster.id,
7037
+ keywords: cs.pages.length,
7038
+ totalFreq: cs.pages.length,
7039
+ dominant: dominant ? { domain: dominant[0], freq: dominant[1] } : null,
7040
+ domains: domainCounts,
7041
+ target_pages: cs.targetPages.length,
7042
+ competitor_pages: cs.competitorPages.length,
7043
+ avg_word_count: avgWc,
7044
+ };
7045
+ }).filter(d => d.keywords > 0).sort((a, b) => b.totalFreq - a.totalFreq);
7046
+
7047
+ if (dashboardData.length > 0) {
7048
+ console.log(` 📊 Topic clusters auto-generated: ${dashboardData.length} clusters from DB for ${project}`);
7049
+ } else {
7050
+ console.log(` ⚠️ No topic clusters found for project: ${project} (no keyword data)`);
7051
+ }
7052
+
7053
+ return dashboardData.length > 0 ? dashboardData : null;
6608
7054
  }
6609
7055
 
6610
7056
  function getLinkDnaData(db, project, config) {