seo-intel 1.3.0 → 1.3.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.3.1 (2026-04-02)
4
+
5
+ ### Fixes
6
+ - **AI Citability Audit** now renders output in dashboard export viewer (was showing "No output")
7
+ - AEO command accepts `--format markdown|json|brief` for structured output
8
+ - Dashboard export viewer captures stderr — command errors are now visible instead of silent
9
+
10
+ ### CI
11
+ - Added job-level timeout (15 min) — prevents 6-hour runaway jobs
12
+ - Cross-platform path handling — Windows CI no longer fails on backslash paths
13
+ - Playwright auto-installed for mock crawl test
14
+ - Step-level timeouts on crawl, setup wizard, and server tests
15
+
3
16
  ## 1.3.0 (2026-04-01)
4
17
 
5
18
  ### New Feature: AEO Blog Draft Generator
package/cli.js CHANGED
@@ -3869,24 +3869,30 @@ program
3869
3869
  .alias('citability')
3870
3870
  .description('AI Citability Audit — score every page for how well AI assistants can cite it')
3871
3871
  .option('--target-only', 'Only score target domain (skip competitors)')
3872
+ .option('--format <type>', 'Output format: brief or json', 'brief')
3872
3873
  .option('--save', 'Save report to reports/')
3873
3874
  .action(async (project, opts) => {
3874
3875
  if (!requirePro('aeo')) return;
3875
3876
  const db = getDb();
3876
3877
  const config = loadConfig(project);
3878
+ const isBrief = opts.format !== 'json';
3877
3879
 
3878
- printAttackHeader('🤖 AEO — AI Citability Audit', project);
3880
+ if (!isBrief) {
3881
+ // JSON mode — skip header
3882
+ } else {
3883
+ printAttackHeader('🤖 AEO — AI Citability Audit', project);
3884
+ }
3879
3885
 
3880
3886
  const { runAeoAnalysis, persistAeoScores, upsertCitabilityInsights } = await import('./analyses/aeo/index.js');
3881
3887
 
3882
3888
  const results = runAeoAnalysis(db, project, {
3883
3889
  includeCompetitors: !opts.targetOnly,
3884
- log: (msg) => console.log(chalk.gray(msg)),
3890
+ log: (msg) => isBrief ? console.log(chalk.gray(msg)) : null,
3885
3891
  });
3886
3892
 
3887
3893
  if (!results.target.length && !results.competitors.size) {
3888
- console.log(chalk.yellow('\n ⚠️ No pages with body_text found.'));
3889
- console.log(chalk.gray(' Run: seo-intel crawl ' + project + ' (crawl stores body text since v1.1.6)\n'));
3894
+ console.log(isBrief ? chalk.yellow('\n ⚠️ No pages with body_text found.') : 'No pages with body_text found.');
3895
+ console.log(isBrief ? chalk.gray(' Run: seo-intel crawl ' + project + ' (crawl stores body text since v1.1.6)\n') : 'Run: seo-intel crawl ' + project);
3890
3896
  return;
3891
3897
  }
3892
3898
 
@@ -3895,88 +3901,149 @@ program
3895
3901
  upsertCitabilityInsights(db, project, results.target);
3896
3902
 
3897
3903
  const { summary } = results;
3904
+ const { tierCounts } = summary;
3905
+ const worst = results.target.filter(r => r.score < 55).slice(0, 10);
3906
+ const best = results.target.filter(r => r.score >= 55).slice(-5).reverse();
3898
3907
 
3899
- // ── Summary ──
3900
- console.log('');
3901
- console.log(chalk.bold(' 📊 Citability Summary'));
3902
- console.log('');
3908
+ // ── Markdown output (used by dashboard export viewer) ──
3909
+ if (opts.format === 'markdown') {
3903
3910
 
3904
- const scoreFmt = (s) => {
3905
- if (s >= 75) return chalk.bold.green(s + '/100');
3906
- if (s >= 55) return chalk.bold.yellow(s + '/100');
3907
- if (s >= 35) return chalk.hex('#ff8c00')(s + '/100');
3908
- return chalk.bold.red(s + '/100');
3909
- };
3911
+ console.log('# AEO AI Citability Audit\n');
3912
+ console.log(`## Summary\n`);
3913
+ console.log(`- **Target average:** ${summary.avgTargetScore}/100`);
3914
+ if (summary.competitorPages > 0) {
3915
+ console.log(`- **Competitor average:** ${summary.avgCompetitorScore}/100`);
3916
+ const delta = summary.scoreDelta;
3917
+ console.log(`- **Delta:** ${delta > 0 ? '+' : ''}${delta}`);
3918
+ }
3919
+ console.log(`- **Pages scored:** ${results.target.length}\n`);
3920
+
3921
+ console.log(`## Tier Breakdown\n`);
3922
+ console.log(`- Excellent (75+): ${tierCounts.excellent}`);
3923
+ console.log(`- Good (55-74): ${tierCounts.good}`);
3924
+ console.log(`- Needs work (35-54): ${tierCounts.needs_work}`);
3925
+ console.log(`- Poor (<35): ${tierCounts.poor}\n`);
3926
+
3927
+ if (summary.weakestSignals.length) {
3928
+ console.log(`## Weakest Signals\n`);
3929
+ for (const s of summary.weakestSignals) {
3930
+ const pct = Math.round(s.avg);
3931
+ const bar = '█'.repeat(Math.round(pct / 5)) + '░'.repeat(20 - Math.round(pct / 5));
3932
+ console.log(`- **${s.signal}** ${bar} ${pct}/100`);
3933
+ }
3934
+ console.log('');
3935
+ }
3910
3936
 
3911
- console.log(` Target average: ${scoreFmt(summary.avgTargetScore)}`);
3912
- if (summary.competitorPages > 0) {
3913
- console.log(` Competitor average: ${scoreFmt(summary.avgCompetitorScore)}`);
3914
- const delta = summary.scoreDelta;
3915
- const deltaStr = delta > 0 ? chalk.green(`+${delta}`) : delta < 0 ? chalk.red(`${delta}`) : chalk.gray('0');
3916
- console.log(` Delta: ${deltaStr}`);
3917
- }
3918
- console.log('');
3937
+ if (worst.length) {
3938
+ console.log(`## Pages Needing Work\n`);
3939
+ for (const p of worst) {
3940
+ const path = p.url.replace(/https?:\/\/[^/]+/, '') || '/';
3941
+ const weakest = Object.entries(p.breakdown)
3942
+ .sort(([, a], [, b]) => a - b)
3943
+ .slice(0, 2)
3944
+ .map(([k]) => k.replace(/_/g, ' '));
3945
+ console.log(`- **${path.slice(0, 60)}** — ${p.score}/100 (weak: ${weakest.join(', ')})`);
3946
+ }
3947
+ console.log('');
3948
+ }
3919
3949
 
3920
- // ── Tier breakdown ──
3921
- const { tierCounts } = summary;
3922
- console.log(` ${chalk.green('●')} Excellent (75+): ${tierCounts.excellent}`);
3923
- console.log(` ${chalk.yellow('')} Good (55-74): ${tierCounts.good}`);
3924
- console.log(` ${chalk.hex('#ff8c00')('●')} Needs work (35-54): ${tierCounts.needs_work}`);
3925
- console.log(` ${chalk.red('●')} Poor (<35): ${tierCounts.poor}`);
3926
- console.log('');
3950
+ if (best.length) {
3951
+ console.log(`## Top Citable Pages\n`);
3952
+ for (const p of best) {
3953
+ const path = p.url.replace(/https?:\/\/[^/]+/, '') || '/';
3954
+ console.log(`- **${path.slice(0, 60)}** ${p.score}/100 (${p.aiIntents.join(', ')})`);
3955
+ }
3956
+ console.log('');
3957
+ }
3927
3958
 
3928
- // ── Weakest signals ──
3929
- if (summary.weakestSignals.length) {
3930
- console.log(chalk.bold(' 🔍 Weakest Signals (target average)'));
3931
- console.log('');
3932
- for (const s of summary.weakestSignals) {
3933
- const bar = '█'.repeat(Math.round(s.avg / 5)) + chalk.gray('░'.repeat(20 - Math.round(s.avg / 5)));
3934
- console.log(` ${s.signal.padEnd(20)} ${bar} ${s.avg}/100`);
3959
+ console.log(`## Actions\n`);
3960
+ if (tierCounts.poor > 0) {
3961
+ console.log(`1. Fix ${tierCounts.poor} poor-scoring pages — add structured headings, Q&A format, entity depth`);
3962
+ }
3963
+ if (summary.weakestSignals.length && summary.weakestSignals[0].avg < 40) {
3964
+ console.log(`2. Site-wide weakness: "${summary.weakestSignals[0].signal}" systematically improve across all pages`);
3965
+ }
3966
+ if (summary.scoreDelta < 0) {
3967
+ console.log(`3. Competitors are ${Math.abs(summary.scoreDelta)} points ahead — prioritise top-traffic pages first`);
3935
3968
  }
3936
3969
  console.log('');
3937
- }
3970
+ } else if (opts.format === 'json') {
3971
+ // ── JSON output ──
3972
+ console.log(JSON.stringify({ summary, target: results.target, competitors: [...results.competitors.entries()] }, null, 2));
3973
+ } else {
3974
+ // ── Rich CLI output (default brief format) ──
3975
+ const scoreFmt = (s) => {
3976
+ if (s >= 75) return chalk.bold.green(s + '/100');
3977
+ if (s >= 55) return chalk.bold.yellow(s + '/100');
3978
+ if (s >= 35) return chalk.hex('#ff8c00')(s + '/100');
3979
+ return chalk.bold.red(s + '/100');
3980
+ };
3938
3981
 
3939
- // ── Worst pages (actionable) ──
3940
- const worst = results.target.filter(r => r.score < 55).slice(0, 10);
3941
- if (worst.length) {
3942
- console.log(chalk.bold.red(' ⚡ Pages Needing Work'));
3943
3982
  console.log('');
3944
- for (const p of worst) {
3945
- const path = p.url.replace(/https?:\/\/[^/]+/, '') || '/';
3946
- const weakest = Object.entries(p.breakdown)
3947
- .sort(([, a], [, b]) => a - b)
3948
- .slice(0, 2)
3949
- .map(([k]) => k.replace(/_/g, ' '));
3950
- console.log(` ${scoreFmt(p.score)} ${chalk.bold(path.slice(0, 50))}`);
3951
- console.log(chalk.gray(` Weak: ${weakest.join(', ')}`));
3983
+ console.log(chalk.bold(' 📊 Citability Summary'));
3984
+ console.log('');
3985
+ console.log(` Target average: ${scoreFmt(summary.avgTargetScore)}`);
3986
+ if (summary.competitorPages > 0) {
3987
+ console.log(` Competitor average: ${scoreFmt(summary.avgCompetitorScore)}`);
3988
+ const delta = summary.scoreDelta;
3989
+ const deltaStr = delta > 0 ? chalk.green(`+${delta}`) : delta < 0 ? chalk.red(`${delta}`) : chalk.gray('0');
3990
+ console.log(` Delta: ${deltaStr}`);
3952
3991
  }
3953
3992
  console.log('');
3954
- }
3955
3993
 
3956
- // ── Best pages ──
3957
- const best = results.target.filter(r => r.score >= 55).slice(-5).reverse();
3958
- if (best.length) {
3959
- console.log(chalk.bold.green(' Top Citable Pages'));
3994
+ console.log(` ${chalk.green('●')} Excellent (75+): ${tierCounts.excellent}`);
3995
+ console.log(` ${chalk.yellow('●')} Good (55-74): ${tierCounts.good}`);
3996
+ console.log(` ${chalk.hex('#ff8c00')('●')} Needs work (35-54): ${tierCounts.needs_work}`);
3997
+ console.log(` ${chalk.red('●')} Poor (<35): ${tierCounts.poor}`);
3960
3998
  console.log('');
3961
- for (const p of best) {
3962
- const path = p.url.replace(/https?:\/\/[^/]+/, '') || '/';
3963
- console.log(` ${scoreFmt(p.score)} ${chalk.bold(path.slice(0, 50))} ${chalk.gray(p.aiIntents.join(', '))}`);
3999
+
4000
+ if (summary.weakestSignals.length) {
4001
+ console.log(chalk.bold(' 🔍 Weakest Signals (target average)'));
4002
+ console.log('');
4003
+ for (const s of summary.weakestSignals) {
4004
+ const bar = '█'.repeat(Math.round(s.avg / 5)) + chalk.gray('░'.repeat(20 - Math.round(s.avg / 5)));
4005
+ console.log(` ${s.signal.padEnd(20)} ${bar} ${s.avg}/100`);
4006
+ }
4007
+ console.log('');
3964
4008
  }
3965
- console.log('');
3966
- }
3967
4009
 
3968
- // ── Actions ──
3969
- console.log(chalk.bold.green(' 💡 Actions:'));
3970
- if (tierCounts.poor > 0) {
3971
- console.log(chalk.green(` 1. Fix ${tierCounts.poor} poor-scoring pages — add structured headings, Q&A format, entity depth`));
3972
- }
3973
- if (summary.weakestSignals.length && summary.weakestSignals[0].avg < 40) {
3974
- console.log(chalk.green(` 2. Site-wide weakness: "${summary.weakestSignals[0].signal}" systematically improve across all pages`));
3975
- }
3976
- if (summary.scoreDelta < 0) {
3977
- console.log(chalk.green(` 3. Competitors are ${Math.abs(summary.scoreDelta)} points ahead — prioritise top-traffic pages first`));
4010
+ if (worst.length) {
4011
+ console.log(chalk.bold.red(' Pages Needing Work'));
4012
+ console.log('');
4013
+ for (const p of worst) {
4014
+ const path = p.url.replace(/https?:\/\/[^/]+/, '') || '/';
4015
+ const weakest = Object.entries(p.breakdown)
4016
+ .sort(([, a], [, b]) => a - b)
4017
+ .slice(0, 2)
4018
+ .map(([k]) => k.replace(/_/g, ' '));
4019
+ console.log(` ${scoreFmt(p.score)} ${chalk.bold(path.slice(0, 50))}`);
4020
+ console.log(chalk.gray(` Weak: ${weakest.join(', ')}`));
4021
+ }
4022
+ console.log('');
4023
+ }
4024
+
4025
+ if (best.length) {
4026
+ console.log(chalk.bold.green(' ✨ Top Citable Pages'));
4027
+ console.log('');
4028
+ for (const p of best) {
4029
+ const path = p.url.replace(/https?:\/\/[^/]+/, '') || '/';
4030
+ console.log(` ${scoreFmt(p.score)} ${chalk.bold(path.slice(0, 50))} ${chalk.gray(p.aiIntents.join(', '))}`);
4031
+ }
4032
+ console.log('');
4033
+ }
4034
+
4035
+ console.log(chalk.bold.green(' 💡 Actions:'));
4036
+ if (tierCounts.poor > 0) {
4037
+ console.log(chalk.green(` 1. Fix ${tierCounts.poor} poor-scoring pages — add structured headings, Q&A format, entity depth`));
4038
+ }
4039
+ if (summary.weakestSignals.length && summary.weakestSignals[0].avg < 40) {
4040
+ console.log(chalk.green(` 2. Site-wide weakness: "${summary.weakestSignals[0].signal}" — systematically improve across all pages`));
4041
+ }
4042
+ if (summary.scoreDelta < 0) {
4043
+ console.log(chalk.green(` 3. Competitors are ${Math.abs(summary.scoreDelta)} points ahead — prioritise top-traffic pages first`));
4044
+ }
4045
+ console.log('');
3978
4046
  }
3979
- console.log('');
3980
4047
 
3981
4048
  // ── Regenerate dashboard ──
3982
4049
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "seo-intel",
3
- "version": "1.3.0",
3
+ "version": "1.3.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",
@@ -1553,6 +1553,43 @@ function buildHtmlTemplate(data, opts = {}) {
1553
1553
  transition: opacity 0.15s;
1554
1554
  }
1555
1555
  .draft-generate-btn:hover { opacity: 0.9; }
1556
+ .export-expand-btn {
1557
+ position: absolute;
1558
+ top: 6px;
1559
+ right: 6px;
1560
+ z-index: 10;
1561
+ background: rgba(255,255,255,0.06);
1562
+ border: 1px solid var(--border-subtle);
1563
+ color: var(--text-muted);
1564
+ width: 24px; height: 24px;
1565
+ border-radius: 4px;
1566
+ cursor: pointer;
1567
+ display: flex;
1568
+ align-items: center;
1569
+ justify-content: center;
1570
+ font-size: 0.55rem;
1571
+ transition: all 0.15s;
1572
+ }
1573
+ .export-expand-btn:hover { border-color: var(--accent-gold); color: var(--accent-gold); }
1574
+ .export-viewer-expanded {
1575
+ position: fixed !important;
1576
+ top: 5vh; left: 5vw; right: 5vw; bottom: 5vh;
1577
+ max-height: none !important;
1578
+ z-index: 9999;
1579
+ background: #111;
1580
+ border: 1px solid var(--accent-gold);
1581
+ border-radius: var(--radius);
1582
+ padding: 24px;
1583
+ overflow-y: auto;
1584
+ box-shadow: 0 0 80px rgba(0,0,0,0.8);
1585
+ }
1586
+ .export-viewer-backdrop {
1587
+ position: fixed;
1588
+ inset: 0;
1589
+ background: rgba(0,0,0,0.7);
1590
+ z-index: 9998;
1591
+ cursor: pointer;
1592
+ }
1556
1593
  .export-viewer {
1557
1594
  flex: 1;
1558
1595
  padding: 12px;
@@ -2082,6 +2119,7 @@ function buildHtmlTemplate(data, opts = {}) {
2082
2119
  <div class="export-sidebar">
2083
2120
  <div class="export-sidebar-header">
2084
2121
  <i class="fa-solid fa-file-export"></i> Exports
2122
+ <span style="margin-left:auto;font-size:.55rem;color:var(--text-muted);font-weight:400;letter-spacing:0;">→ reports/</span>
2085
2123
  </div>
2086
2124
  ${pro ? `
2087
2125
  <div class="export-sidebar-btns">
@@ -2111,10 +2149,16 @@ function buildHtmlTemplate(data, opts = {}) {
2111
2149
  </div>
2112
2150
  <button class="export-btn" data-export-cmd="aeo" data-export-project="${project}"><i class="fa-solid fa-robot"></i> AI Citability Audit</button>
2113
2151
  </div>
2114
- <div id="exportViewer${suffix}" class="export-viewer">
2115
- <div style="color:#444;padding:20px 0;text-align:center;">
2116
- <i class="fa-solid fa-file-export" style="font-size:1.2rem;margin-bottom:8px;display:block;"></i>
2117
- Click an export to generate an<br/>implementation-ready action brief.
2152
+ <div style="position:relative;">
2153
+ <div id="exportSaveStatus${suffix}" style="display:none;padding:4px 10px;font-size:.6rem;color:var(--color-success);background:rgba(80,200,120,0.06);border-bottom:1px solid rgba(80,200,120,0.15);font-family:'SF Mono',monospace;">
2154
+ <i class="fa-solid fa-check" style="margin-right:4px;"></i><span></span>
2155
+ </div>
2156
+ <button id="exportExpand${suffix}" class="export-expand-btn" title="Expand viewer"><i class="fa-solid fa-expand"></i></button>
2157
+ <div id="exportViewer${suffix}" class="export-viewer">
2158
+ <div style="color:#444;padding:20px 0;text-align:center;">
2159
+ <i class="fa-solid fa-file-export" style="font-size:1.2rem;margin-bottom:8px;display:block;"></i>
2160
+ Click an export to generate an<br/>implementation-ready action brief.
2161
+ </div>
2118
2162
  </div>
2119
2163
  </div>
2120
2164
  ` : `
@@ -2298,6 +2342,7 @@ function buildHtmlTemplate(data, opts = {}) {
2298
2342
  try {
2299
2343
  const msg = JSON.parse(e.data);
2300
2344
  if (msg.type === 'stdout') mdContent += msg.data + '\\n';
2345
+ else if (msg.type === 'stderr') mdContent += msg.data + '\\n';
2301
2346
  else if (msg.type === 'exit') {
2302
2347
  running = false;
2303
2348
  status.textContent = 'done';
@@ -2319,6 +2364,14 @@ function buildHtmlTemplate(data, opts = {}) {
2319
2364
  exportViewer.innerHTML = html || '<div style="color:var(--text-muted);">No output.</div>';
2320
2365
  exportViewer.scrollTop = 0;
2321
2366
  }
2367
+ // Show save status
2368
+ var saveEl = document.getElementById('exportSaveStatus' + suffix);
2369
+ if (saveEl && code === 0) {
2370
+ var slugName = cmd === 'suggest-usecases' ? 'suggestions' : (scope || 'all');
2371
+ var dateStr = new Date().toISOString().slice(0, 10);
2372
+ saveEl.style.display = 'block';
2373
+ saveEl.querySelector('span').textContent = 'Saved → reports/' + proj + '-' + slugName + '-' + dateStr + '.md';
2374
+ }
2322
2375
  }
2323
2376
  } catch (_) {}
2324
2377
  };
@@ -2428,6 +2481,33 @@ function buildHtmlTemplate(data, opts = {}) {
2428
2481
  });
2429
2482
  }
2430
2483
 
2484
+ // Expand viewer button
2485
+ var expandBtn = document.getElementById('exportExpand' + suffix);
2486
+ if (expandBtn && exportViewer) {
2487
+ expandBtn.addEventListener('click', function() {
2488
+ if (exportViewer.classList.contains('export-viewer-expanded')) {
2489
+ // Collapse
2490
+ exportViewer.classList.remove('export-viewer-expanded');
2491
+ expandBtn.innerHTML = '<i class="fa-solid fa-expand"></i>';
2492
+ var bd = document.getElementById('exportBackdrop' + suffix);
2493
+ if (bd) bd.remove();
2494
+ } else {
2495
+ // Expand
2496
+ exportViewer.classList.add('export-viewer-expanded');
2497
+ expandBtn.innerHTML = '<i class="fa-solid fa-compress"></i>';
2498
+ var bd = document.createElement('div');
2499
+ bd.id = 'exportBackdrop' + suffix;
2500
+ bd.className = 'export-viewer-backdrop';
2501
+ document.body.appendChild(bd);
2502
+ bd.addEventListener('click', function() {
2503
+ exportViewer.classList.remove('export-viewer-expanded');
2504
+ expandBtn.innerHTML = '<i class="fa-solid fa-expand"></i>';
2505
+ bd.remove();
2506
+ });
2507
+ }
2508
+ });
2509
+ }
2510
+
2431
2511
  // Input enter
2432
2512
  input.addEventListener('keydown', function(e) {
2433
2513
  if (e.key !== 'Enter') return;
package/server.js CHANGED
@@ -614,6 +614,17 @@ async function handleRequest(req, res) {
614
614
  if (params.get('model')) args.push('--model', params.get('model'));
615
615
  if (params.has('save')) args.push('--save');
616
616
 
617
+ // Auto-save exports from dashboard to reports/
618
+ const EXPORT_CMDS = ['export-actions', 'suggest-usecases', 'competitive-actions'];
619
+ if (EXPORT_CMDS.includes(command) && project) {
620
+ const scope = params.get('scope') || 'all';
621
+ const ts = new Date().toISOString().slice(0, 10);
622
+ const slug = command === 'suggest-usecases' ? 'suggestions' : scope;
623
+ const outFile = join(__dirname, 'reports', `${project}-${slug}-${ts}.md`);
624
+ args.push('--output', outFile);
625
+ args.push('--format', 'brief');
626
+ }
627
+
617
628
  // SSE headers
618
629
  res.writeHead(200, {
619
630
  'Content-Type': 'text/event-stream',