seo-intel 1.5.50 → 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 +35 -0
- package/agent-harness.js +851 -0
- package/analyses/aeo/rescore.js +59 -0
- package/cli.js +52 -1
- package/config/example.json +1 -0
- package/extractor/qwen.js +23 -7
- package/lib/intel.js +101 -0
- package/package.json +4 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,40 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.5.52 (2026-07-01)
|
|
4
|
+
|
|
5
|
+
### New: per-project `sourcePath` — agent dispatch knows where the code lives
|
|
6
|
+
- Project configs can now declare `sourcePath` (an absolute path to the site's source — a git repo or a plain folder, both work; git-ness is auto-detected, never declared). Any local agent integration can use this to dispatch a fix directly into the right codebase instead of guessing.
|
|
7
|
+
- seo-intel itself self-registers its own install location to `~/.seo-intel/install.json` on every CLI run, so any local agent harness can find it without environment variables or hardcoded paths.
|
|
8
|
+
|
|
9
|
+
### Updated: product logo — gold to intel-blue
|
|
10
|
+
- The product mark now uses the brand's intel-blue accent instead of gold, matching the dashboard and site. Shows up as the dashboard favicon and in the npm package.
|
|
11
|
+
|
|
12
|
+
### New: `rescore <project> <url>` — close the agent loop
|
|
13
|
+
- Re-check a single URL's AI citability after a fix. Read-only re-measurement on the raw-HTML ("what bots see") lens, returning `before / after / delta` + the per-signal breakdown — so an agent (or you) can verify a change actually moved the score. Server-rendered fixes move it; JS-only fixes correctly don't.
|
|
14
|
+
- Available via the CLI (`--format json`) and the agent-harness (`run('rescore', project, { url })`).
|
|
15
|
+
|
|
16
|
+
### Improved: `intel --for audit` now emits ranked opportunities
|
|
17
|
+
- The audit slice now includes a ranked `opportunities[]` (each tagged `side: fix | attack` with real value/status/confidence/suggested_action/proof) plus a `summary` (counts + per-signal averages) — decision-ready data any agent or dashboard can act on directly. Flows through to the MCP `get_intel` and the agent-harness automatically.
|
|
18
|
+
|
|
19
|
+
### Renamed: `froggo.js` → `agent-harness.js`
|
|
20
|
+
- The agent integration entry point is now `seo-intel/agent-harness` (clearer name). The legacy `seo-intel/froggo` import still resolves to it, so existing code keeps working.
|
|
21
|
+
|
|
22
|
+
### Claude Code plugin — install the MCP server *and* skill in one command
|
|
23
|
+
- Added a Claude Code plugin marketplace. Claude Code users can install seo-intel with `/plugin marketplace add Ukkometa/seo-intel` then `/plugin install seo-intel@ukkometa` — one step wires both the MCP server **and** the seo-intel skill, with no manual `claude mcp add` or config editing.
|
|
24
|
+
- The existing `claude mcp add seo-intel "npx seo-intel-mcp"` (and Cursor/Cline setup) still works unchanged.
|
|
25
|
+
|
|
26
|
+
## 1.5.51 (2026-06-11)
|
|
27
|
+
|
|
28
|
+
### Fixed — LM Studio extraction now actually works (it was silently degrading)
|
|
29
|
+
Local extraction via LM Studio never functioned: every page fell back to degraded (regex) mode, so entity-level signals (entity authority, intent, keywords) came back empty even when a model was loaded and serving. Four stacked bugs:
|
|
30
|
+
|
|
31
|
+
- **Preflight hit the wrong endpoint.** It probed `/api/v1/models` (LM Studio's *native* API, shape `{models:[{key}]}`) but parsed it as the OpenAI shape `{data:[{id}]}` — so it always concluded "no models loaded." Now uses the OpenAI-compatible `/v1/models`.
|
|
32
|
+
- **Inference hit the wrong endpoint.** It POSTed to `/api/v1/chat` (returns `400 'input' is required`). Now uses `/v1/chat/completions`.
|
|
33
|
+
- **Unsupported `response_format`.** It sent `{type:'json_object'}`, which LM Studio rejects (`must be 'json_schema' or 'text'`). Now sends `text` and relies on the existing JSON-extraction/repair pass.
|
|
34
|
+
- **A few bad pages disabled the whole model.** Content/parse failures (a small model returning unparseable JSON for one long page) were counted as host failures and retired the local model for the rest of the run. Now only *transport* failures (host unreachable/timeout/5xx) retire a host; a single unparseable page just degrades itself.
|
|
35
|
+
|
|
36
|
+
Point at a loaded LM Studio model with `LMSTUDIO_MODEL=<model-id>` (e.g. `google/gemma-4-e2b`). Note: very small models (≈2B) still struggle to emit clean JSON for very long pages — use a 4B+ extraction model (Gemma E4B, Qwen 3.5) for higher coverage, or see `seo-intel models`.
|
|
37
|
+
|
|
3
38
|
## 1.5.50 (2026-06-11)
|
|
4
39
|
|
|
5
40
|
### New MCP tool: `setup_project` — zero → configured → audited, entirely from chat
|
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
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// rescore.js — re-check a single URL's AI citability after a fix.
|
|
2
|
+
//
|
|
3
|
+
// Read-only re-measurement (SEO Intel mutates nothing). Uses the lightweight
|
|
4
|
+
// HTTP crawl — the RAW-HTML / "what bots see" lens: if an agent's fix is
|
|
5
|
+
// server-rendered, the score moves; if it's JS-only, it correctly does not.
|
|
6
|
+
// This closes the agent loop: act → re-score → see the delta.
|
|
7
|
+
import { lightCrawl } from '../../crawler/light.js';
|
|
8
|
+
|
|
9
|
+
const bucket = (s) => (s == null ? null : s >= 75 ? 'good' : s >= 55 ? 'needs-work' : s >= 35 ? 'weak' : 'poor');
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param {object} db open SQLite handle (for the stored baseline)
|
|
13
|
+
* @param {string} project project name (scopes the baseline lookup)
|
|
14
|
+
* @param {string} url the URL to re-score
|
|
15
|
+
* @param {object} [opts] { log?: (msg)=>void }
|
|
16
|
+
* @returns {Promise<{url, status_code, lens, before, after, delta, improved, status_before, status_after, signals, note}>}
|
|
17
|
+
*/
|
|
18
|
+
export async function rescorePage(db, project, url, opts = {}) {
|
|
19
|
+
// Baseline = most recent stored citability score (from the last full crawl).
|
|
20
|
+
let before = null;
|
|
21
|
+
try {
|
|
22
|
+
const row = db.prepare(`
|
|
23
|
+
SELECT c.score
|
|
24
|
+
FROM citability_scores c
|
|
25
|
+
JOIN pages p ON p.id = c.page_id
|
|
26
|
+
JOIN domains d ON d.id = p.domain_id
|
|
27
|
+
WHERE d.project = ? AND p.url = ?
|
|
28
|
+
ORDER BY c.scored_at DESC
|
|
29
|
+
LIMIT 1
|
|
30
|
+
`).get(project, url);
|
|
31
|
+
before = row ? row.score : null;
|
|
32
|
+
} catch { /* citability_scores table may not exist yet */ }
|
|
33
|
+
|
|
34
|
+
const r = await lightCrawl(url, {
|
|
35
|
+
maxPages: 1,
|
|
36
|
+
includeCitability: true,
|
|
37
|
+
sameOrigin: true,
|
|
38
|
+
onProgress: opts.log,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const page = (r.pages || [])[0] || null;
|
|
42
|
+
const cite = page && page.citability && !page.citability.error ? page.citability : null;
|
|
43
|
+
const after = cite ? cite.score : null;
|
|
44
|
+
const delta = before != null && after != null ? after - before : null;
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
url,
|
|
48
|
+
status_code: page ? page.status_code : null,
|
|
49
|
+
lens: 'raw-html', // bot's-eye view — what crawlers / LLMs actually see
|
|
50
|
+
before, // last full-crawl score (baseline)
|
|
51
|
+
after, // current raw / bot-visible score
|
|
52
|
+
delta,
|
|
53
|
+
improved: delta != null ? delta > 0 : null,
|
|
54
|
+
status_before: bucket(before),
|
|
55
|
+
status_after: bucket(after),
|
|
56
|
+
signals: cite ? cite.breakdown : null,
|
|
57
|
+
note: cite ? null : ((page && page.citability && page.citability.error) || 'no citability computed for this URL'),
|
|
58
|
+
};
|
|
59
|
+
}
|
package/cli.js
CHANGED
|
@@ -16,7 +16,7 @@ import { program } from 'commander';
|
|
|
16
16
|
import { spawnSync } from 'child_process';
|
|
17
17
|
import { readFileSync, writeFileSync, readdirSync, unlinkSync, existsSync, mkdirSync } from 'fs';
|
|
18
18
|
import { dirname, join } from 'path';
|
|
19
|
-
import { totalmem } from 'os';
|
|
19
|
+
import { totalmem, homedir } from 'os';
|
|
20
20
|
import { fileURLToPath } from 'url';
|
|
21
21
|
import chalk from 'chalk';
|
|
22
22
|
|
|
@@ -53,6 +53,24 @@ import { getCurrentVersion, checkForUpdates, printUpdateNotice, forceUpdateCheck
|
|
|
53
53
|
|
|
54
54
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
55
55
|
|
|
56
|
+
// Self-register where this checkout/install lives, in ~/.seo-intel/install.json.
|
|
57
|
+
// Any local agent harness (Hermes, a future Claude Code plugin, anything) can
|
|
58
|
+
// read this one file to find SEO Intel — no env vars, no hardcoded paths, no
|
|
59
|
+
// per-harness wiring. Cheap to refresh on every run; last run to execute wins,
|
|
60
|
+
// which is the right default for the common one-checkout-per-machine case.
|
|
61
|
+
function registerInstallLocation() {
|
|
62
|
+
try {
|
|
63
|
+
const dir = join(homedir(), '.seo-intel');
|
|
64
|
+
mkdirSync(dir, { recursive: true });
|
|
65
|
+
writeFileSync(join(dir, 'install.json'), JSON.stringify({
|
|
66
|
+
root: __dirname,
|
|
67
|
+
version: getCurrentVersion(),
|
|
68
|
+
updatedAt: new Date().toISOString(),
|
|
69
|
+
}, null, 2));
|
|
70
|
+
} catch { /* non-fatal — harnesses fall back to PATH resolution */ }
|
|
71
|
+
}
|
|
72
|
+
registerInstallLocation();
|
|
73
|
+
|
|
56
74
|
// Start background update check (non-blocking, never slows startup)
|
|
57
75
|
checkForUpdates();
|
|
58
76
|
|
|
@@ -4634,6 +4652,39 @@ program
|
|
|
4634
4652
|
}
|
|
4635
4653
|
});
|
|
4636
4654
|
|
|
4655
|
+
// ── RESCORE (single-URL re-check, closes the agent loop) ──────────────────
|
|
4656
|
+
program
|
|
4657
|
+
.command('rescore <project> <url>')
|
|
4658
|
+
.description("Re-check one URL's AI citability after a fix — read-only, raw-HTML (bot's-eye) score + delta vs baseline")
|
|
4659
|
+
.option('--format <type>', 'Output format: brief or json', 'brief')
|
|
4660
|
+
.action(async (project, url, opts) => {
|
|
4661
|
+
const isJson = opts.format === 'json';
|
|
4662
|
+
const { getDb } = await import('./db/db.js');
|
|
4663
|
+
const { rescorePage } = await import('./analyses/aeo/rescore.js');
|
|
4664
|
+
const db = getDb();
|
|
4665
|
+
const res = await rescorePage(db, project, url, {
|
|
4666
|
+
log: isJson ? undefined : (m) => console.log(chalk.gray(' ' + m)),
|
|
4667
|
+
});
|
|
4668
|
+
if (isJson) {
|
|
4669
|
+
console.log(JSON.stringify(res, null, 2));
|
|
4670
|
+
return;
|
|
4671
|
+
}
|
|
4672
|
+
const tone = (s) => (s == null ? chalk.gray : s >= 60 ? chalk.green : s >= 35 ? chalk.yellow : chalk.red);
|
|
4673
|
+
console.log('');
|
|
4674
|
+
console.log(chalk.bold(` 🔁 Re-score — ${url}`));
|
|
4675
|
+
console.log(chalk.gray(` lens: raw HTML (what bots see) · HTTP ${res.status_code ?? '—'}`));
|
|
4676
|
+
if (res.after == null) {
|
|
4677
|
+
console.log(chalk.red(` ✗ ${res.note || 'could not score'}`));
|
|
4678
|
+
} else {
|
|
4679
|
+
const b = res.before == null ? chalk.gray('—') : tone(res.before)(String(res.before));
|
|
4680
|
+
const a = tone(res.after)(String(res.after));
|
|
4681
|
+
const arrow =
|
|
4682
|
+
res.delta == null ? '' : res.delta > 0 ? chalk.green(` ▲ +${res.delta}`) : res.delta < 0 ? chalk.red(` ▼ ${res.delta}`) : chalk.gray(' ±0');
|
|
4683
|
+
console.log(` citability ${b} → ${a}${arrow} (${res.status_before ?? '—'} → ${res.status_after ?? '—'})`);
|
|
4684
|
+
}
|
|
4685
|
+
console.log('');
|
|
4686
|
+
});
|
|
4687
|
+
|
|
4637
4688
|
// ── SITE WATCH ──────────────────────────────────────────────────────────
|
|
4638
4689
|
program
|
|
4639
4690
|
.command('watch <project>')
|
package/config/example.json
CHANGED
package/extractor/qwen.js
CHANGED
|
@@ -24,14 +24,17 @@ function modelMatches(available, target) {
|
|
|
24
24
|
// ── LM Studio support (OpenAI-compatible API) ──────────────────────────────
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
|
-
* Ping an LM Studio host. Uses GET /
|
|
27
|
+
* Ping an LM Studio host. Uses the OpenAI-compatible GET /v1/models (returns
|
|
28
|
+
* { data: [{ id }] } and lists models that are actually loaded/servable) —
|
|
29
|
+
* NOT the native /api/v1/models, which returns a different shape ({ models:
|
|
30
|
+
* [{ key }] }) and lists every downloaded model regardless of load state.
|
|
28
31
|
*/
|
|
29
32
|
export async function pingLmStudioHost(host, model, timeoutMs = OLLAMA_PREFLIGHT_TIMEOUT_MS) {
|
|
30
33
|
const controller = new AbortController();
|
|
31
34
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
32
35
|
|
|
33
36
|
try {
|
|
34
|
-
const res = await fetch(`${host}/
|
|
37
|
+
const res = await fetch(`${host}/v1/models`, { signal: controller.signal });
|
|
35
38
|
if (!res.ok) {
|
|
36
39
|
return { host, model, reachable: false, modelAvailable: false, type: 'lmstudio',
|
|
37
40
|
error: `HTTP ${res.status} ${res.statusText}`.trim() };
|
|
@@ -62,14 +65,17 @@ async function callLmStudio(route, prompt) {
|
|
|
62
65
|
const timeout = setTimeout(() => controller.abort(), OLLAMA_TIMEOUT_MS);
|
|
63
66
|
|
|
64
67
|
try {
|
|
65
|
-
const res = await fetch(`${route.host}/
|
|
68
|
+
const res = await fetch(`${route.host}/v1/chat/completions`, {
|
|
66
69
|
signal: controller.signal,
|
|
67
70
|
method: 'POST',
|
|
68
71
|
headers: { 'Content-Type': 'application/json' },
|
|
69
72
|
body: JSON.stringify({
|
|
70
73
|
model: route.model,
|
|
71
74
|
messages: [{ role: 'user', content: prompt }],
|
|
72
|
-
|
|
75
|
+
// LM Studio rejects OpenAI's 'json_object'; it accepts only 'json_schema'
|
|
76
|
+
// or 'text'. The prompt already demands JSON and the response is run
|
|
77
|
+
// through extractLastJsonObject/repairJson, so 'text' is the portable choice.
|
|
78
|
+
response_format: { type: 'text' },
|
|
73
79
|
temperature: 0,
|
|
74
80
|
max_tokens: 1200,
|
|
75
81
|
stream: false,
|
|
@@ -427,10 +433,20 @@ JSON output:`;
|
|
|
427
433
|
console.log(`[extractor] used ${route.label} for ${url}`);
|
|
428
434
|
break;
|
|
429
435
|
} catch (err) {
|
|
430
|
-
route.failures = (route.failures || 0) + 1;
|
|
431
436
|
console.warn(`[extractor] ${route.label} failed for ${url}: ${describeOllamaError(err, route)}`);
|
|
432
|
-
|
|
433
|
-
|
|
437
|
+
// Only TRANSPORT failures (host down/unreachable) should retire a host for
|
|
438
|
+
// the run. CONTENT failures (a small model returns unparseable JSON for one
|
|
439
|
+
// long page) must NOT disable the host — that page degrades on its own; the
|
|
440
|
+
// next page may extract fine. Conflating the two killed local extraction
|
|
441
|
+
// after a couple of hard pages.
|
|
442
|
+
const msg = String(err?.message || '');
|
|
443
|
+
const transportFail = err?.name === 'AbortError'
|
|
444
|
+
|| /fetch failed|ECONNREFUSED|ENOTFOUND|EHOSTUNREACH|socket hang up|network|timeout|HTTP 5\d\d/i.test(msg);
|
|
445
|
+
if (transportFail) {
|
|
446
|
+
route.failures = (route.failures || 0) + 1;
|
|
447
|
+
if (route.failures >= OLLAMA_HOST_FAILURE_LIMIT) {
|
|
448
|
+
removeRouteFromActivePool(runtimeState, route);
|
|
449
|
+
}
|
|
434
450
|
}
|
|
435
451
|
}
|
|
436
452
|
}
|
package/lib/intel.js
CHANGED
|
@@ -108,11 +108,112 @@ function collectRaw(db, project) {
|
|
|
108
108
|
};
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
// Map a page's weakest citability signal to a concrete on-page fix.
|
|
112
|
+
const SIGNAL_FIX = {
|
|
113
|
+
'schema coverage': 'Add JSON-LD schema (FAQPage / Product / Organization)',
|
|
114
|
+
'qa proximity': 'Add a Q&A / FAQ section answering real questions',
|
|
115
|
+
'entity authority': 'Add author + credentials and sameAs entity grounding',
|
|
116
|
+
'structured claims': 'Add extractable, standalone factual claims',
|
|
117
|
+
'answer density': 'Lead each section with a direct one-line answer',
|
|
118
|
+
'freshness': 'Add visible published / updated dates',
|
|
119
|
+
};
|
|
120
|
+
const scoreBucket = (s) => (s >= 75 ? 'good' : s >= 55 ? 'needs-work' : s >= 35 ? 'weak' : 'poor');
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Ranked, role-attributed opportunities from citability scores. Each row is
|
|
124
|
+
* dashboard-ready: real value/status/confidence + a concrete suggested fix.
|
|
125
|
+
* side='fix' = your own pages to improve; side='attack' = thin competitor pages.
|
|
126
|
+
*/
|
|
127
|
+
function buildOpportunities(db, project) {
|
|
128
|
+
let rows = [];
|
|
129
|
+
try {
|
|
130
|
+
rows = db.prepare(`
|
|
131
|
+
SELECT p.url, p.title, d.domain, d.role,
|
|
132
|
+
c.score, c.entity_authority, c.structured_claims, c.answer_density,
|
|
133
|
+
c.qa_proximity, c.freshness, c.schema_coverage, c.ai_intents
|
|
134
|
+
FROM citability_scores c
|
|
135
|
+
JOIN pages p ON p.id = c.page_id
|
|
136
|
+
JOIN domains d ON d.id = p.domain_id
|
|
137
|
+
WHERE d.project = ?
|
|
138
|
+
ORDER BY c.score ASC
|
|
139
|
+
`).all(project);
|
|
140
|
+
} catch { return { opportunities: [], summary: null }; }
|
|
141
|
+
|
|
142
|
+
const opportunities = rows.map((r, i) => {
|
|
143
|
+
const own = r.role === 'target' || r.role === 'owned';
|
|
144
|
+
const signals = {
|
|
145
|
+
'entity authority': r.entity_authority, 'structured claims': r.structured_claims,
|
|
146
|
+
'answer density': r.answer_density, 'qa proximity': r.qa_proximity,
|
|
147
|
+
'freshness': r.freshness, 'schema coverage': r.schema_coverage,
|
|
148
|
+
};
|
|
149
|
+
const ranked = Object.entries(signals).filter(([, v]) => v != null).sort((a, b) => a[1] - b[1]);
|
|
150
|
+
const weak = ranked.slice(0, 2).map(([k]) => k);
|
|
151
|
+
let intent = null;
|
|
152
|
+
try { intent = (JSON.parse(r.ai_intents || '[]') || [])[0] || null; } catch { /* ignore */ }
|
|
153
|
+
let label = r.title || r.url;
|
|
154
|
+
try { const u = new URL(r.url); label = u.hostname + (u.pathname === '/' ? '' : u.pathname); } catch { /* keep label */ }
|
|
155
|
+
return {
|
|
156
|
+
id: `cit:${i}`,
|
|
157
|
+
side: own ? 'fix' : 'attack',
|
|
158
|
+
role: r.role,
|
|
159
|
+
domain: r.domain,
|
|
160
|
+
url: r.url,
|
|
161
|
+
title: r.title || null,
|
|
162
|
+
finding: label,
|
|
163
|
+
kind: 'citability',
|
|
164
|
+
score: r.score,
|
|
165
|
+
status: scoreBucket(r.score),
|
|
166
|
+
value: Math.round((100 - r.score) * (own ? 1 : 0.6)), // your low pages rank highest
|
|
167
|
+
confidence: +(ranked.length / 6).toFixed(2), // signal completeness
|
|
168
|
+
intent,
|
|
169
|
+
weak_signals: weak,
|
|
170
|
+
suggested_action: own ? (SIGNAL_FIX[weak[0]] || 'Improve on-page citability') : 'Outrank — thin competitor page',
|
|
171
|
+
proof: r.url,
|
|
172
|
+
};
|
|
173
|
+
}).sort((a, b) => b.value - a.value);
|
|
174
|
+
|
|
175
|
+
const owned = opportunities.filter((o) => o.side === 'fix');
|
|
176
|
+
const attack = opportunities.filter((o) => o.side === 'attack');
|
|
177
|
+
const avg = (arr) => (arr.length ? Math.round((arr.reduce((s, o) => s + o.score, 0) / arr.length) * 10) / 10 : null);
|
|
178
|
+
|
|
179
|
+
// Per-signal averages across YOUR pages — the AEO citability breakdown.
|
|
180
|
+
const ownedRows = rows.filter((r) => r.role === 'target' || r.role === 'owned');
|
|
181
|
+
const signalAvg = (key) => {
|
|
182
|
+
const vals = ownedRows.map((r) => r[key]).filter((v) => v != null);
|
|
183
|
+
return vals.length ? Math.round(vals.reduce((a, b) => a + b, 0) / vals.length) : null;
|
|
184
|
+
};
|
|
185
|
+
const signals = {
|
|
186
|
+
entity_authority: signalAvg('entity_authority'),
|
|
187
|
+
structured_claims: signalAvg('structured_claims'),
|
|
188
|
+
answer_density: signalAvg('answer_density'),
|
|
189
|
+
qa_proximity: signalAvg('qa_proximity'),
|
|
190
|
+
freshness: signalAvg('freshness'),
|
|
191
|
+
schema_coverage: signalAvg('schema_coverage'),
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
opportunities,
|
|
196
|
+
summary: {
|
|
197
|
+
pages_scored: rows.length,
|
|
198
|
+
owned_pages: owned.length,
|
|
199
|
+
competitor_pages: attack.length,
|
|
200
|
+
avg_citability_owned: avg(owned),
|
|
201
|
+
avg_citability_all: avg(opportunities),
|
|
202
|
+
fix_opportunities: owned.length,
|
|
203
|
+
attack_targets: attack.length,
|
|
204
|
+
signals,
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
111
209
|
function collectAudit(db, project) {
|
|
112
210
|
const insights = getActiveInsights(db, project);
|
|
113
211
|
let citability = null;
|
|
114
212
|
try { citability = getCitabilityScores(db, project); } catch { /* citability_scores table may not exist if AEO never run */ }
|
|
213
|
+
const { opportunities, summary } = buildOpportunities(db, project);
|
|
115
214
|
return {
|
|
215
|
+
summary,
|
|
216
|
+
opportunities,
|
|
116
217
|
citability,
|
|
117
218
|
insights: {
|
|
118
219
|
keyword_gaps: insights.keyword_gaps,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "seo-intel",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.52",
|
|
4
4
|
"description": "Local Ahrefs-style SEO competitor intelligence. Crawl → SQLite → cloud analysis.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE",
|
|
@@ -25,7 +25,8 @@
|
|
|
25
25
|
},
|
|
26
26
|
"exports": {
|
|
27
27
|
".": "./cli.js",
|
|
28
|
-
"./
|
|
28
|
+
"./agent-harness": "./agent-harness.js",
|
|
29
|
+
"./froggo": "./agent-harness.js",
|
|
29
30
|
"./aeo": "./analyses/aeo/index.js",
|
|
30
31
|
"./aeo/scorer": "./analyses/aeo/scorer.js",
|
|
31
32
|
"./gap-intel": "./analyses/gap-intel/index.js",
|
|
@@ -44,6 +45,7 @@
|
|
|
44
45
|
},
|
|
45
46
|
"files": [
|
|
46
47
|
"cli.js",
|
|
48
|
+
"agent-harness.js",
|
|
47
49
|
"server.js",
|
|
48
50
|
"scheduler.js",
|
|
49
51
|
"seo-audit.js",
|