seo-intel 1.5.1 → 1.5.2

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,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.5.2 (2026-04-11)
4
+
5
+ ### Unified Export
6
+ - Merged dev/content/ai-pipeline profiles into a single unified export
7
+ - One file, all actionable sections: scorecard → fixes → content strategy → reference
8
+ - Removed profile picker — just choose format (MD/JSON/CSV/ZIP) and download
9
+ - Cleaner filenames: `carbium-2026-04-11.md` instead of `carbium-dev-2026-04-11.md`
10
+
3
11
  ## 1.5.1 (2026-04-11)
4
12
 
5
13
  ### Setup Wizard
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "seo-intel",
3
- "version": "1.5.1",
3
+ "version": "1.5.2",
4
4
  "description": "Local Ahrefs-style SEO competitor intelligence. Crawl → SQLite → cloud analysis.",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -2214,11 +2214,7 @@ function buildHtmlTemplate(data, opts = {}) {
2214
2214
  <div class="profile-export-picker" id="profilePicker${suffix}">
2215
2215
  <button class="export-btn profile-export-trigger"><i class="fa-solid fa-download"></i> Export Report <i class="fa-solid fa-chevron-down" style="font-size:0.55rem;margin-left:auto;opacity:0.5;"></i></button>
2216
2216
  <div class="profile-export-menu">
2217
- <div class="draft-menu-section">Profile</div>
2218
- <label class="draft-option"><input type="radio" name="exportProfile${suffix}" value="dev" /> <i class="fa-solid fa-wrench"></i> Developer <span style="font-size:0.5rem;opacity:0.45;margin-left:2px;">technical fixes, schema gaps</span></label>
2219
- <label class="draft-option"><input type="radio" name="exportProfile${suffix}" value="content" checked /> <i class="fa-solid fa-pen-fancy"></i> Content <span style="font-size:0.5rem;opacity:0.45;margin-left:2px;">keyword gaps, opportunities</span></label>
2220
- <label class="draft-option"><input type="radio" name="exportProfile${suffix}" value="ai-pipeline" /> <i class="fa-solid fa-robot"></i> AI Pipeline <span style="font-size:0.5rem;opacity:0.45;margin-left:2px;">structured JSON for LLMs</span></label>
2221
- <div class="draft-menu-section" style="margin-top:8px;">Format</div>
2217
+ <div class="draft-menu-section">Format</div>
2222
2218
  <div style="display:flex;gap:6px;flex-wrap:wrap;">
2223
2219
  <label class="draft-option" style="flex:1;min-width:60px;"><input type="radio" name="exportFmt${suffix}" value="md" checked /> <i class="fa-solid fa-file-lines"></i> MD</label>
2224
2220
  <label class="draft-option" style="flex:1;min-width:60px;"><input type="radio" name="exportFmt${suffix}" value="json" /> <i class="fa-solid fa-code"></i> JSON</label>
@@ -2649,13 +2645,11 @@ function buildHtmlTemplate(data, opts = {}) {
2649
2645
  if (picker2) {
2650
2646
  e.stopImmediatePropagation();
2651
2647
  var projP = profDl.getAttribute('data-project');
2652
- var profVal = picker2.querySelector('input[name^="exportProfile"]:checked');
2653
2648
  var fmtVal = picker2.querySelector('input[name^="exportFmt"]:checked');
2654
- var prof = profVal ? profVal.value : 'content';
2655
2649
  var fmt2 = fmtVal ? fmtVal.value : 'md';
2656
2650
  picker2.querySelector('.profile-export-menu').style.display = 'none';
2657
2651
  if (window.location.protocol.startsWith('http')) {
2658
- window.location = '/api/export/download?project=' + encodeURIComponent(projP) + '&profile=' + encodeURIComponent(prof) + '&format=' + encodeURIComponent(fmt2);
2652
+ window.location = '/api/export/download?project=' + encodeURIComponent(projP) + '&format=' + encodeURIComponent(fmt2);
2659
2653
  }
2660
2654
  return;
2661
2655
  }
package/server.js CHANGED
@@ -594,7 +594,6 @@ async function handleRequest(req, res) {
594
594
  const project = url.searchParams.get('project');
595
595
  const section = url.searchParams.get('section') || 'all';
596
596
  const format = url.searchParams.get('format') || 'json';
597
- const profile = url.searchParams.get('profile'); // dev | content | ai-pipeline
598
597
 
599
598
  if (!project || !/^[a-z0-9_-]+$/i.test(project)) { json(res, 400, { error: 'Invalid project name' }); return; }
600
599
 
@@ -611,111 +610,73 @@ async function handleRequest(req, res) {
611
610
  // ── Gather dashboard data — same source as the HTML dashboard ──
612
611
  const dash = gatherProjectData(db, project, config);
613
612
 
614
- // ── Build export from dashboard data — exactly what the UI shows ──
615
- function buildDashboardExport(dash, prof) {
613
+ // ── Build unified export from dashboard data ──
614
+ function buildDashboardExport(dash) {
616
615
  const a = dash.latestAnalysis || {};
617
616
  const sections = {};
618
617
 
619
- // ── Technical: own-site scorecard (summary, not per-page dump) ──
620
- if (!prof || prof === 'dev' || prof === 'ai-pipeline') {
621
- const target = dash.technicalScores?.find(d => d.isTarget);
622
- if (target) {
623
- sections.technical = {
624
- score: target.score,
625
- h1_coverage: target.h1Pct + '%',
626
- meta_coverage: target.metaPct + '%',
627
- schema_coverage: target.schemaPct + '%',
628
- title_coverage: target.titlePct + '%',
618
+ // ── Status: scorecard + crawl ──
619
+ const target = dash.technicalScores?.find(d => d.isTarget);
620
+ if (target) {
621
+ sections.technical = {
622
+ score: target.score,
623
+ h1_coverage: target.h1Pct + '%',
624
+ meta_coverage: target.metaPct + '%',
625
+ schema_coverage: target.schemaPct + '%',
626
+ title_coverage: target.titlePct + '%',
627
+ };
628
+ }
629
+
630
+ // ── Site Watch ──
631
+ if (dash.watchData?.events?.length) {
632
+ const critical = dash.watchData.events.filter(e => e.severity === 'error' || e.severity === 'warning');
633
+ if (critical.length) sections.watch_alerts = critical;
634
+ if (dash.watchData.snapshot) {
635
+ sections.watch_summary = {
636
+ health_score: dash.watchData.snapshot.health_score,
637
+ errors: dash.watchData.snapshot.errors_count,
638
+ warnings: dash.watchData.snapshot.warnings_count,
629
639
  };
630
640
  }
631
- if (a.technical_gaps?.length) sections.technical_gaps = a.technical_gaps;
632
641
  }
633
642
 
634
- // ── Quick Wins ──
635
- if (!prof || prof === 'dev' || prof === 'ai-pipeline') {
636
- if (a.quick_wins?.length) sections.quick_wins = a.quick_wins;
637
- }
638
-
639
- // ── Internal Links: summary stats, not raw links ──
640
- if (!prof || prof === 'dev' || prof === 'ai-pipeline') {
641
- if (dash.internalLinks) {
642
- sections.internal_links = {
643
- total_links: dash.internalLinks.totalLinks,
644
- orphan_pages: dash.internalLinks.orphanCount,
645
- top_pages: dash.internalLinks.topPages,
646
- };
647
- }
648
- }
649
-
650
- // ── Keyword Gaps ──
651
- if (!prof || prof === 'content' || prof === 'ai-pipeline') {
652
- if (a.keyword_gaps?.length) sections.keyword_gaps = a.keyword_gaps;
653
- if (dash.keywordGaps?.length) {
654
- sections.top_keyword_gaps = dash.keywordGaps.slice(0, 50);
655
- }
656
- }
657
-
658
- // ── Long-tail Opportunities ──
659
- if (!prof || prof === 'content' || prof === 'ai-pipeline') {
660
- if (a.long_tails?.length) sections.long_tails = a.long_tails;
661
- }
662
-
663
- // ── New Pages to Create ──
664
- if (!prof || prof === 'content' || prof === 'ai-pipeline') {
665
- if (a.new_pages?.length) sections.new_pages = a.new_pages;
666
- }
667
-
668
- // ── Content Gaps ──
669
- if (!prof || prof === 'content' || prof === 'ai-pipeline') {
670
- if (a.content_gaps?.length) sections.content_gaps = a.content_gaps;
671
- }
672
-
673
- // ── Keyword Inventor ──
674
- if (!prof || prof === 'content' || prof === 'ai-pipeline') {
675
- if (a.keyword_inventor?.length) sections.keyword_inventor = a.keyword_inventor;
676
- }
677
-
678
- // ── Positioning Strategy ──
679
- if (!prof || prof === 'content' || prof === 'ai-pipeline') {
680
- if (a.positioning) sections.positioning = a.positioning;
681
- }
682
-
683
- // ── AI Citability (AEO) — own site, low scores only ──
684
- if (!prof || prof === 'content' || prof === 'ai-pipeline') {
685
- if (dash.citabilityData?.scores?.length) {
686
- const own = dash.citabilityData.scores.filter(s => s.role === 'target' || s.role === 'owned');
687
- const needsWork = own.filter(s => s.score < 60);
688
- if (needsWork.length) sections.citability_low_scores = needsWork;
689
- sections.citability_summary = {
690
- avg_score: own.length ? Math.round(own.reduce((a, s) => a + s.score, 0) / own.length) : null,
691
- pages_scored: own.length,
692
- pages_below_60: needsWork.length,
693
- };
694
- }
695
- }
696
-
697
- // ── Site Watch — errors and warnings only ──
698
- if (!prof || prof === 'dev' || prof === 'ai-pipeline') {
699
- if (dash.watchData?.events?.length) {
700
- const critical = dash.watchData.events.filter(e => e.severity === 'error' || e.severity === 'warning');
701
- if (critical.length) sections.watch_alerts = critical;
702
- if (dash.watchData.snapshot) {
703
- sections.watch_summary = {
704
- health_score: dash.watchData.snapshot.health_score,
705
- errors: dash.watchData.snapshot.errors_count,
706
- warnings: dash.watchData.snapshot.warnings_count,
707
- };
708
- }
709
- }
710
- }
711
-
712
- // ── Schema Breakdown — own site type counts, not per-page dump ──
713
- if (!prof || prof === 'dev') {
714
- if (dash.schemaBreakdown?.length) {
715
- const target = dash.schemaBreakdown.find(d => d.isTarget);
716
- if (target?.types?.length) sections.schema_types = target.types;
717
- }
718
- }
643
+ // ── Fix Now: technical gaps + quick wins ──
644
+ if (a.technical_gaps?.length) sections.technical_gaps = a.technical_gaps;
645
+ if (a.quick_wins?.length) sections.quick_wins = a.quick_wins;
646
+
647
+ // ── Content Strategy: keywords, gaps, new pages, positioning ──
648
+ if (a.keyword_gaps?.length) sections.keyword_gaps = a.keyword_gaps;
649
+ if (dash.keywordGaps?.length) sections.top_keyword_gaps = dash.keywordGaps.slice(0, 50);
650
+ if (a.long_tails?.length) sections.long_tails = a.long_tails;
651
+ if (a.new_pages?.length) sections.new_pages = a.new_pages;
652
+ if (a.content_gaps?.length) sections.content_gaps = a.content_gaps;
653
+ if (a.positioning) sections.positioning = a.positioning;
654
+
655
+ // ── AI Citability ──
656
+ if (dash.citabilityData?.scores?.length) {
657
+ const own = dash.citabilityData.scores.filter(s => s.role === 'target' || s.role === 'owned');
658
+ const needsWork = own.filter(s => s.score < 60);
659
+ if (needsWork.length) sections.citability_low_scores = needsWork;
660
+ sections.citability_summary = {
661
+ avg_score: own.length ? Math.round(own.reduce((a, s) => a + s.score, 0) / own.length) : null,
662
+ pages_scored: own.length,
663
+ pages_below_60: needsWork.length,
664
+ };
665
+ }
666
+
667
+ // ── Reference: internal links, schema types, keyword ideas ──
668
+ if (dash.internalLinks) {
669
+ sections.internal_links = {
670
+ total_links: dash.internalLinks.totalLinks,
671
+ orphan_pages: dash.internalLinks.orphanCount,
672
+ top_pages: dash.internalLinks.topPages,
673
+ };
674
+ }
675
+ if (dash.schemaBreakdown?.length) {
676
+ const tgt = dash.schemaBreakdown.find(d => d.isTarget);
677
+ if (tgt?.types?.length) sections.schema_types = tgt.types;
678
+ }
679
+ if (a.keyword_inventor?.length) sections.keyword_inventor = a.keyword_inventor;
719
680
 
720
681
  // ── Crawl Stats ──
721
682
  sections.crawl_stats = dash.crawlStats;
@@ -723,10 +684,9 @@ async function handleRequest(req, res) {
723
684
  return sections;
724
685
  }
725
686
 
726
- function dashboardToMarkdown(sections, proj, prof) {
687
+ function dashboardToMarkdown(sections, proj) {
727
688
  const date = new Date().toISOString().slice(0, 10);
728
- const label = prof ? { dev: 'Developer', content: 'Content', 'ai-pipeline': 'AI Pipeline' }[prof] : 'Full';
729
- let md = `# SEO Intel — ${label} Report\n\n- Project: ${proj}\n- Date: ${date}\n\n`;
689
+ let md = `# SEO Intel Report ${proj}\n\n- Date: ${date}\n\n`;
730
690
 
731
691
  const s = sections;
732
692
 
@@ -841,39 +801,31 @@ async function handleRequest(req, res) {
841
801
  }
842
802
 
843
803
  // ── Build and serve ──
844
- const validProfiles = ['dev', 'content', 'ai-pipeline'];
845
- if (profile && !validProfiles.includes(profile)) {
846
- json(res, 400, { error: `Invalid profile. Allowed: ${validProfiles.join(', ')}` });
847
- return;
848
- }
849
-
850
- const sections = buildDashboardExport(dash, profile);
851
- const profileLabel = profile ? { dev: 'Developer', content: 'Content', 'ai-pipeline': 'AI Pipeline' }[profile] : 'Full';
852
- const tag = profile || 'full';
804
+ const sections = buildDashboardExport(dash);
853
805
 
854
806
  if (format === 'json') {
855
- const content = JSON.stringify({ profile: profileLabel, project, date: dateStr, ...sections }, null, 2);
856
- const fileName = `${project}-${tag}-${dateStr}.json`;
807
+ const content = JSON.stringify({ project, date: dateStr, ...sections }, null, 2);
808
+ const fileName = `${project}-${dateStr}.json`;
857
809
  res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Disposition': `attachment; filename="${fileName}"` });
858
810
  res.end(content);
859
811
  } else if (format === 'md') {
860
- const content = dashboardToMarkdown(sections, project, profile);
861
- const fileName = `${project}-${tag}-${dateStr}.md`;
812
+ const content = dashboardToMarkdown(sections, project);
813
+ const fileName = `${project}-${dateStr}.md`;
862
814
  res.writeHead(200, { 'Content-Type': 'text/markdown; charset=utf-8', 'Content-Disposition': `attachment; filename="${fileName}"` });
863
815
  res.end(content);
864
816
  } else if (format === 'csv') {
865
817
  const content = toCSV(sections);
866
- const fileName = `${project}-${tag}-${dateStr}.csv`;
818
+ const fileName = `${project}-${dateStr}.csv`;
867
819
  res.writeHead(200, { 'Content-Type': 'text/csv; charset=utf-8', 'Content-Disposition': `attachment; filename="${fileName}"` });
868
820
  res.end(content || 'No data.');
869
821
  } else if (format === 'zip') {
870
822
  const entries = [];
871
- entries.push({ name: `${project}-${tag}-${dateStr}.json`, content: JSON.stringify({ profile: profileLabel, project, date: dateStr, ...sections }, null, 2) });
872
- entries.push({ name: `${project}-${tag}-${dateStr}.md`, content: dashboardToMarkdown(sections, project, profile) });
823
+ entries.push({ name: `${project}-${dateStr}.json`, content: JSON.stringify({ project, date: dateStr, ...sections }, null, 2) });
824
+ entries.push({ name: `${project}-${dateStr}.md`, content: dashboardToMarkdown(sections, project) });
873
825
  const csv = toCSV(sections);
874
- if (csv) entries.push({ name: `${project}-${tag}-${dateStr}.csv`, content: csv });
826
+ if (csv) entries.push({ name: `${project}-${dateStr}.csv`, content: csv });
875
827
  const zipBuf = createZip(entries);
876
- res.writeHead(200, { 'Content-Type': 'application/zip', 'Content-Disposition': `attachment; filename="${project}-${tag}-${dateStr}.zip"`, 'Content-Length': zipBuf.length });
828
+ res.writeHead(200, { 'Content-Type': 'application/zip', 'Content-Disposition': `attachment; filename="${project}-${dateStr}.zip"`, 'Content-Length': zipBuf.length });
877
829
  res.end(zipBuf);
878
830
  } else {
879
831
  json(res, 400, { error: 'Invalid format. Allowed: json, csv, md, zip' });