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.
package/server.js CHANGED
@@ -251,6 +251,116 @@ async function handleRequest(req, res) {
251
251
  return;
252
252
  }
253
253
 
254
+ // ─── AI Smart Export loader page (standalone popup) ───
255
+ if (req.method === 'GET' && path === '/ai-loader') {
256
+ const exportUrl = url.searchParams.get('url') || '';
257
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
258
+ res.end(`<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
259
+ <title>AI Smart Export</title>
260
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
261
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&family=Syne:wght@600;700;800&display=swap" rel="stylesheet">
262
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"><\/script>
263
+ <style>
264
+ *{margin:0;padding:0;box-sizing:border-box;}
265
+ body,html{width:100%;height:100%;background:#0a0a0a;font-family:'Inter',sans-serif;color:#e0e0e0;overflow:hidden;}
266
+ #swarmBg{position:fixed;inset:0;z-index:0;}
267
+ .card{position:relative;z-index:2;display:flex;flex-direction:column;align-items:center;justify-content:center;height:100vh;padding:32px;}
268
+ .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%;}
269
+ h1{font-family:'Syne',sans-serif;font-size:1.3rem;color:#d4af37;margin-bottom:4px;letter-spacing:-0.02em;}
270
+ h1 i{margin-right:8px;}
271
+ .sub{font-size:0.72rem;color:#777;margin-bottom:28px;}
272
+ .status{font-size:0.76rem;color:#aaa;margin-bottom:18px;min-height:1.4em;transition:all 0.3s;}
273
+ .status i{color:#d4af37;margin-right:8px;}
274
+ .track{width:100%;height:5px;background:rgba(255,255,255,0.05);border-radius:5px;overflow:hidden;margin-bottom:6px;}
275
+ .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);}
276
+ @keyframes shimmer{0%{background-position:200% 0}100%{background-position:-200% 0}}
277
+ .pct{font-size:0.62rem;color:#555;font-family:'SF Mono',ui-monospace,monospace;text-align:right;margin-bottom:20px;}
278
+ .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;}
279
+ .cancel:hover{color:#ccc;border-color:#666;}
280
+ .done-msg{display:none;margin-top:16px;font-size:0.7rem;color:#50c878;}
281
+ .done-msg i{margin-right:6px;}
282
+ </style></head><body>
283
+ <div id="swarmBg"></div>
284
+ <div class="card"><div class="inner">
285
+ <h1><i class="fa-solid fa-wand-magic-sparkles"></i> AI Smart Export</h1>
286
+ <p class="sub">Enriching your report with AI intelligence</p>
287
+ <div class="status" id="status"><i class="fa-solid fa-brain fa-beat-fade"></i> Initializing...</div>
288
+ <div class="track"><div class="bar" id="bar"></div></div>
289
+ <div class="pct" id="pct">0%</div>
290
+ <button class="cancel" id="cancelBtn">Cancel</button>
291
+ <div class="done-msg" id="doneMsg"><i class="fa-solid fa-circle-check"></i> Export complete — file downloaded</div>
292
+ </div></div>
293
+ <script>
294
+ (function(){
295
+ // ── Swarm ──
296
+ var el=document.getElementById('swarmBg'),N=350;
297
+ var sc=new THREE.Scene();sc.fog=new THREE.FogExp2(0x0a0a0a,0.003);
298
+ var cam=new THREE.PerspectiveCamera(60,innerWidth/innerHeight,1,800);cam.position.set(0,0,220);
299
+ 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);
300
+ var cv=document.createElement('canvas');cv.width=cv.height=64;var cx=cv.getContext('2d'),g=cx.createRadialGradient(32,32,0,32,32,32);
301
+ 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);
302
+ var tex=new THREE.CanvasTexture(cv);
303
+ var pos=new Float32Array(N*3),col=new Float32Array(N*3),sz=new Float32Array(N);
304
+ 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;
305
+ 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;
306
+ 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;}
307
+ var geo=new THREE.BufferGeometry();geo.setAttribute('position',new THREE.BufferAttribute(pos,3));
308
+ geo.setAttribute('color',new THREE.BufferAttribute(col,3));geo.setAttribute('size',new THREE.BufferAttribute(sz,1));
309
+ 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;}';
310
+ 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;}';
311
+ var mat=new THREE.ShaderMaterial({uniforms:{pointTexture:{value:tex}},vertexShader:vs,fragmentShader:fs,blending:THREE.AdditiveBlending,depthTest:false,transparent:true});
312
+ sc.add(new THREE.Points(geo,mat));
313
+ var maxD=30*30,lp=new Float32Array(N*36),lg=new THREE.BufferGeometry();lg.setAttribute('position',new THREE.BufferAttribute(lp,3));
314
+ sc.add(new THREE.LineSegments(lg,new THREE.LineBasicMaterial({color:0xd4af37,transparent:true,opacity:0.07,blending:THREE.AdditiveBlending})));
315
+ 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++;}}}
316
+ lg.setDrawRange(0,cnt*2);lg.attributes.position.needsUpdate=true;
317
+ (function anim(){requestAnimationFrame(anim);sc.rotation.y+=0.0025;sc.rotation.x+=0.0008;r.render(sc,cam)})();
318
+ window.addEventListener('resize',function(){cam.aspect=innerWidth/innerHeight;cam.updateProjectionMatrix();r.setSize(innerWidth,innerHeight);});
319
+
320
+ // ── Progress ──
321
+ var STEPS=[
322
+ {at:0,icon:'fa-brain fa-beat-fade',text:'Analyzing report structure...'},
323
+ {at:12,icon:'fa-table-cells fa-fade',text:'Filling empty table cells...'},
324
+ {at:28,icon:'fa-diagram-project fa-beat-fade',text:'Mapping keyword clusters...'},
325
+ {at:45,icon:'fa-magnifying-glass-chart fa-fade',text:'Cross-referencing competitors...'},
326
+ {at:58,icon:'fa-ranking-star fa-beat-fade',text:'Scoring priorities...'},
327
+ {at:72,icon:'fa-list-check fa-fade',text:'Building action plan...'},
328
+ {at:88,icon:'fa-file-export fa-fade',text:'Finalizing export...'}
329
+ ];
330
+ var barEl=document.getElementById('bar'),pctEl=document.getElementById('pct'),statusEl=document.getElementById('status');
331
+ var cur=0,timer=null;
332
+ 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);}
333
+ 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);
334
+
335
+ // ── Fetch ──
336
+ var exportUrl=${JSON.stringify(exportUrl)};
337
+ var aborted=false;
338
+ var ctrl=new AbortController();
339
+ document.getElementById('cancelBtn').onclick=function(){aborted=true;ctrl.abort();window.close();};
340
+ fetch(exportUrl,{signal:ctrl.signal}).then(function(resp){
341
+ if(!resp.ok)throw new Error('HTTP '+resp.status);
342
+ var cd=resp.headers.get('content-disposition')||'';var m=cd.match(/filename="?([^"]+)"?/);
343
+ var fn=m?m[1]:'export.md';
344
+ return resp.blob().then(function(b){return{blob:b,fn:fn}});
345
+ }).then(function(res){
346
+ clearInterval(timer);cur=100;barEl.style.width='100%';pctEl.textContent='100%';
347
+ statusEl.innerHTML='<i class="fa-solid fa-circle-check" style="color:#50c878"></i> Export complete!';
348
+ document.getElementById('doneMsg').style.display='block';
349
+ document.getElementById('cancelBtn').textContent='Close';
350
+ document.getElementById('cancelBtn').onclick=function(){window.close();};
351
+ var a=document.createElement('a');a.href=URL.createObjectURL(res.blob);a.download=res.fn;a.click();URL.revokeObjectURL(a.href);
352
+ }).catch(function(err){
353
+ if(aborted)return;
354
+ clearInterval(timer);
355
+ statusEl.innerHTML='<i class="fa-solid fa-triangle-exclamation" style="color:#ff6b6b"></i> '+err.message;
356
+ document.getElementById('cancelBtn').textContent='Close';
357
+ document.getElementById('cancelBtn').onclick=function(){window.close();};
358
+ });
359
+ })();
360
+ <\/script></body></html>`);
361
+ return;
362
+ }
363
+
254
364
  // ─── API: Get progress ───
