seo-intel 1.4.0 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/cli.js +266 -111
  3. package/package.json +17 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.4.1 (2026-04-03)
4
+
5
+ ### Fixes
6
+ - **CLI JSON output** — all 11 commands now produce clean JSON with zero chalk/ANSI leakage
7
+ - **Brief `--format json`** — full rich data (keyword gaps, schema gaps, actions) instead of lean subset
8
+ - **Templates `--format json`** — suppressed chalk header and log output in JSON mode
9
+ - **JS-Delta `--format json`** — suppressed per-page progress chalk in JSON mode
10
+
11
+ ### Agent Integration
12
+ - Model selection hints (`modelHint`, `modelNote`) on extract, gap-intel, blog-draft capabilities
13
+ - AGENT_GUIDE.md — added Model Selection Guidance table (light-local vs cloud-medium per phase)
14
+ - GitHub Releases now auto-created on tag push via CI
15
+
3
16
  ## 1.4.0 (2026-04-03)
4
17
 
5
18
  ### New Feature: Gap Intelligence
@@ -17,6 +30,19 @@
17
30
  - Qwen models remain fully supported as alternatives
18
31
  - Setup wizard, model recommendations, and VRAM tiers updated for Gemma 4
19
32
 
33
+ ### Agent-Ready JSON Output
34
+ - All 11 analysis commands support `--format json` for clean, parseable output
35
+ - JSON output is chalk-free — no ANSI escape codes mixed into structured data
36
+ - Commands: shallow, decay, headings-audit, orphans, entities, schemas, friction, brief, velocity, templates, js-delta
37
+
38
+ ### Programmatic API (`seo-intel/froggo`)
39
+ - Unified agent runner: `run(command, project, opts)` returns `{ ok, command, project, timestamp, data }`
40
+ - 18 capabilities with machine-readable manifest (inputs, outputs, dependencies, tier)
41
+ - Pipeline dependency graph for orchestration
42
+ - Model selection hints per capability (light-local vs cloud-medium)
43
+ - Deep imports: `seo-intel/aeo`, `seo-intel/crawler`, `seo-intel/db`, etc.
44
+ - Agent Guide (`AGENT_GUIDE.md`) with orchestration patterns and model guidance
45
+
20
46
  ### Server
21
47
  - Added `gap-intel` to terminal command whitelist
22
48
  - Forward `--vs`, `--type`, `--limit`, `--raw`, `--out` params from dashboard to CLI
package/cli.js CHANGED
@@ -2000,11 +2000,15 @@ program
2000
2000
  .option('--skip-crawl', 'Skip sample crawl (pattern analysis + GSC only)')
2001
2001
  .option('--skip-gsc', 'Skip GSC overlay phase')
2002
2002
  .option('--skip-competitors', 'Skip competitor sitemap census')
