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.
- package/CHANGELOG.md +84 -0
- package/agent-harness.js +851 -0
- package/analyses/aeo/ai-access.js +210 -0
- package/analyses/aeo/index.js +52 -9
- package/analyses/aeo/rescore.js +59 -0
- package/analyses/aeo/scorer.js +36 -13
- package/cli.js +227 -19
- package/config/example.json +1 -0
- package/extractor/qwen.js +23 -7
- package/lib/intel.js +101 -0
- package/lib/license.js +26 -15
- package/lib/updater.js +17 -6
- package/mcp/server.js +250 -6
- package/package.json +4 -2
- package/seo-intel.png +0 -0
- package/setup/engine.js +3 -0
- package/setup/models.js +90 -2
package/agent-harness.js
ADDED
|
@@ -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
|
+
}
|