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/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 +490 -44
- package/server.js +294 -17
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
731
|
-
|
|
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
|
|
736
|
-
|
|
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
|
|
741
|
-
|
|
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
|
|
746
|
-
|
|
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
|
|
751
|
-
|
|
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
|
|
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
|
-
|
|
813
|
-
|
|
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
|
-
|
|
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, {
|