seo-intel 1.5.23 → 1.5.25

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 CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.5.25 (2026-05-16)
4
+
5
+ ### New — `seo-intel intel <project>` — canonical agent-facing entry point
6
+ - Returns structured project intelligence as JSON or markdown — the single source of truth that upcoming MCP server, dashboard, and prompt-copy modal will all wrap (one function, four surfaces).
7
+ - Slices:
8
+ - `--for=raw` (**free**) — page/keyword/heading/schema/sitemap inventory per domain. Pipe into your own AI agent for self-service analysis.
9
+ - `--for=audit` (paid) — citability scores + active insights ledger
10
+ - `--for=blog` (paid) — keyword gaps + long tails + drafting hints
11
+ - `--for=competitor` (paid) — competitor summary + keyword matrix + positioning
12
+ - `--format=json` for agents; `--format=md` for humans / agent context windows
13
+ - Paid slices use the existing `requirePro()` gate — free users see a standard upgrade message; paid users get the data.
14
+ - New library: `lib/intel.js` exports `getIntel(db, project, opts)` + `intelToMarkdown(envelope)` for reuse from any surface.
15
+
16
+ ## 1.5.24 (2026-05-16)
17
+
18
+ ### Dashboard — projects with owned subdomains + sitemap data no longer vanish
19
+ - Fixed: clicking **Analyse** (or any dashboard refresh) made projects with crawled sitemaps disappear from the panel list. The render-time "merge owned subdomains into target" pass deleted `domains` rows without first clearing the new `sitemap_urls` FK, hit `FOREIGN KEY constraint failed`, and the project was silently dropped from the rendered HTML.
20
+ - The merge now clears `sitemap_urls` for owned subdomains inside the savepoint (rollback at end of render still restores everything — on-disk data is never mutated).
21
+ - Wrapped the merge in try/catch so the savepoint always releases — future tables that add a `domain_id` FK can't poison subsequent renders.
22
+ - Fixed: `getSchemaBreakdown` crashed on extractions whose `schema_types` JSON contained a nested array (e.g. `[..., ["SoftwareApplication","WebAPI"], ...]`). Now flattens one level and skips non-string entries instead of throwing.
23
+
3
24
  ## 1.5.23 (2026-04-23)
4
25
 
5
26
  ### Technical Audit — extended-data checks
package/cli.js CHANGED
@@ -1415,6 +1415,47 @@ program
1415
1415
  process.exit(0);
1416
1416
  });
1417
1417
 
1418
+ // ── INTEL (canonical agent-facing entry point) ─────────────────────────────
1419
+ // Returns structured intelligence about a project. Same function backs MCP +
1420
+ // future surfaces. Free tier gets `raw`; paid slices are license-gated.
1421
+ program
1422
+ .command('intel <project>')
1423
+ .description('Structured project intelligence for AI agents (raw=free; audit/blog/competitor=paid)')
1424
+ .option('--for <slice>', 'Slice: raw | audit | blog | competitor', 'raw')
1425
+ .option('--format <fmt>', 'Output format: json | md', 'json')
1426
+ .action(async (project, opts) => {
1427
+ const isJson = opts.format === 'json';
1428
+ const { getIntel, intelToMarkdown, FREE_SLICES, INTEL_SLICES } = await import('./lib/intel.js');
1429
+
1430
+ if (!INTEL_SLICES.includes(opts.for)) {
1431
+ const msg = `Unknown slice "${opts.for}". Available: ${INTEL_SLICES.join(', ')}`;
1432
+ if (isJson) { console.log(JSON.stringify({ error: msg })); process.exit(1); }
1433
+ console.error(chalk.red(msg)); process.exit(1);
1434
+ }
1435
+
1436
+ // Free slices skip the gate; paid slices use the standard requirePro pattern.
1437
+ if (!FREE_SLICES.includes(opts.for)) {
1438
+ if (!requirePro(`intel-${opts.for}`)) return;
1439
+ }
1440
+
1441
+ // Validate project exists (loadConfig will throw with a helpful message otherwise)
1442
+ loadConfig(project);
1443
+ const db = getDb();
1444
+
1445
+ try {
1446
+ const envelope = getIntel(db, project, { for: opts.for });
1447
+ if (isJson) {
1448
+ console.log(JSON.stringify(envelope, null, 2));
1449
+ } else {
1450
+ console.log(intelToMarkdown(envelope));
1451
+ }
1452
+ } catch (err) {
1453
+ if (isJson) { console.log(JSON.stringify({ error: err.message })); process.exit(1); }
1454
+ console.error(chalk.red('intel failed: ') + err.message);
1455
+ process.exit(1);
1456
+ }
1457
+ });
1458
+
1418
1459
  // ── STATUS ─────────────────────────────────────────────────────────────────
