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