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.
Files changed (46) hide show
  1. package/.env.example +41 -0
  2. package/LICENSE +75 -0
  3. package/README.md +243 -0
  4. package/Start SEO Intel.bat +9 -0
  5. package/Start SEO Intel.command +8 -0
  6. package/cli.js +3727 -0
  7. package/config/example.json +29 -0
  8. package/config/setup-wizard.js +522 -0
  9. package/crawler/index.js +566 -0
  10. package/crawler/robots.js +103 -0
  11. package/crawler/sanitize.js +124 -0
  12. package/crawler/schema-parser.js +168 -0
  13. package/crawler/sitemap.js +103 -0
  14. package/crawler/stealth.js +393 -0
  15. package/crawler/subdomain-discovery.js +341 -0
  16. package/db/db.js +213 -0
  17. package/db/schema.sql +120 -0
  18. package/exports/competitive.js +186 -0
  19. package/exports/heuristics.js +67 -0
  20. package/exports/queries.js +197 -0
  21. package/exports/suggestive.js +230 -0
  22. package/exports/technical.js +180 -0
  23. package/exports/templates.js +77 -0
  24. package/lib/gate.js +204 -0
  25. package/lib/license.js +369 -0
  26. package/lib/oauth.js +432 -0
  27. package/lib/updater.js +324 -0
  28. package/package.json +68 -0
  29. package/reports/generate-html.js +6194 -0
  30. package/reports/generate-site-graph.js +949 -0
  31. package/reports/gsc-loader.js +190 -0
  32. package/scheduler.js +142 -0
  33. package/seo-audit.js +619 -0
  34. package/seo-intel.png +0 -0
  35. package/server.js +602 -0
  36. package/setup/ROADMAP.md +109 -0
  37. package/setup/checks.js +483 -0
  38. package/setup/config-builder.js +227 -0
  39. package/setup/engine.js +65 -0
  40. package/setup/installers.js +197 -0
  41. package/setup/models.js +328 -0
  42. package/setup/openclaw-bridge.js +329 -0
  43. package/setup/validator.js +395 -0
  44. package/setup/web-routes.js +688 -0
  45. package/setup/wizard.html +2920 -0
  46. package/start-seo-intel.sh +8 -0
