seo-intel 1.1.7 → 1.1.9

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 CHANGED
@@ -2,12 +2,14 @@
2
2
  # Run `node cli.js setup` to configure interactively
3
3
 
4
4
  # ── License (Pro features) ───────────────────────────────────────────────
5
- # Get your key at https://froggo.pro/seo-intel
5
+ # Get your key at https://ukkometa.fi/en/seo-intel/
6
6
  # SEO_INTEL_LICENSE=SI-xxxx-xxxx-xxxx-xxxx
7
7
 
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 ADDED
@@ -0,0 +1,54 @@
1
+ # Changelog
2
+
3
+ ## 1.1.9 (2026-03-27)
4
+
5
+ ### Security
6
+ - Fix shell injection risk in Gemini CLI integration (execSync → spawnSync + stdin)
7
+ - Fix SSRF vector in llms.txt URL processing (hostname validation)
8
+ - Fix SSRF: llms.txt URLs now respect robots.txt before enqueue
9
+ - Set license cache file permissions to 0600 (owner-only)
10
+ - SQL injection audit — all queries verified to use parameterised statements
11
+
12
+ ### Fixes
13
+ - Crawler no longer upgrades http://localhost to https (fixes local/mock testing)
14
+ - Updater cache moved to ~/.seo-intel/ (fixes permission errors on Linux global install)
15
+ - Clear actionable error when Gemini times out and OpenClaw gateway is down
16
+ - Project name passed to error context for timeout messages
17
+
18
+ ### Improvements
19
+ - Defensive logging when license variant name is unrecognised
20
+ - 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
21
+ - Setup wizard: Agentic Setup picker — OpenClaw, Claude Code, Codex CLI, Perplexity with tailored copy-paste prompts
22
+ - Setup wizard: Step 2 expanded to 1100px, OpenClaw as floating sidebar
23
+
24
+ ## 1.1.7 (2026-03-26)
25
+
26
+ ### New
27
+ - **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
28
+ - **Stale domain auto-pruning** — domains removed from config are automatically cleaned from DB on next crawl
29
+ - **Manual prune** — `seo-intel competitors <project> --prune` to clean stale DB entries on demand
30
+ - **Full body text storage** — crawler stores full page content in DB for richer offline extraction
31
+
32
+ ### Improvements
33
+ - **Background crawl/extract** — long-running jobs survive browser tab close
34
+ - **Dashboard terminal** — stealth flag visible, stop button works properly, status bar syncs
35
+ - **Templates button** added to dashboard terminal panel
36
+ - **Dashboard refresh** — crawl and analyze always regenerate full multi-project dashboard
37
+ - **Config remove = DB remove** — `--remove` and `--remove-owned` auto-prune matching DB data
38
+
39
+ ### Fixes
40
+ - SSE disconnect no longer kills crawl/extract processes
41
+ - Terminal command display shows `--stealth` flag when enabled
42
+
43
+ ## 1.1.6 (2026-03-24)
44
+
45
+ - Stop button for crawl/extract jobs in dashboard
46
+ - Stealth toggle sync between status bar and terminal
47
+ - Extraction status bar layout improvements (CSS grid)
48
+ - EADDRINUSE recovery — server opens existing dashboard instead of crashing
49
+
50
+ ## 1.1.5 (2026-03-21)
51
+
52
+ - Update checker, job stop API, background analyze
53
+ - LAN Ollama host support with fallback
54
+ - `html` CLI command, wizard UX improvements
package/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  SEO Intel — Dual License
2
2
 
3
- Copyright (c) 2024-2026 froggo.pro
3
+ Copyright (c) 2024-2026 Ukkometa (ukkometa.fi)
4
4
 
5
5
  This project uses a dual license structure:
6
6
 
@@ -65,11 +65,10 @@ You MAY:
65
65
  - Share generated reports and dashboards (outputs are yours)
66
66
 
67
67
  License:
