seo-intel 1.5.2 → 1.5.23

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/server.js CHANGED
@@ -200,10 +200,15 @@ async function handleRequest(req, res) {
200
200
  // Load all configs that have crawl data
201
201
  const activeConfigs = [];
202
202
  for (const file of configFiles) {
203
- const config = JSON.parse(readFileSync(join(configDir, file), 'utf8'));
204
- const project = file.replace('.json', '');
205
- const pageCount = db.prepare('SELECT COUNT(*) as c FROM pages p JOIN domains d ON d.id=p.domain_id WHERE d.project=?').get(project)?.c || 0;
206
- if (pageCount > 0) activeConfigs.push(config);
203
+ try {
204
+ const config = JSON.parse(readFileSync(join(configDir, file), 'utf8'));
205
+ // Use config.project (the authoritative slug) with filename as fallback
206
+ const project = config.project || file.replace('.json', '');
207
+ const pageCount = db.prepare('SELECT COUNT(*) as c FROM pages p JOIN domains d ON d.id=p.domain_id WHERE d.project=?').get(project)?.c || 0;
208
+ if (pageCount > 0) activeConfigs.push({ ...config, project });
209
+ } catch (err) {
210
+ console.error(`[dashboard] Skipping malformed config ${file}:`, err.message);
211
+ }
207
212
  }
208
213
 
209
214
  if (!activeConfigs.length) {
@@ -251,6 +256,116 @@ async function handleRequest(req, res) {
251
256
  return;
252
257
  }
253
258
 
259
+ // ─── AI Smart Export loader page (standalone popup) ───
260
+ if (req.method === 'GET' && path === '/ai-loader') {
261
+ const exportUrl = url.searchParams.get('url') || '';
262
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
263
+ res.end(`<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
264
+ <title>AI Smart Export</title>
265
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
266
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&family=Syne:wght@600;700;800&display=swap" rel="stylesheet">
267
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"><\/script>
268
+ <style>
269
+ *{margin:0;padding:0;box-sizing:border-box;}
270
+ body,html{width:100%;height:100%;background:#0a0a0a;font-family:'Inter',sans-serif;color:#e0e0e0;overflow:hidden;}
271
+ #swarmBg{position:fixed;inset:0;z-index:0;}
272
+ .card{position:relative;z-index:2;display:flex;flex-direction:column;align-items:center;justify-content:center;height:100vh;padding:32px;}
273
+ .inner{background:rgba(18,18,18,0.75);backdrop-filter:blur(16px);border:1px solid rgba(212,175,55,0.15);border-radius:20px;padding:40px 48px 32px;text-align:center;box-shadow:0 0 80px rgba(212,175,55,0.06),0 32px 64px rgba(0,0,0,0.5);max-width:440px;width:100%;}
274
+ h1{font-family:'Syne',sans-serif;font-size:1.3rem;color:#d4af37;margin-bottom:4px;letter-spacing:-0.02em;}
275
+ h1 i{margin-right:8px;}
276
+ .sub{font-size:0.72rem;color:#777;margin-bottom:28px;}
277
+ .status{font-size:0.76rem;color:#aaa;margin-bottom:18px;min-height:1.4em;transition:all 0.3s;}
278
+ .status i{color:#d4af37;margin-right:8px;}
279
+ .track{width:100%;height:5px;background:rgba(255,255,255,0.05);border-radius:5px;overflow:hidden;margin-bottom:6px;}
280
+ .bar{height:100%;width:0%;border-radius:5px;background:linear-gradient(90deg,#d4af37,#f5c842,#d4af37);background-size:200% 100%;animation:shimmer 1.5s ease infinite;transition:width 0.6s cubic-bezier(0.4,0,0.2,1);}
281
+ @keyframes shimmer{0%{background-position:200% 0}100%{background-position:-200% 0}}
282
+ .pct{font-size:0.62rem;color:#555;font-family:'SF Mono',ui-monospace,monospace;text-align:right;margin-bottom:20px;}
283
+ .cancel{font-size:0.64rem;color:#666;background:none;border:1px solid #333;padding:5px 18px;border-radius:8px;cursor:pointer;transition:all 0.2s;}
284
+ .cancel:hover{color:#ccc;border-color:#666;}
285
+ .done-msg{display:none;margin-top:16px;font-size:0.7rem;color:#50c878;}
286
+ .done-msg i{margin-right:6px;}
287
+ </style></head><body>
288
+ <div id="swarmBg"></div>
289
+ <div class="card"><div class="inner">
290
+ <h1><i class="fa-solid fa-wand-magic-sparkles"></i> AI Smart Export</h1>
291
+ <p class="sub">Enriching your report with AI intelligence</p>
292
+ <div class="status" id="status"><i class="fa-solid fa-brain fa-beat-fade"></i> Initializing...</div>
293
+ <div class="track"><div class="bar" id="bar"></div></div>
294
+ <div class="pct" id="pct">0%</div>
295
+ <button class="cancel" id="cancelBtn">Cancel</button>
296
+ <div class="done-msg" id="doneMsg"><i class="fa-solid fa-circle-check"></i> Export complete — file downloaded</div>
297
+ </div></div>
298
+ <script>
299
+ (function(){
300
+ // ── Swarm ──
301
+ var el=document.getElementById('swarmBg'),N=350;
302
+ var sc=new THREE.Scene();sc.fog=new THREE.FogExp2(0x0a0a0a,0.003);
303
+ var cam=new THREE.PerspectiveCamera(60,innerWidth/innerHeight,1,800);cam.position.set(0,0,220);
304
+ var r=new THREE.WebGLRenderer({antialias:true,alpha:true});r.setSize(innerWidth,innerHeight);r.setPixelRatio(Math.min(devicePixelRatio,1.5));r.setClearColor(0x0a0a0a,1);el.appendChild(r.domElement);
305
+ var cv=document.createElement('canvas');cv.width=cv.height=64;var cx=cv.getContext('2d'),g=cx.createRadialGradient(32,32,0,32,32,32);
306
+ g.addColorStop(0,'rgba(255,255,255,1)');g.addColorStop(0.3,'rgba(212,175,55,0.9)');g.addColorStop(1,'rgba(212,175,55,0)');cx.fillStyle=g;cx.fillRect(0,0,64,64);
307
+ var tex=new THREE.CanvasTexture(cv);
308
+ var pos=new Float32Array(N*3),col=new Float32Array(N*3),sz=new Float32Array(N);
309
+ for(var i=0;i<N;i++){var t=i/N,ao=(i%4)*(Math.PI/2),rd=Math.pow(t,0.5)*120,a=t*Math.PI*6+ao;
310
+ pos[i*3]=Math.cos(a)*rd;pos[i*3+1]=(Math.random()-0.5)*14*(1-t);pos[i*3+2]=Math.sin(a)*rd;
311
+ var ig=Math.random()>0.65;col[i*3]=ig?0.83:0.35;col[i*3+1]=ig?0.69:0.48;col[i*3+2]=ig?0.22:0.93;sz[i]=ig?3.2:1.6;}
312
+ var geo=new THREE.BufferGeometry();geo.setAttribute('position',new THREE.BufferAttribute(pos,3));
313
+ geo.setAttribute('color',new THREE.BufferAttribute(col,3));geo.setAttribute('size',new THREE.BufferAttribute(sz,1));
314
+ 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.2*(220.0/-mv.z);gl_Position=projectionMatrix*mv;}';
315
+ 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;}';
316
+ var mat=new THREE.ShaderMaterial({uniforms:{pointTexture:{value:tex}},vertexShader:vs,fragmentShader:fs,blending:THREE.AdditiveBlending,depthTest:false,transparent:true});
317
+ sc.add(new THREE.Points(geo,mat));
318
+ var maxD=30*30,lp=new Float32Array(N*36),lg=new THREE.BufferGeometry();lg.setAttribute('position',new THREE.BufferAttribute(lp,3));
319
+ sc.add(new THREE.LineSegments(lg,new THREE.LineBasicMaterial({color:0xd4af37,transparent:true,opacity:0.07,blending:THREE.AdditiveBlending})));
320
+ var vi=0,cnt=0;for(var i=0;i<N&&cnt<N*5;i++){for(var j=i+1;j<N&&cnt<N*5;j++){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];if(dx*dx+dy*dy+dz*dz<maxD){lp[vi++]=pos[i*3];lp[vi++]=pos[i*3+1];lp[vi++]=pos[i*3+2];lp[vi++]=pos[j*3];lp[vi++]=pos[j*3+1];lp[vi++]=pos[j*3+2];cnt++;}}}
321
+ lg.setDrawRange(0,cnt*2);lg.attributes.position.needsUpdate=true;
322
+ (function anim(){requestAnimationFrame(anim);sc.rotation.y+=0.0025;sc.rotation.x+=0.0008;r.render(sc,cam)})();
323
+ window.addEventListener('resize',function(){cam.aspect=innerWidth/innerHeight;cam.updateProjectionMatrix();r.setSize(innerWidth,innerHeight);});
324
+
325
+ // ── Progress ──
326
+ var STEPS=[
327
+ {at:0,icon:'fa-brain fa-beat-fade',text:'Analyzing report structure...'},
328
+ {at:12,icon:'fa-table-cells fa-fade',text:'Filling empty table cells...'},
329
+ {at:28,icon:'fa-diagram-project fa-beat-fade',text:'Mapping keyword clusters...'},
330
+ {at:45,icon:'fa-magnifying-glass-chart fa-fade',text:'Cross-referencing competitors...'},
331
+ {at:58,icon:'fa-ranking-star fa-beat-fade',text:'Scoring priorities...'},
332
+ {at:72,icon:'fa-list-check fa-fade',text:'Building action plan...'},
333
+ {at:88,icon:'fa-file-export fa-fade',text:'Finalizing export...'}
334
+ ];
335
+ var barEl=document.getElementById('bar'),pctEl=document.getElementById('pct'),statusEl=document.getElementById('status');
336
+ var cur=0,timer=null;
337
+ function ease(s,e,d){var st=Date.now();clearInterval(timer);timer=setInterval(function(){var t=Math.min((Date.now()-st)/d,1),v=s+(e-s)*(1-Math.pow(1-t,3));cur=v;barEl.style.width=v+'%';pctEl.textContent=Math.round(v)+'%';var step=STEPS[0];for(var i=STEPS.length-1;i>=0;i--){if(v>=STEPS[i].at){step=STEPS[i];break;}}statusEl.innerHTML='<i class="fa-solid '+step.icon+'"></i> '+step.text;if(t>=1)clearInterval(timer);},50);}
338
+ ease(0,22,7000);setTimeout(function(){ease(22,48,12000)},7000);setTimeout(function(){ease(48,72,14000)},19000);setTimeout(function(){ease(72,92,20000)},33000);
339
+
340
+ // ── Fetch ──
341
+ var exportUrl=${JSON.stringify(exportUrl)};
342
+ var aborted=false;
343
+ var ctrl=new AbortController();
344
+ document.getElementById('cancelBtn').onclick=function(){aborted=true;ctrl.abort();window.close();};
345
+ fetch(exportUrl,{signal:ctrl.signal}).then(function(resp){
346
+ if(!resp.ok)throw new Error('HTTP '+resp.status);
347
+ var cd=resp.headers.get('content-disposition')||'';var m=cd.match(/filename="?([^"]+)"?/);
348
+ var fn=m?m[1]:'export.md';
349
+ return resp.blob().then(function(b){return{blob:b,fn:fn}});
350
+ }).then(function(res){
351
+ clearInterval(timer);cur=100;barEl.style.width='100%';pctEl.textContent='100%';
352
+ statusEl.innerHTML='<i class="fa-solid fa-circle-check" style="color:#50c878"></i> Export complete!';
353
+ document.getElementById('doneMsg').style.display='block';
354
+ document.getElementById('cancelBtn').textContent='Close';
355
+ document.getElementById('cancelBtn').onclick=function(){window.close();};
356
+ var a=document.createElement('a');a.href=URL.createObjectURL(res.blob);a.download=res.fn;a.click();URL.revokeObjectURL(a.href);
357
+ }).catch(function(err){
358
+ if(aborted)return;
359
+ clearInterval(timer);
360
+ statusEl.innerHTML='<i class="fa-solid fa-triangle-exclamation" style="color:#ff6b6b"></i> '+err.message;
361
+ document.getElementById('cancelBtn').textContent='Close';
362
+ document.getElementById('cancelBtn').onclick=function(){window.close();};
363
+ });
364
+ })();
365
+ <\/script></body></html>`);
366
+ return;
367
+ }
368
+
254
369
  // ─── API: Get progress ───
255
370
  if (req.method === 'GET' && path === '/api/progress') {
256
371
  const progress = readProgress();
@@ -684,27 +799,82 @@ async function handleRequest(req, res) {
684
799
  return sections;
685
800
  }
686
801
 
802
+ // ── Helpers for deterministic enrichment ──
803
+ function inferLongTailParent(phrase, keywordGaps) {
804
+ // Match long-tail to its most likely parent keyword from keyword gaps
805
+ const lower = phrase.toLowerCase();
806
+ let best = null, bestScore = 0;
807
+ for (const g of (keywordGaps || [])) {
808
+ const kw = (g.keyword || '').toLowerCase();
809
+ if (!kw || kw.length < 3) continue;
810
+ // Score: how many words from the gap keyword appear in the phrase
811
+ const words = kw.split(/\s+/);
812
+ const score = words.filter(w => lower.includes(w)).length / words.length;
813
+ if (score > bestScore && score >= 0.5) { bestScore = score; best = g.keyword; }
814
+ }
815
+ return best;
816
+ }
817
+
818
+ function inferLongTailOpportunity(item) {
819
+ const p = (item.phrase || '').toLowerCase();
820
+ const intent = item.intent || '';
821
+ const pageType = item.page_type || '';
822
+ if (p.startsWith('how to ') || p.includes(' tutorial')) return `How-to ${pageType || 'guide'} — ${intent || 'informational'} intent`;
823
+ if (p.includes(' vs ') || p.includes(' comparison')) return `Comparison ${pageType || 'article'} — captures decision-stage traffic`;
824
+ if (p.includes('best ') || p.includes('top ')) return `Listicle / roundup — high commercial intent`;
825
+ if (p.includes('what is ') || p.includes('explained')) return `Explainer ${pageType || 'page'} — top-of-funnel awareness`;
826
+ if (p.includes(' api ') || p.includes(' sdk ')) return `Technical docs ${pageType || 'page'} — developer intent`;
827
+ if (p.includes(' price') || p.includes(' cost') || p.includes(' pricing')) return `Pricing / comparison page — transactional intent`;
828
+ if (intent) return `${pageType || 'Content'} page — ${intent} intent`;
829
+ return pageType ? `${pageType} page` : '';
830
+ }
831
+
832
+ function inferPotential(item) {
833
+ const p = (item.priority || '').toLowerCase();
834
+ if (p === 'high' || p === 'critical') return 'High';
835
+ if (p === 'medium') return 'Medium';
836
+ if (p === 'low') return 'Low';
837
+ // Fallback: questions and comparisons tend to be higher value
838
+ const phrase = (item.phrase || '').toLowerCase();
839
+ if (phrase.startsWith('how') || phrase.includes(' vs ') || phrase.includes('best ')) return 'High';
840
+ if (item.type === 'question' || item.type === 'comparison') return 'High';
841
+ if (item.type === 'ai_query') return 'Medium';
842
+ return 'Medium';
843
+ }
844
+
687
845
  function dashboardToMarkdown(sections, proj) {
688
846
  const date = new Date().toISOString().slice(0, 10);
689
847
  let md = `# SEO Intel Report — ${proj}\n\n- Date: ${date}\n\n`;
690
848
 
691
849
  const s = sections;
692
850
 
851
+ // ── Technical Scorecard ──
693
852
  if (s.technical) {
694
853
  md += `## Technical Scorecard\n\n`;
695
854
  md += `- Overall: **${s.technical.score}/100**\n`;
696
855
  md += `- H1: ${s.technical.h1_coverage} | Meta: ${s.technical.meta_coverage} | Schema: ${s.technical.schema_coverage} | Title: ${s.technical.title_coverage}\n\n`;
697
856
  }
857
+
858
+ // ── Technical Gaps ──
698
859
  if (s.technical_gaps?.length) {
699
- md += `## Technical Gaps (${s.technical_gaps.length})\n\n| Issue | Affected | Fix |\n|-------|----------|-----|\n`;
860
+ md += `## Technical Gaps (${s.technical_gaps.length})\n\n`;
861
+ md += `> Implement these schema and markup fixes to qualify for rich results. Start with FAQ and HowTo schema — they have the highest SERP visibility impact.\n\n`;
862
+ md += `| Issue | Affected | Fix |\n|-------|----------|-----|\n`;
700
863
  for (const g of s.technical_gaps) md += `| ${g.gap || g.issue || ''} | ${g.affected || g.pages || ''} | ${g.recommendation || g.fix || ''} |\n`;
701
864
  md += '\n';
702
865
  }
866
+
867
+ // ── Quick Wins ──
703
868
  if (s.quick_wins?.length) {
704
- md += `## Quick Wins (${s.quick_wins.length})\n\n| Page | Issue | Fix | Impact |\n|------|-------|-----|--------|\n`;
869
+ const highCount = s.quick_wins.filter(w => w.impact === 'high').length;
870
+ md += `## Quick Wins (${s.quick_wins.length})\n\n`;
871
+ md += `> **${highCount} high-impact items.** Sort by Impact, pick the top 3 "high" items and implement this week. Each fix takes <30 min and directly improves rankings.\n\n`;
872
+ md += `| Page | Issue | Fix | Impact |\n|------|-------|-----|--------|\n`;
705
873
  for (const w of s.quick_wins) md += `| ${w.page || ''} | ${w.issue || ''} | ${w.fix || ''} | ${w.impact || ''} |\n`;
706
874
  md += '\n';
707
875
  }
876
+
877
+ // ── Internal Links ──
708
878
  if (s.internal_links) {
709
879
  md += `## Internal Links\n\n- Total links: ${s.internal_links.total_links}\n- Orphan pages: ${s.internal_links.orphan_pages}\n`;
710
880
  if (s.internal_links.top_pages?.length) {
@@ -713,6 +883,8 @@ async function handleRequest(req, res) {
713
883
  }
714
884
  md += '\n';
715
885
  }
886
+
887
+ // ── Site Watch ──
716
888
  if (s.watch_summary) {
717
889
  md += `## Site Watch\n\n- Health: **${s.watch_summary.health_score ?? 'N/A'}** | Errors: ${s.watch_summary.errors} | Warnings: ${s.watch_summary.warnings}\n\n`;
718
890
  }
@@ -721,56 +893,110 @@ async function handleRequest(req, res) {
721
893
  for (const e of s.watch_alerts) md += `| ${e.event_type} | ${e.severity} | ${e.url || ''} | ${(e.details || '').slice(0, 80)} |\n`;
722
894
  md += '\n';
723
895
  }
896
+
897
+ // ── Keyword Gaps ──
724
898
  if (s.keyword_gaps?.length) {
725
- md += `## Keyword Gaps (${s.keyword_gaps.length})\n\n| Keyword | Your Coverage | Competitor Coverage |\n|---------|--------------|--------------------|\n`;
899
+ const highGaps = s.keyword_gaps.filter(g => (g.competitor_coverage || g.competitor_count || 0) >= 4).length;
900
+ md += `## Keyword Gaps (${s.keyword_gaps.length})\n\n`;
901
+ md += `> **${highGaps} high-priority gaps** (competitor coverage >= 4). Focus on gaps that match existing product features — these are "free points" where you have the product but lack the page.\n\n`;
902
+ md += `| Keyword | Your Coverage | Competitor Coverage |\n|---------|--------------|--------------------|\n`;
726
903
  for (const g of s.keyword_gaps) md += `| ${g.keyword || ''} | ${g.your_coverage || g.target_count || 'none'} | ${g.competitor_coverage || g.competitor_count || ''} |\n`;
727
904
  md += '\n';
728
905
  }
906
+
907
+ // ── Top Keyword Gaps (fill frequency + gap from competitor_count) ──
729
908
  if (s.top_keyword_gaps?.length) {
730
- md += `## Top Keyword Gaps\n\n| Keyword | Frequency | Your Count | Gap |\n|---------|-----------|------------|-----|\n`;
731
- for (const g of s.top_keyword_gaps) md += `| ${g.keyword || ''} | ${g.total || ''} | ${g.target || 0} | ${g.gap || ''} |\n`;
909
+ md += `## Top Keyword Gaps\n\n`;
910
+ md += `> Keywords your competitors rank for that you don't cover at all. Frequency = how many competitor sites mention it.\n\n`;
911
+ md += `| Keyword | Frequency | Your Count | Gap |\n|---------|-----------|------------|-----|\n`;
912
+ for (const g of s.top_keyword_gaps) {
913
+ const freq = g.total || g.competitor_count || '';
914
+ const target = g.target || 0;
915
+ const gap = freq ? (Number(freq) - Number(target)) || freq : '';
916
+ md += `| ${g.keyword || ''} | ${freq} | ${target} | ${gap} |\n`;
917
+ }
732
918
  md += '\n';
733
919
  }
920
+
921
+ // ── Long-tail Opportunities (fill parent + opportunity) ──
734
922
  if (s.long_tails?.length) {
735
- md += `## Long-tail Opportunities (${s.long_tails.length})\n\n| Phrase | Parent | Opportunity |\n|-------|--------|-------------|\n`;
736
- for (const l of s.long_tails) md += `| ${l.phrase || ''} | ${l.parent || l.keyword || ''} | ${l.opportunity || l.rationale || ''} |\n`;
923
+ md += `## Long-tail Opportunities (${s.long_tails.length})\n\n`;
924
+ md += `> Long-tail keywords are lower competition and higher conversion. Each phrase maps to a parent cluster and content type.\n\n`;
925
+ md += `| Phrase | Parent | Opportunity |\n|-------|--------|-------------|\n`;
926
+ for (const l of s.long_tails) {
927
+ const parent = l.parent || l.keyword || inferLongTailParent(l.phrase, s.keyword_gaps) || '';
928
+ const opportunity = l.opportunity || l.rationale || inferLongTailOpportunity(l) || '';
929
+ md += `| ${l.phrase || ''} | ${parent} | ${opportunity} |\n`;
930
+ }
737
931
  md += '\n';
738
932
  }
933
+
934
+ // ── New Pages to Create (fill rationale from 'why' field) ──
739
935
  if (s.new_pages?.length) {
740
- md += `## New Pages to Create (${s.new_pages.length})\n\n| Title | Target Keyword | Rationale |\n|-------|----------------|----------|\n`;
741
- for (const p of s.new_pages) md += `| ${p.title || ''} | ${p.target_keyword || ''} | ${p.rationale || ''} |\n`;
936
+ md += `## New Pages to Create (${s.new_pages.length})\n\n`;
937
+ md += `> Each page targets a specific keyword gap. Create these as standalone pages with proper H1, schema, and internal links from existing content.\n\n`;
938
+ md += `| Title | Target Keyword | Rationale |\n|-------|----------------|----------|\n`;
939
+ for (const p of s.new_pages) {
940
+ const rationale = p.rationale || p.why || p.content_angle || '';
941
+ md += `| ${p.title || ''} | ${p.target_keyword || ''} | ${rationale} |\n`;
942
+ }
742
943
  md += '\n';
743
944
  }
945
+
946
+ // ── Content Gaps (fill gap + suggestion from covered_by, why_it_matters, suggested_title) ──
744
947
  if (s.content_gaps?.length) {
745
- md += `## Content Gaps (${s.content_gaps.length})\n\n| Topic | Gap | Suggestion |\n|-------|-----|------------|\n`;
746
- for (const g of s.content_gaps) md += `| ${g.topic || ''} | ${g.gap || ''} | ${g.suggestion || ''} |\n`;
948
+ md += `## Content Gaps (${s.content_gaps.length})\n\n`;
949
+ md += `> Topics your competitors cover that you don't. Prioritise gaps where multiple competitors have content that signals proven search demand.\n\n`;
950
+ md += `| Topic | Gap | Suggestion |\n|-------|-----|------------|\n`;
951
+ for (const g of s.content_gaps) {
952
+ const gap = g.gap || (g.covered_by?.length ? `Covered by ${g.covered_by.join(', ')}` : '') || g.why_it_matters || '';
953
+ const suggestion = g.suggestion || g.suggested_title || (g.format ? `Create a ${g.format} covering this topic` : '') || '';
954
+ md += `| ${g.topic || ''} | ${gap} | ${suggestion} |\n`;
955
+ }
747
956
  md += '\n';
748
957
  }
958
+
959
+ // ── Keyword Ideas (fill potential from priority) ──
749
960
  if (s.keyword_inventor?.length) {
750
- md += `## Keyword Ideas (${s.keyword_inventor.length})\n\n| Phrase | Cluster | Potential |\n|-------|---------|----------|\n`;
751
- for (const k of s.keyword_inventor.slice(0, 50)) md += `| ${k.phrase || ''} | ${k.cluster || ''} | ${k.potential || k.volume || ''} |\n`;
961
+ md += `## Keyword Ideas (${s.keyword_inventor.length})\n\n`;
962
+ md += `> Clustered keyword suggestions for content planning. High-potential keywords are questions, comparisons, or high-priority phrases matching your product features.\n\n`;
963
+ md += `| Phrase | Cluster | Potential |\n|-------|---------|----------|\n`;
964
+ for (const k of s.keyword_inventor.slice(0, 50)) {
965
+ const potential = k.potential || k.volume || inferPotential(k) || '';
966
+ md += `| ${k.phrase || ''} | ${k.cluster || ''} | ${potential} |\n`;
967
+ }
752
968
  if (s.keyword_inventor.length > 50) md += `\n_...and ${s.keyword_inventor.length - 50} more._\n`;
753
969
  md += '\n';
754
970
  }
971
+
972
+ // ── Positioning Strategy ──
755
973
  if (s.positioning) {
756
974
  md += `## Positioning Strategy\n\n`;
757
975
  if (s.positioning.open_angle) md += `**Open angle:** ${s.positioning.open_angle}\n\n`;
758
976
  if (s.positioning.target_differentiator) md += `**Differentiator:** ${s.positioning.target_differentiator}\n\n`;
759
977
  if (s.positioning.competitor_map) md += `**Competitor map:** ${s.positioning.competitor_map}\n\n`;
760
978
  }
979
+
980
+ // ── AI Citability ──
761
981
  if (s.citability_summary) {
762
982
  md += `## AI Citability\n\n- Average: **${s.citability_summary.avg_score ?? 'N/A'}/100** (${s.citability_summary.pages_scored} pages, ${s.citability_summary.pages_below_60} below 60)\n\n`;
763
983
  }
764
984
  if (s.citability_low_scores?.length) {
765
- md += `### Pages Needing Improvement\n\n| Score | URL | Tier |\n|-------|-----|------|\n`;
985
+ md += `### Pages Needing Improvement\n\n`;
986
+ md += `> Pages scoring below 60 are unlikely to be cited by AI assistants. Focus on adding structured Q&A, entity depth, and clear factual claims.\n\n`;
987
+ md += `| Score | URL | Tier |\n|-------|-----|------|\n`;
766
988
  for (const p of s.citability_low_scores) md += `| ${p.score} | ${p.url || ''} | ${p.tier || ''} |\n`;
767
989
  md += '\n';
768
990
  }
991
+
992
+ // ── Schema Types ──
769
993
  if (s.schema_types?.length) {
770
994
  md += `## Schema Types (own site)\n\n| Type | Count |\n|------|-------|\n`;
771
995
  for (const t of s.schema_types) md += `| ${t.type || t.schema_type || ''} | ${t.count || ''} |\n`;
772
996
  md += '\n';
773
997
  }
998
+
999
+ // ── Crawl Info ──
774
1000
  if (s.crawl_stats) {
775
1001
  md += `## Crawl Info\n\n- Last crawl: ${s.crawl_stats.lastCrawl || 'N/A'}\n- Extracted pages: ${s.crawl_stats.extractedPages || 0}\n`;
776
1002
  }
@@ -800,8 +1026,56 @@ async function handleRequest(req, res) {
800
1026
  return [keys.join(','), ...rows.map(r => keys.map(k => escape(r[k])).join(','))].join('\n');
801
1027
  }
802
1028
 
1029
+ // ── AI Smart Export enrichment ──
1030
+ async function aiEnrichMarkdown(md, proj) {
1031
+ const prompt = `You are an SEO strategist reviewing a data export report. Your job is to ENRICH this report, NOT rewrite it.
1032
+
1033
+ Rules:
1034
+ - Keep ALL existing data, tables, headers, and instruction blocks exactly as they are
1035
+ - Fill any empty table cells (marked with empty | | columns) with concise, actionable content
1036
+ - For empty "Parent" cells in Long-tail Opportunities: infer the parent keyword cluster
1037
+ - For empty "Opportunity" cells: classify as how-to guide, comparison, tutorial, landing page, etc.
1038
+ - For empty "Gap" cells in Content Gaps: describe what content is missing
1039
+ - For empty "Suggestion" cells: give a specific content format and angle
1040
+ - For empty "Rationale" cells: explain why this page matters for SEO
1041
+ - For empty "Potential" cells: rate as High/Medium/Low based on keyword type
1042
+ - After the last section, add a new section "## AI Action Plan" with a numbered list of the top 10 highest-impact actions, ordered by priority
1043
+ - Keep the same markdown format — tables, headers, blockquotes
1044
+ - Be concise — table cells should be under 80 chars
1045
+ - Do NOT add commentary, preamble, or explanation outside the report
1046
+
1047
+ Here is the report to enrich:
1048
+
1049
+ ${md}`;
1050
+ return new Promise((resolve) => {
1051
+ const child = spawn('gemini', ['-p', '-'], {
1052
+ stdio: ['pipe', 'pipe', 'pipe'],
1053
+ env: process.env,
1054
+ timeout: 120000,
1055
+ });
1056
+ let stdout = '', stderr = '';
1057
+ child.stdout.on('data', (d) => { stdout += d.toString(); });
1058
+ child.stderr.on('data', (d) => { stderr += d.toString(); });
1059
+ child.on('error', (err) => {
1060
+ console.warn('[ai-export] Gemini spawn failed:', err.message);
1061
+ resolve(md + `\n\n> _AI enrichment unavailable: ${err.message}_\n`);
1062
+ });
1063
+ child.on('close', (code) => {
1064
+ if (code === 0 && stdout.trim()) {
1065
+ resolve(stdout);
1066
+ } else {
1067
+ console.warn('[ai-export] Gemini exited', code, stderr.slice(0, 200));
1068
+ resolve(md + `\n\n> _AI enrichment unavailable: gemini exited ${code}_\n`);
1069
+ }
1070
+ });
1071
+ child.stdin.write(prompt);
1072
+ child.stdin.end();
1073
+ });
1074
+ }
1075
+
803
1076
  // ── Build and serve ──
804
1077
  const sections = buildDashboardExport(dash);
1078
+ const useAi = url.searchParams.get('ai') === 'true';
805
1079
 
806
1080
  if (format === 'json') {
807
1081
  const content = JSON.stringify({ project, date: dateStr, ...sections }, null, 2);
@@ -809,8 +1083,9 @@ async function handleRequest(req, res) {
809
1083
  res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Disposition': `attachment; filename="${fileName}"` });
810
1084
  res.end(content);
811
1085
  } else if (format === 'md') {
812
- const content = dashboardToMarkdown(sections, project);
813
- const fileName = `${project}-${dateStr}.md`;
1086
+ let content = dashboardToMarkdown(sections, project);
1087
+ if (useAi) content = await aiEnrichMarkdown(content, project);
1088
+ const fileName = `${project}-${useAi ? 'ai-' : ''}${dateStr}.md`;
814
1089
  res.writeHead(200, { 'Content-Type': 'text/markdown; charset=utf-8', 'Content-Disposition': `attachment; filename="${fileName}"` });
815
1090
  res.end(content);
816
1091
  } else if (format === 'csv') {
@@ -821,7 +1096,9 @@ async function handleRequest(req, res) {
821
1096
  } else if (format === 'zip') {
822
1097
  const entries = [];
823
1098
  entries.push({ name: `${project}-${dateStr}.json`, content: JSON.stringify({ project, date: dateStr, ...sections }, null, 2) });
824
- entries.push({ name: `${project}-${dateStr}.md`, content: dashboardToMarkdown(sections, project) });
1099
+ let mdContent = dashboardToMarkdown(sections, project);
1100
+ if (useAi) mdContent = await aiEnrichMarkdown(mdContent, project);
1101
+ entries.push({ name: `${project}-${useAi ? 'ai-' : ''}${dateStr}.md`, content: mdContent });
825
1102
  const csv = toCSV(sections);
826
1103
  if (csv) entries.push({ name: `${project}-${dateStr}.csv`, content: csv });
827
1104
  const zipBuf = createZip(entries);
@@ -851,7 +1128,7 @@ async function handleRequest(req, res) {
851
1128
  const ALLOWED = ['crawl', 'extract', 'analyze', 'export-actions', 'competitive-actions',
852
1129
  'suggest-usecases', 'html', 'status', 'brief', 'keywords', 'report', 'guide',
853
1130
  'schemas', 'headings-audit', 'orphans', 'entities', 'friction', 'shallow', 'decay', 'export', 'templates',
854
- 'aeo', 'blog-draft', 'gap-intel', 'watch'];
1131
+ 'aeo', 'blog-draft', 'gap-intel', 'watch', 'scan'];
855
1132
 
856
1133
  if (!command || !ALLOWED.includes(command)) {
857
1134
  json(res, 400, { error: `Invalid command. Allowed: ${ALLOWED.join(', ')}` });
@@ -860,8 +1137,20 @@ async function handleRequest(req, res) {
860
1137
 
861
1138
  // Build args
862
1139
  const args = ['cli.js', command];
863
- if (project && command !== 'status' && command !== 'html') args.push(project);
864
- if (params.get('stealth') === 'true') args.push('--stealth');
1140
+
1141
+ // scan takes a domain (not a project slug) — validate and route separately
1142
+ if (command === 'scan') {
1143
+ const domain = (params.get('domain') || project || '').trim();
1144
+ if (!domain || !/^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}$/i.test(domain)) {
1145
+ json(res, 400, { error: 'scan requires a valid domain (e.g. dgents.ai)' });
1146
+ return;
1147
+ }
1148
+ args.push(domain);
1149
+ if (params.get('stealth') === 'true') args.push('--stealth');
1150
+ } else {
1151
+ if (project && command !== 'status' && command !== 'html') args.push(project);
1152
+ if (params.get('stealth') === 'true') args.push('--stealth');
1153
+ }
865
1154
  if (params.get('scope')) args.push('--scope', params.get('scope'));
866
1155
  if (params.get('format')) args.push('--format', params.get('format'));
867
1156
  if (params.get('topic')) args.push('--topic', params.get('topic'));
@@ -884,6 +1173,11 @@ async function handleRequest(req, res) {
884
1173
  args.push('--output', outFile);
885
1174
  args.push('--format', 'brief');
886
1175
  }
1176
+ // Auto-save AEO audit output
1177
+ if (command === 'aeo' && project) {
1178
+ const ts = new Date().toISOString().slice(0, 10);
1179
+ args.push('--save');
1180
+ }
887
1181
 
888
1182
  // SSE headers
889
1183
  res.writeHead(200, {
@@ -897,7 +1191,7 @@ async function handleRequest(req, res) {
897
1191
  res.write(`data: ${JSON.stringify({ type, data })}\n\n`);
898
1192
  };
899
1193
 
900
- const isLongRunning = ['crawl', 'extract'].includes(command);
1194
+ const isLongRunning = ['crawl', 'extract', 'scan'].includes(command);
901
1195
 
902
1196
  send('start', { command, project, args: args.slice(1) });
903
1197
 
package/setup/checks.js CHANGED
@@ -75,7 +75,27 @@ export async function checkOllamaRemote(host) {
75
75
  }
76
76
  }
77
77
 
78
- // ── Ollama auto-detect (local → custom hosts) ──────────────────────────────
78
+ // ── LM Studio auto-detect ──────────────────────────────────────────────────
79
+
80
+ export async function checkLmStudio(customUrl) {
81
+ const host = customUrl || process.env.LMSTUDIO_URL || 'http://localhost:1234';
82
+
83
+ try {
84
+ const controller = new AbortController();
85
+ const timeout = setTimeout(() => controller.abort(), 3000);
86
+ const res = await fetch(`${host}/api/v1/models`, { signal: controller.signal });
87
+ clearTimeout(timeout);
88
+
89
+ if (!res.ok) return { reachable: false, models: [], host };
90
+ const data = await res.json().catch(() => ({ data: [] }));
91
+ const models = (data.data || []).map(m => m.id || m.model).filter(Boolean);
92
+ return { reachable: true, models, host };
93
+ } catch {
94
+ return { reachable: false, models: [], host };
95
+ }
96
+ }
97
+
98
+ // ── Ollama auto-detect (local → custom hosts → LM Studio) ────────────────
79
99
 
80
100
  export async function checkOllamaAuto(customHosts = []) {
81
101
  // 1. Try local
@@ -87,10 +107,34 @@ export async function checkOllamaAuto(customHosts = []) {
87
107
  }
88
108
 
89
109
  // 2. Try custom/LAN hosts (check ALL, not just first)
110
+ // Detect LM Studio hosts by port (1234) or failed Ollama ping
90
111
  for (const host of customHosts) {
91
112
  if (host === 'http://localhost:11434') continue; // already checked
92
- const remote = await checkOllamaRemote(host);
93
- allHosts.push({ host: remote.host, mode: 'remote', models: remote.models, reachable: remote.reachable });
113
+ let port;
114
+ try { port = new URL(host).port; } catch { port = ''; }
115
+
116
+ if (port === '1234') {
117
+ // Port 1234 → LM Studio
118
+ const lm = await checkLmStudio(host);
119
+ allHosts.push({ host, mode: 'lmstudio', models: lm.models, reachable: lm.reachable });
120
+ } else {
121
+ const remote = await checkOllamaRemote(host);
122
+ if (remote.reachable) {
123
+ allHosts.push({ host: remote.host, mode: 'remote', models: remote.models, reachable: true });
124
+ } else {
125
+ // Ollama failed — try LM Studio as fallback
126
+ const lm = await checkLmStudio(host);
127
+ allHosts.push({ host, mode: lm.reachable ? 'lmstudio' : 'remote', models: lm.reachable ? lm.models : [], reachable: lm.reachable });
128
+ }
129
+ }
130
+ }
131
+
132
+ // 3. Try LM Studio auto-discovery (localhost + env var)
133
+ const lmStudioUrl = process.env.LMSTUDIO_URL || 'http://localhost:1234';
134
+ const alreadyChecked = allHosts.some(h => h.host === lmStudioUrl);
135
+ const lmStudio = alreadyChecked ? (allHosts.find(h => h.host === lmStudioUrl && h.mode === 'lmstudio') || { reachable: false, models: [] }) : await checkLmStudio();
136
+ if (!alreadyChecked && lmStudio.reachable) {
137
+ allHosts.push({ host: lmStudio.host, mode: 'lmstudio', models: lmStudio.models, reachable: true });
94
138
  }
95
139
 
96
140
  // Pick best available host (first with models)
@@ -106,10 +150,11 @@ export async function checkOllamaAuto(customHosts = []) {
106
150
  models: allModels,
107
151
  installed: local.installed,
108
152
  allHosts,
153
+ lmStudio,
109
154
  };
110
155
  }
111
156
 
112
- // 3. Local installed but not running or no models
157
+ // 4. Local installed but not running or no models
113
158
  if (local.installed) {
114
159
  return {
115
160
  available: false,
@@ -118,6 +163,20 @@ export async function checkOllamaAuto(customHosts = []) {
118
163
  models: [],
119
164
  installed: true,
120
165
  allHosts,
166
+ lmStudio,
167
+ };
168
+ }
169
+
170
+ // 5. LM Studio reachable but no models loaded
171
+ if (lmStudio.reachable) {
172
+ return {
173
+ available: false,
174
+ mode: 'lmstudio-no-models',
175
+ host: lmStudio.host,
176
+ models: [],
177
+ installed: false,
178
+ allHosts,
179
+ lmStudio,
121
180
  };
122
181
  }
123
182
 
@@ -128,6 +187,7 @@ export async function checkOllamaAuto(customHosts = []) {
128
187
  models: [],
129
188
  installed: false,
130
189
  allHosts,
190
+ lmStudio,
131
191
  };
132
192
  }
133
193
 
@@ -418,7 +478,7 @@ export async function fullSystemCheck(options = {}) {
418
478
  hasAnalysisKey,
419
479
  summary: {
420
480
  canCrawl: node.meetsMinimum && playwright.installed,
421
- canExtract: ollama.available,
481
+ canExtract: ollama.available || ollama.lmStudio?.reachable,
422
482
  canAnalyze: hasAnalysisKey,
423
483
  canGenerateHtml: node.meetsMinimum,
424
484
  hasGscData: gsc.hasData,
package/setup/engine.js CHANGED
@@ -15,6 +15,7 @@ export {
15
15
  checkOllamaLocal,
16
16
  checkOllamaRemote,
17
17
  checkOllamaAuto,
18
+ checkLmStudio,
18
19
  checkPlaywright,
19
20
  checkNpmDeps,
20
21
  checkEnvFile,
@@ -312,9 +312,28 @@ async function handlePingOllama(req, res) {
312
312
  const host = url.searchParams.get('host');
313
313
  if (!host) { jsonResponse(res, { error: 'Missing host param' }, 400); return; }
314
314
 
315
- const { checkOllamaRemote } = await import('./checks.js');
316
- const result = await checkOllamaRemote(host);
317
- jsonResponse(res, result);
315
+ const { checkOllamaRemote, checkLmStudio } = await import('./checks.js');
316
+
317
+ // Try Ollama first, then LM Studio if port suggests it or Ollama fails
318
+ const port = new URL(host).port;
319
+ if (port === '1234') {
320
+ // Port 1234 = LM Studio default
321
+ const lmResult = await checkLmStudio(host);
322
+ jsonResponse(res, { ...lmResult, host, mode: 'lmstudio' });
323
+ } else {
324
+ const result = await checkOllamaRemote(host);
325
+ if (result.reachable) {
326
+ jsonResponse(res, result);
327
+ } else {
328
+ // Ollama unreachable — try LM Studio as fallback
329
+ const lmResult = await checkLmStudio(host);
330
+ if (lmResult.reachable) {
331
+ jsonResponse(res, { ...lmResult, host, mode: 'lmstudio' });
332
+ } else {
333
+ jsonResponse(res, result); // return original Ollama failure
334
+ }
335
+ }
336
+ }
318
337
  } catch (err) {
319
338
  jsonResponse(res, { error: err.message }, 500);
320
339
  }