seo-intel 1.5.21 → 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.
@@ -42,7 +42,7 @@ function inferPotential(item) {
42
42
  return 'Medium';
43
43
  }
44
44
 
45
- export function buildScanMarkdown(dash, projectSlug, domain) {
45
+ export function buildScanMarkdown(dash, projectSlug, domain, scanMeta = {}) {
46
46
  const date = new Date().toISOString().slice(0, 10);
47
47
  const a = dash.latestAnalysis || {};
48
48
  const s = {};
@@ -75,6 +75,17 @@ export function buildScanMarkdown(dash, projectSlug, domain) {
75
75
  md += `- H1: ${s.technical.h1_coverage} | Meta: ${s.technical.meta_coverage} | Schema: ${s.technical.schema_coverage} | Title: ${s.technical.title_coverage}\n\n`;
76
76
  }
77
77
 
78
+ // ── Redirect / infrastructure warnings from scan probe ──
79
+ if (scanMeta.wwwRedirectMissing) {
80
+ const bare = scanMeta.bareDomain || domain.replace(/^www\./, '');
81
+ if (!s.technical_gaps) s.technical_gaps = [];
82
+ s.technical_gaps.unshift({
83
+ gap: 'Missing www redirect',
84
+ affected: bare,
85
+ fix: `Set up a 301 redirect from https://${bare} to https://www.${bare}. On Cloudflare: use a Page Rule or Redirect Rule with wildcard. This consolidates link equity and prevents duplicate-domain indexing.`,
86
+ });
87
+ }
88
+
78
89
  if (s.technical_gaps?.length) {
79
90
  md += `## Technical Gaps (${s.technical_gaps.length})\n\n`;
80
91
  md += `> Implement these schema and markup fixes to qualify for rich results. Start with FAQ and HowTo schema.\n\n`;
@@ -102,10 +113,19 @@ export function buildScanMarkdown(dash, projectSlug, domain) {
102
113
  }
103
114
 
104
115
  if (s.keyword_gaps?.length) {
105
- md += `## Keyword Opportunities (${s.keyword_gaps.length})\n\n`;
106
- md += `> Keywords identified from your content that could be targeted more aggressively.\n\n`;
107
- md += `| Keyword | Coverage | Priority |\n|---------|----------|----------|\n`;
108
- for (const g of s.keyword_gaps) md += `| ${g.keyword || ''} | ${g.your_coverage || g.target_count || 'low'} | ${g.priority || ''} |\n`;
116
+ // Solo mode: show search demand + source; competitive mode: show competitor coverage
117
+ const hasCoverage = s.keyword_gaps.some(g => g.competitor_count != null);
118
+ md += `## Keyword ${hasCoverage ? 'Gaps' : 'Opportunities'} (${s.keyword_gaps.length})\n\n`;
119
+ if (hasCoverage) {
120
+ const highCount = s.keyword_gaps.filter(g => (g.competitor_count || 0) >= 4).length;
121
+ md += `> **${highCount} high-priority gaps** (competitor coverage >= 4). Focus on gaps that match existing product features.\n\n`;
122
+ md += `| Keyword | Your Coverage | Competitor Coverage |\n|---------|--------------|--------------------||\n`;
123
+ for (const g of s.keyword_gaps) md += `| ${g.keyword || ''} | ${g.your_coverage || 'none'} | ${g.competitor_count || ''} |\n`;
124
+ } else {
125
+ md += `> Keywords identified from site content and industry research.\n\n`;
126
+ md += `| Keyword | Search Demand | Source | Priority |\n|---------|---------------|--------|----------|\n`;
127
+ for (const g of s.keyword_gaps) md += `| ${g.keyword || ''} | ${g.search_demand || 'medium'} | ${g.source || 'industry research'} | ${g.priority || ''} |\n`;
128
+ }
109
129
  md += '\n';
110
130
  }
111
131
 
@@ -144,11 +164,14 @@ export function buildScanMarkdown(dash, projectSlug, domain) {
144
164
  }
145
165
 
146
166
  if (s.content_gaps?.length) {
147
- md += `## Content Gaps (${s.content_gaps.length})\n\n`;
148
- md += `> Topics you should cover but currently don't.\n\n`;
149
- md += `| Topic | Gap | Suggestion |\n|-------|-----|------------|\n`;
167
+ const hasCoveredBy = s.content_gaps.some(g => g.covered_by?.length);
168
+ md += `## ${hasCoveredBy ? 'Content Gaps' : 'Content Expansion'} (${s.content_gaps.length})\n\n`;
169
+ md += `> ${hasCoveredBy ? 'Topics your competitors cover that you don\'t. Prioritise gaps where multiple competitors have content.' : 'Topics you should cover based on industry norms and audience needs.'}\n\n`;
170
+ md += `| Topic | ${hasCoveredBy ? 'Gap' : 'Why It Matters'} | Suggestion |\n|-------|${hasCoveredBy ? '-----|' : '----------------|'}------------|\n`;
150
171
  for (const g of s.content_gaps) {
151
- const gap = g.gap || (g.covered_by?.length ? `Covered by ${g.covered_by.join(', ')}` : '') || g.why_it_matters || '';
172
+ const gap = hasCoveredBy
173
+ ? (g.gap || (g.covered_by?.length ? `Covered by ${g.covered_by.join(', ')}` : '') || g.why_it_matters || '')
174
+ : (g.why_it_matters || g.gap || '');
152
175
  const suggestion = g.suggestion || g.suggested_title || (g.format ? `Create a ${g.format} covering this topic` : '') || '';
153
176
  md += `| ${g.topic || ''} | ${gap} | ${suggestion} |\n`;
154
177
  }
@@ -170,6 +193,7 @@ export function buildScanMarkdown(dash, projectSlug, domain) {
170
193
  if (s.positioning.open_angle) md += `**Open angle:** ${s.positioning.open_angle}\n\n`;
171
194
  if (s.positioning.target_differentiator) md += `**Differentiator:** ${s.positioning.target_differentiator}\n\n`;
172
195
  if (s.positioning.competitor_map) md += `**Competitor map:** ${s.positioning.competitor_map}\n\n`;
196
+ if (s.positioning.market_context) md += `**Market context:** ${s.positioning.market_context}\n\n`;
173
197
  }
174
198
 
175
199
  if (s.crawl_stats) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "seo-intel",
3
- "version": "1.5.21",
3
+ "version": "1.5.23",
4
4
  "description": "Local Ahrefs-style SEO competitor intelligence. Crawl → SQLite → cloud analysis.",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -43,7 +43,7 @@ function cardExportHtml(section, project) {
43
43
  */
44
44
  export function gatherProjectData(db, project, config) {
45
45
  const targetDomain = config.target.domain;
46
- const competitorDomains = config.competitors.map(c => c.domain);
46
+ const competitorDomains = (config.competitors || []).map(c => c.domain);
47
47
  const allDomains = [targetDomain, ...competitorDomains];
48
48
 
49
49
  // Domain architecture needs the raw owned domains, so gather BEFORE merge
@@ -179,9 +179,18 @@ export function generateHtmlDashboard(db, project, config) {
179
179
  * @returns {string} Path to generated HTML file
180
180
  */
181
181
  export function generateMultiDashboard(db, configs) {
182
- const allProjectData = configs.map(config =>
183
- gatherProjectData(db, config.project, config)
184
- );
182
+ const allProjectData = configs.flatMap(config => {
183
+ try {
184
+ return [gatherProjectData(db, config.project, config)];
185
+ } catch (err) {
186
+ console.error(`[dashboard] Error gathering data for project "${config.project}":`, err.message);
187
+ return [];
188
+ }
189
+ });
190
+
191
+ if (!allProjectData.length) {
192
+ throw new Error('No project data could be gathered — all projects failed.');
193
+ }
185
194
 
186
195
  const html = buildMultiHtmlTemplate(allProjectData);
187
196
  const outPath = join(__dirname, 'all-projects-dashboard.html');
@@ -2352,6 +2361,13 @@ function buildHtmlTemplate(data, opts = {}) {
2352
2361
  return;
2353
2362
  }
2354
2363
 
2364
+ // Serve is not a terminal command — the server is already running
2365
+ if (command === 'serve') {
2366
+ appendLine('Server is already running (you are connected to it).', 'stdout');
2367
+ appendLine('To restart: stop the server and run seo-intel serve again.', 'stdout');
2368
+ return;
2369
+ }
2370
+
2355
2371
  if (!isServed) {
2356
2372
  appendLine('', 'cmd');
2357
2373
  appendLine('Not connected to server. Run in your terminal:', 'error');
@@ -2366,7 +2382,12 @@ function buildHtmlTemplate(data, opts = {}) {
2366
2382
  status.style.color = 'var(--color-warning)';
2367
2383
 
2368
2384
  const params = new URLSearchParams({ command });
2369
- if (proj) params.set('project', proj);
2385
+ // scan uses domain param; all other commands use project
2386
+ if (command === 'scan') {
2387
+ if (proj) params.set('domain', proj);
2388
+ } else {
2389
+ if (proj) params.set('project', proj);
2390
+ }
2370
2391
  if (extra?.scope) params.set('scope', extra.scope);
2371
2392
  if (extra?.stealth) params.set('stealth', 'true');
2372
2393
  if (extra?.format) params.set('format', extra.format);
@@ -2374,7 +2395,7 @@ function buildHtmlTemplate(data, opts = {}) {
2374
2395
  var stealthFlag = extra?.stealth ? ' --stealth' : '';
2375
2396
  appendLine('$ seo-intel ' + command + (proj ? ' ' + proj : '') + stealthFlag + (extra?.scope ? ' --scope ' + extra.scope : ''), 'cmd');
2376
2397
 
2377
- var isCrawlOrExtract = (command === 'crawl' || command === 'extract');
2398
+ var isCrawlOrExtract = (command === 'crawl' || command === 'extract' || command === 'scan');
2378
2399
 
2379
2400
  eventSource = new EventSource('/api/terminal?' + params.toString());
2380
2401
  eventSource.onmessage = function(e) {
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
- const config = JSON.parse(readFileSync(join(configDir, file), 'utf8'));
204
- const project = file.replace('.json', '');
205
- 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;
206
- if (pageCount > 0) activeConfigs.push(config);
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) {
@@ -1123,7 +1128,7 @@ ${md}`;
1123
1128
  const ALLOWED = ['crawl', 'extract', 'analyze', 'export-actions', 'competitive-actions',
1124
1129
  'suggest-usecases', 'html', 'status', 'brief', 'keywords', 'report', 'guide',
1125
1130
  'schemas', 'headings-audit', 'orphans', 'entities', 'friction', 'shallow', 'decay', 'export', 'templates',
1126
- 'aeo', 'blog-draft', 'gap-intel', 'watch'];
1131
+ 'aeo', 'blog-draft', 'gap-intel', 'watch', 'scan'];
1127
1132
 
1128
1133
  if (!command || !ALLOWED.includes(command)) {
1129
1134
  json(res, 400, { error: `Invalid command. Allowed: ${ALLOWED.join(', ')}` });
@@ -1132,8 +1137,20 @@ ${md}`;
1132
1137
 
1133
1138
  // Build args
1134
1139
  const args = ['cli.js', command];
1135
- if (project && command !== 'status' && command !== 'html') args.push(project);
1136
- if (params.get('stealth') === 'true') args.push('--stealth');
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
+ }
1137
1154
  if (params.get('scope')) args.push('--scope', params.get('scope'));
1138
1155
  if (params.get('format')) args.push('--format', params.get('format'));
1139
1156
  if (params.get('topic')) args.push('--topic', params.get('topic'));
@@ -1174,7 +1191,7 @@ ${md}`;
1174
1191
  res.write(`data: ${JSON.stringify({ type, data })}\n\n`);
1175
1192
  };
1176
1193
 
1177
- const isLongRunning = ['crawl', 'extract'].includes(command);
1194
+ const isLongRunning = ['crawl', 'extract', 'scan'].includes(command);
1178
1195
 
1179
1196
  send('start', { command, project, args: args.slice(1) });
1180
1197
 
package/setup/checks.js CHANGED
@@ -75,7 +75,27 @@ export async function checkOllamaRemote(host) {
75
75
  }
76
76
  }
77
77
 
78
- // ── Ollama auto-detect (local → custom hosts) ──────────────────────────────
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
- const remote = await checkOllamaRemote(host);
93
- allHosts.push({ host: remote.host, mode: 'remote', models: remote.models, reachable: remote.reachable });
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
- // 3. Local installed but not running or no models
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
@@ -15,6 +15,7 @@ export {
15
15
  checkOllamaLocal,
16
16
  checkOllamaRemote,
17
17
  checkOllamaAuto,
18
+ checkLmStudio,
18
19
  checkPlaywright,
19
20
  checkNpmDeps,
20
21
  checkEnvFile,
@@ -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
- const result = await checkOllamaRemote(host);
317
- jsonResponse(res, result);
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
  }
package/setup/wizard.html CHANGED
@@ -1344,7 +1344,7 @@ input::placeholder {
1344
1344
  <!-- Ollama Hosts -->
1345
1345
  <div id="ollamaHostsPanel" style="margin-bottom:16px; padding:12px 14px; background:var(--bg-card); border:1px solid var(--border-card); border-radius:var(--radius);">
1346
1346
  <div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:8px;">
1347
- <span style="font-size:0.72rem; font-weight:600; color:var(--text-muted); letter-spacing:0.04em; text-transform:uppercase;">Ollama Hosts</span>
1347
+ <span style="font-size:0.72rem; font-weight:600; color:var(--text-muted); letter-spacing:0.04em; text-transform:uppercase;">Extraction Hosts</span>
1348
1348
  <button class="btn btn-sm" onclick="addOllamaHost()" style="padding:3px 8px; font-size:0.6rem;"><i class="fa-solid fa-plus"></i> Add LAN Host</button>
1349
1349
  </div>
1350
1350
  <div id="ollamaHostList" style="display:flex; flex-direction:column; gap:6px;">
@@ -1359,14 +1359,14 @@ input::placeholder {
1359
1359
  <p id="pingResult" style="font-size:0.6rem; color:var(--text-muted); margin-top:4px;"></p>
1360
1360
  </div>
1361
1361
  <p style="font-size:0.58rem; color:var(--text-muted); margin-top:6px; line-height:1.4;">
1362
- Run Ollama on any machine in your network. SEO Intel auto-detects localhost and falls back through configured hosts.
1362
+ Ollama (port 11434) or LM Studio (port 1234) on any machine. SEO Intel auto-detects both and falls back through configured hosts.
1363
1363
  </p>
1364
1364
  </div>
1365
1365
 
1366
1366
  <div class="model-columns">
1367
1367
  <div class="model-section" id="extractionSection">
1368
1368
  <h3><i class="fa-solid fa-robot"></i> Extraction Tier</h3>
1369
- <p class="section-note">Local Ollama model for structured data extraction during crawl</p>
1369
+ <p class="section-note">Local model (Ollama or LM Studio) for structured data extraction during crawl</p>
1370
1370
  <div id="extractionModels">
1371
1371
  <!-- Populated by JS -->
1372
1372
  </div>
@@ -1399,7 +1399,7 @@ input::placeholder {
1399
1399
  </div>
1400
1400
  <div class="model-section" id="analysisSection">
1401
1401
  <h3><i class="fa-solid fa-brain"></i> Analysis Tier</h3>
1402
- <p class="section-note">Local Ollama model for competitive gap analysis (needs more VRAM than extraction)</p>
1402
+ <p class="section-note">Local or cloud model for competitive gap analysis (needs more VRAM than extraction)</p>
1403
1403
  <div id="analysisModels">
1404
1404
  <!-- Populated by JS -->
1405
1405
  </div>
@@ -2245,13 +2245,15 @@ input::placeholder {
2245
2245
  list.innerHTML = allHosts.map(h => {
2246
2246
  const hostname = h.mode === 'local' ? 'localhost' : new URL(h.host).hostname;
2247
2247
  const port = new URL(h.host).port || '11434';
2248
+ const isLmStudio = h.mode === 'lmstudio' || port === '1234';
2248
2249
  const dot = h.reachable ? 'color:var(--color-success)' : 'color:var(--color-danger); opacity:0.5';
2249
2250
  const status = h.reachable ? `${h.models.length} model(s)` : 'unreachable';
2251
+ const typeLabel = isLmStudio ? '<span style="font-size:0.5rem; padding:1px 4px; border-radius:3px; background:rgba(180,160,255,0.12); color:#b4a0ff; margin-left:4px;">LM Studio</span>' : '';
2250
2252
  const active = h.host === state.systemStatus.ollama.host;
2251
2253
  return `
2252
2254
  <div style="display:flex; align-items:center; gap:8px; padding:6px 8px; background:${active ? 'rgba(142,203,168,0.06)' : 'transparent'}; border:1px solid ${active ? 'rgba(142,203,168,0.15)' : 'var(--border-subtle)'}; border-radius:6px;">
2253
2255
  <i class="fa-solid fa-circle" style="font-size:0.45rem; ${dot}"></i>
2254
- <span style="font-family:var(--font-mono); font-size:0.68rem; color:var(--text-secondary);">${hostname}:${port}</span>
2256
+ <span style="font-family:var(--font-mono); font-size:0.68rem; color:var(--text-secondary);">${hostname}:${port}</span>${typeLabel}
2255
2257
  <span style="font-size:0.6rem; color:var(--text-muted); margin-left:auto;">${status}</span>
2256
2258
  ${active ? '<span style="font-size:0.55rem; padding:1px 6px; border-radius:3px; background:rgba(142,203,168,0.12); color:#8ecba8;">active</span>' : ''}
2257
2259
  ${!h.reachable && h.mode !== 'local' ? `<button class="btn btn-sm" style="padding:1px 5px; font-size:0.5rem;" onclick="removeOllamaHost('${h.host}')"><i class="fa-solid fa-xmark"></i></button>` : ''}
@@ -3368,7 +3370,7 @@ input::placeholder {
3368
3370
  ['Project', slug],
3369
3371
  ['Target Domain', document.getElementById('cfgTargetUrl').value || '-'],
3370
3372
  ['Extraction', state.selectedExtraction || 'none'],
3371
- ['Analysis', state.selectedAnalysis || 'none'],
3373
+ ['Analysis', (state.selectedAnalysis || 'none').replace(/^cloud:/, '')],
3372
3374
  ['Crawl Mode', state.crawlMode],
3373
3375
  ['Competitor Domains', getListValues('competitorList').length + ' configured'],
3374
3376
  ['Search Console', state.gscUploaded ? 'Connected' : 'Not configured'],