seo-intel 1.5.46 → 1.5.52

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.
@@ -0,0 +1,851 @@
1
+ /**
2
+ * agent-harness.js — Agent-harness API for SEO Intel
3
+ *
4
+ * Single entry point any agent platform can "ride" (Hermes, MCP hosts, custom
5
+ * orchestrators). Self-describing via `capabilities` + `pipeline`. Three usage levels:
6
+ *
7
+ * 1. Unified runner (recommended):
8
+ * import { run, capabilities } from 'seo-intel/agent-harness';
9
+ * const result = await run('aeo', 'myproject');
10
+ *
11
+ * 2. Direct function imports:
12
+ * import { aeo, gapIntel } from 'seo-intel/agent-harness';
13
+ *
14
+ * 3. Deep imports (tree-shakeable):
15
+ * import { runAeoAnalysis } from 'seo-intel/aeo';
16
+ *
17
+ * Back-compat: the legacy `seo-intel/froggo` export still resolves here.
18
+ */
19
+
20
+ import { readFileSync, readdirSync, existsSync } from 'fs';
21
+ import { join, dirname } from 'path';
22
+ import { fileURLToPath } from 'url';
23
+ import { getDb, getActiveInsights, getSchemasByProject } from './db/db.js';
24
+
25
+ const __dirname = dirname(fileURLToPath(import.meta.url));
26
+
27
+ // ═══════════════════════════════════════════════════════════════════════════
28
+ // HELPERS (extracted from cli.js for agent use)
29
+ // ═══════════════════════════════════════════════════════════════════════════
30
+
31
+ /** Load project config by name. Returns null if not found. */
32
+ export function loadConfig(project) {
33
+ if (!project || !/^[a-z0-9_-]+$/i.test(project)) return null;
34
+ const path = join(__dirname, 'config', `${project}.json`);
35
+ try { return JSON.parse(readFileSync(path, 'utf8')); }
36
+ catch { return null; }
37
+ }
38
+
39
+ /** List all configured projects. */
40
+ export function listProjects() {
41
+ const dir = join(__dirname, 'config');
42
+ try {
43
+ return readdirSync(dir)
44
+ .filter(f => f.endsWith('.json') && f !== 'example.json')
45
+ .map(f => {
46
+ const name = f.replace('.json', '');
47
+ const config = loadConfig(name);
48
+ return {
49
+ name,
50
+ targetDomain: config?.target?.domain || null,
51
+ competitors: (config?.competitors || []).map(c => c.domain),
52
+ };
53
+ });
54
+ } catch { return []; }
55
+ }
56
+
57
+ /** Filter content pages (exclude app/dashboard URLs). */
58
+ export function isContentPage(url) {
59
+ if (url.includes('?')) return false;
60
+ const appPaths = ['/signup', '/login', '/register', '/onboarding', '/dashboard',
61
+ '/app/', '/swap', '/portfolio', '/send', '/rewards', '/perps', '/vaults'];
62
+ const appSubdomains = ['dashboard.', 'app.', 'customers.', 'console.'];
63
+ if (appPaths.some(p => url.includes(p))) return false;
64
+ if (appSubdomains.some(s => url.includes(s))) return false;
65
+ return true;
66
+ }
67
+
68
+ // ═══════════════════════════════════════════════════════════════════════════
69
+ // ANALYSIS MODULES (direct exports)
70
+ // ═══════════════════════════════════════════════════════════════════════════
71
+
72
+ export { runAeoAnalysis as aeo } from './analyses/aeo/index.js';
73
+ export { scorePage as scorePageCitability } from './analyses/aeo/scorer.js';
74
+ export { runGapIntel as gapIntel } from './analyses/gap-intel/index.js';
75
+ export { runWatch as watch, getWatchData } from './analyses/watch/index.js';
76
+ export { gatherBlogDraftContext as blogDraftContext } from './analyses/blog-draft/index.js';
77
+ export { buildBlogDraftPrompt as blogDraftPrompt } from './analyses/blog-draft/index.js';
78
+ export { runTemplatesAnalysis as templates } from './analyses/templates/index.js';
79
+
80
+ // ═══════════════════════════════════════════════════════════════════════════
81
+ // EXPORT MODULES (structured action lists)
82
+ // ═══════════════════════════════════════════════════════════════════════════
83
+
84
+ export { buildTechnicalActions as technicalActions } from './exports/technical.js';
85
+ export { buildCompetitiveActions as competitiveActions } from './exports/competitive.js';
86
+ export { buildSuggestiveActions as suggestiveActions } from './exports/suggestive.js';
87
+ export { getProjectDomains, getTargetDomains, getCompetitorDomains } from './exports/queries.js';
88
+
89
+ // ═══════════════════════════════════════════════════════════════════════════
90
+ // CRAWLER + DATA LAYER
91
+ // ═══════════════════════════════════════════════════════════════════════════
92
+
93
+ export { crawlDomain } from './crawler/index.js';
94
+ export { getDb, getActiveInsights } from './db/db.js';
95
+
96
+ // ═══════════════════════════════════════════════════════════════════════════
97
+ // DASHBOARD (embeddable HTML)
98
+ // ═══════════════════════════════════════════════════════════════════════════
99
+
100
+ export { generateMultiDashboard, generateHtmlDashboard } from './reports/generate-html.js';
101
+
102
+ /**
103
+ * Generate dashboard HTML as a string (for embedding in iframes/panels).
104
+ * Does NOT write to disk — returns HTML directly.
105
+ */
106
+ export async function getDashboardHtml(project) {
107
+ const db = getDb();
108
+ const config = loadConfig(project);
109
+ if (!config) return { error: `Project "${project}" not found` };
110
+
111
+ const { generateHtmlDashboard } = await import('./reports/generate-html.js');
112
+ const filePath = generateHtmlDashboard(db, project, config);
113
+ try {
114
+ return { html: readFileSync(filePath, 'utf8'), project };
115
+ } catch (e) {
116
+ return { error: e.message };
117
+ }
118
+ }
119
+
120
+ // ═══════════════════════════════════════════════════════════════════════════
121
+ // CAPABILITIES MANIFEST
122
+ // ═══════════════════════════════════════════════════════════════════════════
123
+
124
+ /**
125
+ * Machine-readable capability list. Agents can introspect what SEO Intel offers.
126
+ */
127
+ export const capabilities = [
128
+ {
129
+ id: 'crawl',
130
+ name: 'Site Crawler',
131
+ description: 'Crawl a website and store page structure, content, metadata, and schemas in SQLite',
132
+ requires: ['playwright'],
133
+ inputs: { project: 'string', options: { stealth: 'boolean', maxPages: 'number', scope: 'string' } },
134
+ outputs: { pages: 'number', domains: 'number', schemas: 'number' },
135
+ phase: 'collect',
136
+ tier: 'free',
137
+ },
138
+ {
139
+ id: 'extract',
140
+ name: 'Content Extractor',
141
+ description: 'Extract SEO signals from crawled pages using local LLM (keywords, entities, intent, CTAs)',
142
+ requires: ['ollama'],
143
+ inputs: { project: 'string', options: { model: 'string' } },
144
+ outputs: { keywords: 'array', entities: 'array', intent: 'string', cta: 'string' },
145
+ modelHint: 'light-local',
146
+ modelNote: 'Use gemma4:e2b (fast) or gemma4:e4b (balanced). Extraction is structured data work — heavy models waste resources.',
147
+ phase: 'extract',
148
+ tier: 'free',
149
+ dependsOn: ['crawl'],
150
+ },
151
+ {
152
+ id: 'aeo',
153
+ name: 'AI Citability Audit',
154
+ description: 'Score each page for AI citability (0-100) across 6 signals. Find pages search AI will ignore.',
155
+ requires: [],
156
+ inputs: { project: 'string', options: { format: 'json|brief' } },
157
+ outputs: { scores: 'array<PageScore>', summary: 'object', insights: 'array' },
158
+ phase: 'analyze',
159
+ tier: 'free',
160
+ dependsOn: ['crawl'],
161
+ },
162
+ {
163
+ id: 'rescore',
164
+ name: 'Re-score a URL (close the loop)',
165
+ description: "Re-check one URL's AI citability after a fix. Read-only re-measurement on the RAW-HTML (what-bots-see) lens — returns before/after/delta so an agent can verify its own change. If the fix is server-rendered the score moves; if JS-only, it correctly does not.",
166
+ requires: [],
167
+ inputs: { project: 'string', options: { url: 'string (required)' } },
168
+ outputs: { url: 'string', before: 'number', after: 'number', delta: 'number', improved: 'boolean', signals: 'object', lens: 'string' },
169
+ phase: 'verify',
170
+ tier: 'free',
171
+ dependsOn: ['aeo'],
172
+ },
173
+ {
174
+ id: 'watch',
175
+ name: 'Site Health Watch',
176
+ description: 'Detect changes between crawl runs — new/removed pages, status changes, title/content changes, health score',
177
+ requires: [],
178
+ inputs: { project: 'string' },
179
+ outputs: { snapshot: 'object', events: 'array<WatchEvent>', healthScore: 'number', trend: 'number' },
180
+ phase: 'analyze',
181
+ tier: 'free',
182
+ dependsOn: ['crawl'],
183
+ },
184
+ {
185
+ id: 'gap-intel',
186
+ name: 'Gap Intelligence',
187
+ description: 'Topic/content gap analysis — find what competitors cover that you don\'t',
188
+ requires: ['ollama'],
189
+ inputs: { project: 'string', options: { vs: 'string[]', type: 'string', limit: 'number', raw: 'boolean' } },
190
+ outputs: { gaps: 'array<TopicGap>', matrix: 'object', report: 'string' },
191
+ modelHint: 'light-local',
192
+ modelNote: 'gemma4:e4b handles topic clustering well. Cloud models add minimal value here.',
193
+ phase: 'analyze',
194
+ tier: 'pro',
195
+ dependsOn: ['crawl', 'extract'],
196
+ },
197
+ {
198
+ id: 'shallow',
199
+ name: 'Shallow Champion Attack',
200
+ description: 'Find competitor pages that are important but thin — easy to outwrite',
201
+ requires: [],
202
+ inputs: { project: 'string', options: { maxWords: 'number', maxDepth: 'number', format: 'json|brief' } },
203
+ outputs: { targets: 'array<Page>', byDomain: 'object' },
204
+ phase: 'analyze',
205
+ tier: 'pro',
206
+ dependsOn: ['crawl'],
207
+ },
208
+ {
209
+ id: 'decay',
210
+ name: 'Content Decay Arbitrage',
211
+ description: 'Find competitor pages decaying due to staleness — your freshness advantage',
212
+ requires: [],
213
+ inputs: { project: 'string', options: { months: 'number', format: 'json|brief' } },
214
+ outputs: { confirmedStale: 'array<Page>', unknownFreshness: 'array<Page>' },
215
+ phase: 'analyze',
216
+ tier: 'pro',
217
+ dependsOn: ['crawl'],
218
+ },
219
+ {
220
+ id: 'headings-audit',
221
+ name: 'Heading Architecture Audit',
222
+ description: 'Pull competitor heading structures — find topic gaps in H1-H3 hierarchy',
223
+ requires: [],
224
+ inputs: { project: 'string', options: { domain: 'string', depth: 'number', format: 'json|brief' } },
225
+ outputs: { pages: 'array<{url, headings: Heading[]>}' },
226
+ phase: 'analyze',
227
+ tier: 'pro',
228
+ dependsOn: ['crawl'],
229
+ },
230
+ {
231
+ id: 'orphans',
232
+ name: 'Orphan Entity Attack',
233
+ description: 'Find entities mentioned by competitors with no dedicated page — content opportunities',
234
+ requires: [],
235
+ inputs: { project: 'string', options: { format: 'json|brief' } },
236
+ outputs: { orphans: 'array<{entity, domains, suggestedUrl}>' },
237
+ phase: 'analyze',
238
+ tier: 'pro',
239
+ dependsOn: ['extract'],
240
+ },
241
+ {
242
+ id: 'entities',
243
+ name: 'Entity Coverage Map',
244
+ description: 'Semantic gap analysis at the entity level — concepts competitors mention that you don\'t',
245
+ requires: [],
246
+ inputs: { project: 'string', options: { minMentions: 'number', format: 'json|brief' } },
247
+ outputs: { gaps: 'array', shared: 'array', unique: 'array', summary: 'object' },
248
+ phase: 'analyze',
249
+ tier: 'pro',
250
+ dependsOn: ['extract'],
251
+ },
252
+ {
253
+ id: 'schemas',
254
+ name: 'Schema Intelligence',
255
+ description: 'Structured data competitive analysis — ratings, pricing, rich results gaps',
256
+ requires: [],
257
+ inputs: { project: 'string', options: { format: 'json|brief' } },
258
+ outputs: { coverageMatrix: 'object', gaps: 'array', ratings: 'array', pricing: 'array', actions: 'array' },
259
+ phase: 'analyze',
260
+ tier: 'free',
261
+ dependsOn: ['crawl'],
262
+ },
263
+ {
264
+ id: 'friction',
265
+ name: 'Intent & Friction Hijacking',
266
+ description: 'Find competitor pages with intent/CTA mismatch — high friction you can undercut',
267
+ requires: [],
268
+ inputs: { project: 'string', options: { format: 'json|brief' } },
269
+ outputs: { targets: 'array<FrictionTarget>', totalAnalyzed: 'number' },
270
+ phase: 'analyze',
271
+ tier: 'pro',
272
+ dependsOn: ['extract'],
273
+ },
274
+ {
275
+ id: 'brief',
276
+ name: 'Weekly Intel Brief',
277
+ description: 'What changed this week — competitor moves, new gaps, wins, actions',
278
+ requires: [],
279
+ inputs: { project: 'string', options: { days: 'number', format: 'json|brief' } },
280
+ outputs: { competitorMoves: 'array', keywordGaps: 'array', schemaGaps: 'array', actions: 'array' },
281
+ phase: 'report',
282
+ tier: 'pro',
283
+ dependsOn: ['crawl'],
284
+ },
285
+ {
286
+ id: 'velocity',
287
+ name: 'Content Velocity Tracker',
288
+ description: 'Publishing rate comparison — who\'s producing content fastest',
289
+ requires: [],
290
+ inputs: { project: 'string', options: { days: 'number', format: 'json|brief' } },
291
+ outputs: { velocities: 'array<DomainVelocity>', recentlyPublished: 'array', newPages: 'array' },
292
+ phase: 'analyze',
293
+ tier: 'pro',
294
+ dependsOn: ['crawl'],
295
+ },
296
+ {
297
+ id: 'js-delta',
298
+ name: 'JS Rendering Delta',
299
+ description: 'Compare raw HTML vs rendered DOM — find pages with hidden JS-only content',
300
+ requires: ['playwright'],
301
+ inputs: { project: 'string', options: { domain: 'string', maxPages: 'number', threshold: 'number', format: 'json|brief' } },
302
+ outputs: { results: 'array<RenderDelta>', summary: 'object' },
303
+ phase: 'analyze',
304
+ tier: 'pro',
305
+ dependsOn: ['crawl'],
306
+ },
307
+ {
308
+ id: 'export-actions',
309
+ name: 'Technical Action Export',
310
+ description: 'Generate prioritised technical SEO fix list from crawl data',
311
+ requires: [],
312
+ inputs: { project: 'string', options: { scope: 'string', format: 'json|brief' } },
313
+ outputs: { actions: 'array<Action>' },
314
+ phase: 'export',
315
+ tier: 'free',
316
+ dependsOn: ['crawl'],
317
+ },
318
+ {
319
+ id: 'competitive-actions',
320
+ name: 'Competitive Action Export',
321
+ description: 'Generate competitive intelligence action list — what to build based on competitor analysis',
322
+ requires: [],
323
+ inputs: { project: 'string', options: { format: 'json|brief' } },
324
+ outputs: { actions: 'array<Action>' },
325
+ phase: 'export',
326
+ tier: 'free',
327
+ dependsOn: ['crawl'],
328
+ },
329
+ {
330
+ id: 'suggest-usecases',
331
+ name: 'Use Case Suggestions',
332
+ description: 'AI-suggested pages/features to build based on competitor patterns',
333
+ requires: [],
334
+ inputs: { project: 'string', options: { format: 'json|brief' } },
335
+ outputs: { suggestions: 'array<Action>' },
336
+ phase: 'export',
337
+ tier: 'free',
338
+ dependsOn: ['crawl'],
339
+ },
340
+ {
341
+ id: 'blog-draft',
342
+ name: 'AEO Blog Draft Generator',
343
+ description: 'Generate AEO-optimised blog post drafts from Intelligence Ledger data',
344
+ requires: ['cloud-llm'],
345
+ inputs: { project: 'string', options: { topic: 'string', lang: 'string', model: 'string' } },
346
+ outputs: { draft: 'string', context: 'object' },
347
+ modelHint: 'cloud-medium',
348
+ modelNote: 'Sonnet or equivalent — needs creative + strategic reasoning for quality drafts.',
349
+ phase: 'create',
350
+ tier: 'pro',
351
+ dependsOn: ['crawl', 'extract', 'aeo'],
352
+ },
353
+ ];
354
+
355
+ /**
356
+ * Dependency graph for agent orchestration.
357
+ * Agents should follow this order: collect → extract → analyze → report → create
358
+ */
359
+ export const pipeline = {
360
+ phases: ['collect', 'extract', 'analyze', 'report', 'create'],
361
+ graph: {
362
+ crawl: [],
363
+ extract: ['crawl'],
364
+ aeo: ['crawl'],
365
+ rescore: ['aeo'],
366
+ watch: ['crawl'],
367
+ 'gap-intel': ['crawl', 'extract'],
368
+ shallow: ['crawl'],
369
+ decay: ['crawl'],
370
+ 'headings-audit': ['crawl'],
371
+ orphans: ['extract'],
372
+ entities: ['extract'],
373
+ schemas: ['crawl'],
374
+ friction: ['extract'],
375
+ brief: ['crawl'],
376
+ velocity: ['crawl'],
377
+ 'js-delta': ['crawl'],
378
+ 'export-actions': ['crawl'],
379
+ 'competitive-actions': ['crawl'],
380
+ 'suggest-usecases': ['crawl'],
381
+ 'blog-draft': ['crawl', 'extract', 'aeo'],
382
+ },
383
+ };
384
+
385
+ // ═══════════════════════════════════════════════════════════════════════════
386
+ // UNIFIED RUNNER
387
+ // ═══════════════════════════════════════════════════════════════════════════
388
+
389
+ /**
390
+ * Run any SEO Intel command and get structured JSON back.
391
+ *
392
+ * @param {string} command - Command ID (e.g. 'aeo', 'shallow', 'gap-intel')
393
+ * @param {string} project - Project name
394
+ * @param {object} [opts={}] - Command-specific options
395
+ * @returns {Promise<{ok: boolean, command: string, project: string, timestamp: string, data?: object, error?: string}>}
396
+ *
397
+ * Usage:
398
+ * const result = await run('aeo', 'carbium');
399
+ * const result = await run('gap-intel', 'carbium', { vs: ['helius.dev'] });
400
+ * const result = await run('shallow', 'carbium', { maxWords: 500 });
401
+ */
402
+ export async function run(command, project, opts = {}) {
403
+ const timestamp = new Date().toISOString();
404
+ const wrap = (data) => ({ ok: true, command, project, timestamp, data });
405
+ const fail = (error) => ({ ok: false, command, project, timestamp, error });
406
+
407
+ try {
408
+ const db = getDb();
409
+ const config = loadConfig(project);
410
+ if (!config && !['status'].includes(command)) {
411
+ return fail(`Project "${project}" not configured. Available: ${listProjects().map(p => p.name).join(', ')}`);
412
+ }
413
+
414
+ switch (command) {
415
+ // ── Analysis commands (return structured data) ──
416
+
417
+ case 'aeo': {
418
+ const { runAeoAnalysis } = await import('./analyses/aeo/index.js');
419
+ const result = await runAeoAnalysis(db, project, {
420
+ ...opts,
421
+ log: opts.log || (() => {}),
422
+ });
423
+ return wrap(result);
424
+ }
425
+
426
+ case 'rescore': {
427
+ if (!opts.url) return fail('rescore requires opts.url (the page to re-check)');
428
+ const { rescorePage } = await import('./analyses/aeo/rescore.js');
429
+ const result = await rescorePage(db, project, opts.url, { log: opts.log });
430
+ return wrap(result);
431
+ }
432
+
433
+ case 'watch': {
434
+ const { runWatch } = await import('./analyses/watch/index.js');
435
+ const result = runWatch(db, project, { log: opts.log || (() => {}) });
436
+ return wrap(result);
437
+ }
438
+
439
+ case 'gap-intel': {
440
+ const { runGapIntel } = await import('./analyses/gap-intel/index.js');
441
+ const vs = Array.isArray(opts.vs) ? opts.vs : (opts.vs ? opts.vs.split(',') : []);
442
+ const report = await runGapIntel(db, project, config, {
443
+ vs,
444
+ type: opts.type || 'all',
445
+ limit: opts.limit || 100,
446
+ raw: opts.raw || false,
447
+ log: opts.log || (() => {}),
448
+ });
449
+ return wrap({ report });
450
+ }
451
+
452
+ case 'shallow': {
453
+ const maxWords = parseInt(opts.maxWords) || 700;
454
+ const maxDepth = parseInt(opts.maxDepth) || 2;
455
+ const rows = db.prepare(`
456
+ SELECT p.url, p.click_depth, p.word_count, d.domain
457
+ FROM pages p JOIN domains d ON d.id = p.domain_id
458
+ WHERE d.project = ? AND d.role = 'competitor'
459
+ AND p.click_depth <= ? AND p.word_count <= ? AND p.word_count > 80
460
+ AND p.is_indexable = 1
461
+ ORDER BY p.click_depth ASC, p.word_count ASC
462
+ `).all(project, maxDepth, maxWords).filter(r => isContentPage(r.url));
463
+
464
+ return wrap({
465
+ targets: rows.map(r => ({ url: r.url, domain: r.domain, wordCount: r.word_count, clickDepth: r.click_depth })),
466
+ totalTargets: rows.length,
467
+ });
468
+ }
469
+
470
+ case 'decay': {
471
+ const monthsAgo = parseInt(opts.months) || 18;
472
+ const cutoffDate = new Date();
473
+ cutoffDate.setMonth(cutoffDate.getMonth() - monthsAgo);
474
+ const cutoff = cutoffDate.toISOString().split('T')[0];
475
+
476
+ const staleKnown = db.prepare(`
477
+ SELECT p.url, p.click_depth, p.word_count, p.modified_date, d.domain
478
+ FROM pages p JOIN domains d ON d.id = p.domain_id
479
+ WHERE d.project = ? AND d.role = 'competitor'
480
+ AND p.click_depth <= 2 AND p.word_count > 100
481
+ AND p.modified_date IS NOT NULL AND p.modified_date < ?
482
+ AND p.is_indexable = 1
483
+ ORDER BY p.modified_date ASC
484
+ `).all(project, cutoff).filter(r => isContentPage(r.url));
485
+
486
+ const staleUnknown = db.prepare(`
487
+ SELECT p.url, p.click_depth, p.word_count, d.domain
488
+ FROM pages p JOIN domains d ON d.id = p.domain_id
489
+ WHERE d.project = ? AND d.role = 'competitor'
490
+ AND p.click_depth <= 2 AND p.word_count BETWEEN 300 AND 1500
491
+ AND p.modified_date IS NULL AND p.published_date IS NULL
492
+ AND p.is_indexable = 1
493
+ ORDER BY p.word_count ASC LIMIT 20
494
+ `).all(project).filter(r => isContentPage(r.url));
495
+
496
+ return wrap({
497
+ confirmedStale: staleKnown.map(r => ({ url: r.url, domain: r.domain, wordCount: r.word_count, modifiedDate: r.modified_date, clickDepth: r.click_depth })),
498
+ unknownFreshness: staleUnknown.map(r => ({ url: r.url, domain: r.domain, wordCount: r.word_count, clickDepth: r.click_depth })),
499
+ monthsThreshold: monthsAgo,
500
+ });
501
+ }
502
+
503
+ case 'orphans': {
504
+ const extractions = db.prepare(`
505
+ SELECT e.primary_entities, p.url, d.domain
506
+ FROM extractions e JOIN pages p ON p.id = e.page_id JOIN domains d ON d.id = p.domain_id
507
+ WHERE d.project = ? AND d.role = 'competitor'
508
+ AND e.primary_entities IS NOT NULL AND e.primary_entities != ''
509
+ `).all(project);
510
+
511
+ const entityMap = new Map();
512
+ for (const row of extractions) {
513
+ let entities = [];
514
+ try { entities = JSON.parse(row.primary_entities); } catch {}
515
+ for (const entity of entities) {
516
+ const key = entity.toLowerCase().trim();
517
+ if (!entityMap.has(key)) entityMap.set(key, new Set());
518
+ entityMap.get(key).add(row.domain);
519
+ }
520
+ }
521
+
522
+ const allUrls = db.prepare(`
523
+ SELECT p.url FROM pages p JOIN domains d ON d.id = p.domain_id
524
+ WHERE d.project = ? AND d.role = 'competitor'
525
+ `).all(project).map(r => r.url.toLowerCase());
526
+
527
+ const orphans = [];
528
+ for (const [entity, domains] of entityMap.entries()) {
529
+ if (domains.size < 2) continue;
530
+ const slug = entity.replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
531
+ const hasDedicatedPage = allUrls.some(u => u.includes(slug));
532
+ if (!hasDedicatedPage) {
533
+ orphans.push({ entity, domains: [...domains], domainCount: domains.size, suggestedUrl: '/solutions/' + slug });
534
+ }
535
+ }
536
+ orphans.sort((a, b) => b.domainCount - a.domainCount);
537
+
538
+ return wrap({ orphans, totalOrphans: orphans.length });
539
+ }
540
+
541
+ case 'entities': {
542
+ const minMentions = parseInt(opts.minMentions) || 2;
543
+ const allExtractions = db.prepare(`
544
+ SELECT e.primary_entities, d.domain, d.role, p.url
545
+ FROM extractions e JOIN pages p ON p.id = e.page_id JOIN domains d ON d.id = p.domain_id
546
+ WHERE d.project = ? AND e.primary_entities IS NOT NULL AND e.primary_entities != '[]' AND e.primary_entities != ''
547
+ `).all(project);
548
+
549
+ const entityMap = new Map();
550
+ for (const row of allExtractions) {
551
+ let entities = [];
552
+ try { entities = JSON.parse(row.primary_entities); } catch { continue; }
553
+ for (const entity of entities) {
554
+ const key = entity.toLowerCase().trim();
555
+ if (key.length < 2) continue;
556
+ if (!entityMap.has(key)) entityMap.set(key, { target: new Set(), competitor: new Set(), owned: new Set() });
557
+ const e = entityMap.get(key);
558
+ if (row.role === 'target') e.target.add(row.domain);
559
+ else if (row.role === 'owned') e.owned.add(row.domain);
560
+ else e.competitor.add(row.domain);
561
+ }
562
+ }
563
+
564
+ const gaps = [], shared = [], unique = [];
565
+ for (const [entity, data] of entityMap) {
566
+ const compCount = data.competitor.size;
567
+ const hasTarget = data.target.size > 0 || data.owned.size > 0;
568
+ if (compCount >= minMentions && !hasTarget) gaps.push({ entity, competitorCount: compCount, domains: [...data.competitor] });
569
+ else if (compCount > 0 && hasTarget) shared.push({ entity, competitorCount: compCount, targetDomains: [...data.target, ...data.owned], competitorDomains: [...data.competitor] });
570
+ else if (compCount === 0 && hasTarget) unique.push({ entity, targetDomains: [...data.target, ...data.owned] });
571
+ }
572
+ gaps.sort((a, b) => b.competitorCount - a.competitorCount);
573
+
574
+ return wrap({ gaps, shared, unique, summary: { totalEntities: entityMap.size, gapCount: gaps.length, sharedCount: shared.length, uniqueCount: unique.length } });
575
+ }
576
+
577
+ case 'schemas': {
578
+ const rows = getSchemasByProject(db, project);
579
+ const byDomain = new Map();
580
+ for (const row of rows) {
581
+ if (!byDomain.has(row.domain)) byDomain.set(row.domain, []);
582
+ byDomain.get(row.domain).push(row);
583
+ }
584
+ const allTypes = [...new Set(rows.map(r => r.schema_type))].sort();
585
+
586
+ let targetDomain = null;
587
+ try { targetDomain = config?.target?.domain; } catch {}
588
+ const targetTypes = new Set((byDomain.get(targetDomain) || []).map(s => s.schema_type));
589
+ const compTypes = new Set(rows.filter(r => r.domain !== targetDomain).map(r => r.schema_type));
590
+ const schemaGaps = [...compTypes].filter(t => !targetTypes.has(t));
591
+ const exclusives = [...targetTypes].filter(t => !compTypes.has(t));
592
+
593
+ return wrap({
594
+ coverageMatrix: Object.fromEntries([...byDomain.entries()].map(([dom, schemas]) => [dom, schemas.map(s => ({ type: s.schema_type, url: s.url, name: s.name, rating: s.rating, price: s.price }))])),
595
+ gaps: schemaGaps,
596
+ exclusives,
597
+ ratings: rows.filter(r => r.rating !== null).map(r => ({ domain: r.domain, url: r.url, rating: r.rating, ratingCount: r.rating_count })),
598
+ pricing: rows.filter(r => r.price !== null).map(r => ({ domain: r.domain, url: r.url, price: r.price, currency: r.currency })),
599
+ summary: { totalSchemas: rows.length, uniqueTypes: allTypes.length, domainsWithSchemas: byDomain.size, gapCount: schemaGaps.length },
600
+ });
601
+ }
602
+
603
+ case 'friction': {
604
+ const rows = db.prepare(`
605
+ SELECT e.search_intent, e.cta_primary, e.pricing_tier, p.url, p.word_count, d.domain
606
+ FROM extractions e JOIN pages p ON p.id = e.page_id JOIN domains d ON d.id = p.domain_id
607
+ WHERE d.project = ? AND d.role = 'competitor'
608
+ AND e.search_intent IS NOT NULL AND e.cta_primary IS NOT NULL
609
+ ORDER BY d.domain
610
+ `).all(project).filter(r => isContentPage(r.url));
611
+
612
+ const highFrictionCTAs = ['enterprise', 'sales', 'contact', 'book a demo', 'request', 'talk to'];
613
+ const targets = rows.filter(r => {
614
+ const cta = (r.cta_primary || '').toLowerCase();
615
+ const intent = (r.search_intent || '').toLowerCase();
616
+ return highFrictionCTAs.some(f => cta.includes(f)) && (intent.includes('informational') || intent.includes('commercial'));
617
+ });
618
+
619
+ return wrap({
620
+ targets: targets.map(t => ({ url: t.url, domain: t.domain, searchIntent: t.search_intent, ctaPrimary: t.cta_primary, pricingTier: t.pricing_tier, wordCount: t.word_count })),
621
+ totalAnalyzed: rows.length,
622
+ totalHighFriction: targets.length,
623
+ });
624
+ }
625
+
626
+ case 'velocity': {
627
+ const days = parseInt(opts.days) || 30;
628
+ const cutoff = Date.now() - (days * 24 * 60 * 60 * 1000);
629
+
630
+ const newPages = db.prepare(`
631
+ SELECT d.domain, d.role, p.url, p.first_seen_at, p.published_date, p.word_count
632
+ FROM pages p JOIN domains d ON d.id = p.domain_id
633
+ WHERE d.project = ? AND p.first_seen_at > ? AND p.is_indexable = 1
634
+ ORDER BY p.first_seen_at DESC
635
+ `).all(project, cutoff).filter(r => isContentPage(r.url));
636
+
637
+ const totals = db.prepare(`
638
+ SELECT d.domain, d.role, COUNT(*) as total_pages
639
+ FROM pages p JOIN domains d ON d.id = p.domain_id
640
+ WHERE d.project = ? AND p.is_indexable = 1
641
+ GROUP BY d.domain
642
+ `).all(project);
643
+
644
+ const domainNewMap = {};
645
+ for (const np of newPages) {
646
+ if (!domainNewMap[np.domain]) domainNewMap[np.domain] = [];
647
+ domainNewMap[np.domain].push(np);
648
+ }
649
+
650
+ const velocities = totals.map(t => {
651
+ const newCount = (domainNewMap[t.domain] || []).length;
652
+ const ratePerWeek = days > 0 ? +(newCount / (days / 7)).toFixed(1) : 0;
653
+ return { domain: t.domain, role: t.role, totalPages: t.total_pages, newPages: newCount, ratePerWeek };
654
+ });
655
+
656
+ return wrap({ velocities, period: { days, cutoff: new Date(cutoff).toISOString() } });
657
+ }
658
+
659
+ case 'brief': {
660
+ const days = parseInt(opts.days) || 7;
661
+ const cutoff = Date.now() - (days * 24 * 60 * 60 * 1000);
662
+ const compDomains = (config?.competitors || []).map(c => c.domain);
663
+
664
+ const competitorMoves = [];
665
+ for (const comp of compDomains) {
666
+ const newPages = db.prepare(`
667
+ SELECT p.url, p.word_count FROM pages p JOIN domains d ON d.id = p.domain_id
668
+ WHERE d.domain = ? AND d.project = ? AND p.first_seen_at > ? AND p.is_indexable = 1
669
+ `).all(comp, project, cutoff).filter(r => isContentPage(r.url));
670
+
671
+ const changedPages = db.prepare(`
672
+ SELECT p.url, p.word_count FROM pages p JOIN domains d ON d.id = p.domain_id
673
+ WHERE d.domain = ? AND d.project = ? AND p.crawled_at > ? AND p.first_seen_at < ? AND p.is_indexable = 1
674
+ `).all(comp, project, cutoff, cutoff).filter(r => isContentPage(r.url));
675
+
676
+ competitorMoves.push({
677
+ domain: comp,
678
+ newPages: newPages.map(p => ({ url: p.url, wordCount: p.word_count })),
679
+ changedPages: changedPages.map(p => ({ url: p.url, wordCount: p.word_count })),
680
+ });
681
+ }
682
+
683
+ return wrap({ competitorMoves, period: { days, weekOf: new Date().toISOString().slice(0, 10) } });
684
+ }
685
+
686
+ // ── Export commands ──
687
+
688
+ case 'export-actions': {
689
+ const { buildTechnicalActions } = await import('./exports/technical.js');
690
+ return wrap({ actions: buildTechnicalActions(db, project) });
691
+ }
692
+
693
+ case 'competitive-actions': {
694
+ const { buildCompetitiveActions } = await import('./exports/competitive.js');
695
+ return wrap({ actions: buildCompetitiveActions(db, project, opts) });
696
+ }
697
+
698
+ case 'suggest-usecases': {
699
+ const { buildSuggestiveActions } = await import('./exports/suggestive.js');
700
+ return wrap({ actions: buildSuggestiveActions(db, project, opts) });
701
+ }
702
+
703
+ // ── Blog draft ──
704
+
705
+ case 'blog-draft': {
706
+ const { gatherBlogDraftContext, buildBlogDraftPrompt } = await import('./analyses/blog-draft/index.js');
707
+ const context = await gatherBlogDraftContext(db, project, opts.topic);
708
+ const prompt = buildBlogDraftPrompt(context, { config, lang: opts.lang || 'en', topic: opts.topic });
709
+ return wrap({ context, prompt });
710
+ }
711
+
712
+ // ── Intelligence Ledger ──
713
+
714
+ case 'insights': {
715
+ const insights = getActiveInsights(db, project);
716
+ return wrap({ insights, totalActive: insights.length });
717
+ }
718
+
719
+ // ── Headings Audit ──
720
+
721
+ case 'headings-audit': {
722
+ const maxDepth = parseInt(opts.depth) || 2;
723
+ const domainFilter = opts.domain ? 'AND d.domain = ?' : '';
724
+ const params = opts.domain ? [project, maxDepth, opts.domain] : [project, maxDepth];
725
+
726
+ const pages = db.prepare(`
727
+ SELECT p.id, p.url, p.word_count, p.click_depth, d.domain
728
+ FROM pages p JOIN domains d ON d.id = p.domain_id
729
+ WHERE d.project = ? AND d.role = 'competitor'
730
+ AND p.click_depth <= ? AND p.word_count > 200 ${domainFilter}
731
+ AND p.is_indexable = 1
732
+ ORDER BY d.domain, p.click_depth ASC
733
+ `).all(...params).filter(r => isContentPage(r.url));
734
+
735
+ const results = [];
736
+ for (const page of pages.slice(0, 30)) {
737
+ const headings = db.prepare('SELECT level, text FROM headings WHERE page_id = ? ORDER BY rowid ASC').all(page.id);
738
+ if (!headings.length) continue;
739
+ results.push({ url: page.url, domain: page.domain, wordCount: page.word_count, clickDepth: page.click_depth, headings: headings.map(h => ({ level: h.level, text: h.text })) });
740
+ }
741
+ return wrap({ pages: results, totalPages: results.length });
742
+ }
743
+
744
+ // ── JS Rendering Delta ──
745
+
746
+ case 'js-delta': {
747
+ // This requires Playwright — return instructions if called from agent
748
+ return fail('js-delta requires Playwright browser automation. Use the CLI: seo-intel js-delta ' + project + ' --format json');
749
+ }
750
+
751
+ // ── Templates ──
752
+
753
+ case 'templates': {
754
+ const { runTemplatesAnalysis } = await import('./analyses/templates/index.js');
755
+ const report = await runTemplatesAnalysis(project, {
756
+ log: opts.log || (() => {}),
757
+ minGroupSize: opts.minGroupSize || 10,
758
+ sampleSize: opts.sampleSize || 20,
759
+ });
760
+ return wrap(report);
761
+ }
762
+
763
+ // ── Crawl ──
764
+
765
+ case 'crawl': {
766
+ const { crawlDomain } = await import('./crawler/index.js');
767
+ const config_ = loadConfig(project);
768
+ if (!config_) return fail(`Project "${project}" not configured`);
769
+
770
+ const targetUrl = config_.target.url || `https://${config_.target.domain}`;
771
+ const maxPages = opts.maxPages || 200;
772
+ let pagesFound = 0;
773
+ const pageSummary = [];
774
+
775
+ for await (const page of crawlDomain(targetUrl, {
776
+ maxPages,
777
+ stealth: opts.stealth || false,
778
+ ...opts,
779
+ })) {
780
+ pagesFound++;
781
+ pageSummary.push({ url: page.url, status: page.statusCode, depth: page.depth, wordCount: page.wordCount || 0 });
782
+ if (opts.onPage) opts.onPage(page);
783
+ }
784
+
785
+ return wrap({ pagesFound, pages: pageSummary.slice(0, 50), targetUrl });
786
+ }
787
+
788
+ // ── Extract ──
789
+
790
+ case 'extract': {
791
+ const { extractPage, pingOllamaHost } = await import('./extractor/qwen.js');
792
+ const ollamaHost = process.env.OLLAMA_HOST || 'http://127.0.0.1:11434';
793
+ const model = opts.model || process.env.OLLAMA_MODEL || 'gemma4:e4b';
794
+
795
+ // Preflight check
796
+ const ping = await pingOllamaHost(ollamaHost, model).catch(() => null);
797
+ if (!ping) return fail(`Ollama not reachable at ${ollamaHost} or model "${model}" not available`);
798
+
799
+ // Get pages needing extraction
800
+ const pages = db.prepare(`
801
+ SELECT p.id, p.url, p.title, p.meta_desc, p.body_text, p.published_date, p.modified_date
802
+ FROM pages p JOIN domains d ON d.id = p.domain_id
803
+ LEFT JOIN extractions e ON e.page_id = p.id
804
+ WHERE d.project = ? AND p.status_code = 200 AND p.body_text IS NOT NULL AND p.body_text != ''
805
+ AND e.id IS NULL
806
+ ORDER BY p.click_depth ASC
807
+ LIMIT ?
808
+ `).all(project, opts.limit || 500);
809
+
810
+ if (!pages.length) return wrap({ extracted: 0, message: 'All pages already extracted' });
811
+
812
+ let extracted = 0, failed = 0;
813
+ for (const page of pages) {
814
+ try {
815
+ const headings = db.prepare('SELECT level, text FROM headings WHERE page_id = ?').all(page.id);
816
+ const schemas = db.prepare('SELECT schema_type FROM page_schemas WHERE page_id = ?').all(page.id);
817
+
818
+ await extractPage({
819
+ url: page.url,
820
+ title: page.title,
821
+ metaDesc: page.meta_desc,
822
+ headings: headings.map(h => ({ level: h.level, text: h.text })),
823
+ bodyText: page.body_text,
824
+ schemaTypes: schemas.map(s => s.schema_type),
825
+ publishedDate: page.published_date,
826
+ modifiedDate: page.modified_date,
827
+ });
828
+ extracted++;
829
+ if (opts.onExtract) opts.onExtract({ url: page.url, index: extracted });
830
+ } catch {
831
+ failed++;
832
+ }
833
+ }
834
+
835
+ return wrap({ extracted, failed, totalPending: pages.length });
836
+ }
837
+
838
+ // ── Status ──
839
+
840
+ case 'status': {
841
+ const projects = listProjects();
842
+ return wrap({ projects, totalProjects: projects.length });
843
+ }
844
+
845
+ default:
846
+ return fail(`Unknown command: "${command}". Available: ${capabilities.map(c => c.id).join(', ')}`);
847
+ }
848
+ } catch (e) {
849
+ return fail(e.message);
850
+ }
851
+ }