68
- Solo — €19.99/month or €199/year — full AI analysis, all commands
69
- Also available at $9.99/month via froggo.pro marketplace
68
+ Solo — €19.99/month or €199.99/year — full AI analysis, all commands
70
69
 
71
- Purchase at: https://ukkometa.fi or https://froggo.pro/seo-intel
70
+ Purchase at: https://ukkometa.fi/en/seo-intel/
72
71
 
73
72
  ================================================================================
74
73
 
75
- For questions: hello@froggo.pro
74
+ For questions: ukko@ukkometa.fi
package/README.md CHANGED
@@ -75,7 +75,7 @@ seo-intel suggest-usecases myproject --scope docs # infer what pages/docs s
75
75
  | `schemas <project>` | Schema.org coverage analysis |
76
76
  | `update` | Check for updates |
77
77
 
78
- ### Solo (€19.99/mo · [ukkometa.fi/seo-intel](https://ukkometa.fi/seo-intel))
78
+ ### Solo (€19.99/mo · [ukkometa.fi/seo-intel](https://ukkometa.fi/en/seo-intel/))
79
79
 
80
80
  | Command | Description |
81
81
  |---------|-------------|
@@ -194,7 +194,7 @@ Upload your GSC data for ranking insights:
194
194
  - 1 project, 500 pages/domain
195
195
  - Crawl, extract, setup, basic reports
196
196
 
197
- ### Pro Tier ($49 one-time)
197
+ ### Solo (€19.99/mo · €199.99/yr)
198
198
  - Unlimited projects and pages
199
199
  - All analysis commands, GSC insights, scheduling
200
200
 
@@ -203,7 +203,7 @@ Upload your GSC data for ranking insights:
203
203
  echo "SEO_INTEL_LICENSE=SI-xxxx-xxxx-xxxx-xxxx" >> .env