255
365
  if (req.method === 'GET' && path === '/api/progress') {
256
366
  const progress = readProgress();
@@ -684,27 +794,82 @@ async function handleRequest(req, res) {
684
794
  return sections;
685
795
  }
686
796
 
797
+ // ── Helpers for deterministic enrichment ──
798
+ function inferLongTailParent(phrase, keywordGaps) {
799
+ // Match long-tail to its most likely parent keyword from keyword gaps
800
+ const lower = phrase.toLowerCase();
801
+ let best = null, bestScore = 0;
802
+ for (const g of (keywordGaps || [])) {
803
+ const kw = (g.keyword || '').toLowerCase();
804
+ if (!kw || kw.length < 3) continue;
805
+ // Score: how many words from the gap keyword appear in the phrase
806
+ const words = kw.split(/\s+/);
807
+ const score = words.filter(w => lower.includes(w)).length / words.length;
808
+ if (score > bestScore && score >= 0.5) { bestScore = score; best = g.keyword; }
809
+ }
810
+ return best;
811
+ }
812
+
813
+ function inferLongTailOpportunity(item) {
814
+ const p = (item.phrase || '').toLowerCase();
815
+ const intent = item.intent || '';
816
+ const pageType = item.page_type || '';
817
+ if (p.startsWith('how to ') || p.includes(' tutorial')) return `How-to ${pageType || 'guide'} — ${intent || 'informational'} intent`;
818
+ if (p.includes(' vs ') || p.includes(' comparison')) return `Comparison ${pageType || 'article'} — captures decision-stage traffic`;
819
+ if (p.includes('best ') || p.includes('top ')) return `Listicle / roundup — high commercial intent`;
820
+ if (p.includes('what is ') || p.includes('explained')) return `Explainer ${pageType || 'page'} — top-of-funnel awareness`;
821
+ if (p.includes(' api ') || p.includes(' sdk ')) return `Technical docs ${pageType || 'page'} — developer intent`;
822
+ if (p.includes(' price') || p.includes(' cost') || p.includes(' pricing')) return `Pricing / comparison page — transactional intent`;
823
+ if (intent) return `${pageType || 'Content'} page — ${intent} intent`;
824
+ return pageType ? `${pageType} page` : '';
825
+ }
826
+
827
+ function inferPotential(item) {
828
+ const p = (item.priority || '').toLowerCase();
829
+ if (p === 'high' || p === 'critical') return 'High';
830
+ if (p === 'medium') return 'Medium';
831
+ if (p === 'low') return 'Low';
832
+ // Fallback: questions and comparisons tend to be higher value
833
+ const phrase = (item.phrase || '').toLowerCase();
834
+ if (phrase.startsWith('how') || phrase.includes(' vs ') || phrase.includes('best ')) return 'High';
835
+ if (item.type === 'question' || item.type === 'comparison') return 'High';
836
+ if (item.type === 'ai_query') return 'Medium';
837
+ return 'Medium';
838
+ }
839
+
687
840
  function dashboardToMarkdown(sections, proj) {
688
841
  const date = new Date().toISOString().slice(0, 10);
689
842
  let md = `# SEO Intel Report — ${proj}\n\n- Date: ${date}\n\n`;
690
843
 
691
844
  const s = sections;
692
845
 
846
+ // ── Technical Scorecard ──
693
847
  if (s.technical) {
694
848
  md += `## Technical Scorecard\n\n`;
695
849
  md += `- Overall: **${s.technical.score}/100**\n`;
696
850
  md += `- H1: ${s.technical.h1_coverage} | Meta: ${s.technical.meta_coverage} | Schema: ${s.technical.schema_coverage} | Title: ${s.technical.title_coverage}\n\n`;
697
851
  }
852
+
853
+ // ── Technical Gaps ──
698
854
  if (s.technical_gaps?.length) {
699
- md += `## Technical Gaps (${s.technical_gaps.length})\n\n| Issue | Affected | Fix |\n|-------|----------|-----|\n`;
855
+ md += `## Technical Gaps (${s.technical_gaps.length})\n\n`;
856
+ 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`;
857
+ md += `| Issue | Affected | Fix |\n|-------|----------|-----|\n`;
700
858
  for (const g of s.technical_gaps) md += `| ${g.gap || g.issue || ''} | ${g.affected || g.pages || ''} | ${g.recommendation || g.fix || ''} |\n`;
701
859
  md += '\n';
702
860
  }
861
+
862
+ // ── Quick Wins ──
703
863
  if (s.quick_wins?.length) {
704
- md += `## Quick Wins (${s.quick_wins.length})\n\n| Page | Issue | Fix | Impact |\n|------|-------|-----|--------|\n`;
864
+ const highCount = s.quick_wins.filter(w => w.impact === 'high').length;
865
+ md += `## Quick Wins (${s.quick_wins.length})\n\n`;
866
+ 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`;
867
+ md += `| Page | Issue | Fix | Impact |\n|------|-------|-----|--------|\n`;
705
868
  for (const w of s.quick_wins) md += `| ${w.page || ''} | ${w.issue || ''} | ${w.fix || ''} | ${w.impact || ''} |\n`;
706
869
  md += '\n';
707
870
  }
871
+
872
+ // ── Internal Links ──
708
873
  if (s.internal_links) {
709
874
  md += `## Internal Links\n\n- Total links: ${s.internal_links.total_links}\n- Orphan pages: ${s.internal_links.orphan_pages}\n`;
710
875
  if (s.internal_links.top_pages?.length) {
@@ -713,6 +878,8 @@ async function handleRequest(req, res) {
713
878
  }
714
879
  md += '\n';
715
880
  }
881
+
882
+ // ── Site Watch ──
716
883
  if (s.watch_summary) {
717
884
  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
885
  }
@@ -721,56 +888,110 @@ async function handleRequest(req, res) {
721
888
  for (const e of s.watch_alerts) md += `| ${e.event_type} | ${e.severity} | ${e.url || ''} | ${(e.details || '').slice(0, 80)} |\n`;
722
889
  md += '\n';
723
890
  }
891
+
892
+ // ── Keyword Gaps ──
724
893
  if (s.keyword_gaps?.length) {
725
- md += `## Keyword Gaps (${s.keyword_gaps.length})\n\n| Keyword | Your Coverage | Competitor Coverage |\n|---------|--------------|--------------------|\n`;
894
+ const highGaps = s.keyword_gaps.filter(g => (g.competitor_coverage || g.competitor_count || 0) >= 4).length;
895
+ md += `## Keyword Gaps (${s.keyword_gaps.length})\n\n`;
896
+ 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`;
897
+ md += `| Keyword | Your Coverage | Competitor Coverage |\n|---------|--------------|--------------------|\n`;
726
898
  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
899
  md += '\n';
728
900
  }
901
+
902
+ // ── Top Keyword Gaps (fill frequency + gap from competitor_count) ──
729
903
  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`;
904
+ md += `## Top Keyword Gaps\n\n`;
905
+ md += `> Keywords your competitors rank for that you don't cover at all. Frequency = how many competitor sites mention it.\n\n`;
906
+ md += `| Keyword | Frequency | Your Count | Gap |\n|---------|-----------|------------|-----|\n`;
907
+ for (const g of s.top_keyword_gaps) {
908
+ const freq = g.total || g.competitor_count || '';
909
+ const target = g.target || 0;
910
+ const gap = freq ? (Number(freq) - Number(target)) || freq : '';
911
+ md += `| ${g.keyword || ''} | ${freq} | ${target} | ${gap} |\n`;
912
+ }
732
913
  md += '\n';
733
914
  }
915
+
916
+ // ── Long-tail Opportunities (fill parent + opportunity) ──
734
917
  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`;
918
+ md += `## Long-tail Opportunities (${s.long_tails.length})\n\n`;
919
+ md += `> Long-tail keywords are lower competition and higher conversion. Each phrase maps to a parent cluster and content type.\n\n`;
920
+ md += `| Phrase | Parent | Opportunity |\n|-------|--------|-------------|\n`;
921
+ for (const l of s.long_tails) {
922
+ const parent = l.parent || l.keyword || inferLongTailParent(l.phrase, s.keyword_gaps) || '';
923
+ const opportunity = l.opportunity || l.rationale || inferLongTailOpportunity(l) || '';
924
+ md += `| ${l.phrase || ''} | ${parent} | ${opportunity} |\n`;
925
+ }
737
926
  md += '\n';
738
927
  }
928
+
929
+ // ── New Pages to Create (fill rationale from 'why' field) ──
739
930
  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`;
931
+ md += `## New Pages to Create (${s.new_pages.length})\n\n`;
932
+ 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`;
933
+ md += `| Title | Target Keyword | Rationale |\n|-------|----------------|----------|\n`;
934
+ for (const p of s.new_pages) {
935
+ const rationale = p.rationale || p.why || p.content_angle || '';
936
+ md += `| ${p.title || ''} | ${p.target_keyword || ''} | ${rationale} |\n`;
937
+ }
742
938
  md += '\n';
743
939
  }
940
+
941
+ // ── Content Gaps (fill gap + suggestion from covered_by, why_it_matters, suggested_title) ──
744
942
  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`;
943
+ md += `## Content Gaps (${s.content_gaps.length})\n\n`;
944
+ md += `> Topics your competitors cover that you don't. Prioritise gaps where multiple competitors have content that signals proven search demand.\n\n`;
945
+ md += `| Topic | Gap | Suggestion |\n|-------|-----|------------|\n`;
946
+ for (const g of s.content_gaps) {
947
+ const gap = g.gap || (g.covered_by?.length ? `Covered by ${g.covered_by.join(', ')}` : '') || g.why_it_matters || '';
948
+ const suggestion = g.suggestion || g.suggested_title || (g.format ? `Create a ${g.format} covering this topic` : '') || '';
949
+ md += `| ${g.topic || ''} | ${gap} | ${suggestion} |\n`;
950
+ }
747
951
  md += '\n';
748
952
  }
953
+
954
+ // ── Keyword Ideas (fill potential from priority) ──
749
955
  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`;
956
+ md += `## Keyword Ideas (${s.keyword_inventor.length})\n\n`;
957
+ md += `> Clustered keyword suggestions for content planning. High-potential keywords are questions, comparisons, or high-priority phrases matching your product features.\n\n`;
958
+ md += `| Phrase | Cluster | Potential |\n|-------|---------|----------|\n`;
959
+ for (const k of s.keyword_inventor.slice(0, 50)) {
960
+ const potential = k.potential || k.volume || inferPotential(k) || '';
961
+ md += `| ${k.phrase || ''} | ${k.cluster || ''} | ${potential} |\n`;
962
+ }
752
963
  if (s.keyword_inventor.length > 50) md += `\n_...and ${s.keyword_inventor.length - 50} more._\n`;
753
964
  md += '\n';
754
965
  }
