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/CHANGELOG.md +8 -0
- package/Start SEO Intel.command +10 -0
- package/analyses/blog-draft/index.js +62 -10
- package/cli.js +239 -0
- package/lib/scan-export.js +180 -0
- package/package.json +1 -1
- package/reports/generate-html.js +491 -51
- package/server.js +355 -126
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
|
|
615
|
-
function buildDashboardExport(dash
|
|
723
|
+
// ── Build unified export from dashboard data ──
|
|
724
|
+
function buildDashboardExport(dash) {
|
|
616
725
|
const a = dash.latestAnalysis || {};
|
|
617
726
|
const sections = {};
|
|
618
727
|
|
|
619
|
-
// ──
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
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
|
-
// ──
|
|
635
|
-
if (
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
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
|
-
// ──
|
|
651
|
-
if (
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
if (
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
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
|
-
// ──
|
|
674
|
-
if (
|
|
675
|
-
|
|
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
|
-
|
|
679
|
-
|
|
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
|
-
// ──
|
|
684
|
-
|
|
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
|
-
|
|
698
|
-
|
|
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
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
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
|
-
|
|
721
|
-
|
|
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
|
-
|
|
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
|
|
840
|
+
function dashboardToMarkdown(sections, proj) {
|
|
727
841
|
const date = new Date().toISOString().slice(0, 10);
|
|
728
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
771
|
-
|
|
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
|
|
776
|
-
|
|
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
|
|
781
|
-
|
|
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
|
|
786
|
-
|
|
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
|
|
791
|
-
|
|
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
|
|
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
|
-
// ──
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
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
|
-
|
|
851
|
-
const
|
|
852
|
-
const
|
|
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({
|
|
856
|
-
const fileName = `${project}-${
|
|
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
|
-
|
|
861
|
-
|
|
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}-${
|
|
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}-${
|
|
872
|
-
|
|
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}-${
|
|
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}-${
|
|
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, {
|