seo-intel 1.1.8 → 1.1.10
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/.env.example +2 -0
- package/CHANGELOG.md +51 -26
- package/cli.js +136 -16
- package/crawler/index.js +14 -1
- package/lib/license.js +150 -69
- package/lib/updater.js +16 -15
- package/package.json +1 -1
- package/server.js +3 -5
- package/setup/wizard.html +351 -31
package/.env.example
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
# ── Analysis Model (cloud, pick one) ──────────────────────────────────────
|
|
9
9
|
# Gemini: Best value — 1M context, cheapest (~$0.01-0.05/analysis)
|
|
10
10
|
GEMINI_API_KEY=
|
|
11
|
+
# GEMINI_TIMEOUT_MS=120000
|
|
12
|
+
# OPENCLAW_ANALYSIS_MODEL=default
|
|
11
13
|
|
|
12
14
|
# Claude: Best quality — nuanced strategic reasoning (~$0.10-0.30/analysis)
|
|
13
15
|
# ANTHROPIC_API_KEY=
|
package/CHANGELOG.md
CHANGED
|
@@ -1,42 +1,67 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 1.1.
|
|
3
|
+
## 1.1.10 (2026-03-27)
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
-
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
### Security
|
|
6
|
+
- Fix SSRF: llms.txt URLs now respect robots.txt before enqueue (crawler/index.js)
|
|
7
|
+
|
|
8
|
+
### Fixes
|
|
9
|
+
- SQL injection audit complete — all queries use parameterised statements (no changes needed)
|
|
10
|
+
|
|
11
|
+
### Testing
|
|
12
|
+
- Mock crawl test passes end-to-end: crawls http://localhost:19876, stores 7 pages in SQLite
|
|
13
|
+
- CI: Ubuntu job now runs mock crawl test after smoke checks
|
|
14
|
+
- Fixed mock-crawl-test.js: server binds to 127.0.0.1, CLI resolved from install root, DB assertions corrected
|
|
15
|
+
|
|
16
|
+
## 1.1.9 (2026-03-27)
|
|
17
|
+
|
|
18
|
+
### Security
|
|
19
|
+
- Fix shell injection risk in Gemini CLI integration (execSync → spawnSync + stdin)
|
|
20
|
+
- Fix SSRF vector in llms.txt URL processing (hostname validation)
|
|
21
|
+
- Fix SSRF: llms.txt URLs now respect robots.txt before enqueue
|
|
22
|
+
- Set license cache file permissions to 0600 (owner-only)
|
|
23
|
+
- SQL injection audit — all queries verified to use parameterised statements
|
|
24
|
+
|
|
25
|
+
### Fixes
|
|
26
|
+
- Crawler no longer upgrades http://localhost to https (fixes local/mock testing)
|
|
27
|
+
- Updater cache moved to ~/.seo-intel/ (fixes permission errors on Linux global install)
|
|
28
|
+
- Clear actionable error when Gemini times out and OpenClaw gateway is down
|
|
29
|
+
- Project name passed to error context for timeout messages
|
|
30
|
+
|
|
31
|
+
### Improvements
|
|
32
|
+
- Defensive logging when license variant name is unrecognised
|
|
33
|
+
- Setup wizard: Cloud Analysis column (gemini-3.1-pro, claude-sonnet-4-6, claude-opus-4-6, gpt-5.4, deepseek-r1) with API key input
|
|
34
|
+
- Setup wizard: Agentic Setup picker — OpenClaw, Claude Code, Codex CLI, Perplexity with tailored copy-paste prompts
|
|
35
|
+
- Setup wizard: Step 2 expanded to 1100px, OpenClaw as floating sidebar
|
|
9
36
|
|
|
10
37
|
## 1.1.7 (2026-03-26)
|
|
11
38
|
|
|
12
|
-
### New
|
|
13
|
-
- **Programmatic Template Intelligence** (`seo-intel templates <project>`) — detect URL pattern groups (e.g. `/token/*`, `/blog/*`), stealth-crawl samples, overlay GSC data, and score each group with keep/noindex/improve verdicts
|
|
14
|
-
- **Stale domain auto-pruning** — domains removed from config are
|
|
15
|
-
- **Manual prune** — `seo-intel competitors <project> --prune` to clean stale DB entries on demand
|
|
16
|
-
- **Full body text storage** — crawler
|
|
39
|
+
### New
|
|
40
|
+
- **Programmatic Template Intelligence** (`seo-intel templates <project>`) — detect URL pattern groups (e.g. `/token/*`, `/blog/*`), stealth-crawl samples, overlay GSC data, and score each group with keep/noindex/improve verdicts
|
|
41
|
+
- **Stale domain auto-pruning** — domains removed from config are automatically cleaned from DB on next crawl
|
|
42
|
+
- **Manual prune** — `seo-intel competitors <project> --prune` to clean stale DB entries on demand
|
|
43
|
+
- **Full body text storage** — crawler stores full page content in DB for richer offline extraction
|
|
17
44
|
|
|
18
45
|
### Improvements
|
|
19
|
-
- **Background crawl/extract** — long-running
|
|
20
|
-
- **Dashboard terminal** — stealth flag
|
|
21
|
-
- **Templates button** added to dashboard terminal panel
|
|
22
|
-
- **Dashboard refresh** — crawl and analyze
|
|
23
|
-
- **Config remove = DB remove** — `--remove` and `--remove-owned`
|
|
46
|
+
- **Background crawl/extract** — long-running jobs survive browser tab close
|
|
47
|
+
- **Dashboard terminal** — stealth flag visible, stop button works properly, status bar syncs
|
|
48
|
+
- **Templates button** added to dashboard terminal panel
|
|
49
|
+
- **Dashboard refresh** — crawl and analyze always regenerate full multi-project dashboard
|
|
50
|
+
- **Config remove = DB remove** — `--remove` and `--remove-owned` auto-prune matching DB data
|
|
24
51
|
|
|
25
52
|
### Fixes
|
|
26
|
-
- SSE disconnect no longer kills crawl/extract processes
|
|
27
|
-
- Terminal command display
|
|
53
|
+
- SSE disconnect no longer kills crawl/extract processes
|
|
54
|
+
- Terminal command display shows `--stealth` flag when enabled
|
|
28
55
|
|
|
29
56
|
## 1.1.6 (2026-03-24)
|
|
30
57
|
|
|
31
|
-
- Stop button
|
|
58
|
+
- Stop button for crawl/extract jobs in dashboard
|
|
59
|
+
- Stealth toggle sync between status bar and terminal
|
|
60
|
+
- Extraction status bar layout improvements (CSS grid)
|
|
61
|
+
- EADDRINUSE recovery — server opens existing dashboard instead of crashing
|
|
32
62
|
|
|
33
63
|
## 1.1.5 (2026-03-21)
|
|
34
64
|
|
|
35
|
-
- Update checker, job stop API, background analyze
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
- Rebranded all references from froggo.pro → ukkometa.fi (endpoints, dashboard links, license validation, bot user-agents, skill)
|
|
40
|
-
- Pricing updated: €9.99/mo · €79/yr
|
|
41
|
-
- Contact updated: ukko@ukkometa.fi
|
|
42
|
-
- Added README.md and CHANGELOG.md to npm package and LS zip
|
|
65
|
+
- Update checker, job stop API, background analyze
|
|
66
|
+
- LAN Ollama host support with fallback
|
|
67
|
+
- `html` CLI command, wizard UX improvements
|
package/cli.js
CHANGED
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// ── Node.js version guard ───────────────────────────────────────────────
|
|
4
|
+
const [major, minor] = process.versions.node.split('.').map(Number);
|
|
5
|
+
if (major < 22 || (major === 22 && minor < 5)) {
|
|
6
|
+
console.error(
|
|
7
|
+
`\n SEO Intel requires Node.js 22.5 or later.\n` +
|
|
8
|
+
` You have Node.js ${process.versions.node}.\n\n` +
|
|
9
|
+
` Install the latest LTS: https://nodejs.org\n`
|
|
10
|
+
);
|
|
11
|
+
process.exit(1);
|
|
12
|
+
}
|
|
13
|
+
|
|
2
14
|
import 'dotenv/config';
|
|
3
15
|
import { program } from 'commander';
|
|
4
16
|
import { spawnSync } from 'child_process';
|
|
@@ -47,6 +59,13 @@ checkForUpdates();
|
|
|
47
59
|
try { mkdirSync(join(__dirname, 'reports'), { recursive: true }); } catch { /* ok */ }
|
|
48
60
|
try { mkdirSync(join(__dirname, 'config'), { recursive: true }); } catch { /* ok */ }
|
|
49
61
|
|
|
62
|
+
function defaultSiteUrl(domain) {
|
|
63
|
+
const host = String(domain || '').trim();
|
|
64
|
+
const hostname = host.split(':')[0].replace(/^\[|\]$/g, '');
|
|
65
|
+
const protocol = hostname === 'localhost' || hostname === '127.0.0.1' ? 'http' : 'https';
|
|
66
|
+
return `${protocol}://${host}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
50
69
|
// ── AI AVAILABILITY PREFLIGHT ────────────────────────────────────────────
|
|
51
70
|
/**
|
|
52
71
|
* Check if any AI extraction backend is reachable.
|
|
@@ -435,7 +454,7 @@ program
|
|
|
435
454
|
else if (config.competitors?.includes(site)) site.role = 'competitor';
|
|
436
455
|
else site.role = 'owned';
|
|
437
456
|
}
|
|
438
|
-
if (!site.url && site.domain) site.url =
|
|
457
|
+
if (!site.url && site.domain) site.url = defaultSiteUrl(site.domain);
|
|
439
458
|
}
|
|
440
459
|
|
|
441
460
|
const sites = opts.domain
|
|
@@ -674,7 +693,8 @@ program
|
|
|
674
693
|
console.log(chalk.gray(`Prompt saved: ${promptPath}`));
|
|
675
694
|
|
|
676
695
|
// Call Gemini via gemini CLI (reuse existing auth)
|
|
677
|
-
|
|
696
|
+
process.env._SEO_INTEL_PROJECT = project;
|
|
697
|
+
const result = await callAnalysisModel(prompt, opts.model);
|
|
678
698
|
|
|
679
699
|
if (!result) {
|
|
680
700
|
console.error(chalk.red('No response from model.'));
|
|
@@ -991,17 +1011,117 @@ function loadConfig(project) {
|
|
|
991
1011
|
}
|
|
992
1012
|
|
|
993
1013
|
async function callGemini(prompt) {
|
|
994
|
-
|
|
995
|
-
|
|
1014
|
+
return callAnalysisModel(prompt, 'gemini');
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
function getOpenClawToken() {
|
|
1018
|
+
const envToken = process.env.OPENCLAW_TOKEN?.trim();
|
|
1019
|
+
if (envToken) return envToken;
|
|
1020
|
+
|
|
996
1021
|
try {
|
|
997
|
-
const
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
).
|
|
1001
|
-
|
|
1022
|
+
const configPath = join(process.env.HOME || process.env.USERPROFILE || '', '.openclaw', 'openclaw.json');
|
|
1023
|
+
const raw = readFileSync(configPath, 'utf8');
|
|
1024
|
+
const matches = [...raw.matchAll(/"token":\s*"([a-f0-9]{40,})"/g)];
|
|
1025
|
+
if (matches.length > 0) return matches[matches.length - 1][1];
|
|
1026
|
+
} catch {}
|
|
1027
|
+
|
|
1028
|
+
return null;
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
async function callOpenClaw(prompt, model = 'default') {
|
|
1032
|
+
const token = getOpenClawToken();
|
|
1033
|
+
if (!token) throw new Error('OpenClaw token not found');
|
|
1034
|
+
|
|
1035
|
+
const timeoutMs = parseInt(process.env.OPENCLAW_TIMEOUT_MS || process.env.GEMINI_TIMEOUT_MS || '120000', 10);
|
|
1036
|
+
const controller = new AbortController();
|
|
1037
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
1038
|
+
|
|
1039
|
+
try {
|
|
1040
|
+
const res = await fetch('http://127.0.0.1:18789/v1/chat/completions', {
|
|
1041
|
+
method: 'POST',
|
|
1042
|
+
signal: controller.signal,
|
|
1043
|
+
headers: {
|
|
1044
|
+
'Authorization': `Bearer ${token}`,
|
|
1045
|
+
'Content-Type': 'application/json',
|
|
1046
|
+
},
|
|
1047
|
+
body: JSON.stringify({
|
|
1048
|
+
model: model === 'openclaw' ? 'default' : model,
|
|
1049
|
+
messages: [{ role: 'user', content: prompt }],
|
|
1050
|
+
temperature: 0.2,
|
|
1051
|
+
max_tokens: 4000,
|
|
1052
|
+
}),
|
|
1053
|
+
});
|
|
1054
|
+
|
|
1055
|
+
if (!res.ok) throw new Error(`OpenClaw API error: ${res.status} ${await res.text()}`);
|
|
1056
|
+
|
|
1057
|
+
const data = await res.json();
|
|
1058
|
+
return data.choices?.[0]?.message?.content || null;
|
|
1059
|
+
} finally {
|
|
1060
|
+
clearTimeout(timeout);
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
async function callAnalysisModel(prompt, model = 'gemini') {
|
|
1065
|
+
const requestedModel = String(model || 'gemini').trim();
|
|
1066
|
+
const normalizedModel = requestedModel.toLowerCase();
|
|
1067
|
+
|
|
1068
|
+
if (normalizedModel !== 'gemini') {
|
|
1069
|
+
try {
|
|
1070
|
+
return await callOpenClaw(prompt, requestedModel);
|
|
1071
|
+
} catch (err) {
|
|
1072
|
+
console.error('[openclaw]', err.message);
|
|
1073
|
+
return null;
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
const timeoutMs = parseInt(process.env.GEMINI_TIMEOUT_MS || '120000', 10);
|
|
1078
|
+
try {
|
|
1079
|
+
const result = spawnSync('gemini', ['-p', '-'], {
|
|
1080
|
+
input: prompt,
|
|
1081
|
+
encoding: 'utf8',
|
|
1082
|
+
timeout: timeoutMs,
|
|
1083
|
+
maxBuffer: 10 * 1024 * 1024
|
|
1084
|
+
});
|
|
1085
|
+
|
|
1086
|
+
if (result.error) throw result.error;
|
|
1087
|
+
if (result.status !== 0) {
|
|
1088
|
+
throw new Error(result.stderr?.trim() || `gemini exited with status ${result.status}`);
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
return result.stdout;
|
|
1002
1092
|
} catch (err) {
|
|
1003
|
-
|
|
1004
|
-
|
|
1093
|
+
const fallbackModel = process.env.OPENCLAW_ANALYSIS_MODEL || 'default';
|
|
1094
|
+
try {
|
|
1095
|
+
console.warn(`[gemini] ${err.message}`);
|
|
1096
|
+
console.log(chalk.yellow(`Gemini CLI unavailable, retrying via OpenClaw (${fallbackModel})...\n`));
|
|
1097
|
+
return await callOpenClaw(prompt, fallbackModel);
|
|
1098
|
+
} catch (fallbackErr) {
|
|
1099
|
+
// Produce clear, actionable error messages
|
|
1100
|
+
const geminiMsg = err.message || '';
|
|
1101
|
+
const ocMsg = fallbackErr.message || '';
|
|
1102
|
+
|
|
1103
|
+
const isTimeout = geminiMsg.includes('ETIMEDOUT') || geminiMsg.includes('timeout') || err.name === 'AbortError';
|
|
1104
|
+
const isGatewayDown = ocMsg.includes('ECONNREFUSED') || ocMsg.includes('token not found') || ocMsg.includes('gateway');
|
|
1105
|
+
|
|
1106
|
+
console.error(chalk.red('\n ✗ Analysis failed — no model available\n'));
|
|
1107
|
+
|
|
1108
|
+
if (isTimeout) {
|
|
1109
|
+
console.error(chalk.yellow(' Gemini timed out.') + chalk.dim(' Try: GEMINI_TIMEOUT_MS=180000 seo-intel analyze ' + (process.env._SEO_INTEL_PROJECT || '<project>')));
|
|
1110
|
+
} else {
|
|
1111
|
+
console.error(chalk.dim(` Gemini: ${geminiMsg}`));
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
if (isGatewayDown) {
|
|
1115
|
+
console.error(chalk.yellow(' OpenClaw gateway is not running.'));
|
|
1116
|
+
console.error(chalk.dim(' Start it: ') + chalk.cyan('openclaw gateway'));
|
|
1117
|
+
console.error(chalk.dim(' Or set key: ') + chalk.cyan('echo "GEMINI_API_KEY=your-key" >> .env'));
|
|
1118
|
+
} else {
|
|
1119
|
+
console.error(chalk.dim(` OpenClaw: ${ocMsg}`));
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
console.error(chalk.dim('\n Docs: https://ukkometa.fi/en/seo-intel/setup/\n'));
|
|
1123
|
+
return null;
|
|
1124
|
+
}
|
|
1005
1125
|
}
|
|
1006
1126
|
}
|
|
1007
1127
|
|
|
@@ -1268,8 +1388,8 @@ program
|
|
|
1268
1388
|
if (info.npmVersion) {
|
|
1269
1389
|
console.log(chalk.gray(' npm registry: ') + chalk.white(info.npmVersion));
|
|
1270
1390
|
}
|
|
1271
|
-
if (info.
|
|
1272
|
-
console.log(chalk.gray(' ukkometa.fi: ') + chalk.white(info.
|
|
1391
|
+
if (info.ukkometaVersion) {
|
|
1392
|
+
console.log(chalk.gray(' ukkometa.fi: ') + chalk.white(info.ukkometaVersion));
|
|
1273
1393
|
}
|
|
1274
1394
|
|
|
1275
1395
|
if (!info.hasUpdate) {
|
|
@@ -1428,7 +1548,7 @@ program
|
|
|
1428
1548
|
// ── Add competitor
|
|
1429
1549
|
if (opts.add) {
|
|
1430
1550
|
const domain = domainFromUrl(opts.add);
|
|
1431
|
-
const url = opts.add.startsWith('http') ? opts.add :
|
|
1551
|
+
const url = opts.add.startsWith('http') ? opts.add : defaultSiteUrl(opts.add);
|
|
1432
1552
|
if (config.competitors.some(c => c.domain === domain)) {
|
|
1433
1553
|
console.log(chalk.yellow(` ⚠ ${domain} is already a competitor`));
|
|
1434
1554
|
} else {
|
|
@@ -1455,7 +1575,7 @@ program
|
|
|
1455
1575
|
if (opts.addOwned) {
|
|
1456
1576
|
if (!config.owned) config.owned = [];
|
|
1457
1577
|
const domain = domainFromUrl(opts.addOwned);
|
|
1458
|
-
const url = opts.addOwned.startsWith('http') ? opts.addOwned :
|
|
1578
|
+
const url = opts.addOwned.startsWith('http') ? opts.addOwned : defaultSiteUrl(opts.addOwned);
|
|
1459
1579
|
if (config.owned.some(o => o.domain === domain)) {
|
|
1460
1580
|
console.log(chalk.yellow(` ⚠ ${domain} is already an owned domain`));
|
|
1461
1581
|
} else {
|
|
@@ -1482,7 +1602,7 @@ program
|
|
|
1482
1602
|
// ── Change target
|
|
1483
1603
|
if (opts.setTarget) {
|
|
1484
1604
|
const domain = domainFromUrl(opts.setTarget);
|
|
1485
|
-
const url = opts.setTarget.startsWith('http') ? opts.setTarget :
|
|
1605
|
+
const url = opts.setTarget.startsWith('http') ? opts.setTarget : defaultSiteUrl(opts.setTarget);
|
|
1486
1606
|
config.target = { url, domain, role: 'target' };
|
|
1487
1607
|
config.context.url = url;
|
|
1488
1608
|
console.log(chalk.green(` ✓ Target changed to: ${domain}`));
|
package/crawler/index.js
CHANGED
|
@@ -11,6 +11,7 @@ const MAX_PAGES = parseInt(process.env.CRAWL_MAX_PAGES || '50');
|
|
|
11
11
|
const MAX_DEPTH = parseInt(process.env.CRAWL_MAX_DEPTH || '3');
|
|
12
12
|
const TIMEOUT = parseInt(process.env.CRAWL_TIMEOUT_MS || '12000');
|
|
13
13
|
const PAGE_BUDGET = parseInt(process.env.PAGE_BUDGET_MS || '25000'); // hard per-page wall-clock limit
|
|
14
|
+
const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1']);
|
|
14
15
|
|
|
15
16
|
// ── Content quality gate ────────────────────────────────────────────────
|
|
16
17
|
const SHELL_PATTERNS = /id=["'](root|app|__next|__nuxt)["']|<noscript[^>]*>.*enable javascript/i;
|
|
@@ -190,7 +191,10 @@ export async function* crawlDomain(startUrl, opts = {}) {
|
|
|
190
191
|
const effectiveUA = isDocsHostname ? GOOGLEBOT_UA : defaultUA;
|
|
191
192
|
|
|
192
193
|
async function tryLoadLlmsTxt() {
|
|
193
|
-
const
|
|
194
|
+
const llmsOrigin = base.protocol === 'http:' && !LOOPBACK_HOSTNAMES.has(base.hostname)
|
|
195
|
+
? `https://${base.host}`
|
|
196
|
+
: base.origin;
|
|
197
|
+
const llmsUrl = `${llmsOrigin}/llms.txt`;
|
|
194
198
|
try {
|
|
195
199
|
const controller = new AbortController();
|
|
196
200
|
const t = setTimeout(() => controller.abort(), Math.min(TIMEOUT, 8000));
|
|
@@ -224,6 +228,15 @@ export async function* crawlDomain(startUrl, opts = {}) {
|
|
|
224
228
|
const unique = [...new Set(urls)];
|
|
225
229
|
let added = 0;
|
|
226
230
|
for (const u of unique) {
|
|
231
|
+
try {
|
|
232
|
+
if (new URL(u).hostname !== base.hostname) continue;
|
|
233
|
+
} catch {
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
if (!opts.stealth) {
|
|
237
|
+
const robotsResult = await checkRobots(u).catch(() => ({ allowed: true }));
|
|
238
|
+
if (!robotsResult.allowed) continue;
|
|
239
|
+
}
|
|
227
240
|
if (!queue.some(q => q.url === u)) {
|
|
228
241
|
queue.push({ url: u, depth: 1 });
|
|
229
242
|
added++;
|
package/lib/license.js
CHANGED
|
@@ -1,22 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* SEO Intel — License System
|
|
3
3
|
*
|
|
4
|
-
* Validation
|
|
5
|
-
* 1.
|
|
6
|
-
* 2.
|
|
7
|
-
* 3. No key → Free tier
|
|
4
|
+
* Validation flow:
|
|
5
|
+
* 1. SEO_INTEL_LICENSE → activate/validate against Lemon Squeezy License API
|
|
6
|
+
* 2. No key → Free tier
|
|
8
7
|
*
|
|
9
8
|
* Local cache: ~/.seo-intel/license-cache.json
|
|
10
9
|
* - LS keys: cache 24h, stale up to 7 days if API unreachable
|
|
11
|
-
* - Froggo tokens: cache 24h, stale up to 24h if API unreachable
|
|
12
10
|
* - Beyond stale limit → degrade to free tier + warn
|
|
13
11
|
*
|
|
12
|
+
* Activation flow (Lemon Squeezy):
|
|
13
|
+
* - First run: POST /v1/licenses/activate → stores instance_id locally
|
|
14
|
+
* - Subsequent runs: POST /v1/licenses/validate with instance_id
|
|
15
|
+
* - Machine-specific: instance_name = "seo-intel-<machineId>"
|
|
16
|
+
*
|
|
14
17
|
* Free tier: crawl + raw HTML export only. No AI extraction or analysis.
|
|
15
|
-
* Solo (€19.99/mo or €199.99/yr
|
|
18
|
+
* Solo (€19.99/mo or €199.99/yr): Full AI extraction + analysis, all commands.
|
|
16
19
|
* Agency: Later phase — not sold yet.
|
|
17
20
|
*/
|
|
18
21
|
|
|
19
|
-
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
22
|
+
import { chmodSync, existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
20
23
|
import { join, dirname } from 'path';
|
|
21
24
|
import { fileURLToPath } from 'url';
|
|
22
25
|
import { hostname, userInfo, platform } from 'os';
|
|
@@ -32,8 +35,6 @@ const CACHE_PATH = join(CACHE_DIR, 'license-cache.json');
|
|
|
32
35
|
// Stale limits (ms)
|
|
33
36
|
const LS_CACHE_TTL = 24 * 60 * 60 * 1000; // 24h fresh
|
|
34
37
|
const LS_STALE_LIMIT = 7 * 24 * 60 * 60 * 1000; // 7 days stale max
|
|
35
|
-
const FROGGO_CACHE_TTL = 24 * 60 * 60 * 1000; // 24h fresh
|
|
36
|
-
const FROGGO_STALE_LIMIT = 24 * 60 * 60 * 1000; // 24h stale max
|
|
37
38
|
|
|
38
39
|
// ── Tiers ──────────────────────────────────────────────────────────────────
|
|
39
40
|
|
|
@@ -91,9 +92,18 @@ function writeCache(data) {
|
|
|
91
92
|
try {
|
|
92
93
|
mkdirSync(CACHE_DIR, { recursive: true });
|
|
93
94
|
writeFileSync(CACHE_PATH, JSON.stringify(data, null, 2), 'utf8');
|
|
95
|
+
try { chmodSync(CACHE_PATH, 0o600); } catch {}
|
|
94
96
|
} catch { /* best-effort */ }
|
|
95
97
|
}
|
|
96
98
|
|
|
99
|
+
function getTierFromVariantName(variantName) {
|
|
100
|
+
const normalized = (variantName || '').toLowerCase();
|
|
101
|
+
if (!normalized.includes('agency') && !normalized.includes('solo') && !normalized.includes('pro')) {
|
|
102
|
+
console.warn(`[license] Unknown variant name: "${normalized}" — defaulting to solo`);
|
|
103
|
+
}
|
|
104
|
+
return normalized.includes('agency') ? 'agency' : 'solo';
|
|
105
|
+
}
|
|
106
|
+
|
|
97
107
|
/**
|
|
98
108
|
* Check if cached validation is still usable.
|
|
99
109
|
* Returns { valid, tier, stale } or null if cache is expired/missing.
|
|
@@ -103,123 +113,176 @@ function checkCache(key) {
|
|
|
103
113
|
if (!cache || cache.key !== key) return null;
|
|
104
114
|
|
|
105
115
|
const age = Date.now() - (cache.validatedAt || 0);
|
|
106
|
-
const ttl = cache.source === 'froggo' ? FROGGO_CACHE_TTL : LS_CACHE_TTL;
|
|
107
|
-
const staleLimit = cache.source === 'froggo' ? FROGGO_STALE_LIMIT : LS_STALE_LIMIT;
|
|
108
116
|
|
|
109
|
-
if (age <
|
|
110
|
-
return { valid: true, tier: cache.tier, stale: false, source: cache.
|
|
117
|
+
if (age < LS_CACHE_TTL) {
|
|
118
|
+
return { valid: true, tier: cache.tier, stale: false, source: 'lemon-squeezy', instanceId: cache.instanceId };
|
|
111
119
|
}
|
|
112
|
-
if (age <
|
|
113
|
-
return { valid: true, tier: cache.tier, stale: true, source: cache.
|
|
120
|
+
if (age < LS_STALE_LIMIT) {
|
|
121
|
+
return { valid: true, tier: cache.tier, stale: true, source: 'lemon-squeezy', instanceId: cache.instanceId };
|
|
114
122
|
}
|
|
115
123
|
return null; // Expired beyond stale limit
|
|
116
124
|
}
|
|
117
125
|
|
|
118
|
-
// ── Lemon Squeezy
|
|
126
|
+
// ── Lemon Squeezy License API ────────────────────────────────────────────
|
|
119
127
|
|
|
120
128
|
/**
|
|
121
|
-
*
|
|
122
|
-
*
|
|
129
|
+
* Activate a license key against Lemon Squeezy.
|
|
130
|
+
* POST https://api.lemonsqueezy.com/v1/licenses/activate
|
|
131
|
+
* Params: license_key, instance_name
|
|
132
|
+
* Returns instance_id on success.
|
|
123
133
|
*/
|
|
124
|
-
async function
|
|
134
|
+
async function activateWithLS(key) {
|
|
125
135
|
try {
|
|
126
136
|
const controller = new AbortController();
|
|
127
137
|
const timeout = setTimeout(() => controller.abort(), 8000);
|
|
128
138
|
|
|
129
|
-
const
|
|
139
|
+
const body = new URLSearchParams({
|
|
140
|
+
license_key: key,
|
|
141
|
+
instance_name: `seo-intel-${getMachineId()}`,
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const res = await fetch('https://api.lemonsqueezy.com/v1/licenses/activate', {
|
|
130
145
|
signal: controller.signal,
|
|
131
146
|
method: 'POST',
|
|
132
|
-
headers: { '
|
|
133
|
-
body
|
|
134
|
-
license_key: key,
|
|
135
|
-
instance_name: `seo-intel-${getMachineId()}`,
|
|
136
|
-
}),
|
|
147
|
+
headers: { 'Accept': 'application/json' },
|
|
148
|
+
body,
|
|
137
149
|
});
|
|
138
150
|
|
|
139
151
|
clearTimeout(timeout);
|
|
140
152
|
const data = await res.json();
|
|
141
153
|
|
|
142
|
-
if (data.
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
154
|
+
if (data.activated) {
|
|
155
|
+
const variantName = (data.meta?.variant_name || '').toLowerCase();
|
|
156
|
+
const tier = getTierFromVariantName(variantName);
|
|
157
|
+
return {
|
|
158
|
+
valid: true,
|
|
159
|
+
tier,
|
|
160
|
+
instanceId: data.instance?.id || null,
|
|
161
|
+
meta: data.meta,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Already at activation limit? Try validate instead (might be same machine re-activating)
|
|
166
|
+
if (data.error && data.error.includes('activation limit')) {
|
|
167
|
+
return { valid: false, error: data.error, activationLimitReached: true };
|
|
148
168
|
}
|
|
149
169
|
|
|
150
|
-
return { valid: false, error: data.error || '
|
|
170
|
+
return { valid: false, error: data.error || 'Activation failed' };
|
|
151
171
|
} catch (err) {
|
|
152
172
|
return { valid: false, error: `Network error: ${err.message}`, offline: true };
|
|
153
173
|
}
|
|
154
174
|
}
|
|
155
175
|
|
|
156
|
-
// ── Froggo Token Validation ────────────────────────────────────────────────
|
|
157
|
-
|
|
158
176
|
/**
|
|
159
|
-
* Validate a
|
|
160
|
-
*
|
|
177
|
+
* Validate a license key against Lemon Squeezy.
|
|
178
|
+
* POST https://api.lemonsqueezy.com/v1/licenses/validate
|
|
179
|
+
* Params: license_key, instance_id (optional)
|
|
161
180
|
*/
|
|
162
|
-
async function
|
|
181
|
+
async function validateWithLS(key, instanceId) {
|
|
163
182
|
try {
|
|
164
183
|
const controller = new AbortController();
|
|
165
184
|
const timeout = setTimeout(() => controller.abort(), 8000);
|
|
166
185
|
|
|
167
|
-
const
|
|
186
|
+
const params = { license_key: key };
|
|
187
|
+
if (instanceId) params.instance_id = instanceId;
|
|
188
|
+
const body = new URLSearchParams(params);
|
|
189
|
+
|
|
190
|
+
const res = await fetch('https://api.lemonsqueezy.com/v1/licenses/validate', {
|
|
168
191
|
signal: controller.signal,
|
|
169
192
|
method: 'POST',
|
|
170
|
-
headers: { '
|
|
171
|
-
body
|
|
172
|
-
token,
|
|
173
|
-
product: 'seo-intel',
|
|
174
|
-
}),
|
|
193
|
+
headers: { 'Accept': 'application/json' },
|
|
194
|
+
body,
|
|
175
195
|
});
|
|
176
196
|
|
|
177
197
|
clearTimeout(timeout);
|
|
178
198
|
const data = await res.json();
|
|
179
199
|
|
|
180
200
|
if (data.valid) {
|
|
181
|
-
const
|
|
182
|
-
|
|
201
|
+
const variantName = (data.meta?.variant_name || '').toLowerCase();
|
|
202
|
+
const tier = getTierFromVariantName(variantName);
|
|
203
|
+
return {
|
|
204
|
+
valid: true,
|
|
205
|
+
tier,
|
|
206
|
+
instanceId: data.instance?.id || instanceId,
|
|
207
|
+
status: data.license_key?.status,
|
|
208
|
+
meta: data.meta,
|
|
209
|
+
};
|
|
183
210
|
}
|
|
184
211
|
|
|
185
|
-
return { valid: false, error: data.error || '
|
|
212
|
+
return { valid: false, error: data.error || 'License key not valid', status: data.license_key?.status };
|
|
186
213
|
} catch (err) {
|
|
187
214
|
return { valid: false, error: `Network error: ${err.message}`, offline: true };
|
|
188
215
|
}
|
|
189
216
|
}
|
|
190
217
|
|
|
218
|
+
/**
|
|
219
|
+
* Deactivate a license key instance.
|
|
220
|
+
* POST https://api.lemonsqueezy.com/v1/licenses/deactivate
|
|
221
|
+
* Params: license_key, instance_id
|
|
222
|
+
*/
|
|
223
|
+
export async function deactivateLicense() {
|
|
224
|
+
const keyInfo = readKeyFromEnv();
|
|
225
|
+
if (!keyInfo) return { deactivated: false, error: 'No license key found' };
|
|
226
|
+
|
|
227
|
+
const cache = readCache();
|
|
228
|
+
const instanceId = cache?.instanceId;
|
|
229
|
+
if (!instanceId) return { deactivated: false, error: 'No active instance to deactivate' };
|
|
230
|
+
|
|
231
|
+
try {
|
|
232
|
+
const controller = new AbortController();
|
|
233
|
+
const timeout = setTimeout(() => controller.abort(), 8000);
|
|
234
|
+
|
|
235
|
+
const body = new URLSearchParams({
|
|
236
|
+
license_key: keyInfo.value,
|
|
237
|
+
instance_id: instanceId,
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
const res = await fetch('https://api.lemonsqueezy.com/v1/licenses/deactivate', {
|
|
241
|
+
signal: controller.signal,
|
|
242
|
+
method: 'POST',
|
|
243
|
+
headers: { 'Accept': 'application/json' },
|
|
244
|
+
body,
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
clearTimeout(timeout);
|
|
248
|
+
const data = await res.json();
|
|
249
|
+
|
|
250
|
+
if (data.deactivated) {
|
|
251
|
+
// Clear local cache
|
|
252
|
+
writeCache({});
|
|
253
|
+
_cachedLicense = undefined;
|
|
254
|
+
return { deactivated: true };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return { deactivated: false, error: data.error || 'Deactivation failed' };
|
|
258
|
+
} catch (err) {
|
|
259
|
+
return { deactivated: false, error: `Network error: ${err.message}` };
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
191
263
|
// ── License Loading ─────────────────────────────────────────────────────────
|
|
192
264
|
|
|
193
265
|
let _cachedLicense = undefined;
|
|
194
266
|
|
|
195
267
|
/**
|
|
196
|
-
* Read the license key
|
|
268
|
+
* Read the license key from environment or .env file.
|
|
197
269
|
* Returns { type, value } or null.
|
|
198
270
|
*/
|
|
199
271
|
function readKeyFromEnv() {
|
|
200
|
-
// 1. Check Froggo token first (marketplace priority)
|
|
201
|
-
let froggoToken = process.env.FROGGO_TOKEN;
|
|
202
272
|
let lsKey = process.env.SEO_INTEL_LICENSE;
|
|
203
273
|
|
|
204
|
-
//
|
|
205
|
-
if (!
|
|
274
|
+
// Check .env file
|
|
275
|
+
if (!lsKey) {
|
|
206
276
|
const envPath = join(ROOT, '.env');
|
|
207
277
|
if (existsSync(envPath)) {
|
|
208
278
|
try {
|
|
209
279
|
const content = readFileSync(envPath, 'utf8');
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
if (match) froggoToken = match[1].trim().replace(/^["']|["']$/g, '');
|
|
213
|
-
}
|
|
214
|
-
if (!lsKey) {
|
|
215
|
-
const match = content.match(/^SEO_INTEL_LICENSE=(.+)$/m);
|
|
216
|
-
if (match) lsKey = match[1].trim().replace(/^["']|["']$/g, '');
|
|
217
|
-
}
|
|
280
|
+
const match = content.match(/^SEO_INTEL_LICENSE=(.+)$/m);
|
|
281
|
+
if (match) lsKey = match[1].trim().replace(/^["']|["']$/g, '');
|
|
218
282
|
} catch { /* ok */ }
|
|
219
283
|
}
|
|
220
284
|
}
|
|
221
285
|
|
|
222
|
-
if (froggoToken) return { type: 'froggo', value: froggoToken };
|
|
223
286
|
if (lsKey) return { type: 'lemon-squeezy', value: lsKey };
|
|
224
287
|
return null;
|
|
225
288
|
}
|
|
@@ -260,7 +323,13 @@ export function loadLicense() {
|
|
|
260
323
|
}
|
|
261
324
|
|
|
262
325
|
/**
|
|
263
|
-
* Async license activation — validates against
|
|
326
|
+
* Async license activation — validates against Lemon Squeezy and caches result.
|
|
327
|
+
*
|
|
328
|
+
* Flow:
|
|
329
|
+
* 1. If we have a cached instanceId → validate with it
|
|
330
|
+
* 2. If no instanceId → activate (creates new instance)
|
|
331
|
+
* 3. If activation limit reached → validate without instanceId (key-level check)
|
|
332
|
+
*
|
|
264
333
|
* Call this at startup or when loadLicense() returns needsActivation: true.
|
|
265
334
|
* Returns the validated license object.
|
|
266
335
|
*/
|
|
@@ -273,34 +342,46 @@ export async function activateLicense() {
|
|
|
273
342
|
return _cachedLicense;
|
|
274
343
|
}
|
|
275
344
|
|
|
345
|
+
// Check if we already have an instanceId from a previous activation
|
|
346
|
+
const cache = readCache();
|
|
347
|
+
const existingInstanceId = (cache && cache.key === keyInfo.value) ? cache.instanceId : null;
|
|
348
|
+
|
|
276
349
|
let result;
|
|
277
|
-
|
|
278
|
-
|
|
350
|
+
|
|
351
|
+
if (existingInstanceId) {
|
|
352
|
+
// We have a stored instance — validate it
|
|
353
|
+
result = await validateWithLS(keyInfo.value, existingInstanceId);
|
|
279
354
|
} else {
|
|
280
|
-
|
|
355
|
+
// No stored instance — try to activate
|
|
356
|
+
result = await activateWithLS(keyInfo.value);
|
|
357
|
+
|
|
358
|
+
// If activation limit reached, fall back to key-level validate
|
|
359
|
+
if (!result.valid && result.activationLimitReached) {
|
|
360
|
+
result = await validateWithLS(keyInfo.value);
|
|
361
|
+
}
|
|
281
362
|
}
|
|
282
363
|
|
|
283
364
|
if (result.valid) {
|
|
284
|
-
// Cache the successful validation
|
|
365
|
+
// Cache the successful validation with instanceId
|
|
285
366
|
writeCache({
|
|
286
367
|
key: keyInfo.value,
|
|
287
368
|
tier: result.tier,
|
|
288
369
|
validatedAt: Date.now(),
|
|
289
|
-
source:
|
|
370
|
+
source: 'lemon-squeezy',
|
|
371
|
+
instanceId: result.instanceId || existingInstanceId,
|
|
290
372
|
machineId: getMachineId(),
|
|
291
373
|
});
|
|
292
374
|
|
|
293
375
|
const tierData = TIERS[result.tier] || TIERS.solo;
|
|
294
|
-
_cachedLicense = { active: true, tier: result.tier, key: keyInfo.value, source:
|
|
376
|
+
_cachedLicense = { active: true, tier: result.tier, key: keyInfo.value, source: 'lemon-squeezy', ...tierData };
|
|
295
377
|
return _cachedLicense;
|
|
296
378
|
}
|
|
297
379
|
|
|
298
380
|
if (result.offline) {
|
|
299
381
|
// Network error — check if we have any stale cache at all
|
|
300
|
-
const cache = readCache();
|
|
301
382
|
if (cache && cache.key === keyInfo.value) {
|
|
302
383
|
const tierData = TIERS[cache.tier] || TIERS.solo;
|
|
303
|
-
_cachedLicense = { active: true, tier: cache.tier, key: keyInfo.value, source:
|
|
384
|
+
_cachedLicense = { active: true, tier: cache.tier, key: keyInfo.value, source: 'lemon-squeezy', stale: true, ...tierData };
|
|
304
385
|
return _cachedLicense;
|
|
305
386
|
}
|
|
306
387
|
}
|
package/lib/updater.js
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
20
|
+
import { homedir } from 'os';
|
|
20
21
|
import { join, dirname } from 'path';
|
|
21
22
|
import { fileURLToPath } from 'url';
|
|
22
23
|
|
|
@@ -40,8 +41,8 @@ export function getCurrentVersion() {
|
|
|
40
41
|
|
|
41
42
|
// ── Cache file ─────────────────────────────────────────────────────────────
|
|
42
43
|
|
|
43
|
-
const CACHE_DIR = join(
|
|
44
|
-
const CACHE_FILE = join(CACHE_DIR, 'update-
|
|
44
|
+
const CACHE_DIR = join(homedir(), '.seo-intel');
|
|
45
|
+
const CACHE_FILE = join(CACHE_DIR, 'update-cache.json');
|
|
45
46
|
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
46
47
|
|
|
47
48
|
function readCache() {
|
|
@@ -116,7 +117,7 @@ async function checkNpm() {
|
|
|
116
117
|
* Check ukkometa.fi for latest version.
|
|
117
118
|
* Endpoint returns { version, changelog?, downloadUrl? }
|
|
118
119
|
*/
|
|
119
|
-
async function
|
|
120
|
+
async function checkUkkometa() {
|
|
120
121
|
const controller = new AbortController();
|
|
121
122
|
const timeout = setTimeout(() => controller.abort(), 5000);
|
|
122
123
|
|
|
@@ -165,12 +166,12 @@ export function checkForUpdates() {
|
|
|
165
166
|
const current = getCurrentVersion();
|
|
166
167
|
|
|
167
168
|
// Check both sources in parallel
|
|
168
|
-
const [npmVersion,
|
|
169
|
+
const [npmVersion, ukkometaData] = await Promise.all([
|
|
169
170
|
checkNpm(),
|
|
170
|
-
|
|
171
|
+
checkUkkometa(),
|
|
171
172
|
]);
|
|
172
173
|
|
|
173
|
-
const
|
|
174
|
+
const ukkometaVersion = ukkometaData?.version || null;
|
|
174
175
|
|
|
175
176
|
// Determine the highest available version
|
|
176
177
|
let latestVersion = current;
|
|
@@ -182,11 +183,11 @@ export function checkForUpdates() {
|
|
|
182
183
|
latestVersion = npmVersion;
|
|
183
184
|
source = 'npm';
|
|
184
185
|
}
|
|
185
|
-
if (
|
|
186
|
-
latestVersion =
|
|
187
|
-
source = '
|
|
188
|
-
changelog =
|
|
189
|
-
downloadUrl =
|
|
186
|
+
if (ukkometaVersion && compareSemver(ukkometaVersion, latestVersion) > 0) {
|
|
187
|
+
latestVersion = ukkometaVersion;
|
|
188
|
+
source = 'ukkometa';
|
|
189
|
+
changelog = ukkometaData.changelog;
|
|
190
|
+
downloadUrl = ukkometaData.downloadUrl;
|
|
190
191
|
}
|
|
191
192
|
|
|
192
193
|
const hasUpdate = compareSemver(latestVersion, current) > 0;
|
|
@@ -199,10 +200,10 @@ export function checkForUpdates() {
|
|
|
199
200
|
changelog,
|
|
200
201
|
downloadUrl,
|
|
201
202
|
npmVersion,
|
|
202
|
-
|
|
203
|
-
security:
|
|
204
|
-
securitySeverity:
|
|
205
|
-
updatePolicy:
|
|
203
|
+
ukkometaVersion,
|
|
204
|
+
security: ukkometaData?.security || false,
|
|
205
|
+
securitySeverity: ukkometaData?.securitySeverity || null,
|
|
206
|
+
updatePolicy: ukkometaData?.updatePolicy || null,
|
|
206
207
|
};
|
|
207
208
|
|
|
208
209
|
writeCache(_updateResult);
|
package/package.json
CHANGED
package/server.js
CHANGED
|
@@ -501,13 +501,11 @@ async function handleRequest(req, res) {
|
|
|
501
501
|
envContent = readFileSync(envPath, 'utf8');
|
|
502
502
|
}
|
|
503
503
|
|
|
504
|
-
|
|
505
|
-
const isFroggo = key.startsWith('FROGGO_') || key.length > 60;
|
|
506
|
-
const envVar = isFroggo ? 'FROGGO_TOKEN' : 'SEO_INTEL_LICENSE';
|
|
504
|
+
const envVar = 'SEO_INTEL_LICENSE';
|
|
507
505
|
|
|
508
|
-
// Remove existing
|
|
506
|
+
// Remove existing license line
|
|
509
507
|
const lines = envContent.split('\n').filter(l =>
|
|
510
|
-
!l.startsWith('SEO_INTEL_LICENSE=')
|
|
508
|
+
!l.startsWith('SEO_INTEL_LICENSE=')
|
|
511
509
|
);
|
|
512
510
|
lines.push(`${envVar}=${key}`);
|
|
513
511
|
|
package/setup/wizard.html
CHANGED
|
@@ -170,6 +170,10 @@ body {
|
|
|
170
170
|
max-width: var(--max-width);
|
|
171
171
|
margin: 0 auto;
|
|
172
172
|
}
|
|
173
|
+
#step2.step-panel {
|
|
174
|
+
max-width: 1100px;
|
|
175
|
+
margin: 0 auto;
|
|
176
|
+
}
|
|
173
177
|
.step-panel {
|
|
174
178
|
display: none;
|
|
175
179
|
animation: fadeIn 0.3s ease;
|
|
@@ -333,7 +337,7 @@ body {
|
|
|
333
337
|
/* ─── Model Cards (Step 2) ───────────────────────────────────────────── */
|
|
334
338
|
.model-columns {
|
|
335
339
|
display: grid;
|
|
336
|
-
grid-template-columns: 1fr 1fr;
|
|
340
|
+
grid-template-columns: 1fr 1fr 1fr;
|
|
337
341
|
gap: 20px;
|
|
338
342
|
}
|
|
339
343
|
.model-section {
|
|
@@ -1286,39 +1290,133 @@ input::placeholder {
|
|
|
1286
1290
|
|
|
1287
1291
|
<!-- OpenClaw Setup Banner -->
|
|
1288
1292
|
<div id="openclawBanner" class="openclaw-banner" style="display:none;">
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
<
|
|
1293
|
-
|
|
1294
|
-
<path d="M9 12c0-2.8 2.2-5 5-5s5 2.2 5 5c0 1.8-1 3.4-2.4 4.3L18 20H10l1.4-3.7C10 15.4 9 13.8 9 12z" fill="currentColor" opacity="0.8"/>
|
|
1295
|
-
</svg>
|
|
1293
|
+
<!-- Always-shown agentic setup block -->
|
|
1294
|
+
<div id="ocBannerRecommended" style="display:none;">
|
|
1295
|
+
<div class="openclaw-banner-title" style="margin-bottom:8px;">
|
|
1296
|
+
<i class="fa-solid fa-wand-magic-sparkles" style="color:var(--accent-purple); margin-right:6px;"></i>
|
|
1297
|
+
Agentic Setup — let an AI handle this
|
|
1296
1298
|
</div>
|
|
1297
|
-
<div class="openclaw-banner-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1299
|
+
<div class="openclaw-banner-desc" style="margin-bottom:14px;">Pick your agent. Copy the prompt. Paste it in. Done.</div>
|
|
1300
|
+
|
|
1301
|
+
<!-- Runtime tabs -->
|
|
1302
|
+
<div style="display:flex; gap:6px; flex-wrap:wrap; margin-bottom:12px;" id="agentRuntimeTabs">
|
|
1303
|
+
<button class="btn btn-sm agent-runtime-tab active" data-runtime="openclaw" onclick="selectAgentRuntime('openclaw', this)" style="font-size:0.65rem;">
|
|
1304
|
+
<i class="fa-solid fa-wand-magic-sparkles" style="margin-right:4px;"></i>OpenClaw
|
|
1305
|
+
</button>
|
|
1306
|
+
<button class="btn btn-sm agent-runtime-tab" data-runtime="claudecode" onclick="selectAgentRuntime('claudecode', this)" style="font-size:0.65rem;">
|
|
1307
|
+
<i class="fa-solid fa-terminal" style="margin-right:4px;"></i>Claude Code
|
|
1308
|
+
</button>
|
|
1309
|
+
<button class="btn btn-sm agent-runtime-tab" data-runtime="codex" onclick="selectAgentRuntime('codex', this)" style="font-size:0.65rem;">
|
|
1310
|
+
<i class="fa-solid fa-code" style="margin-right:4px;"></i>Codex CLI
|
|
1311
|
+
</button>
|
|
1312
|
+
<button class="btn btn-sm agent-runtime-tab" data-runtime="perplexity" onclick="selectAgentRuntime('perplexity', this)" style="font-size:0.65rem;">
|
|
1313
|
+
<i class="fa-solid fa-magnifying-glass" style="margin-right:4px;"></i>Perplexity
|
|
1314
|
+
</button>
|
|
1315
|
+
</div>
|
|
1316
|
+
|
|
1317
|
+
<!-- Runtime panels -->
|
|
1318
|
+
<div id="agentRuntime-openclaw" class="agent-runtime-panel">
|
|
1319
|
+
<div style="font-size:0.68rem; color:var(--text-muted); margin-bottom:8px;">OpenClaw is running — it can guide setup directly. Run this in your terminal first:</div>
|
|
1320
|
+
<div style="padding:8px 10px; background:rgba(10,10,10,0.5); border:1px solid var(--border-subtle); border-radius:var(--radius); font-family:var(--font-mono); font-size:0.68rem; color:var(--text-secondary); display:flex; align-items:center; gap:8px; margin-bottom:8px;">
|
|
1301
1321
|
<span style="color:var(--text-muted);">$</span>
|
|
1302
1322
|
<span id="clawhubCmd">clawhub install ukkometa/seo-intel</span>
|
|
1303
|
-
<button class="btn btn-sm" style="margin-left:auto; padding:3px 8px; font-size:0.
|
|
1323
|
+
<button class="btn btn-sm" style="margin-left:auto; padding:3px 8px; font-size:0.6rem;" onclick="navigator.clipboard.writeText('clawhub install ukkometa/seo-intel');this.textContent='Copied!';setTimeout(()=>this.textContent='Copy',1500);">Copy</button>
|
|
1324
|
+
</div>
|
|
1325
|
+
<div style="font-size:0.65rem; color:var(--text-muted); margin-bottom:8px;">Or paste this into your OpenClaw chat:</div>
|
|
1326
|
+
<div style="position:relative; padding:8px 10px; background:rgba(10,10,10,0.5); border:1px solid var(--border-subtle); border-radius:var(--radius); font-family:var(--font-mono); font-size:0.62rem; color:var(--text-secondary); line-height:1.6; margin-bottom:10px;">
|
|
1327
|
+
<div id="oc-prompt">Set up SEO Intel on my machine.
|
|
1328
|
+
Check dependencies, install Ollama if needed, recommend a model based on my VRAM, configure a project with my target site, and run a test crawl.</div>
|
|
1329
|
+
<button class="btn btn-sm" style="position:absolute; top:6px; right:6px; padding:2px 6px; font-size:0.5rem;" onclick="navigator.clipboard.writeText(document.getElementById('oc-prompt').textContent.trim());this.textContent='Copied!';setTimeout(()=>this.textContent='Copy',1500);">Copy</button>
|
|
1304
1330
|
</div>
|
|
1305
1331
|
<div class="openclaw-banner-actions">
|
|
1306
|
-
<button class="btn btn-gold" onclick="startAgentSetup()"><i class="fa-solid fa-play"></i> Start Setup</button>
|
|
1307
|
-
<button class="btn" onclick="continueManualSetup()"><i class="fa-solid fa-list-check"></i>
|
|
1332
|
+
<button class="btn btn-gold" onclick="startAgentSetup()"><i class="fa-solid fa-play"></i> Start Agent Setup</button>
|
|
1333
|
+
<button class="btn" onclick="continueManualSetup()"><i class="fa-solid fa-list-check"></i> Manual</button>
|
|
1308
1334
|
</div>
|
|
1309
1335
|
</div>
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
<
|
|
1314
|
-
<
|
|
1315
|
-
|
|
1316
|
-
|
|
1336
|
+
|
|
1337
|
+
<div id="agentRuntime-claudecode" class="agent-runtime-panel" style="display:none;">
|
|
1338
|
+
<div style="font-size:0.68rem; color:var(--text-muted); margin-bottom:8px;">Open Claude Code in your terminal (<code style="background:rgba(255,255,255,0.05);padding:1px 4px;border-radius:3px;">claude</code>) and paste this prompt:</div>
|
|
1339
|
+
<div style="position:relative; padding:8px 10px; background:rgba(10,10,10,0.5); border:1px solid var(--border-subtle); border-radius:var(--radius); font-family:var(--font-mono); font-size:0.62rem; color:var(--text-secondary); line-height:1.6; margin-bottom:8px;">
|
|
1340
|
+
<div id="cc-prompt">I'm setting up SEO Intel (npm package: seo-intel).
|
|
1341
|
+
Help me:
|
|
1342
|
+
1. Check Node.js 22.5+ is installed
|
|
1343
|
+
2. Install Ollama if missing (https://ollama.com)
|
|
1344
|
+
3. Pull a Qwen model matching my VRAM (run: system_profiler SPDisplaysDataType | grep VRAM)
|
|
1345
|
+
4. Create a config file at ./config/myproject.json with my target site
|
|
1346
|
+
5. Run: seo-intel crawl myproject
|
|
1347
|
+
6. Fix any errors that come up
|
|
1348
|
+
|
|
1349
|
+
Start by checking what's already installed.</div>
|
|
1350
|
+
<button class="btn btn-sm" style="position:absolute; top:6px; right:6px; padding:2px 6px; font-size:0.5rem;" onclick="navigator.clipboard.writeText(document.getElementById('cc-prompt').textContent.trim());this.textContent='Copied!';setTimeout(()=>this.textContent='Copy',1500);">Copy</button>
|
|
1351
|
+
</div>
|
|
1352
|
+
<div style="font-size:0.62rem; color:var(--text-muted);">Run Claude Code with: <code style="background:rgba(255,255,255,0.05);padding:1px 4px;border-radius:3px;">claude --permission-mode bypassPermissions</code> for full access</div>
|
|
1317
1353
|
</div>
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1354
|
+
|
|
1355
|
+
<div id="agentRuntime-codex" class="agent-runtime-panel" style="display:none;">
|
|
1356
|
+
<div style="font-size:0.68rem; color:var(--text-muted); margin-bottom:8px;">Open Codex CLI (<code style="background:rgba(255,255,255,0.05);padding:1px 4px;border-radius:3px;">codex</code>) and paste this prompt:</div>
|
|
1357
|
+
<div style="position:relative; padding:8px 10px; background:rgba(10,10,10,0.5); border:1px solid var(--border-subtle); border-radius:var(--radius); font-family:var(--font-mono); font-size:0.62rem; color:var(--text-secondary); line-height:1.6; margin-bottom:8px;">
|
|
1358
|
+
<div id="cx-prompt">Set up the seo-intel npm CLI tool on this machine.
|
|
1359
|
+
Tasks:
|
|
1360
|
+
1. Verify Node.js 22.5+ (install via nvm if needed)
|
|
1361
|
+
2. Install Ollama (https://ollama.com) if not present
|
|
1362
|
+
3. Pull qwen3.5:9b or smaller if VRAM < 6GB
|
|
1363
|
+
4. Create ./config/myproject.json — ask me for my target domain and up to 3 competitor domains
|
|
1364
|
+
5. Add GEMINI_API_KEY or ANTHROPIC_API_KEY to .env if I have one
|
|
1365
|
+
6. Run: seo-intel crawl myproject
|
|
1366
|
+
7. Fix any errors
|
|
1367
|
+
|
|
1368
|
+
Use full disk access. Check what's installed before proceeding.</div>
|
|
1369
|
+
<button class="btn btn-sm" style="position:absolute; top:6px; right:6px; padding:2px 6px; font-size:0.5rem;" onclick="navigator.clipboard.writeText(document.getElementById('cx-prompt').textContent.trim());this.textContent='Copied!';setTimeout(()=>this.textContent='Copy',1500);">Copy</button>
|
|
1321
1370
|
</div>
|
|
1371
|
+
<div style="font-size:0.62rem; color:var(--text-muted);">Codex config: <code style="background:rgba(255,255,255,0.05);padding:1px 4px;border-radius:3px;">sandbox_mode = "danger-full-access"</code> recommended for install tasks</div>
|
|
1372
|
+
</div>
|
|
1373
|
+
|
|
1374
|
+
<div id="agentRuntime-perplexity" class="agent-runtime-panel" style="display:none;">
|
|
1375
|
+
<div style="font-size:0.68rem; color:var(--text-muted); margin-bottom:8px;">Open Perplexity Computer and paste this prompt:</div>
|
|
1376
|
+
<div style="position:relative; padding:8px 10px; background:rgba(10,10,10,0.5); border:1px solid var(--border-subtle); border-radius:var(--radius); font-family:var(--font-mono); font-size:0.62rem; color:var(--text-secondary); line-height:1.6; margin-bottom:8px;">
|
|
1377
|
+
<div id="pp-prompt">I want to set up a local SEO analysis tool called SEO Intel.
|
|
1378
|
+
It's installed via: npm install -g seo-intel
|
|
1379
|
+
|
|
1380
|
+
Please help me:
|
|
1381
|
+
1. Check that Node.js 22.5+ is installed on my machine
|
|
1382
|
+
2. Install Ollama (ollama.com) if it's not there
|
|
1383
|
+
3. Download a Qwen AI model that fits my machine's RAM
|
|
1384
|
+
4. Create a project config file pointing at my website and competitors
|
|
1385
|
+
5. Run the first crawl and show me the results
|
|
1386
|
+
|
|
1387
|
+
Ask me for my website URL before starting.</div>
|
|
1388
|
+
<button class="btn btn-sm" style="position:absolute; top:6px; right:6px; padding:2px 6px; font-size:0.5rem;" onclick="navigator.clipboard.writeText(document.getElementById('pp-prompt').textContent.trim());this.textContent='Copied!';setTimeout(()=>this.textContent='Copy',1500);">Copy</button>
|
|
1389
|
+
</div>
|
|
1390
|
+
<div style="font-size:0.62rem; color:var(--text-muted);">Works best with Perplexity Computer's full computer-use mode enabled</div>
|
|
1391
|
+
</div>
|
|
1392
|
+
</div>
|
|
1393
|
+
|
|
1394
|
+
<div id="ocBannerMinimal" style="display:none;">
|
|
1395
|
+
<div class="openclaw-banner-desc" style="color:var(--text-muted); font-size:0.72rem; margin-bottom:10px;">
|
|
1396
|
+
<strong style="color:var(--text-secondary);">Agent setup available.</strong> Pick a runtime below and copy the prompt:
|
|
1397
|
+
</div>
|
|
1398
|
+
<!-- Same runtime tabs, minimal version -->
|
|
1399
|
+
<div style="display:flex; gap:6px; flex-wrap:wrap; margin-bottom:10px;">
|
|
1400
|
+
<button class="btn btn-sm agent-runtime-tab-min active" data-runtime="claudecode" onclick="selectAgentRuntimeMin('claudecode', this)" style="font-size:0.62rem;"><i class="fa-solid fa-terminal" style="margin-right:3px;"></i>Claude Code</button>
|
|
1401
|
+
<button class="btn btn-sm agent-runtime-tab-min" data-runtime="codex" onclick="selectAgentRuntimeMin('codex', this)" style="font-size:0.62rem;"><i class="fa-solid fa-code" style="margin-right:3px;"></i>Codex CLI</button>
|
|
1402
|
+
<button class="btn btn-sm agent-runtime-tab-min" data-runtime="perplexity" onclick="selectAgentRuntimeMin('perplexity', this)" style="font-size:0.62rem;"><i class="fa-solid fa-magnifying-glass" style="margin-right:3px;"></i>Perplexity</button>
|
|
1403
|
+
<button class="btn btn-sm agent-runtime-tab-min" data-runtime="openclaw" onclick="selectAgentRuntimeMin('openclaw', this)" style="font-size:0.62rem;"><i class="fa-solid fa-wand-magic-sparkles" style="margin-right:3px;"></i>OpenClaw</button>
|
|
1404
|
+
</div>
|
|
1405
|
+
<div id="minRuntime-claudecode" class="min-runtime-panel" style="position:relative; padding:7px 9px; background:rgba(10,10,10,0.5); border:1px solid var(--border-subtle); border-radius:var(--radius); font-family:var(--font-mono); font-size:0.6rem; color:var(--text-secondary); line-height:1.5;">
|
|
1406
|
+
<div>Set up seo-intel: check Node 22.5+, install Ollama, pull qwen3.5:9b, create config, run crawl.</div>
|
|
1407
|
+
<button class="btn btn-sm" style="position:absolute; top:4px; right:4px; padding:2px 5px; font-size:0.48rem;" onclick="navigator.clipboard.writeText(this.previousElementSibling.textContent.trim());this.textContent='Copied!';setTimeout(()=>this.textContent='Copy',1500);">Copy</button>
|
|
1408
|
+
</div>
|
|
1409
|
+
<div id="minRuntime-codex" class="min-runtime-panel" style="display:none; position:relative; padding:7px 9px; background:rgba(10,10,10,0.5); border:1px solid var(--border-subtle); border-radius:var(--radius); font-family:var(--font-mono); font-size:0.6rem; color:var(--text-secondary); line-height:1.5;">
|
|
1410
|
+
<div>Install seo-intel deps (Node 22.5+, Ollama, qwen3.5:9b), create project config, run: seo-intel crawl myproject. Use full disk access.</div>
|
|
1411
|
+
<button class="btn btn-sm" style="position:absolute; top:4px; right:4px; padding:2px 5px; font-size:0.48rem;" onclick="navigator.clipboard.writeText(this.previousElementSibling.textContent.trim());this.textContent='Copied!';setTimeout(()=>this.textContent='Copy',1500);">Copy</button>
|
|
1412
|
+
</div>
|
|
1413
|
+
<div id="minRuntime-perplexity" class="min-runtime-panel" style="display:none; position:relative; padding:7px 9px; background:rgba(10,10,10,0.5); border:1px solid var(--border-subtle); border-radius:var(--radius); font-family:var(--font-mono); font-size:0.6rem; color:var(--text-secondary); line-height:1.5;">
|
|
1414
|
+
<div>Help me set up SEO Intel local SEO tool: install Node.js 22.5+, Ollama, download a Qwen model, create config for my website. Ask for my URL.</div>
|
|
1415
|
+
<button class="btn btn-sm" style="position:absolute; top:4px; right:4px; padding:2px 5px; font-size:0.48rem;" onclick="navigator.clipboard.writeText(this.previousElementSibling.textContent.trim());this.textContent='Copied!';setTimeout(()=>this.textContent='Copy',1500);">Copy</button>
|
|
1416
|
+
</div>
|
|
1417
|
+
<div id="minRuntime-openclaw" class="min-runtime-panel" style="display:none; position:relative; padding:7px 9px; background:rgba(10,10,10,0.5); border:1px solid var(--border-subtle); border-radius:var(--radius); font-family:var(--font-mono); font-size:0.6rem; color:var(--text-secondary); line-height:1.5;">
|
|
1418
|
+
<div>clawhub install ukkometa/seo-intel</div>
|
|
1419
|
+
<button class="btn btn-sm" style="position:absolute; top:4px; right:4px; padding:2px 5px; font-size:0.48rem;" onclick="navigator.clipboard.writeText('clawhub install ukkometa/seo-intel');this.textContent='Copied!';setTimeout(()=>this.textContent='Copy',1500);">Copy</button>
|
|
1322
1420
|
</div>
|
|
1323
1421
|
</div>
|
|
1324
1422
|
</div>
|
|
@@ -1338,10 +1436,15 @@ input::placeholder {
|
|
|
1338
1436
|
</div>
|
|
1339
1437
|
|
|
1340
1438
|
<!-- ─── Step 2: Model Selection ───────────────────────────────────────── -->
|
|
1341
|
-
<div class="step-panel" id="step2">
|
|
1342
|
-
<div style="
|
|
1439
|
+
<div class="step-panel" id="step2" style="max-width:none;">
|
|
1440
|
+
<div style="position:relative;">
|
|
1343
1441
|
<div class="card" style="margin-bottom:0; min-width:0;">
|
|
1344
|
-
<
|
|
1442
|
+
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:4px;">
|
|
1443
|
+
<h2 style="margin-bottom:0;"><i class="fa-solid fa-microchip"></i> Model Selection</h2>
|
|
1444
|
+
<button class="btn btn-sm" onclick="document.getElementById('openclawFloating').style.display=''" style="font-size:0.65rem; color:var(--accent-purple); border-color:rgba(124,109,235,0.3); background:rgba(124,109,235,0.06);">
|
|
1445
|
+
<i class="fa-solid fa-wand-magic-sparkles" style="margin-right:4px;"></i>OpenClaw
|
|
1446
|
+
</button>
|
|
1447
|
+
</div>
|
|
1345
1448
|
|
|
1346
1449
|
<!-- Ollama Hosts -->
|
|
1347
1450
|
<div id="ollamaHostsPanel" style="margin-bottom:16px; padding:12px 14px; background:var(--bg-card); border:1px solid var(--border-card); border-radius:var(--radius);">
|
|
@@ -1443,6 +1546,122 @@ input::placeholder {
|
|
|
1443
1546
|
</div>
|
|
1444
1547
|
</div>
|
|
1445
1548
|
</div>
|
|
1549
|
+
<!-- ─── Cloud Analysis column ─────────────────────────────────────── -->
|
|
1550
|
+
<div class="model-section" id="cloudAnalysisSection">
|
|
1551
|
+
<h3><i class="fa-solid fa-cloud"></i> Cloud Analysis</h3>
|
|
1552
|
+
<p class="section-note">Use cloud frontier models for analysis. Add your API key to .env</p>
|
|
1553
|
+
<!-- Cloud model cards -->
|
|
1554
|
+
<div id="cloudAnalysisModels">
|
|
1555
|
+
<!-- Gemini 3.1 Pro -->
|
|
1556
|
+
<div class="model-radio-card cloud-model" data-model="gemini-3.1-pro" data-provider="gemini" data-envvar="GEMINI_API_KEY" onclick="selectCloudAnalysisModel(this)">
|
|
1557
|
+
<div style="display:flex; justify-content:space-between; align-items:flex-start;">
|
|
1558
|
+
<div>
|
|
1559
|
+
<div style="font-weight:600; font-size:0.82rem; color:var(--text-primary);">gemini-3.1-pro</div>
|
|
1560
|
+
<div style="font-size:0.68rem; color:var(--text-muted); margin-top:2px;">Google Gemini</div>
|
|
1561
|
+
</div>
|
|
1562
|
+
<span style="font-size:0.55rem; font-weight:700; letter-spacing:0.06em; padding:2px 6px; border-radius:4px; background:rgba(232,213,163,0.15); color:#e8d5a3;">BEST</span>
|
|
1563
|
+
</div>
|
|
1564
|
+
<div style="font-size:0.65rem; color:var(--text-muted); margin-top:6px;">Highest quality Gemini — best for deep competitive analysis</div>
|
|
1565
|
+
<div class="cloud-privacy-note"><i class="fa-solid fa-key"></i> GEMINI_API_KEY</div>
|
|
1566
|
+
</div>
|
|
1567
|
+
<!-- Claude Sonnet 4.6 -->
|
|
1568
|
+
<div class="model-radio-card cloud-model" data-model="claude-sonnet-4-6" data-provider="anthropic" data-envvar="ANTHROPIC_API_KEY" onclick="selectCloudAnalysisModel(this)">
|
|
1569
|
+
<div style="display:flex; justify-content:space-between; align-items:flex-start;">
|
|
1570
|
+
<div>
|
|
1571
|
+
<div style="font-weight:600; font-size:0.82rem; color:var(--text-primary);">claude-sonnet-4-6</div>
|
|
1572
|
+
<div style="font-size:0.68rem; color:var(--text-muted); margin-top:2px;">Anthropic</div>
|
|
1573
|
+
</div>
|
|
1574
|
+
<span style="font-size:0.55rem; font-weight:700; letter-spacing:0.06em; padding:2px 6px; border-radius:4px; background:rgba(96,165,250,0.15); color:#60a5fa;">FAST</span>
|
|
1575
|
+
</div>
|
|
1576
|
+
<div style="font-size:0.65rem; color:var(--text-muted); margin-top:6px;">Best cost/quality balance — fast and reliable</div>
|
|
1577
|
+
<div class="cloud-privacy-note"><i class="fa-solid fa-key"></i> ANTHROPIC_API_KEY</div>
|
|
1578
|
+
</div>
|
|
1579
|
+
<!-- Claude Opus 4.6 -->
|
|
1580
|
+
<div class="model-radio-card cloud-model" data-model="claude-opus-4-6" data-provider="anthropic" data-envvar="ANTHROPIC_API_KEY" onclick="selectCloudAnalysisModel(this)">
|
|
1581
|
+
<div style="display:flex; justify-content:space-between; align-items:flex-start;">
|
|
1582
|
+
<div>
|
|
1583
|
+
<div style="font-weight:600; font-size:0.82rem; color:var(--text-primary);">claude-opus-4-6</div>
|
|
1584
|
+
<div style="font-size:0.68rem; color:var(--text-muted); margin-top:2px;">Anthropic</div>
|
|
1585
|
+
</div>
|
|
1586
|
+
<span style="font-size:0.55rem; font-weight:700; letter-spacing:0.06em; padding:2px 6px; border-radius:4px; background:rgba(124,109,235,0.15); color:#a78bfa;">DEEP</span>
|
|
1587
|
+
</div>
|
|
1588
|
+
<div style="font-size:0.65rem; color:var(--text-muted); margin-top:6px;">Deepest analysis, highest quality — slower</div>
|
|
1589
|
+
<div class="cloud-privacy-note"><i class="fa-solid fa-key"></i> ANTHROPIC_API_KEY</div>
|
|
1590
|
+
</div>
|
|
1591
|
+
<!-- GPT-5.4 -->
|
|
1592
|
+
<div class="model-radio-card cloud-model" data-model="gpt-5.4" data-provider="openai" data-envvar="OPENAI_API_KEY" onclick="selectCloudAnalysisModel(this)">
|
|
1593
|
+
<div style="display:flex; justify-content:space-between; align-items:flex-start;">
|
|
1594
|
+
<div>
|
|
1595
|
+
<div style="font-weight:600; font-size:0.82rem; color:var(--text-primary);">gpt-5.4</div>
|
|
1596
|
+
<div style="font-size:0.68rem; color:var(--text-muted); margin-top:2px;">OpenAI</div>
|
|
1597
|
+
</div>
|
|
1598
|
+
<span style="font-size:0.55rem; font-weight:700; letter-spacing:0.06em; padding:2px 6px; border-radius:4px; background:rgba(156,163,175,0.15); color:#9ca3af;">SOLID</span>
|
|
1599
|
+
</div>
|
|
1600
|
+
<div style="font-size:0.65rem; color:var(--text-muted); margin-top:6px;">Reliable, broad knowledge — solid all-around</div>
|
|
1601
|
+
<div class="cloud-privacy-note"><i class="fa-solid fa-key"></i> OPENAI_API_KEY</div>
|
|
1602
|
+
</div>
|
|
1603
|
+
<!-- DeepSeek R1 -->
|
|
1604
|
+
<div class="model-radio-card cloud-model" data-model="deepseek-r1" data-provider="deepseek" data-envvar="DEEPSEEK_API_KEY" onclick="selectCloudAnalysisModel(this)">
|
|
1605
|
+
<div style="display:flex; justify-content:space-between; align-items:flex-start;">
|
|
1606
|
+
<div>
|
|
1607
|
+
<div style="font-weight:600; font-size:0.82rem; color:var(--text-primary);">deepseek-r1</div>
|
|
1608
|
+
<div style="font-size:0.68rem; color:var(--text-muted); margin-top:2px;">DeepSeek</div>
|
|
1609
|
+
</div>
|
|
1610
|
+
<span style="font-size:0.55rem; font-weight:700; letter-spacing:0.06em; padding:2px 6px; border-radius:4px; background:rgba(52,211,153,0.15); color:#34d399;">BUDGET</span>
|
|
1611
|
+
</div>
|
|
1612
|
+
<div style="font-size:0.65rem; color:var(--text-muted); margin-top:6px;">Best reasoning model at budget price</div>
|
|
1613
|
+
<div class="cloud-privacy-note"><i class="fa-solid fa-key"></i> DEEPSEEK_API_KEY</div>
|
|
1614
|
+
</div>
|
|
1615
|
+
</div>
|
|
1616
|
+
<!-- API Key input area (shown when a cloud card is selected) -->
|
|
1617
|
+
<div id="cloudKeyArea" style="display:none; margin-top:12px; padding:12px; background:rgba(124,109,235,0.04); border:1px solid rgba(124,109,235,0.18); border-radius:var(--radius);">
|
|
1618
|
+
<label style="font-size:0.7rem; font-weight:600; color:var(--text-secondary); display:block; margin-bottom:6px;"><i class="fa-solid fa-key" style="color:var(--accent-purple); margin-right:5px;"></i> API Key</label>
|
|
1619
|
+
<div style="display:flex; gap:6px; align-items:center; margin-bottom:6px;">
|
|
1620
|
+
<input type="password" id="cloudApiKeyInput" placeholder="" style="flex:1; background:var(--bg-elevated); border:1px solid var(--border-subtle); border-radius:var(--radius); padding:6px 8px; color:var(--text-primary); font-family:var(--font-mono); font-size:0.7rem;">
|
|
1621
|
+
<button class="btn btn-sm" onclick="saveCloudApiKeyFromUI()" style="white-space:nowrap; padding:4px 10px; font-size:0.62rem;"><i class="fa-solid fa-floppy-disk"></i> Save to .env</button>
|
|
1622
|
+
</div>
|
|
1623
|
+
<p id="cloudKeySaveStatus" style="font-size:0.62rem; color:var(--text-muted); min-height:1em;"></p>
|
|
1624
|
+
<p style="font-size:0.6rem; color:var(--text-muted); margin-top:4px;">Or use <strong style="color:var(--accent-purple);">OpenClaw</strong> to skip API key management entirely</p>
|
|
1625
|
+
</div>
|
|
1626
|
+
</div>
|
|
1627
|
+
|
|
1628
|
+
|
|
1629
|
+
<p style="font-size:0.72rem; color:var(--text-muted); line-height:1.6; margin-bottom:12px;">
|
|
1630
|
+
Route analysis through frontier models. OpenClaw manages API keys, OAuth, and model routing automatically.
|
|
1631
|
+
</p>
|
|
1632
|
+
|
|
1633
|
+
<div style="font-size:0.68rem; color:var(--text-secondary); margin-bottom:12px;">
|
|
1634
|
+
<div style="display:flex; align-items:center; gap:6px; margin-bottom:6px;">
|
|
1635
|
+
<i class="fa-solid fa-check" style="color:var(--accent-purple); font-size:0.6rem; width:12px;"></i>
|
|
1636
|
+
Claude Opus 4.6
|
|
1637
|
+
</div>
|
|
1638
|
+
<div style="display:flex; align-items:center; gap:6px; margin-bottom:6px;">
|
|
1639
|
+
<i class="fa-solid fa-check" style="color:var(--accent-purple); font-size:0.6rem; width:12px;"></i>
|
|
1640
|
+
Gemini 3.1 Pro
|
|
1641
|
+
</div>
|
|
1642
|
+
<div style="display:flex; align-items:center; gap:6px; margin-bottom:6px;">
|
|
1643
|
+
<i class="fa-solid fa-check" style="color:var(--accent-purple); font-size:0.6rem; width:12px;"></i>
|
|
1644
|
+
GPT-5.4
|
|
1645
|
+
</div>
|
|
1646
|
+
</div>
|
|
1647
|
+
|
|
1648
|
+
<p style="font-size:0.62rem; color:var(--text-muted); line-height:1.5; margin-bottom:8px;">
|
|
1649
|
+
No .env keys needed. OAuth-based model access means the pipeline test may show "no API key" — this is normal when using OpenClaw.
|
|
1650
|
+
</p>
|
|
1651
|
+
|
|
1652
|
+
<div style="padding:6px 8px; background:rgba(10,10,10,0.5); border:1px solid var(--border-subtle); border-radius:var(--radius); font-family:var(--font-mono); font-size:0.65rem; color:var(--text-secondary); display:flex; align-items:center; gap:6px; margin-bottom:8px;">
|
|
1653
|
+
<span style="color:var(--text-muted);">$</span>
|
|
1654
|
+
<span>clawhub install ukkometa/seo-intel</span>
|
|
1655
|
+
<button class="btn btn-sm" style="margin-left:auto; padding:2px 6px; font-size:0.55rem;" onclick="navigator.clipboard.writeText('clawhub install ukkometa/seo-intel');this.textContent='Copied!';setTimeout(()=>this.textContent='Copy',1500);">Copy</button>
|
|
1656
|
+
</div>
|
|
1657
|
+
|
|
1658
|
+
<p style="font-size:0.6rem; color:var(--text-muted); margin-bottom:6px;">Or paste into OpenClaw chat:</p>
|
|
1659
|
+
<div style="padding:8px 10px; background:rgba(10,10,10,0.5); border:1px solid var(--border-subtle); border-radius:var(--radius); font-family:var(--font-mono); font-size:0.6rem; color:var(--text-secondary); line-height:1.6; position:relative;">
|
|
1660
|
+
<div style="white-space:pre;">Set up SEO Intel cloud analysis.
|
|
1661
|
+
Use Gemini for extraction, Claude for analysis.
|
|
1662
|
+
Configure API keys and run a test crawl.</div>
|
|
1663
|
+
<button class="btn btn-sm" style="position:absolute; top:6px; right:6px; padding:2px 6px; font-size:0.5rem;" onclick="navigator.clipboard.writeText('Set up SEO Intel cloud analysis.\nUse Gemini for extraction, Claude for analysis.\nConfigure API keys and run a test crawl.');this.textContent='Copied!';setTimeout(()=>this.textContent='Copy',1500);">Copy</button>
|
|
1664
|
+
</div>
|
|
1446
1665
|
</div>
|
|
1447
1666
|
<!-- Unified upgrade card (free tier only) -->
|
|
1448
1667
|
<div id="unifiedUpgrade" style="display:none; position:relative; margin-top:0; padding:48px 32px; border:1px solid rgba(232,213,163,0.15); border-radius:var(--radius); overflow:hidden; text-align:center;">
|
|
@@ -1475,8 +1694,9 @@ input::placeholder {
|
|
|
1475
1694
|
</div>
|
|
1476
1695
|
</div>
|
|
1477
1696
|
|
|
1478
|
-
<!-- OpenClaw sidebar
|
|
1479
|
-
<div class="card" style="margin-bottom:0; position:
|
|
1697
|
+
<!-- OpenClaw floating sidebar -->
|
|
1698
|
+
<div class="card" id="openclawFloating" style="margin-bottom:0; position:fixed; top:80px; right:20px; width:240px; z-index:50; box-shadow:0 8px 40px rgba(0,0,0,0.5); border-color:rgba(124,109,235,0.3);">
|
|
1699
|
+
<button onclick="document.getElementById('openclawFloating').style.display='none'" style="position:absolute;top:8px;right:8px;background:none;border:none;color:var(--text-muted);cursor:pointer;font-size:0.7rem;"><i class="fa-solid fa-xmark"></i></button>
|
|
1480
1700
|
<div style="display:flex; align-items:center; gap:10px; margin-bottom:12px;">
|
|
1481
1701
|
<svg width="22" height="22" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
1482
1702
|
<circle cx="14" cy="14" r="13" stroke="var(--accent-purple)" stroke-width="1.5" opacity="0.6"/>
|
|
@@ -1523,7 +1743,7 @@ Configure API keys and run a test crawl.</div>
|
|
|
1523
1743
|
</div>
|
|
1524
1744
|
</div>
|
|
1525
1745
|
|
|
1526
|
-
</div><!-- close grid -->
|
|
1746
|
+
</div><!-- close step2 outer grid -->
|
|
1527
1747
|
</div>
|
|
1528
1748
|
|
|
1529
1749
|
<!-- ─── Step 3: Project Configuration ─────────────────────────────────── -->
|
|
@@ -2987,6 +3207,22 @@ Configure API keys and run a test crawl.</div>
|
|
|
2987
3207
|
let agentHistory = [];
|
|
2988
3208
|
let agentBusy = false;
|
|
2989
3209
|
|
|
3210
|
+
window.selectAgentRuntime = function(runtime, btn) {
|
|
3211
|
+
document.querySelectorAll('.agent-runtime-tab').forEach(b => b.classList.remove('active'));
|
|
3212
|
+
btn.classList.add('active');
|
|
3213
|
+
document.querySelectorAll('.agent-runtime-panel').forEach(p => p.style.display = 'none');
|
|
3214
|
+
const panel = document.getElementById('agentRuntime-' + runtime);
|
|
3215
|
+
if (panel) panel.style.display = '';
|
|
3216
|
+
};
|
|
3217
|
+
|
|
3218
|
+
window.selectAgentRuntimeMin = function(runtime, btn) {
|
|
3219
|
+
document.querySelectorAll('.agent-runtime-tab-min').forEach(b => b.classList.remove('active'));
|
|
3220
|
+
btn.classList.add('active');
|
|
3221
|
+
document.querySelectorAll('.min-runtime-panel').forEach(p => p.style.display = 'none');
|
|
3222
|
+
const panel = document.getElementById('minRuntime-' + runtime);
|
|
3223
|
+
if (panel) panel.style.display = '';
|
|
3224
|
+
};
|
|
3225
|
+
|
|
2990
3226
|
window.startAgentSetup = function() {
|
|
2991
3227
|
// Hide wizard steps, show agent panel
|
|
2992
3228
|
document.querySelector('.wizard-body').style.display = 'none';
|
|
@@ -3152,6 +3388,90 @@ Configure API keys and run a test crawl.</div>
|
|
|
3152
3388
|
btn.disabled = false;
|
|
3153
3389
|
};
|
|
3154
3390
|
|
|
3391
|
+
// ── Cloud Analysis Column ─────────────────────────────────────────────
|
|
3392
|
+
|
|
3393
|
+
window.selectCloudAnalysisModel = function(card) {
|
|
3394
|
+
const modelId = card.dataset.modelId;
|
|
3395
|
+
const provider = card.dataset.provider;
|
|
3396
|
+
const envVar = card.dataset.envVar;
|
|
3397
|
+
const placeholder = card.dataset.keyPlaceholder || '';
|
|
3398
|
+
|
|
3399
|
+
// Set global state
|
|
3400
|
+
window.selectedAnalysisModel = 'cloud:' + modelId;
|
|
3401
|
+
window.selectedCloudProvider = provider;
|
|
3402
|
+
|
|
3403
|
+
// Deselect all local Ollama analysis model cards
|
|
3404
|
+
document.querySelectorAll('#analysisModels .model-radio-card').forEach(c => c.classList.remove('selected'));
|
|
3405
|
+
|
|
3406
|
+
// Deselect other cloud cards, select this one
|
|
3407
|
+
document.querySelectorAll('#cloudAnalysisModels .model-radio-card').forEach(c => c.classList.remove('selected'));
|
|
3408
|
+
card.classList.add('selected');
|
|
3409
|
+
|
|
3410
|
+
// Hide analysis pull row and old cloud hint
|
|
3411
|
+
const pullRow = document.getElementById('analysisPullRow');
|
|
3412
|
+
const cloudHint = document.getElementById('analysisCloudHint');
|
|
3413
|
+
if (pullRow) pullRow.style.display = 'none';
|
|
3414
|
+
if (cloudHint) cloudHint.style.display = 'none';
|
|
3415
|
+
|
|
3416
|
+
// Show key input area with correct env var placeholder
|
|
3417
|
+
const keyArea = document.getElementById('cloudKeyArea');
|
|
3418
|
+
const keyInput = document.getElementById('cloudApiKeyInput');
|
|
3419
|
+
const saveStatus = document.getElementById('cloudKeySaveStatus');
|
|
3420
|
+
if (keyArea) keyArea.style.display = 'block';
|
|
3421
|
+
if (keyInput) {
|
|
3422
|
+
keyInput.placeholder = placeholder;
|
|
3423
|
+
keyInput.dataset.envVar = envVar;
|
|
3424
|
+
keyInput.dataset.provider = provider;
|
|
3425
|
+
}
|
|
3426
|
+
if (saveStatus) saveStatus.textContent = '';
|
|
3427
|
+
|
|
3428
|
+
// Also update state.selectedAnalysis for consistency with existing code
|
|
3429
|
+
if (typeof state !== 'undefined') state.selectedAnalysis = 'cloud:' + modelId;
|
|
3430
|
+
};
|
|
3431
|
+
|
|
3432
|
+
window.saveCloudApiKeyFromUI = function() {
|
|
3433
|
+
const keyInput = document.getElementById('cloudApiKeyInput');
|
|
3434
|
+
const saveStatus = document.getElementById('cloudKeySaveStatus');
|
|
3435
|
+
if (!keyInput || !saveStatus) return;
|
|
3436
|
+
const envVar = keyInput.dataset.envVar;
|
|
3437
|
+
const provider = keyInput.dataset.provider;
|
|
3438
|
+
const value = keyInput.value.trim();
|
|
3439
|
+
if (!value) { saveStatus.textContent = 'Enter an API key first.'; saveStatus.style.color = 'var(--color-danger)'; return; }
|
|
3440
|
+
saveCloudApiKey(provider, envVar, value);
|
|
3441
|
+
};
|
|
3442
|
+
|
|
3443
|
+
window.saveCloudApiKey = async function(provider, envVar, keyValue) {
|
|
3444
|
+
const saveStatus = document.getElementById('cloudKeySaveStatus');
|
|
3445
|
+
if (saveStatus) { saveStatus.textContent = 'Saving…'; saveStatus.style.color = 'var(--text-muted)'; }
|
|
3446
|
+
try {
|
|
3447
|
+
const res = await fetch('/api/setup/save-env', {
|
|
3448
|
+
method: 'POST',
|
|
3449
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3450
|
+
body: JSON.stringify({ key: envVar, value: keyValue }),
|
|
3451
|
+
});
|
|
3452
|
+
const data = await res.json();
|
|
3453
|
+
if (!res.ok) throw new Error(data.error || 'Save failed');
|
|
3454
|
+
if (saveStatus) { saveStatus.textContent = '✓ Saved to .env'; saveStatus.style.color = 'var(--color-success)'; }
|
|
3455
|
+
} catch (err) {
|
|
3456
|
+
if (saveStatus) { saveStatus.textContent = err.message; saveStatus.style.color = 'var(--color-danger)'; }
|
|
3457
|
+
}
|
|
3458
|
+
};
|
|
3459
|
+
|
|
3460
|
+
// Patch: selecting a local Ollama analysis model should deselect any cloud card
|
|
3461
|
+
const _origSelectAnalysisModel = typeof selectAnalysisModel !== 'undefined' ? selectAnalysisModel : null;
|
|
3462
|
+
// Hook into the cloud deselect when local analysis cards are clicked via delegated event
|
|
3463
|
+
document.addEventListener('click', function(e) {
|
|
3464
|
+
const card = e.target.closest('#analysisModels .model-radio-card');
|
|
3465
|
+
if (card) {
|
|
3466
|
+
// Deselect cloud cards
|
|
3467
|
+
document.querySelectorAll('#cloudAnalysisModels .model-radio-card').forEach(c => c.classList.remove('selected'));
|
|
3468
|
+
const keyArea = document.getElementById('cloudKeyArea');
|
|
3469
|
+
if (keyArea) keyArea.style.display = 'none';
|
|
3470
|
+
window.selectedAnalysisModel = undefined;
|
|
3471
|
+
window.selectedCloudProvider = undefined;
|
|
3472
|
+
}
|
|
3473
|
+
});
|
|
3474
|
+
|
|
3155
3475
|
// ── Init ──────────────────────────────────────────────────────────────
|
|
3156
3476
|
initStep3();
|
|
3157
3477
|
runSystemCheck();
|