966
+
967
+ // ── Positioning Strategy ──
755
968
  if (s.positioning) {
756
969
  md += `## Positioning Strategy\n\n`;
757
970
  if (s.positioning.open_angle) md += `**Open angle:** ${s.positioning.open_angle}\n\n`;
758
971
  if (s.positioning.target_differentiator) md += `**Differentiator:** ${s.positioning.target_differentiator}\n\n`;
759
972
  if (s.positioning.competitor_map) md += `**Competitor map:** ${s.positioning.competitor_map}\n\n`;
760
973
  }
974
+
975
+ // ── AI Citability ──
761
976
  if (s.citability_summary) {
762
977
  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
978
  }
764
979
  if (s.citability_low_scores?.length) {
765
- md += `### Pages Needing Improvement\n\n| Score | URL | Tier |\n|-------|-----|------|\n`;
980
+ md += `### Pages Needing Improvement\n\n`;
981
+ 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`;
982
+ md += `| Score | URL | Tier |\n|-------|-----|------|\n`;
766
983
  for (const p of s.citability_low_scores) md += `| ${p.score} | ${p.url || ''} | ${p.tier || ''} |\n`;
767
984
  md += '\n';
768
985
  }
986
+
987
+ // ── Schema Types ──
769
988
  if (s.schema_types?.length) {
770
989
  md += `## Schema Types (own site)\n\n| Type | Count |\n|------|-------|\n`;
771
990
  for (const t of s.schema_types) md += `| ${t.type || t.schema_type || ''} | ${t.count || ''} |\n`;
772
991
  md += '\n';
773
992
  }