1419
1460
  program
1420
1461
  .command('status')
package/lib/gate.js CHANGED
@@ -60,6 +60,9 @@ const FEATURE_NAMES = {
60
60
  'unlimited-pages': 'Unlimited Crawl Pages',
61
61
  'unlimited-projects': 'Unlimited Projects',
62
62
  'blog-draft': 'AEO Blog Draft Generator',
63
+ 'intel-audit': 'Intel Audit Digest (AI-agent-ready)',
64
+ 'intel-blog': 'Intel Blog Digest (AI-agent-ready)',
65
+ 'intel-competitor': 'Intel Competitor Digest (AI-agent-ready)',
63
66
  };
64
67
 
65
68
  // ── CLI Gate — blocks command and shows upgrade message ──────────────────────
package/lib/intel.js ADDED
@@ -0,0 +1,177 @@
1
+ /**
2
+ * lib/intel.js — Canonical "give me intelligence about this project" entry point.
3
+ *
4
+ * Single source of truth backing every agent-facing surface:
5
+ * - CLI: `seo-intel intel <project>` (this file's first consumer)
6
+ * - MCP: `seo-intel-mcp` server (v1.5.26 — wraps this same function)
7
+ * - HTTP: dashboard / future REST endpoint
8
+ *
9
+ * Slices:
10
+ * raw (free) — page/keyword/heading inventory, no analysis
11
+ * audit (paid) — citability + technical + active insights
12
+ * blog (paid) — gaps + tone hints for drafting
13
+ * competitor (paid) — competitor summary + schema landscape
14
+ *
15
+ * Output is a stable structured object — agents should be able to chain calls
16
+ * without prompt gymnastics. Keep the schema additive across versions.
17
+ */
18
+
19
+ import { readFileSync } from 'fs';
20
+ import { join, dirname } from 'path';
21
+ import { fileURLToPath } from 'url';
22
+ import { getActiveInsights, getCompetitorSummary, getKeywordMatrix } from '../db/db.js';
23
+ import { getCitabilityScores } from '../analyses/aeo/index.js';
24
+
25
+ const __dirname = dirname(fileURLToPath(import.meta.url));
26
+ const VERSION = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8')).version;
27
+
28
+ export const INTEL_SLICES = ['raw', 'audit', 'blog', 'competitor'];
29
+ export const FREE_SLICES = ['raw'];
30
+
31
+ /**
32
+ * @param {import('node:sqlite').DatabaseSync} db
33
+ * @param {string} project
34
+ * @param {{ for?: string }} [opts]
35
+ * @returns {object} structured intel digest
36
+ */
37
+ export function getIntel(db, project, opts = {}) {
38
+ const slice = opts.for || 'raw';
39
+ if (!INTEL_SLICES.includes(slice)) {
40
+ throw new Error(`Unknown intel slice "${slice}". Available: ${INTEL_SLICES.join(', ')}`);
41
+ }
42
+
43
+ const envelope = {
44
+ project,
45
+ for: slice,
46
+ tier: FREE_SLICES.includes(slice) ? 'free' : 'paid',
47
+ generated_at: new Date().toISOString(),
48
+ seo_intel_version: VERSION,
49
+ data: {},
50
+ };
51
+
52
+ if (slice === 'raw') envelope.data = collectRaw(db, project);
53
+ if (slice === 'audit') envelope.data = collectAudit(db, project);
54
+ if (slice === 'blog') envelope.data = collectBlog(db, project);
55
+ if (slice === 'competitor') envelope.data = collectCompetitor(db, project);
56
+
57
+ return envelope;
58
+ }
59
+
60
+ // ── Slice collectors ────────────────────────────────────────────────────────
61
+
62
+ function collectRaw(db, project) {
63
+ const domains = db.prepare(
64
+ `SELECT d.domain, d.role, d.last_crawled,
65
+ COUNT(p.id) AS pages,
66
+ SUM(CASE WHEN p.status_code = 200 THEN 1 ELSE 0 END) AS pages_ok
67
+ FROM domains d
68
+ LEFT JOIN pages p ON p.domain_id = d.id
69
+ WHERE d.project = ?
70
+ GROUP BY d.id
71
+ ORDER BY d.role, d.domain`
72
+ ).all(project);
73
+
74
+ const totals = db.prepare(
75
+ `SELECT
76
+ (SELECT COUNT(*) FROM pages p JOIN domains d ON d.id=p.domain_id WHERE d.project=?) AS pages,
77
+ (SELECT COUNT(*) FROM keywords k JOIN pages p ON p.id=k.page_id JOIN domains d ON d.id=p.domain_id WHERE d.project=?) AS keywords,
78
+ (SELECT COUNT(*) FROM headings h JOIN pages p ON p.id=h.page_id JOIN domains d ON d.id=p.domain_id WHERE d.project=?) AS headings,
79
+ (SELECT COUNT(*) FROM page_schemas s JOIN pages p ON p.id=s.page_id JOIN domains d ON d.id=p.domain_id WHERE d.project=?) AS schemas,
80
+ (SELECT COUNT(*) FROM sitemap_urls u JOIN domains d ON d.id=u.domain_id WHERE d.project=?) AS sitemap_urls`
81
+ ).get(project, project, project, project, project) || {};
82
+
83
+ const lastCrawl = domains.reduce((m, d) => Math.max(m, d.last_crawled || 0), 0);
84
+
85
+ return {
86
+ domains: domains.map(d => ({
87
+ domain: d.domain,
88
+ role: d.role,
89
+ pages: d.pages,
90
+ pages_ok: d.pages_ok,
91
+ last_crawled: d.last_crawled ? new Date(d.last_crawled).toISOString() : null,
92
+ })),
93
+ totals: {
94
+ pages: totals.pages || 0,
95
+ keywords: totals.keywords || 0,
96
+ headings: totals.headings || 0,
97
+ schemas: totals.schemas || 0,
98
+ sitemap_urls: totals.sitemap_urls || 0,
99
+ },
100
+ last_crawl: lastCrawl ? new Date(lastCrawl).toISOString() : null,
101
+ note: 'Free tier — raw crawl inventory. Pipe into your own AI for analysis, or upgrade to Solo for citability/gap/competitor intel.',
102
+ };
103
+ }
104
+
105
+ function collectAudit(db, project) {
106
+ const insights = getActiveInsights(db, project);
107
+ let citability = null;
108
+ try { citability = getCitabilityScores(db, project); } catch { /* citability_scores table may not exist if AEO never run */ }
109
+ return {
110
+ citability,
111
+ insights: {
112
+ keyword_gaps: insights.keyword_gaps,
113
+ content_gaps: insights.content_gaps,
114
+ technical_gaps: insights.technical_gaps,
115
+ quick_wins: insights.quick_wins,
116
+ site_watch: insights.site_watch,
117
+ },
118
+ last_insight_at: insights.generated_at ? new Date(insights.generated_at).toISOString() : null,
119
+ };
120
+ }
121
+
122
+ function collectBlog(db, project) {
123
+ const insights = getActiveInsights(db, project);
124
+ return {
125
+ keyword_gaps: insights.keyword_gaps,
126
+ long_tails: insights.long_tails,
127
+ content_gaps: insights.content_gaps,
128
+ keyword_inventor: insights.keyword_inventor,
129
+ positioning: insights.positioning,
130
+ drafting_hint: 'Each keyword_gap or long_tail is a candidate draft target. Pair with topic clusters from `seo-intel templates <project>` and citability gaps from `--for=audit` for AEO-aware drafts.',
131
+ };
132
+ }
133
+
134
+ function collectCompetitor(db, project) {
135
+ const summary = getCompetitorSummary(db, project);
136
+ const matrix = getKeywordMatrix(db, project);
137
+ const insights = getActiveInsights(db, project);
138
+ return {
139
+ summary,
140
+ keyword_matrix: matrix,
141
+ positioning: insights.positioning,
142
+ new_pages: insights.new_pages,
143
+ };
144
+ }
145
+
146
+ // ── Markdown formatter ──────────────────────────────────────────────────────
147
+
148
+ export function intelToMarkdown(envelope) {
149
+ const { project, for: slice, tier, generated_at, data } = envelope;
150
+ const lines = [
151
+ `# SEO Intel — ${project}`,
152
+ `> slice: \`${slice}\` · tier: \`${tier}\` · generated: ${generated_at}`,
153
+ '',
154
+ ];
155
+
156
+ if (slice === 'raw') {
157
+ lines.push('## Crawl inventory', '');
158
+ lines.push(`- **Total pages:** ${data.totals.pages}`);
159
+ lines.push(`- **Keywords:** ${data.totals.keywords}`);
160
+ lines.push(`- **Headings:** ${data.totals.headings}`);
161
+ lines.push(`- **Schemas:** ${data.totals.schemas}`);
162
+ lines.push(`- **Sitemap URLs:** ${data.totals.sitemap_urls}`);
163
+ lines.push(`- **Last crawl:** ${data.last_crawl || 'never'}`, '');
164
+ lines.push('## Domains', '');
165
+ lines.push('| Domain | Role | Pages (200) | Last crawled |');
166
+ lines.push('| --- | --- | --- | --- |');
167
+ for (const d of data.domains) {
168
+ lines.push(`| ${d.domain} | ${d.role} | ${d.pages_ok}/${d.pages} | ${d.last_crawled || '—'} |`);
169
+ }
170
+ lines.push('', `> ${data.note}`);
171
+ } else {
172
+ // Generic JSON-in-fence fallback for paid slices — agents can parse either way.
173
+ lines.push(`## ${slice} (data)`, '', '```json', JSON.stringify(data, null, 2), '```');
174
+ }
175
+
176
+ return lines.join('\n');
177
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "seo-intel",
3
- "version": "1.5.23",
3
+ "version": "1.5.25",
4
4
  "description": "Local Ahrefs-style SEO competitor intelligence. Crawl → SQLite → cloud analysis.",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -61,17 +61,28 @@ export function gatherProjectData(db, project, config) {
61
61
  const hasOwned = ownedDomains.length > 0;
62
62
  if (hasOwned) {
63
63
  db.prepare('SAVEPOINT owned_merge').run();
64
- const targetDomainId = db.prepare(
65
- `SELECT id FROM domains WHERE project = ? AND domain = ?`
66
- ).get(project, targetDomain)?.id;
67
- if (targetDomainId) {
68
- for (const ownedDomain of ownedDomains) {
69
- const ownedRow = db.prepare(`SELECT id FROM domains WHERE project = ? AND domain = ?`).get(project, ownedDomain);
70
- if (ownedRow && ownedRow.id !== targetDomainId) {
71
- db.prepare(`UPDATE pages SET domain_id = ? WHERE domain_id = ?`).run(targetDomainId, ownedRow.id);
72
- db.prepare(`DELETE FROM domains WHERE id = ?`).run(ownedRow.id);
64
+ try {
65
+ const targetDomainId = db.prepare(
66
+ `SELECT id FROM domains WHERE project = ? AND domain = ?`
67
+ ).get(project, targetDomain)?.id;
68
+ if (targetDomainId) {
69
+ for (const ownedDomain of ownedDomains) {
70
+ const ownedRow = db.prepare(`SELECT id FROM domains WHERE project = ? AND domain = ?`).get(project, ownedDomain);
71
+ if (ownedRow && ownedRow.id !== targetDomainId) {
72
+ db.prepare(`UPDATE pages SET domain_id = ? WHERE domain_id = ?`).run(targetDomainId, ownedRow.id);
73
+ // Clear FK-bearing rows for the owned subdomain inside the savepoint so
74
+ // DELETE FROM domains doesn't trip the FOREIGN KEY constraint. Rollback
75
+ // at the end of gather restores everything.
76
+ db.prepare(`DELETE FROM sitemap_urls WHERE domain_id = ?`).run(ownedRow.id);
77
+ db.prepare(`DELETE FROM domains WHERE id = ?`).run(ownedRow.id);
78
+ }
73
79
  }
74
80
  }
81
+ } catch (e) {
82
+ // Always release the savepoint so a partial merge can't poison the next render.
83
+ try { db.prepare('ROLLBACK TO owned_merge').run(); } catch {}
84
+ try { db.prepare('RELEASE owned_merge').run(); } catch {}
85
+ throw e;
75
86
  }
76
87
  }
77
88
 
@@ -6695,8 +6706,12 @@ function getSchemaBreakdown(db, project) {
6695
6706
  for (const r of rows) {
6696
6707
  let types = [];
6697
6708
  try { types = JSON.parse(r.schema_types); } catch {}
6709
+ if (!Array.isArray(types)) continue;
6698
6710
  if (!schemas[r.domain]) schemas[r.domain] = { role: r.role, types: new Set() };
6699
- for (const t of types) {
6711
+ // Flatten one level extractor occasionally produces nested arrays like
6712
+ // [..., ["SoftwareApplication","WebAPI"], ...]. Skip anything that isn't a string.
6713
+ for (const t of types.flat()) {
6714
+ if (typeof t !== 'string') continue;
6700
6715
  const key = t.trim();
6701
6716
  if (key.length < 2) continue;
6702
6717
  schemas[r.domain].types.add(key);