seo-intel 1.0.0
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 +41 -0
- package/LICENSE +75 -0
- package/README.md +243 -0
- package/Start SEO Intel.bat +9 -0
- package/Start SEO Intel.command +8 -0
- package/cli.js +3727 -0
- package/config/example.json +29 -0
- package/config/setup-wizard.js +522 -0
- package/crawler/index.js +566 -0
- package/crawler/robots.js +103 -0
- package/crawler/sanitize.js +124 -0
- package/crawler/schema-parser.js +168 -0
- package/crawler/sitemap.js +103 -0
- package/crawler/stealth.js +393 -0
- package/crawler/subdomain-discovery.js +341 -0
- package/db/db.js +213 -0
- package/db/schema.sql +120 -0
- package/exports/competitive.js +186 -0
- package/exports/heuristics.js +67 -0
- package/exports/queries.js +197 -0
- package/exports/suggestive.js +230 -0
- package/exports/technical.js +180 -0
- package/exports/templates.js +77 -0
- package/lib/gate.js +204 -0
- package/lib/license.js +369 -0
- package/lib/oauth.js +432 -0
- package/lib/updater.js +324 -0
- package/package.json +68 -0
- package/reports/generate-html.js +6194 -0
- package/reports/generate-site-graph.js +949 -0
- package/reports/gsc-loader.js +190 -0
- package/scheduler.js +142 -0
- package/seo-audit.js +619 -0
- package/seo-intel.png +0 -0
- package/server.js +602 -0
- package/setup/ROADMAP.md +109 -0
- package/setup/checks.js +483 -0
- package/setup/config-builder.js +227 -0
- package/setup/engine.js +65 -0
- package/setup/installers.js +197 -0
- package/setup/models.js +328 -0
- package/setup/openclaw-bridge.js +329 -0
- package/setup/validator.js +395 -0
- package/setup/web-routes.js +688 -0
- package/setup/wizard.html +2920 -0
- package/start-seo-intel.sh +8 -0
package/server.js
ADDED
|
@@ -0,0 +1,602 @@
|
|
|
1
|
+
import { createServer } from 'http';
|
|
2
|
+
import { readFileSync, writeFileSync, existsSync, readdirSync } from 'fs';
|
|
3
|
+
import { spawn } from 'child_process';
|
|
4
|
+
import { dirname, join, extname } from 'path';
|
|
5
|
+
import { fileURLToPath } from 'url';
|
|
6
|
+
|
|
7
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const PORT = parseInt(process.env.PORT || '3000', 10);
|
|
9
|
+
const PROGRESS_FILE = join(__dirname, '.extraction-progress.json');
|
|
10
|
+
const REPORTS_DIR = join(__dirname, 'reports');
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
function buildActionsMarkdown(payload) {
|
|
14
|
+
const grouped = (payload.actions || []).reduce((acc, action) => {
|
|
15
|
+
const area = action.area || 'other';
|
|
16
|
+
if (!acc[area]) acc[area] = [];
|
|
17
|
+
acc[area].push(action);
|
|
18
|
+
return acc;
|
|
19
|
+
}, {});
|
|
20
|
+
|
|
21
|
+
const lines = [
|
|
22
|
+
`# SEO Intel Actions — ${payload.project}`,
|
|
23
|
+
'',
|
|
24
|
+
`- Generated: ${payload.generatedAt || new Date().toISOString()}`,
|
|
25
|
+
`- Scope: ${payload.scope || 'all'}`,
|
|
26
|
+
`- Total actions: ${(payload.actions || []).length}`,
|
|
27
|
+
`- Priority mix: critical ${payload.summary?.critical || 0}, high ${payload.summary?.high || 0}, medium ${payload.summary?.medium || 0}, low ${payload.summary?.low || 0}`,
|
|
28
|
+
'',
|
|
29
|
+
'## Summary',
|
|
30
|
+
'',
|
|
31
|
+
`- Critical: ${payload.summary?.critical || 0}`,
|
|
32
|
+
`- High: ${payload.summary?.high || 0}`,
|
|
33
|
+
`- Medium: ${payload.summary?.medium || 0}`,
|
|
34
|
+
`- Low: ${payload.summary?.low || 0}`,
|
|
35
|
+
'',
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
const orderedAreas = ['technical', 'content', 'schema', 'structure', 'other'];
|
|
39
|
+
for (const area of orderedAreas) {
|
|
40
|
+
const items = grouped[area] || [];
|
|
41
|
+
if (!items.length) continue;
|
|
42
|
+
lines.push(`## ${area.charAt(0).toUpperCase() + area.slice(1)}`);
|
|
43
|
+
lines.push('');
|
|
44
|
+
for (const action of items) {
|
|
45
|
+
lines.push(`### ${action.title}`);
|
|
46
|
+
lines.push(`- ID: ${action.id}`);
|
|
47
|
+
lines.push(`- Type: ${action.type}`);
|
|
48
|
+
lines.push(`- Priority: ${action.priority}`);
|
|
49
|
+
lines.push(`- Why: ${action.why}`);
|
|
50
|
+
if (action.evidence?.length) {
|
|
51
|
+
lines.push('- Evidence:');
|
|
52
|
+
for (const item of action.evidence) lines.push(` - ${item}`);
|
|
53
|
+
}
|
|
54
|
+
if (action.implementationHints?.length) {
|
|
55
|
+
lines.push('- Implementation hints:');
|
|
56
|
+
for (const item of action.implementationHints) lines.push(` - ${item}`);
|
|
57
|
+
}
|
|
58
|
+
lines.push('');
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (!(payload.actions || []).length) {
|
|
63
|
+
lines.push('## No actions found');
|
|
64
|
+
lines.push('');
|
|
65
|
+
lines.push('- The current dataset did not surface any qualifying actions for this scope.');
|
|
66
|
+
lines.push('');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return lines.join('\n');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function getExportHistory() {
|
|
73
|
+
if (!existsSync(REPORTS_DIR)) return [];
|
|
74
|
+
return readdirSync(REPORTS_DIR)
|
|
75
|
+
.filter(name => /-actions-.*\.(json|md)$/i.test(name))
|
|
76
|
+
.map(name => {
|
|
77
|
+
const match = name.match(/^(.*?)-actions-(.*)\.(json|md)$/i);
|
|
78
|
+
return {
|
|
79
|
+
name,
|
|
80
|
+
project: match?.[1] || null,
|
|
81
|
+
stamp: match?.[2] || null,
|
|
82
|
+
format: (match?.[3] || '').toLowerCase(),
|
|
83
|
+
url: `/reports/${name}`,
|
|
84
|
+
};
|
|
85
|
+
})
|
|
86
|
+
.sort((a, b) => a.name < b.name ? 1 : -1);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── MIME types ──
|
|
90
|
+
const MIME = {
|
|
91
|
+
'.html': 'text/html',
|
|
92
|
+
'.css': 'text/css',
|
|
93
|
+
'.js': 'application/javascript',
|
|
94
|
+
'.json': 'application/json',
|
|
95
|
+
'.png': 'image/png',
|
|
96
|
+
'.svg': 'image/svg+xml',
|
|
97
|
+
'.md': 'text/markdown; charset=utf-8',
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
// ── Read progress with PID liveness check (mirrors cli.js) ──
|
|
101
|
+
function readProgress() {
|
|
102
|
+
try {
|
|
103
|
+
if (!existsSync(PROGRESS_FILE)) return null;
|
|
104
|
+
const data = JSON.parse(readFileSync(PROGRESS_FILE, 'utf8'));
|
|
105
|
+
if (data.status === 'running' && data.pid) {
|
|
106
|
+
try { process.kill(data.pid, 0); } catch (e) {
|
|
107
|
+
if (e.code === 'ESRCH') {
|
|
108
|
+
data.status = 'crashed';
|
|
109
|
+
data.crashed_at = data.updated_at;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return data;
|
|
114
|
+
} catch { return null; }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── Parse JSON body from request ──
|
|
118
|
+
function readBody(req) {
|
|
119
|
+
return new Promise((resolve, reject) => {
|
|
120
|
+
let body = '';
|
|
121
|
+
req.on('data', chunk => { body += chunk; if (body.length > 1e5) reject(new Error('Body too large')); });
|
|
122
|
+
req.on('end', () => { try { resolve(JSON.parse(body || '{}')); } catch { reject(new Error('Invalid JSON')); } });
|
|
123
|
+
req.on('error', reject);
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ── JSON response helper ──
|
|
128
|
+
function json(res, status, data) {
|
|
129
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
130
|
+
res.end(JSON.stringify(data));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ── Serve static file ──
|
|
134
|
+
function serveFile(res, filePath) {
|
|
135
|
+
if (!existsSync(filePath)) { res.writeHead(404); res.end('Not found'); return; }
|
|
136
|
+
const ext = extname(filePath);
|
|
137
|
+
const mime = MIME[ext] || 'application/octet-stream';
|
|
138
|
+
res.writeHead(200, { 'Content-Type': mime });
|
|
139
|
+
res.end(readFileSync(filePath));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ── Available project configs ──
|
|
143
|
+
function getProjects() {
|
|
144
|
+
const configDir = join(__dirname, 'config');
|
|
145
|
+
if (!existsSync(configDir)) return [];
|
|
146
|
+
return readdirSync(configDir)
|
|
147
|
+
.filter(f => f.endsWith('.json'))
|
|
148
|
+
.map(f => {
|
|
149
|
+
try {
|
|
150
|
+
const cfg = JSON.parse(readFileSync(join(configDir, f), 'utf8'));
|
|
151
|
+
return { project: cfg.project, domain: cfg.target?.domain };
|
|
152
|
+
} catch { return null; }
|
|
153
|
+
})
|
|
154
|
+
.filter(Boolean);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── Request handler ──
|
|
158
|
+
async function handleRequest(req, res) {
|
|
159
|
+
const url = new URL(req.url, `http://localhost:${PORT}`);
|
|
160
|
+
const path = url.pathname;
|
|
161
|
+
|
|
162
|
+
// ─── Setup wizard routes ───
|
|
163
|
+
if (path.startsWith('/setup') || path.startsWith('/api/setup/')) {
|
|
164
|
+
try {
|
|
165
|
+
const { handleSetupRequest } = await import('./setup/web-routes.js');
|
|
166
|
+
if (handleSetupRequest(req, res, url)) return;
|
|
167
|
+
} catch (err) {
|
|
168
|
+
console.error('Setup route error:', err);
|
|
169
|
+
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
170
|
+
res.end('Setup wizard error: ' + err.message);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ─── Dashboard: auto-generate and serve ───
|
|
176
|
+
if (req.method === 'GET' && path === '/') {
|
|
177
|
+
// Debug: ?tier=free simulates free tier dashboard
|
|
178
|
+
const forceFree = url.searchParams.get('tier') === 'free';
|
|
179
|
+
|
|
180
|
+
try {
|
|
181
|
+
const configDir = join(__dirname, 'config');
|
|
182
|
+
const configFiles = existsSync(configDir)
|
|
183
|
+
? readdirSync(configDir).filter(f => f.endsWith('.json') && f !== 'example.json' && !f.startsWith('setup'))
|
|
184
|
+
: [];
|
|
185
|
+
|
|
186
|
+
if (!configFiles.length) {
|
|
187
|
+
console.log('[dashboard] No config files found in', configDir);
|
|
188
|
+
res.writeHead(302, { Location: '/setup' });
|
|
189
|
+
res.end();
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
console.log('[dashboard] Found configs:', configFiles.join(', '), forceFree ? '(tier=free)' : '');
|
|
193
|
+
|
|
194
|
+
const { getDb } = await import('./db/db.js');
|
|
195
|
+
const db = getDb(join(__dirname, 'seo-intel.db'));
|
|
196
|
+
|
|
197
|
+
// Load all configs that have crawl data
|
|
198
|
+
const activeConfigs = [];
|
|
199
|
+
for (const file of configFiles) {
|
|
200
|
+
const config = JSON.parse(readFileSync(join(configDir, file), 'utf8'));
|
|
201
|
+
const project = file.replace('.json', '');
|
|
202
|
+
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;
|
|
203
|
+
if (pageCount > 0) activeConfigs.push(config);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (!activeConfigs.length) {
|
|
207
|
+
// Projects configured but no crawl data yet — send to wizard
|
|
208
|
+
console.log('[dashboard] No active configs with crawl data');
|
|
209
|
+
res.writeHead(302, { Location: '/setup' });
|
|
210
|
+
res.end();
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
console.log('[dashboard] Active projects:', activeConfigs.map(c => c.project).join(', '));
|
|
214
|
+
|
|
215
|
+
if (forceFree) {
|
|
216
|
+
process.env.SEO_INTEL_FORCE_FREE = '1';
|
|
217
|
+
const { _resetLicenseCache } = await import('./lib/license.js');
|
|
218
|
+
_resetLicenseCache();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Always generate fresh — one dashboard for all projects (1 or many)
|
|
222
|
+
const { generateMultiDashboard } = await import('./reports/generate-html.js');
|
|
223
|
+
const outPath = generateMultiDashboard(db, activeConfigs);
|
|
224
|
+
|
|
225
|
+
if (forceFree) {
|
|
226
|
+
delete process.env.SEO_INTEL_FORCE_FREE;
|
|
227
|
+
const { _resetLicenseCache } = await import('./lib/license.js');
|
|
228
|
+
_resetLicenseCache();
|
|
229
|
+
}
|
|
230
|
+
serveFile(res, outPath);
|
|
231
|
+
} catch (err) {
|
|
232
|
+
console.error('[dashboard] Generation error:', err.message);
|
|
233
|
+
// Generation failed — try serving a cached dashboard
|
|
234
|
+
const allDash = join(REPORTS_DIR, 'all-projects-dashboard.html');
|
|
235
|
+
if (existsSync(allDash)) { serveFile(res, allDash); return; }
|
|
236
|
+
const htmlFiles = existsSync(REPORTS_DIR) ? readdirSync(REPORTS_DIR).filter(f => f.endsWith('-dashboard.html')) : [];
|
|
237
|
+
if (htmlFiles.length) { serveFile(res, join(REPORTS_DIR, htmlFiles[0])); return; }
|
|
238
|
+
res.writeHead(302, { Location: '/setup' });
|
|
239
|
+
res.end();
|
|
240
|
+
}
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (req.method === 'GET' && path.startsWith('/reports/')) {
|
|
245
|
+
const fileName = path.replace('/reports/', '');
|
|
246
|
+
if (fileName.includes('..') || fileName.includes('/')) { res.writeHead(400); res.end('Bad path'); return; }
|
|
247
|
+
serveFile(res, join(REPORTS_DIR, fileName));
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ─── API: Get progress ───
|
|
252
|
+
if (req.method === 'GET' && path === '/api/progress') {
|
|
253
|
+
const progress = readProgress();
|
|
254
|
+
json(res, 200, progress || { status: 'idle' });
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ─── API: Get projects ───
|
|
259
|
+
if (req.method === 'GET' && path === '/api/projects') {
|
|
260
|
+
json(res, 200, getProjects());
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ─── API: Crawl ───
|
|
265
|
+
if (req.method === 'POST' && path === '/api/crawl') {
|
|
266
|
+
try {
|
|
267
|
+
const body = await readBody(req);
|
|
268
|
+
const { project, stealth } = body;
|
|
269
|
+
if (!project) { json(res, 400, { error: 'Missing project' }); return; }
|
|
270
|
+
|
|
271
|
+
// Conflict guard
|
|
272
|
+
const progress = readProgress();
|
|
273
|
+
if (progress?.status === 'running') {
|
|
274
|
+
json(res, 409, { error: 'Job already running', progress });
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const args = ['cli.js', 'crawl', project];
|
|
279
|
+
if (stealth) args.push('--stealth');
|
|
280
|
+
|
|
281
|
+
const child = spawn(process.execPath, args, {
|
|
282
|
+
cwd: __dirname,
|
|
283
|
+
detached: true,
|
|
284
|
+
stdio: 'ignore',
|
|
285
|
+
});
|
|
286
|
+
child.unref();
|
|
287
|
+
|
|
288
|
+
json(res, 202, { started: true, pid: child.pid, command: 'crawl', project });
|
|
289
|
+
} catch (e) {
|
|
290
|
+
json(res, 500, { error: e.message });
|
|
291
|
+
}
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ─── API: Extract ───
|
|
296
|
+
if (req.method === 'POST' && path === '/api/extract') {
|
|
297
|
+
try {
|
|
298
|
+
const body = await readBody(req);
|
|
299
|
+
const { project, stealth } = body;
|
|
300
|
+
if (!project) { json(res, 400, { error: 'Missing project' }); return; }
|
|
301
|
+
|
|
302
|
+
// Conflict guard
|
|
303
|
+
const progress = readProgress();
|
|
304
|
+
if (progress?.status === 'running') {
|
|
305
|
+
json(res, 409, { error: 'Job already running', progress });
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const args = ['cli.js', 'extract', project];
|
|
310
|
+
if (stealth) args.push('--stealth');
|
|
311
|
+
|
|
312
|
+
const child = spawn(process.execPath, args, {
|
|
313
|
+
cwd: __dirname,
|
|
314
|
+
detached: true,
|
|
315
|
+
stdio: 'ignore',
|
|
316
|
+
});
|
|
317
|
+
child.unref();
|
|
318
|
+
|
|
319
|
+
json(res, 202, { started: true, pid: child.pid, command: 'extract', project });
|
|
320
|
+
} catch (e) {
|
|
321
|
+
json(res, 500, { error: e.message });
|
|
322
|
+
}
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
// ─── API: Export actions ───
|
|
328
|
+
if (req.method === 'POST' && path === '/api/export-actions') {
|
|
329
|
+
try {
|
|
330
|
+
const body = await readBody(req);
|
|
331
|
+
const { project } = body;
|
|
332
|
+
const scope = ['technical', 'competitive', 'suggestive', 'all'].includes(body.scope) ? body.scope : 'all';
|
|
333
|
+
if (!project) { json(res, 400, { error: 'Missing project' }); return; }
|
|
334
|
+
|
|
335
|
+
const progress = readProgress();
|
|
336
|
+
if (progress?.status === 'running') {
|
|
337
|
+
json(res, 409, { error: 'Job already running', progress });
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const args = ['cli.js', 'export-actions', project, '--scope', scope, '--format', 'json'];
|
|
342
|
+
const child = spawn(process.execPath, args, {
|
|
343
|
+
cwd: __dirname,
|
|
344
|
+
env: { ...process.env, FORCE_COLOR: '0' },
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
let stdout = '';
|
|
348
|
+
let stderr = '';
|
|
349
|
+
|
|
350
|
+
child.stdout.on('data', chunk => { stdout += chunk.toString(); });
|
|
351
|
+
child.stderr.on('data', chunk => { stderr += chunk.toString(); });
|
|
352
|
+
child.on('error', err => { json(res, 500, { error: err.message }); });
|
|
353
|
+
child.on('close', code => {
|
|
354
|
+
if (res.writableEnded) return;
|
|
355
|
+
if (code !== 0) {
|
|
356
|
+
json(res, 500, { error: (stderr || stdout || `export-actions exited with code ${code}`).trim() });
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const jsonStart = stdout.indexOf('{');
|
|
361
|
+
const jsonEnd = stdout.lastIndexOf('}');
|
|
362
|
+
const rawJson = jsonStart >= 0 && jsonEnd >= jsonStart ? stdout.slice(jsonStart, jsonEnd + 1) : stdout.trim();
|
|
363
|
+
|
|
364
|
+
try {
|
|
365
|
+
const data = JSON.parse(rawJson);
|
|
366
|
+
const stamp = Date.now();
|
|
367
|
+
const baseName = `${project}-actions-${stamp}`;
|
|
368
|
+
writeFileSync(join(REPORTS_DIR, `${baseName}.json`), JSON.stringify(data, null, 2), 'utf8');
|
|
369
|
+
writeFileSync(join(REPORTS_DIR, `${baseName}.md`), buildActionsMarkdown(data), 'utf8');
|
|
370
|
+
json(res, 200, { success: true, data });
|
|
371
|
+
} catch (err) {
|
|
372
|
+
json(res, 500, {
|
|
373
|
+
error: 'Failed to parse export output',
|
|
374
|
+
details: err.message,
|
|
375
|
+
output: stdout.trim(),
|
|
376
|
+
stderr: stderr.trim(),
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
});
|
|
380
|
+
} catch (e) {
|
|
381
|
+
json(res, 500, { error: e.message });
|
|
382
|
+
}
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// ─── API: Export history ───
|
|
387
|
+
if (req.method === 'GET' && path === '/api/export-history') {
|
|
388
|
+
json(res, 200, { success: true, items: getExportHistory() });
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// ─── API: License status ───
|
|
393
|
+
if (req.method === 'GET' && path === '/api/license-status') {
|
|
394
|
+
try {
|
|
395
|
+
const { loadLicense, activateLicense } = await import('./lib/license.js');
|
|
396
|
+
let license = loadLicense();
|
|
397
|
+
if (license.needsActivation) {
|
|
398
|
+
license = await activateLicense();
|
|
399
|
+
}
|
|
400
|
+
json(res, 200, {
|
|
401
|
+
tier: license.tier || 'free',
|
|
402
|
+
valid: license.active || false,
|
|
403
|
+
key: license.key ? license.key.slice(0, 7) + '...' + license.key.slice(-4) : null,
|
|
404
|
+
source: license.source || null,
|
|
405
|
+
});
|
|
406
|
+
} catch (err) {
|
|
407
|
+
json(res, 200, { tier: 'free', valid: false, key: null, source: null });
|
|
408
|
+
}
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// ─── API: Save license key ───
|
|
413
|
+
if (req.method === 'POST' && path === '/api/save-license') {
|
|
414
|
+
try {
|
|
415
|
+
const body = await readBody(req);
|
|
416
|
+
const { key } = body;
|
|
417
|
+
if (!key || typeof key !== 'string') { json(res, 400, { error: 'No key provided' }); return; }
|
|
418
|
+
|
|
419
|
+
const envPath = join(__dirname, '.env');
|
|
420
|
+
let envContent = '';
|
|
421
|
+
if (existsSync(envPath)) {
|
|
422
|
+
envContent = readFileSync(envPath, 'utf8');
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Determine key type
|
|
426
|
+
const isFroggo = key.startsWith('FROGGO_') || key.length > 60;
|
|
427
|
+
const envVar = isFroggo ? 'FROGGO_TOKEN' : 'SEO_INTEL_LICENSE';
|
|
428
|
+
|
|
429
|
+
// Remove existing lines for both key types
|
|
430
|
+
const lines = envContent.split('\n').filter(l =>
|
|
431
|
+
!l.startsWith('SEO_INTEL_LICENSE=') && !l.startsWith('FROGGO_TOKEN=')
|
|
432
|
+
);
|
|
433
|
+
lines.push(`${envVar}=${key}`);
|
|
434
|
+
|
|
435
|
+
writeFileSync(envPath, lines.join('\n').replace(/\n{3,}/g, '\n\n').trim() + '\n', 'utf8');
|
|
436
|
+
|
|
437
|
+
// Set in process.env so activation picks it up
|
|
438
|
+
process.env[envVar] = key;
|
|
439
|
+
|
|
440
|
+
// Clear cache and re-validate
|
|
441
|
+
const { clearLicenseCache, activateLicense } = await import('./lib/license.js');
|
|
442
|
+
clearLicenseCache();
|
|
443
|
+
const license = await activateLicense();
|
|
444
|
+
|
|
445
|
+
json(res, 200, {
|
|
446
|
+
ok: true,
|
|
447
|
+
tier: license.tier || 'free',
|
|
448
|
+
valid: license.active || false,
|
|
449
|
+
});
|
|
450
|
+
} catch (err) {
|
|
451
|
+
json(res, 500, { error: err.message });
|
|
452
|
+
}
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// ─── API: Analyze (spawn background) ───
|
|
457
|
+
if (req.method === 'POST' && path === '/api/analyze') {
|
|
458
|
+
try {
|
|
459
|
+
const body = await readBody(req);
|
|
460
|
+
const { project } = body;
|
|
461
|
+
if (!project) { json(res, 400, { error: 'Missing project' }); return; }
|
|
462
|
+
|
|
463
|
+
const progress = readProgress();
|
|
464
|
+
if (progress?.status === 'running') {
|
|
465
|
+
json(res, 409, { error: 'Job already running', progress });
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const args = ['cli.js', 'analyze', project];
|
|
470
|
+
const child = spawn(process.execPath, args, {
|
|
471
|
+
cwd: __dirname,
|
|
472
|
+
detached: true,
|
|
473
|
+
stdio: 'ignore',
|
|
474
|
+
});
|
|
475
|
+
child.unref();
|
|
476
|
+
|
|
477
|
+
json(res, 202, { started: true, pid: child.pid, command: 'analyze', project });
|
|
478
|
+
} catch (e) {
|
|
479
|
+
json(res, 500, { error: e.message });
|
|
480
|
+
}
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// ─── API: SSE Terminal — stream command output ───
|
|
485
|
+
if (req.method === 'GET' && path === '/api/terminal') {
|
|
486
|
+
const params = url.searchParams;
|
|
487
|
+
const command = params.get('command');
|
|
488
|
+
const project = params.get('project') || '';
|
|
489
|
+
|
|
490
|
+
// Whitelist allowed commands
|
|
491
|
+
const ALLOWED = ['crawl', 'extract', 'analyze', 'export-actions', 'competitive-actions',
|
|
492
|
+
'suggest-usecases', 'html', 'status', 'brief', 'keywords', 'report', 'guide',
|
|
493
|
+
'schemas', 'headings-audit', 'orphans', 'entities', 'friction', 'shallow', 'decay', 'export'];
|
|
494
|
+
|
|
495
|
+
if (!command || !ALLOWED.includes(command)) {
|
|
496
|
+
json(res, 400, { error: `Invalid command. Allowed: ${ALLOWED.join(', ')}` });
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// Build args
|
|
501
|
+
const args = ['cli.js', command];
|
|
502
|
+
if (project && command !== 'status' && command !== 'html') args.push(project);
|
|
503
|
+
if (params.get('stealth') === 'true') args.push('--stealth');
|
|
504
|
+
if (params.get('scope')) args.push('--scope', params.get('scope'));
|
|
505
|
+
if (params.get('format')) args.push('--format', params.get('format'));
|
|
506
|
+
|
|
507
|
+
// SSE headers
|
|
508
|
+
res.writeHead(200, {
|
|
509
|
+
'Content-Type': 'text/event-stream',
|
|
510
|
+
'Cache-Control': 'no-cache',
|
|
511
|
+
'Connection': 'keep-alive',
|
|
512
|
+
'Access-Control-Allow-Origin': '*',
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
const send = (type, data) => {
|
|
516
|
+
res.write(`data: ${JSON.stringify({ type, data })}\n\n`);
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
send('start', { command, project, args: args.slice(1) });
|
|
520
|
+
|
|
521
|
+
const child = spawn(process.execPath, args, {
|
|
522
|
+
cwd: __dirname,
|
|
523
|
+
env: { ...process.env, FORCE_COLOR: '0', NO_COLOR: '1' },
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
child.stdout.on('data', chunk => {
|
|
527
|
+
const lines = chunk.toString().split('\n');
|
|
528
|
+
for (const line of lines) {
|
|
529
|
+
if (line) send('stdout', line);
|
|
530
|
+
}
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
child.stderr.on('data', chunk => {
|
|
534
|
+
const lines = chunk.toString().split('\n');
|
|
535
|
+
for (const line of lines) {
|
|
536
|
+
if (line) send('stderr', line);
|
|
537
|
+
}
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
child.on('error', err => {
|
|
541
|
+
send('error', err.message);
|
|
542
|
+
res.end();
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
child.on('close', code => {
|
|
546
|
+
send('exit', { code });
|
|
547
|
+
res.end();
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
// Kill child if client disconnects
|
|
551
|
+
req.on('close', () => {
|
|
552
|
+
if (!child.killed) child.kill();
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// ─── Favicon ───
|
|
559
|
+
if (req.method === 'GET' && (path === '/favicon.ico' || path === '/favicon.png')) {
|
|
560
|
+
const faviconPath = join(__dirname, 'seo-intel.png');
|
|
561
|
+
if (existsSync(faviconPath)) {
|
|
562
|
+
serveFile(res, faviconPath);
|
|
563
|
+
} else {
|
|
564
|
+
res.writeHead(204); res.end();
|
|
565
|
+
}
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// ─── 404 ───
|
|
570
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
571
|
+
res.end('Not found');
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// ─── Start server ───
|
|
575
|
+
const server = createServer((req, res) => {
|
|
576
|
+
handleRequest(req, res).catch(err => {
|
|
577
|
+
console.error('Server error:', err);
|
|
578
|
+
if (!res.headersSent) { res.writeHead(500); res.end('Internal error'); }
|
|
579
|
+
});
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
server.listen(PORT, '127.0.0.1', () => {
|
|
583
|
+
console.log(`\n SEO Intel Dashboard Server`);
|
|
584
|
+
console.log(` http://localhost:${PORT}\n`);
|
|
585
|
+
console.log(` Endpoints:`);
|
|
586
|
+
console.log(` GET / → Dashboard`);
|
|
587
|
+
console.log(` GET /setup → Setup Wizard`);
|
|
588
|
+
console.log(` GET /api/progress → Live extraction progress`);
|
|
589
|
+
console.log(` GET /api/projects → Available projects`);
|
|
590
|
+
console.log(` GET /api/export-history → List saved action exports`);
|
|
591
|
+
console.log(` POST /api/crawl → Start crawl { project, stealth? }`);
|
|
592
|
+
console.log(` POST /api/extract → Start extract { project, stealth? }`);
|
|
593
|
+
console.log(` POST /api/export-actions → Run export-actions { project, scope }`);
|
|
594
|
+
console.log(` GET /api/terminal → SSE command streaming\n`);
|
|
595
|
+
|
|
596
|
+
// Auto-open browser if requested
|
|
597
|
+
if (process.env.SEO_INTEL_AUTO_OPEN === '1') {
|
|
598
|
+
const url = `http://localhost:${PORT}`;
|
|
599
|
+
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
600
|
+
import('child_process').then(({ exec }) => exec(`${cmd} "${url}"`));
|
|
601
|
+
}
|
|
602
|
+
});
|