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.
- package/CHANGELOG.md +26 -0
- package/analyses/aeo/scorer.js +60 -6
- package/analyses/templates/index.js +1 -1
- package/analysis/prompt-builder.js +167 -2
- package/analysis/technical-audit.js +177 -0
- package/cli.js +246 -64
- 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 +33 -9
- package/package.json +1 -1
- package/reports/generate-html.js +27 -6
- package/server.js +25 -8
- 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/lib/scan-export.js
CHANGED
|
@@ -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
|
-
|
|
106
|
-
|
|
107
|
-
md +=
|
|
108
|
-
|
|
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
|
-
|
|
148
|
-
md +=
|
|
149
|
-
md +=
|
|
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 =
|
|
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
package/reports/generate-html.js
CHANGED
|
@@ -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.
|
|
183
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
|
@@ -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
|
-
|
|
1136
|
-
|
|
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
|
-
// ──
|
|
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
|
}
|
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;">
|
|
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
|
-
|
|
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
|
|
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
|
|
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'],
|