2003
+ .option('--format <type>', 'Output format: json or brief', 'brief')
2003
2004
  .action(async (project, opts) => {
2004
2005
  if (!requirePro('templates')) return;
2005
2006
 
2006
- console.log(chalk.bold.cyan(`\n🔍 SEO Intel Template Analysis`));
2007
- console.log(chalk.dim(` Project: ${project}`));
2007
+ const isJson = opts.format === 'json';
2008
+ if (!isJson) {
2009
+ console.log(chalk.bold.cyan(`\n🔍 SEO Intel — Template Analysis`));
2010
+ console.log(chalk.dim(` Project: ${project}`));
2011
+ }
2008
2012
 
2009
2013
  try {
2010
2014
  const { runTemplatesAnalysis } = await import('./analyses/templates/index.js');
@@ -2014,9 +2018,14 @@ program
2014
2018
  skipCrawl: !!opts.skipCrawl,
2015
2019
  skipGsc: !!opts.skipGsc,
2016
2020
  skipCompetitors: !!opts.skipCompetitors,
2017
- log: (msg) => console.log(chalk.gray(msg)),
2021
+ log: isJson ? () => {} : (msg) => console.log(chalk.gray(msg)),
2018
2022
  });
2019
2023
 
2024
+ if (isJson) {
2025
+ console.log(JSON.stringify({ command: 'templates', project, timestamp: new Date().toISOString(), data: report }));
2026
+ return;
2027
+ }
2028
+
2020
2029
  if (report.groups.length === 0) {
2021
2030
  console.log(chalk.yellow(`\n No template patterns detected.\n`));
2022
2031
  process.exit(0);
@@ -2183,14 +2192,13 @@ program
2183
2192
  .description('Find competitor pages that are important but thin (Shallow Champion attack)')
2184
2193
  .option('--max-words <n>', 'Max word count threshold', '700')
2185
2194
  .option('--max-depth <n>', 'Max click depth', '2')
2195
+ .option('--format <type>', 'Output format: json or brief', 'brief')
2186
2196
  .action((project, opts) => {
2187
2197
  if (!requirePro('shallow')) return;
2188
2198
  const db = getDb();
2189
2199
  const maxWords = parseInt(opts.maxWords);
2190
2200
  const maxDepth = parseInt(opts.maxDepth);
2191
2201
 
2192
- printAttackHeader('⚡ Shallow Champion Attack', project);
2193
-
2194
2202
  const rows = db.prepare(`
2195
2203
  SELECT p.url, p.click_depth, p.word_count, d.domain
2196
2204
  FROM pages p
@@ -2201,6 +2209,19 @@ program
2201
2209
  ORDER BY p.click_depth ASC, p.word_count ASC
2202
2210
  `).all(project, maxDepth, maxWords).filter(r => isContentPage(r.url));
2203
2211
 
2212
+ const byDomain = {};
2213
+ for (const r of rows) {
2214
+ if (!byDomain[r.domain]) byDomain[r.domain] = [];
2215
+ byDomain[r.domain].push(r);
2216
+ }
2217
+
2218
+ if (opts.format === 'json') {
2219
+ console.log(JSON.stringify({ command: 'shallow', project, timestamp: new Date().toISOString(), data: { targets: rows.map(r => ({ url: r.url, domain: r.domain, wordCount: r.word_count, clickDepth: r.click_depth })), byDomain: Object.fromEntries(Object.entries(byDomain).map(([k,v]) => [k, v.map(r => ({ url: r.url, wordCount: r.word_count, clickDepth: r.click_depth }))])), totalTargets: rows.length } }));
2220
+ return;
2221
+ }
2222
+
2223
+ printAttackHeader('⚡ Shallow Champion Attack', project);
2224
+
2204
2225
  if (!rows.length) {
2205
2226
  console.log(chalk.yellow('No shallow champions found with current thresholds.'));
2206
2227
  return;
@@ -2208,12 +2229,6 @@ program
2208
2229
 
2209
2230
  console.log(chalk.gray(`Found ${rows.length} shallow champion targets (depth ≤${maxDepth}, words ≤${maxWords}):\n`));
2210
2231
 
2211
- const byDomain = {};
2212
- for (const r of rows) {
2213
- if (!byDomain[r.domain]) byDomain[r.domain] = [];
2214
- byDomain[r.domain].push(r);
2215
- }
2216
-
2217
2232
  for (const [domain, pages] of Object.entries(byDomain)) {
2218
2233
  console.log(chalk.bold.yellow(` ${domain}`));
2219
2234
  for (const p of pages) {
@@ -2234,6 +2249,7 @@ program
2234
2249
  .command('decay <project>')
2235
2250
  .description('Find competitor pages decaying due to staleness (Content Decay Arbitrage)')
2236
2251
  .option('--months <n>', 'Months since last update to flag as stale', '18')
2252
+ .option('--format <type>', 'Output format: json or brief', 'brief')
2237
2253
  .action((project, opts) => {
2238
2254
  if (!requirePro('decay')) return;
2239
2255
  const db = getDb();
@@ -2242,8 +2258,6 @@ program
2242
2258
  cutoffDate.setMonth(cutoffDate.getMonth() - monthsAgo);
2243
2259
  const cutoff = cutoffDate.toISOString().split('T')[0];
2244
2260
 
2245
- printAttackHeader('📉 Content Decay Arbitrage', project);
2246
-
2247
2261
  // Pages with known stale modified_date
2248
2262
  const staleKnown = db.prepare(`
2249
2263
  SELECT p.url, p.click_depth, p.word_count, p.modified_date, p.published_date, d.domain
@@ -2269,6 +2283,13 @@ program
2269
2283
  LIMIT 20
2270
2284
  `).all(project).filter(r => isContentPage(r.url));
2271
2285
 
2286
+ if (opts.format === 'json') {
2287
+ console.log(JSON.stringify({ command: 'decay', project, timestamp: new Date().toISOString(), data: { confirmedStale: staleKnown.map(r => ({ url: r.url, domain: r.domain, wordCount: r.word_count, modifiedDate: r.modified_date, clickDepth: r.click_depth })), unknownFreshness: staleUnknown.map(r => ({ url: r.url, domain: r.domain, wordCount: r.word_count, clickDepth: r.click_depth })), monthsThreshold: monthsAgo } }));
2288
+ return;
2289
+ }
2290
+
2291
+ printAttackHeader('📉 Content Decay Arbitrage', project);
2292
+
2272
2293
  if (!staleKnown.length && !staleUnknown.length) {
2273
2294
  console.log(chalk.yellow('No decay targets found. More crawl data or date metadata needed.'));
2274
2295
  return;
@@ -2301,13 +2322,12 @@ program
2301
2322
  .description('Pull competitor heading structures for AI gap analysis')
2302
2323
  .option('--domain <domain>', 'Audit a specific competitor domain')
2303
2324
  .option('--depth <n>', 'Max click depth to include', '2')
2325
+ .option('--format <type>', 'Output format: json or brief', 'brief')
2304
2326
  .action(async (project, opts) => {
2305
2327
  if (!requirePro('headings-audit')) return;
2306
2328
  const db = getDb();
2307
2329
  const maxDepth = parseInt(opts.depth);
2308
2330
 
2309
- printAttackHeader('🏗️ Heading Architecture Audit', project);
2310
-
2311
2331
  const domainFilter = opts.domain ? 'AND d.domain = ?' : '';
2312
2332
  const params = opts.domain ? [project, maxDepth, opts.domain] : [project, maxDepth];
2313
2333
 
@@ -2321,6 +2341,25 @@ program
2321
2341
  ORDER BY d.domain, p.click_depth ASC, p.word_count DESC
2322
2342
  `).all(...params).filter(r => isContentPage(r.url));
2323
2343
 
2344
+ const jsonResults = [];
2345
+ for (const page of pages.slice(0, 30)) {
2346
+ const headings = db.prepare(`
2347
+ SELECT level, text FROM headings WHERE page_id = ?
2348
+ ORDER BY rowid ASC
2349
+ `).all(page.id);
2350
+
2351
+ if (!headings.length) continue;
2352
+
2353
+ jsonResults.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 })) });
2354
+ }
2355
+
2356
+ if (opts.format === 'json') {
2357
+ console.log(JSON.stringify({ command: 'headings-audit', project, timestamp: new Date().toISOString(), data: { pages: jsonResults, totalPages: jsonResults.length } }));
2358
+ return;
2359
+ }
2360
+
2361
+ printAttackHeader('🏗️ Heading Architecture Audit', project);
2362
+
2324
2363
  if (!pages.length) {
2325
2364
  console.log(chalk.yellow('No pages found matching criteria.'));
2326
2365
  return;
@@ -2363,12 +2402,11 @@ program
2363
2402
  program
2364
2403
  .command('orphans <project>')
2365
2404
  .description('Find orphaned entities — mentioned everywhere but no dedicated page (needs Qwen extraction)')
2366
- .action((project) => {
2405
+ .option('--format <type>', 'Output format: json or brief', 'brief')
2406
+ .action((project, opts) => {
2367
2407
  if (!requirePro('orphans')) return;
2368
2408
  const db = getDb();
2369
2409
 
2370
- printAttackHeader('👻 Orphan Entity Attack', project);
2371
-
2372
2410
  // Check if we have any extraction data with primary_entities
2373
2411
  const extractionCount = db.prepare(`
2374
2412
  SELECT COUNT(*) as c FROM extractions e
@@ -2377,14 +2415,8 @@ program
2377
2415
  WHERE d.project = ? AND e.primary_entities IS NOT NULL AND e.primary_entities != '[]' AND e.primary_entities != ''
2378
2416
  `).get(project);
2379
2417
 
2380
- if (!extractionCount || extractionCount.c === 0) {
2381
- console.log(chalk.yellow('⚠️ No entity extraction data found.'));
2382
- console.log(chalk.gray(' Run: node cli.js extract ' + project + ' (requires Ollama + Qwen)\n'));
2383
- return;
2384
- }
2385
-
2386
2418
  // Get all entities from competitor pages
2387
- const extractions = db.prepare(`
2419
+ const extractions = (!extractionCount || extractionCount.c === 0) ? [] : db.prepare(`
2388
2420
  SELECT e.primary_entities, p.url, d.domain
2389
2421
  FROM extractions e
2390
2422
  JOIN pages p ON p.id = e.page_id
@@ -2406,7 +2438,7 @@ program
2406
2438
  }
2407
2439
 
2408
2440
  // Get all competitor URLs to check for dedicated pages
2409
- const allUrls = db.prepare(`
2441
+ const allUrls = (!extractionCount || extractionCount.c === 0) ? [] : db.prepare(`
2410
2442
  SELECT p.url FROM pages p
2411
2443
  JOIN domains d ON d.id = p.domain_id
2412
2444
  WHERE d.project = ? AND d.role = 'competitor'
@@ -2425,6 +2457,19 @@ program
2425
2457
 
2426
2458
  orphans.sort((a, b) => b.domainCount - a.domainCount);
2427
2459
 
2460
+ if (opts.format === 'json') {
2461
+ console.log(JSON.stringify({ command: 'orphans', project, timestamp: new Date().toISOString(), data: { orphans: orphans.map(o => ({ entity: o.entity, domains: o.domains, domainCount: o.domainCount, suggestedUrl: '/solutions/' + o.entity.replace(/\s+/g, '-').toLowerCase() })), totalOrphans: orphans.length } }));
2462
+ return;
2463
+ }
2464
+
2465
+ printAttackHeader('👻 Orphan Entity Attack', project);
2466
+
2467
+ if (!extractionCount || extractionCount.c === 0) {
2468
+ console.log(chalk.yellow('⚠️ No entity extraction data found.'));
2469
+ console.log(chalk.gray(' Run: node cli.js extract ' + project + ' (requires Ollama + Qwen)\n'));
2470
+ return;
2471
+ }
2472
+
2428
2473
  if (!orphans.length) {
2429
2474
  if (entityMap.size === 0) {
2430
2475
  console.log(chalk.yellow('⚠️ Entity extraction data exists but no entities were extracted.'));
@@ -2452,14 +2497,13 @@ program
2452
2497
  .description('Entity coverage map — semantic gap at the entity level (concepts competitors mention, you don\'t)')
2453
2498
  .option('--min-mentions <n>', 'Minimum competitor mentions to show', '2')
2454
2499
  .option('--save', 'Save entity map to reports/')
2500
+ .option('--format <type>', 'Output format: json or brief', 'brief')
2455
2501
  .action((project, opts) => {
2456
2502
  if (!requirePro('entities')) return;
2457
2503
  const db = getDb();
2458
2504
  const config = loadConfig(project);
2459
2505
  const minMentions = parseInt(opts.minMentions) || 2;
2460
2506
 
2461
- printAttackHeader('🧬 Entity Coverage Map', project);
2462
-
2463
2507
  // ── Gather all entities from all domains ──
2464
2508
  const allExtractions = db.prepare(`
2465
2509
  SELECT e.primary_entities, d.domain, d.role, p.url
@@ -2470,12 +2514,6 @@ program
2470
2514
  AND e.primary_entities IS NOT NULL AND e.primary_entities != '[]' AND e.primary_entities != ''
2471
2515
  `).all(project);
2472
2516
 
2473
- if (!allExtractions.length) {
2474
- console.log(chalk.yellow('⚠️ No entity extraction data found.'));
2475
- console.log(chalk.gray(' Run: node cli.js extract ' + project + ' (requires Ollama + Qwen)\n'));
2476
- return;
2477
- }
2478
-
2479
2517
  // Build entity → { targetMentions, competitorMentions, domains, pages }
2480
2518
  const entityMap = new Map();
2481
2519
 
@@ -2519,6 +2557,19 @@ program
2519
2557
  gaps.sort((a, b) => b.compCount - a.compCount);
2520
2558
  shared.sort((a, b) => b.compCount - a.compCount);
2521
2559
 
2560
+ if (opts.format === 'json') {
2561
+ console.log(JSON.stringify({ command: 'entities', project, timestamp: new Date().toISOString(), data: { gaps: gaps.map(g => ({ entity: g.entity, competitorCount: g.compCount, domains: g.domains })), shared: shared.map(s => ({ entity: s.entity, competitorCount: s.compCount, targetDomains: s.targetDomains, competitorDomains: s.compDomains })), unique: yourOnly.map(y => ({ entity: y.entity, targetDomains: y.targetDomains })), summary: { totalEntities: entityMap.size, gapCount: gaps.length, sharedCount: shared.length, uniqueCount: yourOnly.length } } }));
2562
+ return;
2563
+ }
2564
+
2565
+ printAttackHeader('🧬 Entity Coverage Map', project);
2566
+
2567
+ if (!allExtractions.length) {
2568
+ console.log(chalk.yellow('⚠️ No entity extraction data found.'));
2569
+ console.log(chalk.gray(' Run: node cli.js extract ' + project + ' (requires Ollama + Qwen)\n'));
2570
+ return;
2571
+ }
2572
+
2522
2573
  let mdOutput = `# Entity Coverage Map — ${config.target.domain}\nGenerated: ${new Date().toISOString().slice(0, 10)}\n\n`;
2523
2574
 
2524
2575
  // ── Coverage summary ──
@@ -2613,19 +2664,12 @@ program
2613
2664
  .command('schemas <project>')
2614
2665
  .description('Deep structured data competitive analysis — ratings, pricing, rich results gaps')
2615
2666
  .option('--save', 'Save report to reports/')
2667
+ .option('--format <type>', 'Output format: json or brief', 'brief')
2616
2668
  .action((project, opts) => {
2617
2669
  const db = getDb();
2618
2670
 
2619
- printAttackHeader('🔬 Schema Intelligence Report', project);
2620
-
2621
2671
  const rows = getSchemasByProject(db, project);
2622
2672
 
2623
- if (rows.length === 0) {
2624
- console.log(chalk.yellow(' No structured data found. Run a crawl first — schemas are parsed from JSON-LD during crawl.'));
2625
- console.log(chalk.dim(' Tip: node cli.js crawl ' + project + '\n'));
2626
- return;
2627
- }
2628
-
2629
2673
  // Load config to identify target domain
2630
2674
  const configPath = `./config/${project}.json`;
2631
2675
  let targetDomain = null;
@@ -2641,10 +2685,6 @@ program
2641
2685
  byDomain.get(row.domain).push(row);
2642
2686
  }
2643
2687
 
2644
- // ── Schema type coverage matrix ──
2645
- console.log(chalk.bold('\n SCHEMA TYPE COVERAGE'));
2646
- console.log(chalk.dim(' Which structured data types each domain uses\n'));
2647
-
2648
2688
  const allTypes = [...new Set(rows.map(r => r.schema_type))].sort();
2649
2689
  const domainList = [...byDomain.keys()].sort((a, b) => {
2650
2690
  if (a === targetDomain) return -1;
@@ -2652,6 +2692,57 @@ program
2652
2692
  return a.localeCompare(b);
2653
2693
  });
2654
2694
 
2695
+ const withRatings = rows.filter(r => r.rating !== null);
2696
+ const withPricing = rows.filter(r => r.price !== null);
2697
+
2698
+ // ── Gap analysis — what competitors have that you don't ──
2699
+ const targetTypes = new Set((byDomain.get(targetDomain) || []).map(s => s.schema_type));
2700
+ const compTypes = new Set(rows.filter(r => r.domain !== targetDomain).map(r => r.schema_type));
2701
+ const schemaGaps = [...compTypes].filter(t => !targetTypes.has(t));
2702
+ const yourExclusives = [...targetTypes].filter(t => !compTypes.has(t));
2703
+
2704
+ // ── Actionable recommendations ──
2705
+ const actions = [];
2706
+ if (schemaGaps.length > 0) {
2707
+ const highValue = schemaGaps.filter(t => ['Product', 'SoftwareApplication', 'FAQPage', 'HowTo', 'Review', 'AggregateRating'].includes(t));
2708
+ if (highValue.length > 0) {
2709
+ actions.push(`Add high-value schema types: ${highValue.join(', ')}`);
2710
+ }
2711
+ const remaining = schemaGaps.filter(t => !highValue.includes(t));
2712
+ if (remaining.length > 0) {
2713
+ actions.push(`Consider adding: ${remaining.join(', ')}`);
2714
+ }
2715
+ }
2716
+ if (withRatings.length > 0 && !rows.some(r => r.domain === targetDomain && r.rating !== null)) {
2717
+ actions.push('Add aggregateRating schema for star-rich snippets (highest SERP CTR impact)');
2718
+ }
2719
+ if (withPricing.length > 0 && !rows.some(r => r.domain === targetDomain && r.price !== null)) {
2720
+ actions.push('Add pricing schema (Product/Offer) for price-rich results');
2721
+ }
2722
+ if (!targetTypes.has('FAQPage') && compTypes.has('FAQPage')) {
2723
+ actions.push('Add FAQPage schema — expands your SERP real estate with accordion snippets');
2724
+ }
2725
+ if (!targetTypes.has('BreadcrumbList') && compTypes.has('BreadcrumbList')) {
2726
+ actions.push('Add BreadcrumbList schema — improves SERP display and navigation signals');
2727
+ }
2728
+
2729
+ if (opts.format === 'json') {
2730
+ console.log(JSON.stringify({ command: 'schemas', project, timestamp: new Date().toISOString(), data: { 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, ratingCount: s.rating_count, price: s.price, currency: s.currency }))])), gaps: schemaGaps, exclusives: yourExclusives, ratings: withRatings.map(r => ({ domain: r.domain, url: r.url, name: r.name, rating: r.rating, ratingCount: r.rating_count })), pricing: withPricing.map(r => ({ domain: r.domain, url: r.url, name: r.name, price: r.price, currency: r.currency })), actions, summary: { totalSchemas: rows.length, uniqueTypes: allTypes.length, domainsWithSchemas: byDomain.size, gapCount: schemaGaps.length } } }));
2731
+ return;
2732
+ }
2733
+
2734
+ printAttackHeader('🔬 Schema Intelligence Report', project);
2735
+
2736
+ if (rows.length === 0) {
2737
+ console.log(chalk.yellow(' No structured data found. Run a crawl first — schemas are parsed from JSON-LD during crawl.'));
2738
+ console.log(chalk.dim(' Tip: node cli.js crawl ' + project + '\n'));
2739
+ return;
2740
+ }
2741
+
2742
+ // ── Schema type coverage matrix ──
2743
+ console.log(chalk.bold('\n SCHEMA TYPE COVERAGE'));
2744
+ console.log(chalk.dim(' Which structured data types each domain uses\n'));
2745
+
2655
2746
  // Header
2656
2747
  const typeColWidth = 22;
2657
2748
  const domColWidth = 12;
@@ -2680,7 +2771,6 @@ program
2680
2771
  }
2681
2772
 
2682
2773
  // ── Rating intel — who has review stars? ──
2683
- const withRatings = rows.filter(r => r.rating !== null);
2684
2774
  if (withRatings.length > 0) {
2685
2775
  console.log(chalk.bold('\n\n RATING INTELLIGENCE'));
2686
2776
  console.log(chalk.dim(' Competitors with aggregateRating — rich star snippets in SERPs\n'));
@@ -2706,7 +2796,6 @@ program
2706
2796
  }
2707
2797
 
2708
2798
  // ── Pricing intel ──
2709
- const withPricing = rows.filter(r => r.price !== null);
2710
2799
  if (withPricing.length > 0) {
2711
2800
  console.log(chalk.bold('\n\n PRICING SCHEMA'));
2712
2801
  console.log(chalk.dim(' Structured pricing data (enables price rich results)\n'));
@@ -2726,12 +2815,6 @@ program
2726
2815
  }
2727
2816
  }
2728
2817
 
2729
- // ── Gap analysis — what competitors have that you don't ──
2730
- const targetTypes = new Set((byDomain.get(targetDomain) || []).map(s => s.schema_type));
2731
- const compTypes = new Set(rows.filter(r => r.domain !== targetDomain).map(r => r.schema_type));
2732
- const schemaGaps = [...compTypes].filter(t => !targetTypes.has(t));
2733
- const yourExclusives = [...targetTypes].filter(t => !compTypes.has(t));
2734
-
2735
2818
  if (schemaGaps.length > 0 || yourExclusives.length > 0) {
2736
2819
  console.log(chalk.bold('\n\n COMPETITIVE GAPS'));
2737
2820
 
@@ -2752,33 +2835,8 @@ program
2752
2835
  }
2753
2836
  }
2754
2837
 
2755
- // ── Actionable recommendations ──
2756
2838
  console.log(chalk.bold('\n\n ACTIONS'));
2757
2839
 
2758
- const actions = [];
2759
- if (schemaGaps.length > 0) {
2760
- const highValue = schemaGaps.filter(t => ['Product', 'SoftwareApplication', 'FAQPage', 'HowTo', 'Review', 'AggregateRating'].includes(t));
2761
- if (highValue.length > 0) {
2762
- actions.push(`Add high-value schema types: ${highValue.join(', ')}`);
2763
- }
2764
- const remaining = schemaGaps.filter(t => !highValue.includes(t));
2765
- if (remaining.length > 0) {
2766
- actions.push(`Consider adding: ${remaining.join(', ')}`);
2767
- }
2768
- }
2769
- if (withRatings.length > 0 && !rows.some(r => r.domain === targetDomain && r.rating !== null)) {
2770
- actions.push('Add aggregateRating schema for star-rich snippets (highest SERP CTR impact)');
2771
- }
2772
- if (withPricing.length > 0 && !rows.some(r => r.domain === targetDomain && r.price !== null)) {
2773
- actions.push('Add pricing schema (Product/Offer) for price-rich results');
2774
- }
2775
- if (!targetTypes.has('FAQPage') && compTypes.has('FAQPage')) {
2776
- actions.push('Add FAQPage schema — expands your SERP real estate with accordion snippets');
2777
- }
2778
- if (!targetTypes.has('BreadcrumbList') && compTypes.has('BreadcrumbList')) {
2779
- actions.push('Add BreadcrumbList schema — improves SERP display and navigation signals');
2780
- }
2781
-
2782
2840
  if (actions.length > 0) {
2783
2841
  for (let i = 0; i < actions.length; i++) {
2784
2842
  console.log(` ${chalk.cyan(`${i + 1}.`)} ${actions[i]}`);
@@ -2917,12 +2975,11 @@ program
2917
2975
  program
2918
2976
  .command('friction <project>')
2919
2977
  .description('Find competitor pages with intent/CTA mismatch — high friction targets (needs Qwen extraction)')
2920
- .action((project) => {
2978
+ .option('--format <type>', 'Output format: json or brief', 'brief')
2979
+ .action((project, opts) => {
2921
2980
  if (!requirePro('friction')) return;
2922
2981
  const db = getDb();
2923
2982
 
2924
- printAttackHeader('🎯 Intent & Friction Hijacking', project);
2925
-
2926
2983
  const rows = db.prepare(`
2927
2984
  SELECT e.search_intent, e.cta_primary, e.pricing_tier, p.url, p.word_count, d.domain
2928
2985
  FROM extractions e
@@ -2934,12 +2991,6 @@ program
2934
2991
  ORDER BY d.domain, p.click_depth ASC
2935
2992
  `).all(project).filter(r => isContentPage(r.url));
2936
2993
 
2937
- if (!rows.length) {
2938
- console.log(chalk.yellow('⚠️ No intent/CTA extraction data found.'));
2939
- console.log(chalk.gray(' Run: node cli.js extract ' + project + ' (requires Ollama + Qwen)\n'));
2940
- return;
2941
- }
2942
-
2943
2994
  // High friction patterns
2944
2995
  const highFrictionCTAs = ['enterprise', 'sales', 'contact', 'book a demo', 'request', 'talk to'];
2945
2996
  const targets = rows.filter(r => {
@@ -2950,6 +3001,19 @@ program
2950
3001
  return isHighFriction && isInfoOrCommercial;
2951
3002
  });
2952
3003
 
3004
+ if (opts.format === 'json') {
3005
+ console.log(JSON.stringify({ command: 'friction', project, timestamp: new Date().toISOString(), data: { 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 })), totalAnalyzed: rows.length, totalHighFriction: targets.length } }));
3006
+ return;
3007
+ }
3008
+
3009
+ printAttackHeader('🎯 Intent & Friction Hijacking', project);
3010
+
3011
+ if (!rows.length) {
3012
+ console.log(chalk.yellow('⚠️ No intent/CTA extraction data found.'));
3013
+ console.log(chalk.gray(' Run: node cli.js extract ' + project + ' (requires Ollama + Qwen)\n'));
3014
+ return;
3015
+ }
3016
+
2953
3017
  if (!targets.length) {
2954
3018
  console.log(chalk.green('No high-friction mismatches found in current extraction data.'));
2955
3019
  console.log(chalk.gray(` (${rows.length} pages analyzed)\n`));
@@ -2974,6 +3038,7 @@ program
2974
3038
  .description('Weekly SEO Intel Brief — what changed, new gaps, wins, actions')
2975
3039
  .option('--days <n>', 'Lookback window in days', '7')
2976
3040
  .option('--save', 'Save brief to reports/')
3041
+ .option('--format <type>', 'Output format: json or brief', 'brief')
2977
3042
  .action((project, opts) => {
2978
3043
  if (!requirePro('brief')) return;
2979
3044
  const db = getDb();
@@ -2983,6 +3048,75 @@ program
2983
3048
  const cutoffISO = new Date(cutoff).toISOString().slice(0, 10);
2984
3049
  const weekOf = new Date().toISOString().slice(0, 10);
2985
3050
 
3051
+ // ── JSON fast path: compute all data, no chalk ──
3052
+ if (opts.format === 'json') {
3053
+ const compDomains = config.competitors.map(c => c.domain);
3054
+ const targetDomainJ = config.target.domain;
3055
+ const competitorMoves = [];
3056
+
3057
+ // Competitor moves
3058
+ const targetKeywordsJ = new Set(
3059
+ db.prepare(`SELECT DISTINCT LOWER(k.keyword) as kw FROM keywords k JOIN pages p ON p.id = k.page_id JOIN domains d ON d.id = p.domain_id WHERE d.project = ? AND (d.role = 'target' OR d.role = 'owned')`).all(project).map(r => r.kw)
3060
+ );
3061
+ const gapKeywordsJ = new Map();
3062
+ const targetSchemaJ = new Set();
3063
+ try {
3064
+ const ts = db.prepare(`SELECT DISTINCT e.schema_types FROM extractions e JOIN pages p ON p.id = e.page_id JOIN domains d ON d.id = p.domain_id WHERE d.project = ? AND (d.role = 'target' OR d.role = 'owned') AND e.schema_types IS NOT NULL AND e.schema_types != '[]'`).all(project);
3065
+ for (const row of ts) { try { for (const t of JSON.parse(row.schema_types)) targetSchemaJ.add(t); } catch {} }
3066
+ } catch {}
3067
+ const compSchemaJ = new Map();
3068
+
3069
+ for (const comp of compDomains) {
3070
+ const newPages = db.prepare(`SELECT p.url, p.word_count FROM pages p JOIN domains d ON d.id = p.domain_id WHERE d.domain = ? AND d.project = ? AND p.first_seen_at > ? AND p.is_indexable = 1`).all(comp, project, cutoff).filter(r => isContentPage(r.url));
3071
+ const changedPages = db.prepare(`SELECT p.url, p.word_count FROM pages p JOIN domains d ON d.id = p.domain_id WHERE d.domain = ? AND d.project = ? AND p.crawled_at > ? AND p.first_seen_at < ? AND p.is_indexable = 1`).all(comp, project, cutoff, cutoff).filter(r => isContentPage(r.url));
3072
+ competitorMoves.push({ domain: comp, newPages: newPages.map(p => ({ url: p.url, wordCount: p.word_count })), changedPages: changedPages.map(p => ({ url: p.url, wordCount: p.word_count })) });
3073
+
3074
+ // Keyword gaps from new pages
3075
+ for (const np of newPages.slice(0, 10)) {
3076
+ const pageRow = db.prepare('SELECT id FROM pages WHERE url = ?').get(np.url);
3077
+ if (!pageRow) continue;
3078
+ const kws = db.prepare('SELECT keyword FROM keywords WHERE page_id = ?').all(pageRow.id);
3079
+ for (const kw of kws) {
3080
+ const key = kw.keyword.toLowerCase().trim();
3081
+ if (key.length < 3 || targetKeywordsJ.has(key)) continue;
3082
+ if (!gapKeywordsJ.has(key)) gapKeywordsJ.set(key, new Set());
3083
+ gapKeywordsJ.get(key).add(comp);
3084
+ }
3085
+ }
3086
+
3087
+ // Schema gaps from new pages
3088
+ for (const np of newPages.slice(0, 10)) {
3089
+ const pageRow = db.prepare('SELECT id FROM pages WHERE url = ?').get(np.url);
3090
+ if (!pageRow) continue;
3091
+ const ext = db.prepare('SELECT schema_types FROM extractions WHERE page_id = ?').get(pageRow.id);
3092
+ if (!ext?.schema_types) continue;
3093
+ try {
3094
+ for (const st of JSON.parse(ext.schema_types)) {
3095
+ if (!targetSchemaJ.has(st)) {
3096
+ if (!compSchemaJ.has(st)) compSchemaJ.set(st, new Set());
3097
+ compSchemaJ.get(st).add(comp);
3098
+ }
3099
+ }
3100
+ } catch {}
3101
+ }
3102
+ }
3103
+
3104
+ const sortedGapsJ = [...gapKeywordsJ.entries()]
3105
+ .map(([kw, domains]) => ({ keyword: kw, domains: [...domains], count: domains.size }))
3106
+ .sort((a, b) => b.count - a.count).slice(0, 10);
3107
+
3108
+ const actionsJ = [];
3109
+ if (sortedGapsJ.length > 0) actionsJ.push(`Write content covering "${sortedGapsJ[0].keyword}" — ${sortedGapsJ[0].count} competitor(s) rank for it`);
3110
+ if (compSchemaJ.size > 0) { const [schema, doms] = [...compSchemaJ.entries()][0]; actionsJ.push(`Add ${schema} schema markup to relevant pages (${[...doms][0]} already has it)`); }
3111
+ const targetNewJ = db.prepare(`SELECT COUNT(*) as c FROM pages p JOIN domains d ON d.id = p.domain_id WHERE d.domain = ? AND d.project = ? AND p.first_seen_at > ?`).get(targetDomainJ, project, cutoff)?.c || 0;
3112
+ const compVelocitiesJ = competitorMoves.map(m => ({ domain: m.domain, rate: m.newPages.length })).sort((a, b) => b.rate - a.rate);
3113
+ if (compVelocitiesJ.length > 0 && compVelocitiesJ[0].rate > targetNewJ) actionsJ.push(`Increase publishing rate — ${compVelocitiesJ[0].domain} published ${compVelocitiesJ[0].rate} pages vs your ${targetNewJ}`);
3114
+ if (actionsJ.length === 0) { actionsJ.push('Re-crawl competitors to detect new content'); actionsJ.push('Review dashboard for technical SEO fixes'); }
3115
+
3116
+ console.log(JSON.stringify({ command: 'brief', project, timestamp: new Date().toISOString(), data: { competitorMoves, keywordGaps: sortedGapsJ, schemaGaps: [...compSchemaJ.entries()].map(([schema, domains]) => ({ schema, domains: [...domains] })), actions: actionsJ, period: { days, cutoff: cutoffISO, weekOf }, targetNewPages: targetNewJ } }));
3117
+ return;
3118
+ }
3119
+
2986
3120
  const hr = '─'.repeat(60);
2987
3121
  const header = `📊 Weekly SEO Intel Brief — ${config.target.domain}\n Week of ${weekOf} (last ${days} days)`;
2988
3122
 
@@ -3230,14 +3364,13 @@ program
3230
3364
  .command('velocity <project>')
3231
3365
  .description('Content velocity — how fast each domain publishes (publishing rate + new page detection)')
3232
3366
  .option('--days <n>', 'Lookback window in days', '30')
3367
+ .option('--format <type>', 'Output format: json or brief', 'brief')
3233
3368
  .action((project, opts) => {
3234
3369
  if (!requirePro('velocity')) return;
3235
3370
  const db = getDb();
3236
3371
  const days = parseInt(opts.days) || 30;
3237
3372
  const cutoff = Date.now() - (days * 24 * 60 * 60 * 1000);
3238
3373
 
3239
- printAttackHeader('📈 Content Velocity Tracker', project);
3240
-
3241
3374
  // ── 1. Pages discovered recently (first_seen_at within window) ──
3242
3375
  const newPages = db.prepare(`
3243
3376
  SELECT d.domain, d.role, p.url, p.first_seen_at, p.published_date, p.word_count, p.click_depth
@@ -3269,11 +3402,6 @@ program
3269
3402
  GROUP BY d.domain ORDER BY d.role, d.domain
3270
3403
  `).all(project);
3271
3404
 
3272
- // ── Velocity summary per domain ──
3273
- console.log(chalk.bold(' Domain Velocity Summary') + chalk.gray(` (last ${days} days)\n`));
3274
- console.log(chalk.gray(' Domain Role Total New Rate/wk Published'));
3275
- console.log(chalk.gray(' ' + '─'.repeat(85)));
3276
-
3277
3405
  const domainNewMap = {};
3278
3406
  for (const np of newPages) {
3279
3407
  if (!domainNewMap[np.domain]) domainNewMap[np.domain] = [];
@@ -3293,13 +3421,27 @@ program
3293
3421
  const pubCount = (domainPubMap[t.domain] || []).length;
3294
3422
  const weeksInWindow = days / 7;
3295
3423
  const ratePerWeek = weeksInWindow > 0 ? (Math.max(newCount, pubCount) / weeksInWindow).toFixed(1) : '—';
3296
-
3297
3424
  velocities.push({ domain: t.domain, role: t.role, total: t.total_pages, newCount, pubCount, ratePerWeek: parseFloat(ratePerWeek) || 0 });
3425
+ }
3426
+
3427
+ if (opts.format === 'json') {
3428
+ console.log(JSON.stringify({ command: 'velocity', project, timestamp: new Date().toISOString(), data: { velocities, recentlyPublished: publishedRecently.map(p => ({ url: p.url, domain: p.domain, role: p.role, publishedDate: p.published_date, wordCount: p.word_count })), newPages: newPages.map(p => ({ url: p.url, domain: p.domain, role: p.role, firstSeen: p.first_seen_at, wordCount: p.word_count })), period: { days, cutoff: new Date(cutoff).toISOString() } } }));
3429
+ return;
3430
+ }
3431
+
3432
+ printAttackHeader('📈 Content Velocity Tracker', project);
3433
+
3434
+ // ── Velocity summary per domain ──
3435
+ console.log(chalk.bold(' Domain Velocity Summary') + chalk.gray(` (last ${days} days)\n`));
3436
+ console.log(chalk.gray(' Domain Role Total New Rate/wk Published'));
3437
+ console.log(chalk.gray(' ' + '─'.repeat(85)));
3298
3438
 
3439
+ for (const t of totals) {
3440
+ const v = velocities.find(v => v.domain === t.domain);
3299
3441
  const roleColor = t.role === 'target' ? chalk.green : t.role === 'owned' ? chalk.blue : chalk.yellow;
3300
- const rateColor = parseFloat(ratePerWeek) > 2 ? chalk.green : parseFloat(ratePerWeek) > 0 ? chalk.yellow : chalk.gray;
3442
+ const rateColor = v.ratePerWeek > 2 ? chalk.green : v.ratePerWeek > 0 ? chalk.yellow : chalk.gray;
3301
3443
 
3302
- console.log(` ${t.domain.padEnd(30)} ${roleColor(t.role.padEnd(12))} ${String(t.total_pages).padEnd(7)} ${chalk.cyan(String(newCount).padEnd(6))} ${rateColor(String(ratePerWeek + '/wk').padEnd(8))} ${String(pubCount).padEnd(6)}`);
3444
+ console.log(` ${t.domain.padEnd(30)} ${roleColor(t.role.padEnd(12))} ${String(t.total_pages).padEnd(7)} ${chalk.cyan(String(v.newCount).padEnd(6))} ${rateColor(String(v.ratePerWeek + '/wk').padEnd(8))} ${String(v.pubCount).padEnd(6)}`);
3303
3445
  }
3304
3446
 
3305
3447
  // ── Velocity leader ──
@@ -3371,15 +3513,19 @@ program
3371
3513
  .option('--max-pages <n>', 'Max pages to check per domain', '10')
3372
3514
  .option('--threshold <n>', 'Word count difference threshold to flag', '50')
3373
3515
  .option('--save', 'Save report to reports/')
3516
+ .option('--format <type>', 'Output format: json or brief', 'brief')
3374
3517
  .action(async (project, opts) => {
3375
3518
  if (!requirePro('js-delta')) return;
3376
3519
  const config = loadConfig(project);
3377
3520
  const db = getDb();
3378
3521
  const maxPerDomain = parseInt(opts.maxPages) || 10;
3379
3522
  const threshold = parseInt(opts.threshold) || 50;
3523
+ const isJsonFmt = opts.format === 'json';
3380
3524
 
3381
- printAttackHeader('🔬 JS Rendering Delta', project);
3382
- console.log(chalk.gray(' Comparing raw HTML (no JS) vs Playwright render (full JS)\n'));
3525
+ if (!isJsonFmt) {
3526
+ printAttackHeader('🔬 JS Rendering Delta', project);
3527
+ console.log(chalk.gray(' Comparing raw HTML (no JS) vs Playwright render (full JS)\n'));
3528
+ }
3383
3529
 
3384
3530
  // Get pages to check — focus on high-value pages (low depth, indexable)
3385
3531
  const domainFilter = opts.domain ? 'AND d.domain = ?' : '';
@@ -3436,11 +3582,13 @@ program
3436
3582
 
3437
3583
  if (!pages.length) continue;
3438
3584
 
3439
- const roleColor = dom.role === 'target' ? chalk.green : dom.role === 'owned' ? chalk.blue : chalk.yellow;
3440
- console.log(roleColor(chalk.bold(` ${dom.domain}`) + chalk.gray(` (${pages.length} pages)`)));
3585
+ if (!isJsonFmt) {
3586
+ const roleColor = dom.role === 'target' ? chalk.green : dom.role === 'owned' ? chalk.blue : chalk.yellow;
3587
+ console.log(roleColor(chalk.bold(` ${dom.domain}`) + chalk.gray(` (${pages.length} pages)`)));
3588
+ }
3441
3589
 
3442
3590
  for (const pg of pages) {
3443
- process.stdout.write(chalk.gray(` ${pg.url.replace(/https?:\/\/[^/]+/, '').slice(0, 55).padEnd(55)} `));
3591
+ if (!isJsonFmt) process.stdout.write(chalk.gray(` ${pg.url.replace(/https?:\/\/[^/]+/, '').slice(0, 55).padEnd(55)} `));
3444
3592
 
3445
3593
  try {
3446
3594
  // 1. Raw HTML fetch (no JS)
@@ -3474,24 +3622,26 @@ program
3474
3622
  };
3475
3623
  results.push(result);
3476
3624
 
3477
- if (delta > threshold) {
3478
- process.stdout.write(chalk.red(`⚠️ raw:${rawWords} → rendered:${renderedWords} (+${delta} words, +${pctDelta}%)\n`));
3479
- } else if (delta < -threshold) {
3480
- process.stdout.write(chalk.yellow(`📉 raw:${rawWords} rendered:${renderedWords} (${delta} words)\n`));
3481
- } else {
3482
- process.stdout.write(chalk.green(`✓ raw:${rawWords} rendered:${renderedWords}\n`));
3625
+ if (!isJsonFmt) {
3626
+ if (delta > threshold) {
3627
+ process.stdout.write(chalk.red(`⚠️ raw:${rawWords} rendered:${renderedWords} (+${delta} words, +${pctDelta}%)\n`));
3628
+ } else if (delta < -threshold) {
3629
+ process.stdout.write(chalk.yellow(`📉 raw:${rawWords} rendered:${renderedWords} (${delta} words)\n`));
3630
+ } else {
3631
+ process.stdout.write(chalk.green(`✓ raw:${rawWords} ≈ rendered:${renderedWords}\n`));
3632
+ }
3483
3633
  }
3484
3634
  } finally {
3485
3635
  await page.close().catch(() => {});
3486
3636
  }
3487
3637
  } catch (err) {
3488
- process.stdout.write(chalk.red(`✗ ${err.message.slice(0, 40)}\n`));
3638
+ if (!isJsonFmt) process.stdout.write(chalk.red(`✗ ${err.message.slice(0, 40)}\n`));
3489
3639
  }
3490
3640
 
3491
3641
  // Be respectful
3492
3642
  await new Promise(r => setTimeout(r, 1000 + Math.random() * 1500));
3493
3643
  }
3494
- console.log('');
3644
+ if (!isJsonFmt) console.log('');
3495
3645
  }
3496
3646
  } finally {
3497
3647
  await browser.close().catch(() => {});
@@ -3501,6 +3651,11 @@ program
3501
3651
  const hiddenContent = results.filter(r => r.hidden);
3502
3652
  const totalChecked = results.length;
3503
3653
 
3654
+ if (opts.format === 'json') {
3655
+ console.log(JSON.stringify({ command: 'js-delta', project, timestamp: new Date().toISOString(), data: { results: results.map(r => ({ url: r.url, domain: r.domain, role: r.role, rawWords: r.rawWords, renderedWords: r.renderedWords, delta: r.delta, pctDelta: r.pctDelta, hasHiddenContent: r.hidden })), summary: { totalChecked: results.length, hiddenContentPages: hiddenContent.length, threshold } } }));
3656
+ return;
3657
+ }
3658
+
3504
3659
  console.log(chalk.bold(` Summary: ${totalChecked} pages checked\n`));
3505
3660
  console.log(` ${chalk.green('✓')} ${results.filter(r => !r.hidden && r.delta >= -threshold).length} pages render correctly (raw ≈ rendered)`);
3506
3661
  console.log(` ${chalk.red('⚠️')} ${hiddenContent.length} pages with JS-hidden content (${threshold}+ words invisible to raw crawlers)`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "seo-intel",
3
- "version": "1.4.0",
3
+ "version": "1.4.1",
4
4
  "description": "Local Ahrefs-style SEO competitor intelligence. Crawl → SQLite → cloud analysis.",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -22,6 +22,22 @@
22
22
  "bin": {
23
23
  "seo-intel": "cli.js"
24
24
  },
25
+ "exports": {
26
+ ".": "./cli.js",
27
+ "./froggo": "./froggo.js",
28
+ "./aeo": "./analyses/aeo/index.js",
29
+ "./aeo/scorer": "./analyses/aeo/scorer.js",
30
+ "./gap-intel": "./analyses/gap-intel/index.js",
31
+ "./blog-draft": "./analyses/blog-draft/index.js",
32
+ "./crawler": "./crawler/index.js",
33
+ "./db": "./db/db.js",
34
+ "./exports/technical": "./exports/technical.js",
35
+ "./exports/competitive": "./exports/competitive.js",
36
+ "./exports/suggestive": "./exports/suggestive.js",
37
+ "./exports/queries": "./exports/queries.js",
38
+ "./templates": "./analyses/templates/index.js",
39
+ "./dashboard": "./reports/generate-html.js"
40
+ },
25
41
  "engines": {
26
42
  "node": ">=22.5.0"
27
43
  },