204
204
  ```
205
205
 
206
- Get a key at [froggo.pro/seo-intel](https://froggo.pro/seo-intel)
206
+ Get a key at [ukkometa.fi/seo-intel](https://ukkometa.fi/en/seo-intel/)
207
207
 
208
208
  ## Updates
209
209
 
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 = `https://${site.domain}`;
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
- const result = await callGemini(prompt);
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
- // Use gemini CLI (already auth'd via OpenClaw)
995
- const { execSync } = await import('child_process');
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 result = execSync(
998
- `echo ${JSON.stringify(prompt)} | gemini -p -`,
999
- { maxBuffer: 10 * 1024 * 1024, timeout: 120000 }
1000
- ).toString();
1001
- return result;
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
- console.error('[gemini]', err.message);
1004
- return null;
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.froggoVersion) {
1272
- console.log(chalk.gray(' froggo.pro: ') + chalk.white(info.froggoVersion));
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 : `https://${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 : `https://${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 : `https://${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;
@@ -186,11 +187,14 @@ export async function* crawlDomain(startUrl, opts = {}) {
186
187
  // When hostname contains "docs.", spoof Googlebot UA to reduce WAF friction.
187
188
  const isDocsHostname = base.hostname.toLowerCase().includes('docs.');
188
189
  const GOOGLEBOT_UA = 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)';
189
- const defaultUA = 'Mozilla/5.0 (compatible; SEOIntelBot/1.0; +https://froggo.pro/seo-intel/bot)';
190
+ const defaultUA = 'Mozilla/5.0 (compatible; SEOIntelBot/1.0; +https://ukkometa.fi/en/seo-intel/bot)';
190
191
  const effectiveUA = isDocsHostname ? GOOGLEBOT_UA : defaultUA;
191
192
 
192
193
  async function tryLoadLlmsTxt() {
193
- const llmsUrl = `https://${base.hostname}/llms.txt`;
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,11 @@ 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
+ }
227
236
  if (!queue.some(q => q.url === u)) {
228
237
  queue.push({ url: u, depth: 1 });
229
238
  added++;
@@ -181,7 +181,7 @@ async function checkHttp(hostname) {
181
181
  signal: controller.signal,
182
182
  redirect: 'follow',
183
183
  headers: {
184
- 'User-Agent': 'Mozilla/5.0 (compatible; SEOIntelBot/1.0; +https://froggo.pro/seo-intel/bot)',
184
+ 'User-Agent': 'Mozilla/5.0 (compatible; SEOIntelBot/1.0; +https://ukkometa.fi/en/seo-intel/bot)',
185
185
  },
186
186
  });
187
187
 
package/lib/gate.js CHANGED
@@ -31,7 +31,7 @@ function printUpgradeMessage(feature) {
31
31
  console.log(`${GOLD}${BOLD} ⭐ Paid Feature: ${feature}${RESET}`);
32
32
  console.log(`${DIM} This feature requires SEO Intel Solo (€19.99/mo).${RESET}`);
33
33
  console.log('');
34
- console.log(`${DIM} Get your license at ${CYAN}https://froggo.pro/seo-intel${RESET}`);
34
+ console.log(`${DIM} Get your license at ${CYAN}https://ukkometa.fi/en/seo-intel/${RESET}`);
35
35
  console.log(`${DIM} Then add your key: ${CYAN}SEO_INTEL_LICENSE=SI-xxxx-xxxx-xxxx-xxxx${RESET} ${DIM}in .env${RESET}`);
36
36
  console.log('');
37
37
  }
@@ -104,7 +104,7 @@ export function getPremiumPlaceholder(section) {
104
104
  <p style="font-size: 0.72rem; color: var(--text-muted); margin-bottom: 12px;">
105
105
  This section requires SEO Intel Solo (€19.99/mo)
106
106
  </p>
107
- <a href="https://froggo.pro/seo-intel" target="_blank"
107
+ <a href="https://ukkometa.fi/en/seo-intel/" target="_blank"
108
108
  style="color: var(--accent-gold); font-size: 0.72rem; text-decoration: underline;">
109
109
  Upgrade to unlock →
110
110
  </a>
@@ -160,7 +160,7 @@ export function capPages(requestedPages) {
160
160
  export function printLicenseStatus() {
161
161
  const license = loadLicense();
162
162
 
163
- const sourceLabel = license.source === 'froggo' ? ' (Froggo)' : license.source === 'lemon-squeezy' ? ' (LS)' : '';
163
+ const sourceLabel = license.source === 'lemon-squeezy' ? ' (LS)' : '';
164
164
 
165
165
  if (license.tier === 'agency') {
166
166
  console.log(`${GOLD}${BOLD} ⭐ SEO Intel Agency${RESET}`);
@@ -181,7 +181,7 @@ export function printLicenseStatus() {
181
181
  if (license.needsActivation) {
182
182
  console.log(`\x1b[33m ⚠ License key found but not yet validated — run any command to activate${RESET}`);
183
183
  }
184
- console.log(`${DIM} Upgrade: ${CYAN}https://froggo.pro/seo-intel${RESET} ${DIM}— Solo €19.99/mo · €199/yr${RESET}`);
184
+ console.log(`${DIM} Upgrade: ${CYAN}https://ukkometa.fi/en/seo-intel/${RESET} ${DIM}— Solo €19.99/mo · €199.99/yr${RESET}`);
185
185
  }
186
186
  console.log('');
187
187
  }
@@ -200,6 +200,6 @@ export function getLicenseInfo() {
200
200
  maxProjects: Number.isFinite(license.maxProjects) ? license.maxProjects : null,
201
201
  maxPages: Number.isFinite(license.maxPagesPerDomain) ? license.maxPagesPerDomain : null,
202
202
  features: license.features === 'all' ? 'all' : [...license.features],
203
- upgradeUrl: 'https://froggo.pro/seo-intel',
203
+ upgradeUrl: 'https://ukkometa.fi/en/seo-intel/',
204
204
  };
205
205
  }