993
+
994
+ // ── Crawl Info ──
774
995
  if (s.crawl_stats) {
775
996
  md += `## Crawl Info\n\n- Last crawl: ${s.crawl_stats.lastCrawl || 'N/A'}\n- Extracted pages: ${s.crawl_stats.extractedPages || 0}\n`;
776
997
  }
@@ -800,8 +1021,56 @@ async function handleRequest(req, res) {
800
1021
  return [keys.join(','), ...rows.map(r => keys.map(k => escape(r[k])).join(','))].join('\n');
801
1022
  }
802
1023
 
1024
+ // ── AI Smart Export enrichment ──
1025
+ async function aiEnrichMarkdown(md, proj) {
1026
+ const prompt = `You are an SEO strategist reviewing a data export report. Your job is to ENRICH this report, NOT rewrite it.
1027
+
1028
+ Rules:
1029
+ - Keep ALL existing data, tables, headers, and instruction blocks exactly as they are
1030
+ - Fill any empty table cells (marked with empty | | columns) with concise, actionable content
1031
+ - For empty "Parent" cells in Long-tail Opportunities: infer the parent keyword cluster
1032
+ - For empty "Opportunity" cells: classify as how-to guide, comparison, tutorial, landing page, etc.
1033
+ - For empty "Gap" cells in Content Gaps: describe what content is missing
1034
+ - For empty "Suggestion" cells: give a specific content format and angle
1035
+ - For empty "Rationale" cells: explain why this page matters for SEO
1036
+ - For empty "Potential" cells: rate as High/Medium/Low based on keyword type
1037
+ - 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
1038
+ - Keep the same markdown format — tables, headers, blockquotes
1039
+ - Be concise — table cells should be under 80 chars
1040
+ - Do NOT add commentary, preamble, or explanation outside the report
1041
+
1042
+ Here is the report to enrich:
1043
+
1044
+ ${md}`;
1045
+ return new Promise((resolve) => {
1046
+ const child = spawn('gemini', ['-p', '-'], {
1047
+ stdio: ['pipe', 'pipe', 'pipe'],
1048
+ env: process.env,
1049
+ timeout: 120000,
1050
+ });
1051
+ let stdout = '', stderr = '';
1052
+ child.stdout.on('data', (d) => { stdout += d.toString(); });
1053
+ child.stderr.on('data', (d) => { stderr += d.toString(); });
1054
+ child.on('error', (err) => {
1055
+ console.warn('[ai-export] Gemini spawn failed:', err.message);
1056
+ resolve(md + `\n\n> _AI enrichment unavailable: ${err.message}_\n`);
1057
+ });
1058
+ child.on('close', (code) => {
1059
+ if (code === 0 && stdout.trim()) {
1060
+ resolve(stdout);
1061
+ } else {
1062
+ console.warn('[ai-export] Gemini exited', code, stderr.slice(0, 200));
1063
+ resolve(md + `\n\n> _AI enrichment unavailable: gemini exited ${code}_\n`);
1064
+ }
1065
+ });
1066
+ child.stdin.write(prompt);
1067
+ child.stdin.end();
1068
+ });
1069
+ }
1070
+
803
1071
  // ── Build and serve ──