@@ -0,0 +1,29 @@
1
+ {
2
+ "project": "myproject",
3
+ "context": {
4
+ "siteName": "My Project",
5
+ "url": "https://example.com",
6
+ "industry": "Describe your industry and niche here",
7
+ "audience": "Who are your target users/customers?",
8
+ "goal": "What's your SEO objective? (e.g. outrank competitor X, grow organic traffic)",
9
+ "maturity": "early stage | growing | established"
10
+ },
11
+ "target": {
12
+ "domain": "example.com",
13
+ "maxPages": 200,
14
+ "crawlMode": "standard"
15
+ },
16
+ "competitors": [
17
+ {
18
+ "domain": "competitor1.com",
19
+ "maxPages": 100,
20
+ "crawlMode": "standard"
21
+ },
22
+ {
23
+ "domain": "competitor2.com",
24
+ "maxPages": 100,
25
+ "crawlMode": "standard"
26
+ }
27
+ ],
28
+ "owned": []
29
+ }
@@ -0,0 +1,522 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * SEO Intel — Interactive Setup Wizard (CLI)
4
+ *
5
+ * Guides a new user through full setup:
6
+ * 1. System check (Node, npm, Ollama, Playwright)
7
+ * 2. Auto-install missing dependencies
8
+ * 3. Model selection (extraction + analysis tiers)
9
+ * 4. API key setup
10
+ * 5. Project configuration (target + competitors)
11
+ * 6. Pipeline validation (real crawl + extraction test)
12
+ * 7. Summary + next steps
13
+ *
14
+ * Uses the shared setup engine (setup/engine.js) for all logic.
15
+ *
16
+ * Usage:
17
+ * node config/setup-wizard.js
18
+ * node config/setup-wizard.js --project myproject
19
+ */
20
+
21
+ import { createInterface } from 'readline';
22
+ import {
23
+ fullSystemCheck,
24
+ getModelRecommendations,
25
+ EXTRACTION_MODELS,
26
+ ANALYSIS_MODELS,
27
+ installNpmDeps,
28
+ installPlaywright,
29
+ pullOllamaModel,
30
+ createEnvFile,
31
+ runFullValidation,
32
+ buildProjectConfig,
33
+ writeProjectConfig,
34
+ updateEnvForSetup,
35
+ slugify,
36
+ domainFromUrl,
37
+ testApiKey,
38
+ } from '../setup/engine.js';
39
+
40
+ // ─── Chalk-lite (avoid import complexity) ──────────────────────────────────
41
+ const c = {
42
+ bold: s => `\x1b[1m${s}\x1b[0m`,
43
+ dim: s => `\x1b[2m${s}\x1b[0m`,
44
+ green: s => `\x1b[32m${s}\x1b[0m`,
45
+ yellow: s => `\x1b[33m${s}\x1b[0m`,
46
+ cyan: s => `\x1b[36m${s}\x1b[0m`,
47
+ red: s => `\x1b[31m${s}\x1b[0m`,
48
+ gold: s => `\x1b[38;5;214m${s}\x1b[0m`,
49
+ magenta:s => `\x1b[35m${s}\x1b[0m`,
50
+ };
51
+
52
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
53
+ const ask = (q) => new Promise(res => rl.question(q, res));
54
+
55
+ function hr() { console.log(c.dim('─'.repeat(60))); }
56
+ function section(num, title) {
57
+ console.log('');
58
+ hr();
59
+ console.log(c.gold(c.bold(` Chapter ${num} — ${title}`)));
60
+ hr();
61
+ }
62
+ function ok(msg) { console.log(c.green(` ✓ ${msg}`)); }
63
+ function warn(msg) { console.log(c.yellow(` ⚠ ${msg}`)); }
64
+ function fail(msg) { console.log(c.red(` ✗ ${msg}`)); }
65
+ function info(msg) { console.log(c.dim(` ${msg}`)); }
66
+ function next(msg) { console.log(''); console.log(c.cyan(` → ${msg}`)); console.log(''); }
67
+
68
+ // ─── Main wizard ─────────────────────────────────────────────────────────────
69
+
70
+ async function run() {
71
+ console.log('');
72
+ console.log(c.gold(c.bold(' 🔶 SEO Intel — Setup Wizard')));
73
+ console.log(c.dim(' Point it at 5 domains. Get the gap report in 10 minutes.'));
74
+ console.log('');
75
+
76
+ const args = process.argv.slice(2);
77
+ const projectArg = args.includes('--project') ? args[args.indexOf('--project') + 1] : null;
78
+
79
+ // Track choices for later
80
+ let selectedOllamaHost = null;
81
+ let selectedExtractionModel = null;
82
+ let selectedAnalysisProvider = null;
83
+ let selectedApiKey = null;
84
+
85
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
86
+ // Chapter 1: System Check
87
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
88
+ section(1, 'System Check');
89
+ info('Scanning your system...');
90
+ console.log('');
91
+
92
+ const status = await fullSystemCheck();
93
+
94
+ // Node.js
95
+ if (status.node.meetsMinimum) {
96
+ ok(`Node.js ${status.node.version}`);
97
+ } else if (status.node.installed) {
98
+ fail(`Node.js ${status.node.version} — need v18+. Please upgrade.`);
99
+ } else {
100
+ fail('Node.js not found. Install from https://nodejs.org');
101
+ }
102
+
103
+ // npm
104
+ if (status.npm.installed) {
105
+ ok(`npm ${status.npm.version}`);
106
+ } else {
107
+ fail('npm not found');
108
+ }
109
+
110
+ // npm dependencies
111
+ if (status.npmDeps.installed) {
112
+ ok('npm dependencies installed');
113
+ } else {
114
+ warn(`Missing npm packages: ${status.npmDeps.missing.slice(0, 5).join(', ')}`);
115
+ }
116
+
117
+ // Playwright
118
+ if (status.playwright.installed && status.playwright.chromiumReady) {
119
+ ok('Playwright + Chromium ready');
120
+ } else if (status.playwright.installed) {
121
+ warn('Playwright installed but Chromium browser missing');
122
+ } else {
123
+ warn('Playwright not installed');
124
+ }
125
+
126
+ // Ollama
127
+ if (status.ollama.available) {
128
+ ok(`Ollama available (${status.ollama.mode}) — ${status.ollama.models.length} models at ${status.ollama.host}`);
129
+ } else if (status.ollama.installed) {
130
+ warn('Ollama installed but not running or no models. Start Ollama and pull a model.');
131
+ } else {
132
+ warn('Ollama not found — extraction will use degraded mode (regex only)');
133
+ info('Install from https://ollama.com for local AI extraction');
134
+ }
135
+
136
+ // VRAM / GPU
137
+ if (status.vram.available) {
138
+ ok(`GPU: ${status.vram.gpuName} — ${Math.round(status.vram.vramMB / 1024)}GB ${status.vram.source === 'apple-silicon-unified' ? '(unified memory, ~75% available for GPU)' : 'VRAM'}`);
139
+ } else {
140
+ info('No GPU detected — CPU mode available but slower');
141
+ }
142
+
143
+ // .env
144
+ if (status.env.exists) {
145
+ const keys = Object.entries(status.env.keys).filter(([_, v]) => v).map(([k]) => k);
146
+ if (keys.length > 0) {
147
+ ok(`.env found — keys: ${keys.join(', ')}`);
148
+ } else {
149
+ ok('.env found (no API keys configured yet)');
150
+ }
151
+ } else {
152
+ info('No .env file yet — will create one');
153
+ }
154
+
155
+ // Existing configs
156
+ if (status.configs.configs.length > 0) {
157
+ ok(`Existing projects: ${status.configs.configs.map(c => c.project).join(', ')}`);
158
+ }
159
+
160
+ if (!status.node.meetsMinimum) {
161
+ console.log('');
162
+ fail('Node.js 18+ is required. Please install it and re-run this wizard.');
163
+ rl.close();
164
+ process.exit(1);
165
+ }
166
+
167
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
168
+ // Chapter 2: Auto-Install
169
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
170
+ const needsInstall = !status.npmDeps.installed || !status.playwright.installed || !status.playwright.chromiumReady;
171
+
172
+ if (needsInstall) {
173
+ section(2, 'Install Dependencies');
174
+
175
+ // npm install
176
+ if (!status.npmDeps.installed) {
177
+ const answer = await ask(' Install npm dependencies? [Y/n]: ');
178
+ if (answer.toLowerCase() !== 'n') {
179
+ for await (const ev of installNpmDeps()) {
180
+ if (ev.status === 'start') info(ev.message);
181
+ else if (ev.status === 'done') ok(ev.message);
182
+ else if (ev.status === 'error') fail(ev.message);
183
+ }
184
+ }
185
+ }
186
+
187
+ // Playwright
188
+ if (!status.playwright.installed || !status.playwright.chromiumReady) {
189
+ const answer = await ask(' Install Playwright Chromium browser? (~150MB) [Y/n]: ');
190
+ if (answer.toLowerCase() !== 'n') {
191
+ for await (const ev of installPlaywright()) {
192
+ if (ev.status === 'start') info(ev.message);
193
+ else if (ev.status === 'done') ok(ev.message);
194
+ else if (ev.status === 'error') fail(ev.message);
195
+ }
196
+ }
197
+ }
198
+
199
+ // .env
200
+ if (!status.env.exists) {
201
+ for (const ev of createEnvFile()) {
202
+ if (ev.status === 'done') ok(ev.message);
203
+ }
204
+ }
205
+ } else {
206
+ section(2, 'Dependencies');
207
+ ok('All dependencies already installed.');
208
+ }
209
+
210
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
211
+ // Chapter 3: Model Selection
212
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
213
+ section(3, 'Choose Your Models');
214
+
215
+ const models = getModelRecommendations(
216
+ status.ollama.models,
217
+ status.env.keys,
218
+ status.vram.vramMB
219
+ );
220
+
221
+ // ── Extraction tier ──
222
+ console.log('');
223
+ console.log(c.bold(' 📦 Extraction Model (local, runs during crawl)'));
224
+ console.log(c.dim(' Extracts structured SEO data from each crawled page using a local AI model.'));
225
+ console.log(c.dim(' Minimum 4B parameters for reliable JSON extraction.'));
226
+ console.log('');
227
+
228
+ if (status.ollama.available) {
229
+ // Show available options
230
+ const fittingModels = models.allExtraction.filter(m => !m.legacy && m.fitsVram);
231
+ fittingModels.forEach((m, i) => {
232
+ const marker = m.installed ? c.green('✓ installed') : c.dim(' not pulled');
233
+ const rec = models.extraction?.model?.id === m.id ? c.gold(' ★ recommended') : '';
234
+ console.log(` ${i + 1}. ${c.bold(m.name)} (${m.vram}) — ${m.quality}${rec}`);
235
+ console.log(` ${c.dim(m.description)} ${marker}`);
236
+ });
237
+
238
+ console.log(` ${fittingModels.length + 1}. ${c.dim('Skip — use degraded mode (regex only, no AI)')}`);
239
+ console.log('');
240
+
241
+ const choice = await ask(` Choose extraction model [1-${fittingModels.length + 1}] (default: 1): `);
242
+ const idx = parseInt(choice.trim()) - 1;
243
+
244
+ if (idx >= 0 && idx < fittingModels.length) {
245
+ const chosen = fittingModels[idx];
246
+ selectedExtractionModel = chosen.id;
247
+ selectedOllamaHost = status.ollama.host;
248
+ ok(`Selected: ${chosen.name}`);
249
+
250
+ // Offer to pull if not installed
251
+ if (!chosen.installed && status.ollama.available) {
252
+ const pullAnswer = await ask(` Pull ${chosen.id} now? (may take a few minutes) [Y/n]: `);
253
+ if (pullAnswer.toLowerCase() !== 'n') {
254
+ for await (const ev of pullOllamaModel(chosen.id, status.ollama.host)) {
255
+ if (ev.status === 'start') info(ev.message);
256
+ else if (ev.status === 'progress') process.stdout.write(`\r ${c.dim(ev.message)} `);
257
+ else if (ev.status === 'done') { process.stdout.write('\r'); ok(ev.message); }
258
+ else if (ev.status === 'error') { process.stdout.write('\r'); fail(ev.message); }
259
+ }
260
+ }
261
+ }
262
+ } else {
263
+ info('Skipping extraction model — will use regex fallback.');
264
+ }
265
+ } else {
266
+ warn('No Ollama available. Extraction will use degraded mode (regex only).');
267
+ info('Install Ollama (https://ollama.com) and pull a model for better results.');
268
+ info('Recommended: ollama pull qwen3.5:9b');
269
+ }
270
+
271
+ // ── Analysis tier ──
272
+ console.log('');
273
+ console.log(c.bold(' 🧠 Analysis Model (cloud, runs during analysis)'));
274
+ console.log(c.dim(' A powerful model analyzes your crawl data to find keyword gaps,'));
275
+ console.log(c.dim(' competitive opportunities, and strategic recommendations.'));
276
+ console.log(c.dim(' Cloud models recommended — they have larger context windows.'));
277
+ console.log('');
278
+
279
+ const cloudModels = ANALYSIS_MODELS.filter(m => m.type === 'cloud');
280
+ cloudModels.forEach((m, i) => {
281
+ const configured = models.allAnalysis.find(am => am.id === m.id)?.configured;
282
+ const marker = configured ? c.green('✓ key found') : c.dim(' needs key');
283
+ const rec = m.recommended ? c.gold(' ★') : '';
284
+ console.log(` ${i + 1}. ${c.bold(m.name)} — ${m.context} ctx, ${m.costNote}${rec}`);
285
+ console.log(` ${c.dim(m.description)} ${marker}`);
286
+ });
287
+ console.log(` ${cloudModels.length + 1}. ${c.dim('Skip — no cloud analysis (local only)')}`);
288
+ console.log('');
289
+
290
+ const analysisChoice = await ask(` Choose analysis model [1-${cloudModels.length + 1}] (default: 1): `);
291
+ const analysisIdx = parseInt(analysisChoice.trim()) - 1;
292
+
293
+ if (analysisIdx >= 0 && analysisIdx < cloudModels.length) {
294
+ const chosen = cloudModels[analysisIdx];
295
+ selectedAnalysisProvider = chosen.id;
296
+
297
+ // Check if key already configured
298
+ const existing = models.allAnalysis.find(am => am.id === chosen.id);
299
+ if (existing?.configured) {
300
+ ok(`${chosen.name} — API key already configured.`);
301
+ // Read the actual key for validation
302
+ const envKeys = status.env.raw || {};
303
+ selectedApiKey = envKeys[chosen.envKey] || null;
304
+ } else {
305
+ console.log('');
306
+ info(`Get your ${chosen.name} API key: ${chosen.setupUrl}`);
307
+ info(chosen.setupNote);
308
+ const key = await ask(` Paste ${chosen.name} API key (or press Enter to skip): `);
309
+ if (key.trim()) {
310
+ selectedApiKey = key.trim();
311
+ // Validate immediately
312
+ info('Validating key...');
313
+ const testResult = await testApiKey(chosen.id, selectedApiKey);
314
+ if (testResult.valid) {
315
+ ok(`${chosen.name} API key valid (${testResult.latencyMs}ms)`);
316
+ updateEnvForSetup({ [chosen.id === 'gemini' ? 'geminiKey' : chosen.id === 'claude' ? 'anthropicKey' : chosen.id === 'openai' ? 'openaiKey' : 'deepseekKey']: selectedApiKey });
317
+ ok('Key saved to .env');
318
+ } else {
319
+ warn(`Key validation failed: ${testResult.error}`);
320
+ info('You can fix this later by editing .env');
321
+ }
322
+ } else {
323
+ info('Skipping — you can add the key later in .env');
324
+ }
325
+ }
326
+ } else {
327
+ info('Skipping cloud analysis — local mode only.');
328
+ }
329
+
330
+ // Save Ollama config to .env if changed
331
+ if (selectedOllamaHost && selectedExtractionModel) {
332
+ updateEnvForSetup({
333
+ ollamaUrl: selectedOllamaHost,
334
+ ollamaModel: selectedExtractionModel,
335
+ });
336
+ }
337
+
338
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
339
+ // Chapter 4: Project Setup
340
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
341
+ section(4, 'Your Project');
342
+
343
+ let projectName = projectArg;
344
+ if (!projectName) {
345
+ const input = await ask(' Project name (e.g. "myclient" or "mysite"): ');
346
+ projectName = slugify(input.trim()) || 'myproject';
347
+ }
348
+
349
+ // Check for existing config
350
+ if (status.configs.configs.find(c => c.project === projectName)) {
351
+ const overwrite = await ask(c.yellow(` Config for "${projectName}" already exists. Overwrite? [y/N]: `));
352
+ if (overwrite.toLowerCase() !== 'y') {
353
+ info(`Keeping existing config. Skipping to validation.`);
354
+ // Jump ahead to validation
355
+ await runValidationChapter(projectName, selectedOllamaHost, selectedExtractionModel, selectedAnalysisProvider, selectedApiKey);
356
+ rl.close();
357
+ return;
358
+ }
359
+ }
360
+
361
+ const siteUrl = await ask(' Your site URL (e.g. https://mysite.io): ');
362
+ const siteName = await ask(' Site name (e.g. Carbium): ');
363
+ const industry = await ask(' Industry / niche: ');
364
+ const audience = await ask(' Target audience: ');
365
+ const goal = await ask(' SEO goal in one sentence: ');
366
+
367
+ ok(`Project: ${projectName} → ${siteUrl}`);
368
+
369
+ // Owned subdomains
370
+ console.log('');
371
+ info('Do you have subdomains? (e.g. blog.example.com, docs.example.com)');
372
+ const owned = [];
373
+ const hasOwned = await ask(' Add owned subdomains? [y/N]: ');
374
+ if (hasOwned.toLowerCase() === 'y') {
375
+ let i = 1;
376
+ while (true) {
377
+ const sub = await ask(` Subdomain ${i} URL (or press Enter to finish): `);
378
+ if (!sub.trim()) break;
379
+ owned.push({ url: sub.trim() });
380
+ ok(`Added: ${domainFromUrl(sub.trim())}`);
381
+ i++;
382
+ }
383
+ }
384
+
385
+ // Competitors
386
+ console.log('');
387
+ info('Now add competitors (enter one domain per line, blank line when done):');
388
+ const competitors = [];
389
+ let compIdx = 1;
390
+ while (true) {
391
+ const comp = await ask(` Competitor ${compIdx} URL (or press Enter to finish): `);
392
+ if (!comp.trim()) break;
393
+ competitors.push({ url: comp.trim() });
394
+ ok(`Added: ${domainFromUrl(comp.trim())}`);
395
+ compIdx++;
396
+ }
397
+
398
+ if (competitors.length === 0) {
399
+ warn('No competitors added. You can edit the config file later.');
400
+ }
401
+
402
+ // Crawl settings
403
+ console.log('');
404
+ console.log(c.dim(' Crawl mode:'));
405
+ console.log(c.dim(' 1. Standard — fast, good for most sites'));
406
+ console.log(c.dim(' 2. Stealth — Playwright browser, bypasses bot detection (slower)'));
407
+ console.log(c.dim(' 3. Manual — you pass --stealth flag each time'));
408
+
409
+ const modeChoice = await ask(' Mode [1/2/3] (default: 1): ');
410
+ const crawlMode = modeChoice.trim() === '2' ? 'stealth' : modeChoice.trim() === '3' ? 'manual' : 'standard';
411
+
412
+ const pagesInput = await ask(' Pages per domain? [default: 50]: ');
413
+ const pagesPerDomain = parseInt(pagesInput.trim()) || 50;
414
+
415
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
416
+ // Chapter 5: Save Config
417
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
418
+ section(5, 'Saving Configuration');
419
+
420
+ const config = buildProjectConfig({
421
+ projectName,
422
+ targetUrl: siteUrl.trim(),
423
+ siteName: siteName.trim() || projectName,
424
+ industry: industry.trim(),
425
+ audience: audience.trim(),
426
+ goal: goal.trim(),
427
+ competitors,
428
+ owned,
429
+ crawlMode,
430
+ pagesPerDomain,
431
+ ollamaHost: selectedOllamaHost,
432
+ extractionModel: selectedExtractionModel,
433
+ });
434
+
435
+ const result = writeProjectConfig(config);
436
+ ok(`Config saved: ${result.path}`);
437
+
438
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
439
+ // Chapter 6: Pipeline Validation
440
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
441
+ await runValidationChapter(projectName, selectedOllamaHost, selectedExtractionModel, selectedAnalysisProvider, selectedApiKey, siteUrl.trim());
442
+
443
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
444
+ // Summary
445
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
446
+ section('✅', 'Setup Complete');
447
+
448
+ console.log(` ${c.bold('Project:')} ${projectName}`);
449
+ console.log(` ${c.bold('Target:')} ${siteUrl.trim()}`);
450
+ if (owned.length > 0) {
451
+ console.log(` ${c.bold('Owned:')} ${owned.map(o => domainFromUrl(o.url)).join(', ')}`);
452
+ }
453
+ console.log(` ${c.bold('Competitors:')} ${competitors.map(co => domainFromUrl(co.url)).join(', ') || 'none'}`);
454
+ console.log(` ${c.bold('Extraction:')} ${selectedExtractionModel ? `${selectedExtractionModel} (${selectedOllamaHost})` : 'degraded (regex)'}`);
455
+ console.log(` ${c.bold('Analysis:')} ${selectedAnalysisProvider || 'none'}`);
456
+ console.log(` ${c.bold('Config:')} config/${projectName}.json`);
457
+
458
+ console.log('');
459
+ console.log(c.cyan(' → Next steps:'));
460
+ console.log('');
461
+ console.log(c.bold(` node cli.js crawl ${projectName}${crawlMode === 'stealth' ? ' --stealth' : ''}`));
462
+ console.log(c.dim(' ↑ Crawl your site + all competitors'));
463
+ console.log('');
464
+ console.log(c.bold(` node cli.js html ${projectName}`));
465
+ console.log(c.dim(' ↑ Generate your SEO dashboard'));
466
+ console.log('');
467
+ console.log(c.bold(` node cli.js serve`));
468
+ console.log(c.dim(' ↑ Open the live dashboard in your browser'));
469
+ console.log('');
470
+ console.log(c.dim(' Any time you need help: node cli.js --help'));
471
+ console.log('');
472
+
473
+ rl.close();
474
+ }
475
+
476
+ // ── Validation Chapter ──────────────────────────────────────────────────────
477
+
478
+ async function runValidationChapter(projectName, ollamaHost, ollamaModel, apiProvider, apiKey, targetUrl) {
479
+ section(6, 'Pipeline Validation');
480
+
481
+ const runTest = await ask(' Run end-to-end validation? (crawl 1 page + extract) [Y/n]: ');
482
+ if (runTest.toLowerCase() === 'n') {
483
+ info('Skipping validation. You can run it later: node cli.js setup --project ' + projectName);
484
+ return;
485
+ }
486
+
487
+ console.log('');
488
+
489
+ const validationConfig = {
490
+ ollamaHost: ollamaHost || null,
491
+ ollamaModel: ollamaModel || null,
492
+ apiProvider: apiProvider || null,
493
+ apiKey: apiKey || null,
494
+ targetUrl: targetUrl || null,
495
+ };
496
+
497
+ for await (const step of runFullValidation(validationConfig)) {
498
+ if (step.step === 'summary') {
499
+ console.log('');
500
+ if (step.status === 'pass') {
501
+ ok(c.bold(`All tests passed! ${step.detail}`));
502
+ } else {
503
+ warn(`${step.detail} — some features may be limited.`);
504
+ }
505
+ continue;
506
+ }
507
+
508
+ const icon = step.status === 'pass' ? c.green('✓')
509
+ : step.status === 'fail' ? c.red('✗')
510
+ : step.status === 'skip' ? c.dim('○')
511
+ : c.yellow('…');
512
+
513
+ const label = step.step.padEnd(12);
514
+ console.log(` ${icon} ${label} ${step.detail}`);
515
+ }
516
+ }
517
+
518
+ run().catch(err => {
519
+ console.error('\n❌ Wizard error:', err.message);
520
+ if (process.env.DEBUG) console.error(err.stack);
521
+ process.exit(1);
522
+ });