seo-intel 1.5.1 → 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();
@@ -594,7 +704,6 @@ async function handleRequest(req, res) {
594
704
  const project = url.searchParams.get('project');
595
705
  const section = url.searchParams.get('section') || 'all';
596
706
  const format = url.searchParams.get('format') || 'json';
597
- const profile = url.searchParams.get('profile'); // dev | content | ai-pipeline
598
707
 
599
708
  if (!project || !/^[a-z0-9_-]+$/i.test(project)) { json(res, 400, { error: 'Invalid project name' }); return; }
600
709
 
@@ -611,140 +720,156 @@ async function handleRequest(req, res) {
611
720
  // ── Gather dashboard data — same source as the HTML dashboard ──
612
721
  const dash = gatherProjectData(db, project, config);
613
722
 
614
- // ── Build export from dashboard data — exactly what the UI shows ──
615
- function buildDashboardExport(dash, prof) {
723
+ // ── Build unified export from dashboard data ──
724
+ function buildDashboardExport(dash) {
616
725
  const a = dash.latestAnalysis || {};
617
726
  const sections = {};
618
727
 
619
- // ── Technical: own-site scorecard (summary, not per-page dump) ──
620
- if (!prof || prof === 'dev' || prof === 'ai-pipeline') {
621
- const target = dash.technicalScores?.find(d => d.isTarget);
622
- if (target) {
623
- sections.technical = {
624
- score: target.score,
625
- h1_coverage: target.h1Pct + '%',
626
- meta_coverage: target.metaPct + '%',
627
- schema_coverage: target.schemaPct + '%',
628
- title_coverage: target.titlePct + '%',
629
- };
630
- }
631
- if (a.technical_gaps?.length) sections.technical_gaps = a.technical_gaps;
728
+ // ── Status: scorecard + crawl ──
729
+ const target = dash.technicalScores?.find(d => d.isTarget);
730
+ if (target) {
731
+ sections.technical = {
732
+ score: target.score,
733
+ h1_coverage: target.h1Pct + '%',
734
+ meta_coverage: target.metaPct + '%',
735
+ schema_coverage: target.schemaPct + '%',
736
+ title_coverage: target.titlePct + '%',
737
+ };
632
738
  }
633
739
 
634
- // ── Quick Wins ──
635
- if (!prof || prof === 'dev' || prof === 'ai-pipeline') {
636
- if (a.quick_wins?.length) sections.quick_wins = a.quick_wins;
637
- }
638
-
639
- // ── Internal Links: summary stats, not raw links ──
640
- if (!prof || prof === 'dev' || prof === 'ai-pipeline') {
641
- if (dash.internalLinks) {
642
- sections.internal_links = {
643
- total_links: dash.internalLinks.totalLinks,
644
- orphan_pages: dash.internalLinks.orphanCount,
645
- top_pages: dash.internalLinks.topPages,
740
+ // ── Site Watch ──
741
+ if (dash.watchData?.events?.length) {
742
+ const critical = dash.watchData.events.filter(e => e.severity === 'error' || e.severity === 'warning');
743
+ if (critical.length) sections.watch_alerts = critical;
744
+ if (dash.watchData.snapshot) {
745
+ sections.watch_summary = {
746
+ health_score: dash.watchData.snapshot.health_score,
747
+ errors: dash.watchData.snapshot.errors_count,
748
+ warnings: dash.watchData.snapshot.warnings_count,
646
749
  };
647
750
  }
648
751
  }
649
752
 
650
- // ── Keyword Gaps ──
651
- if (!prof || prof === 'content' || prof === 'ai-pipeline') {
652
- if (a.keyword_gaps?.length) sections.keyword_gaps = a.keyword_gaps;
653
- if (dash.keywordGaps?.length) {
654
- sections.top_keyword_gaps = dash.keywordGaps.slice(0, 50);
655
- }
656
- }
657
-
658
- // ── Long-tail Opportunities ──
659
- if (!prof || prof === 'content' || prof === 'ai-pipeline') {
660
- if (a.long_tails?.length) sections.long_tails = a.long_tails;
661
- }
662
-
663
- // ── New Pages to Create ──
664
- if (!prof || prof === 'content' || prof === 'ai-pipeline') {
665
- if (a.new_pages?.length) sections.new_pages = a.new_pages;
666
- }
667
-
668
- // ── Content Gaps ──
669
- if (!prof || prof === 'content' || prof === 'ai-pipeline') {
670
- if (a.content_gaps?.length) sections.content_gaps = a.content_gaps;
753
+ // ── Fix Now: technical gaps + quick wins ──
754
+ if (a.technical_gaps?.length) sections.technical_gaps = a.technical_gaps;
755
+ if (a.quick_wins?.length) sections.quick_wins = a.quick_wins;
756
+
757
+ // ── Content Strategy: keywords, gaps, new pages, positioning ──
758
+ if (a.keyword_gaps?.length) sections.keyword_gaps = a.keyword_gaps;
759
+ if (dash.keywordGaps?.length) sections.top_keyword_gaps = dash.keywordGaps.slice(0, 50);
760
+ if (a.long_tails?.length) sections.long_tails = a.long_tails;
761
+ if (a.new_pages?.length) sections.new_pages = a.new_pages;
762
+ if (a.content_gaps?.length) sections.content_gaps = a.content_gaps;
763
+ if (a.positioning) sections.positioning = a.positioning;
764
+
765
+ // ── AI Citability ──
766
+ if (dash.citabilityData?.scores?.length) {
767
+ const own = dash.citabilityData.scores.filter(s => s.role === 'target' || s.role === 'owned');
768
+ const needsWork = own.filter(s => s.score < 60);
769
+ if (needsWork.length) sections.citability_low_scores = needsWork;
770
+ sections.citability_summary = {
771
+ avg_score: own.length ? Math.round(own.reduce((a, s) => a + s.score, 0) / own.length) : null,
772
+ pages_scored: own.length,
773
+ pages_below_60: needsWork.length,
774
+ };
671
775
  }
672
776
 
673
- // ── Keyword Inventor ──
674
- if (!prof || prof === 'content' || prof === 'ai-pipeline') {
675
- if (a.keyword_inventor?.length) sections.keyword_inventor = a.keyword_inventor;
777
+ // ── Reference: internal links, schema types, keyword ideas ──
778
+ if (dash.internalLinks) {
779
+ sections.internal_links = {
780
+ total_links: dash.internalLinks.totalLinks,
781
+ orphan_pages: dash.internalLinks.orphanCount,
782
+ top_pages: dash.internalLinks.topPages,
783
+ };
676
784
  }
677
-
678
- // ── Positioning Strategy ──
679
- if (!prof || prof === 'content' || prof === 'ai-pipeline') {
680
- if (a.positioning) sections.positioning = a.positioning;
785
+ if (dash.schemaBreakdown?.length) {
786
+ const tgt = dash.schemaBreakdown.find(d => d.isTarget);
787
+ if (tgt?.types?.length) sections.schema_types = tgt.types;
681
788
  }
789
+ if (a.keyword_inventor?.length) sections.keyword_inventor = a.keyword_inventor;
682
790
 
683
- // ── AI Citability (AEO) — own site, low scores only ──
684
- if (!prof || prof === 'content' || prof === 'ai-pipeline') {
685
- if (dash.citabilityData?.scores?.length) {
686
- const own = dash.citabilityData.scores.filter(s => s.role === 'target' || s.role === 'owned');
687
- const needsWork = own.filter(s => s.score < 60);
688
- if (needsWork.length) sections.citability_low_scores = needsWork;
689
- sections.citability_summary = {
690
- avg_score: own.length ? Math.round(own.reduce((a, s) => a + s.score, 0) / own.length) : null,
691
- pages_scored: own.length,
692
- pages_below_60: needsWork.length,
693
- };
694
- }
695
- }
791
+ // ── Crawl Stats ──
792
+ sections.crawl_stats = dash.crawlStats;
696
793
 
697
- // ── Site Watch — errors and warnings only ──
698
- if (!prof || prof === 'dev' || prof === 'ai-pipeline') {
699
- if (dash.watchData?.events?.length) {
700
- const critical = dash.watchData.events.filter(e => e.severity === 'error' || e.severity === 'warning');
701
- if (critical.length) sections.watch_alerts = critical;
702
- if (dash.watchData.snapshot) {
703
- sections.watch_summary = {
704
- health_score: dash.watchData.snapshot.health_score,
705
- errors: dash.watchData.snapshot.errors_count,
706
- warnings: dash.watchData.snapshot.warnings_count,
707
- };
708
- }
709
- }
710
- }
794
+ return sections;
795
+ }
711
796
 
712
- // ── Schema Breakdown own site type counts, not per-page dump ──
713
- if (!prof || prof === 'dev') {
714
- if (dash.schemaBreakdown?.length) {
715
- const target = dash.schemaBreakdown.find(d => d.isTarget);
716
- if (target?.types?.length) sections.schema_types = target.types;
717
- }
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; }
718
809
  }
810
+ return best;
811
+ }
719
812
 
720
- // ── Crawl Stats ──
721
- sections.crawl_stats = dash.crawlStats;
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
+ }
722
826
 
723
- return sections;
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';
724
838
  }
725
839
 
726
- function dashboardToMarkdown(sections, proj, prof) {
840
+ function dashboardToMarkdown(sections, proj) {
727
841
  const date = new Date().toISOString().slice(0, 10);
728
- const label = prof ? { dev: 'Developer', content: 'Content', 'ai-pipeline': 'AI Pipeline' }[prof] : 'Full';
729
- let md = `# SEO Intel — ${label} Report\n\n- Project: ${proj}\n- Date: ${date}\n\n`;
842
+ let md = `# SEO Intel Report ${proj}\n\n- Date: ${date}\n\n`;
730
843
 
731
844
  const s = sections;
732
845
 
846
+ // ── Technical Scorecard ──
733
847
  if (s.technical) {
734
848
  md += `## Technical Scorecard\n\n`;
735
849
  md += `- Overall: **${s.technical.score}/100**\n`;
736
850
  md += `- H1: ${s.technical.h1_coverage} | Meta: ${s.technical.meta_coverage} | Schema: ${s.technical.schema_coverage} | Title: ${s.technical.title_coverage}\n\n`;
737
851
  }
852
+
853
+ // ── Technical Gaps ──
738
854
  if (s.technical_gaps?.length) {
739
- 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`;
740
858
  for (const g of s.technical_gaps) md += `| ${g.gap || g.issue || ''} | ${g.affected || g.pages || ''} | ${g.recommendation || g.fix || ''} |\n`;
741
859
  md += '\n';
742
860
  }
861
+
862
+ // ── Quick Wins ──
743
863
  if (s.quick_wins?.length) {
744
- 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`;
745
868
  for (const w of s.quick_wins) md += `| ${w.page || ''} | ${w.issue || ''} | ${w.fix || ''} | ${w.impact || ''} |\n`;
746
869
  md += '\n';
747
870
  }
871
+
872
+ // ── Internal Links ──
748
873
  if (s.internal_links) {
749
874
  md += `## Internal Links\n\n- Total links: ${s.internal_links.total_links}\n- Orphan pages: ${s.internal_links.orphan_pages}\n`;
750
875
  if (s.internal_links.top_pages?.length) {
@@ -753,6 +878,8 @@ async function handleRequest(req, res) {
753
878
  }
754
879
  md += '\n';
755
880
  }
881
+
882
+ // ── Site Watch ──
756
883
  if (s.watch_summary) {
757
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`;
758
885
  }
@@ -761,56 +888,110 @@ async function handleRequest(req, res) {
761
888
  for (const e of s.watch_alerts) md += `| ${e.event_type} | ${e.severity} | ${e.url || ''} | ${(e.details || '').slice(0, 80)} |\n`;
762
889
  md += '\n';
763
890
  }
891
+
892
+ // ── Keyword Gaps ──
764
893
  if (s.keyword_gaps?.length) {
765
- 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`;
766
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`;
767
899
  md += '\n';
768
900
  }
901
+
902
+ // ── Top Keyword Gaps (fill frequency + gap from competitor_count) ──
769
903
  if (s.top_keyword_gaps?.length) {
770
- md += `## Top Keyword Gaps\n\n| Keyword | Frequency | Your Count | Gap |\n|---------|-----------|------------|-----|\n`;
771
- 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
+ }
772
913
  md += '\n';
773
914
  }
915
+
916
+ // ── Long-tail Opportunities (fill parent + opportunity) ──
774
917
  if (s.long_tails?.length) {
775
- md += `## Long-tail Opportunities (${s.long_tails.length})\n\n| Phrase | Parent | Opportunity |\n|-------|--------|-------------|\n`;
776
- 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
+ }
777
926
  md += '\n';
778
927
  }
928
+
929
+ // ── New Pages to Create (fill rationale from 'why' field) ──
779
930
  if (s.new_pages?.length) {
780
- md += `## New Pages to Create (${s.new_pages.length})\n\n| Title | Target Keyword | Rationale |\n|-------|----------------|----------|\n`;
781
- 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
+ }
782
938
  md += '\n';
783
939
  }
940
+
941
+ // ── Content Gaps (fill gap + suggestion from covered_by, why_it_matters, suggested_title) ──
784
942
  if (s.content_gaps?.length) {
785
- md += `## Content Gaps (${s.content_gaps.length})\n\n| Topic | Gap | Suggestion |\n|-------|-----|------------|\n`;
786
- 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
+ }
787
951
  md += '\n';
788
952
  }
953
+
954
+ // ── Keyword Ideas (fill potential from priority) ──
789
955
  if (s.keyword_inventor?.length) {
790
- md += `## Keyword Ideas (${s.keyword_inventor.length})\n\n| Phrase | Cluster | Potential |\n|-------|---------|----------|\n`;
791
- 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
+ }
792
963
  if (s.keyword_inventor.length > 50) md += `\n_...and ${s.keyword_inventor.length - 50} more._\n`;
793
964
  md += '\n';
794
965
  }
966
+
967
+ // ── Positioning Strategy ──
795
968
  if (s.positioning) {
796
969
  md += `## Positioning Strategy\n\n`;
797
970
  if (s.positioning.open_angle) md += `**Open angle:** ${s.positioning.open_angle}\n\n`;
798
971
  if (s.positioning.target_differentiator) md += `**Differentiator:** ${s.positioning.target_differentiator}\n\n`;
799
972
  if (s.positioning.competitor_map) md += `**Competitor map:** ${s.positioning.competitor_map}\n\n`;
800
973
  }
974
+
975
+ // ── AI Citability ──
801
976
  if (s.citability_summary) {
802
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`;
803
978
  }
804
979
  if (s.citability_low_scores?.length) {
805
- 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`;
806
983
  for (const p of s.citability_low_scores) md += `| ${p.score} | ${p.url || ''} | ${p.tier || ''} |\n`;
807
984
  md += '\n';
808
985
  }
986
+
987
+ // ── Schema Types ──
809
988
  if (s.schema_types?.length) {
810
989
  md += `## Schema Types (own site)\n\n| Type | Count |\n|------|-------|\n`;
811
990
  for (const t of s.schema_types) md += `| ${t.type || t.schema_type || ''} | ${t.count || ''} |\n`;
812
991
  md += '\n';
813
992
  }
993
+
994
+ // ── Crawl Info ──
814
995
  if (s.crawl_stats) {
815
996
  md += `## Crawl Info\n\n- Last crawl: ${s.crawl_stats.lastCrawl || 'N/A'}\n- Extracted pages: ${s.crawl_stats.extractedPages || 0}\n`;
816
997
  }
@@ -840,40 +1021,83 @@ async function handleRequest(req, res) {
840
1021
  return [keys.join(','), ...rows.map(r => keys.map(k => escape(r[k])).join(','))].join('\n');
841
1022
  }
842
1023
 
843
- // ── Build and serve ──
844
- const validProfiles = ['dev', 'content', 'ai-pipeline'];
845
- if (profile && !validProfiles.includes(profile)) {
846
- json(res, 400, { error: `Invalid profile. Allowed: ${validProfiles.join(', ')}` });
847
- return;
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
+ });
848
1069
  }
849
1070
 
850
- const sections = buildDashboardExport(dash, profile);
851
- const profileLabel = profile ? { dev: 'Developer', content: 'Content', 'ai-pipeline': 'AI Pipeline' }[profile] : 'Full';
852
- const tag = profile || 'full';
1071
+ // ── Build and serve ──
1072
+ const sections = buildDashboardExport(dash);
1073
+ const useAi = url.searchParams.get('ai') === 'true';
853
1074
 
854
1075
  if (format === 'json') {
855
- const content = JSON.stringify({ profile: profileLabel, project, date: dateStr, ...sections }, null, 2);
856
- const fileName = `${project}-${tag}-${dateStr}.json`;
1076
+ const content = JSON.stringify({ project, date: dateStr, ...sections }, null, 2);
1077
+ const fileName = `${project}-${dateStr}.json`;
857
1078
  res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Disposition': `attachment; filename="${fileName}"` });
858
1079
  res.end(content);
859
1080
  } else if (format === 'md') {
860
- const content = dashboardToMarkdown(sections, project, profile);
861
- const fileName = `${project}-${tag}-${dateStr}.md`;
1081
+ let content = dashboardToMarkdown(sections, project);
1082
+ if (useAi) content = await aiEnrichMarkdown(content, project);
1083
+ const fileName = `${project}-${useAi ? 'ai-' : ''}${dateStr}.md`;
862
1084
  res.writeHead(200, { 'Content-Type': 'text/markdown; charset=utf-8', 'Content-Disposition': `attachment; filename="${fileName}"` });
863
1085
  res.end(content);
864
1086
  } else if (format === 'csv') {
865
1087
  const content = toCSV(sections);
866
- const fileName = `${project}-${tag}-${dateStr}.csv`;
1088
+ const fileName = `${project}-${dateStr}.csv`;
867
1089
  res.writeHead(200, { 'Content-Type': 'text/csv; charset=utf-8', 'Content-Disposition': `attachment; filename="${fileName}"` });
868
1090
  res.end(content || 'No data.');
869
1091
  } else if (format === 'zip') {
870
1092
  const entries = [];
871
- entries.push({ name: `${project}-${tag}-${dateStr}.json`, content: JSON.stringify({ profile: profileLabel, project, date: dateStr, ...sections }, null, 2) });
872
- entries.push({ name: `${project}-${tag}-${dateStr}.md`, content: dashboardToMarkdown(sections, project, profile) });
1093
+ entries.push({ name: `${project}-${dateStr}.json`, content: JSON.stringify({ project, date: dateStr, ...sections }, null, 2) });
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 });
873
1097
  const csv = toCSV(sections);
874
- if (csv) entries.push({ name: `${project}-${tag}-${dateStr}.csv`, content: csv });
1098
+ if (csv) entries.push({ name: `${project}-${dateStr}.csv`, content: csv });
875
1099
  const zipBuf = createZip(entries);
876
- res.writeHead(200, { 'Content-Type': 'application/zip', 'Content-Disposition': `attachment; filename="${project}-${tag}-${dateStr}.zip"`, 'Content-Length': zipBuf.length });
1100
+ res.writeHead(200, { 'Content-Type': 'application/zip', 'Content-Disposition': `attachment; filename="${project}-${dateStr}.zip"`, 'Content-Length': zipBuf.length });
877
1101
  res.end(zipBuf);
878
1102
  } else {
879
1103
  json(res, 400, { error: 'Invalid format. Allowed: json, csv, md, zip' });
@@ -932,6 +1156,11 @@ async function handleRequest(req, res) {
932
1156
  args.push('--output', outFile);
933
1157
  args.push('--format', 'brief');
934
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
+ }
935
1164
 
936
1165
  // SSE headers
937
1166
  res.writeHead(200, {