804
1072
  const sections = buildDashboardExport(dash);
1073
+ const useAi = url.searchParams.get('ai') === 'true';
805
1074
 
806
1075
  if (format === 'json') {
807
1076
  const content = JSON.stringify({ project, date: dateStr, ...sections }, null, 2);
@@ -809,8 +1078,9 @@ async function handleRequest(req, res) {
809
1078
  res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Disposition': `attachment; filename="${fileName}"` });
810
1079
  res.end(content);
811
1080
  } else if (format === 'md') {
812
- const content = dashboardToMarkdown(sections, project);
813
- const fileName = `${project}-${dateStr}.md`;
1081
+ let content = dashboardToMarkdown(sections, project);
1082
+ if (useAi) content = await aiEnrichMarkdown(content, project);
1083
+ const fileName = `${project}-${useAi ? 'ai-' : ''}${dateStr}.md`;
814
1084
  res.writeHead(200, { 'Content-Type': 'text/markdown; charset=utf-8', 'Content-Disposition': `attachment; filename="${fileName}"` });
815
1085
  res.end(content);
816
1086
  } else if (format === 'csv') {
@@ -821,7 +1091,9 @@ async function handleRequest(req, res) {
821
1091
  } else if (format === 'zip') {
822
1092
  const entries = [];
823
1093
  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) });
1094
+ let mdContent = dashboardToMarkdown(sections, project);
1095
+ if (useAi) mdContent = await aiEnrichMarkdown(mdContent, project);
1096
+ entries.push({ name: `${project}-${useAi ? 'ai-' : ''}${dateStr}.md`, content: mdContent });
825
1097
  const csv = toCSV(sections);
826
1098
  if (csv) entries.push({ name: `${project}-${dateStr}.csv`, content: csv });
827
1099
  const zipBuf = createZip(entries);
@@ -884,6 +1156,11 @@ async function handleRequest(req, res) {
884
1156
  args.push('--output', outFile);
885
1157
  args.push('--format', 'brief');
886
1158
  }
1159
+ // Auto-save AEO audit output
1160
+ if (command === 'aeo' && project) {
1161
+ const ts = new Date().toISOString().slice(0, 10);
1162
+ args.push('--save');
1163
+ }
887
1164
 
888
1165
  // SSE headers
889
1166
  res.writeHead(200, {