seo-intel 1.4.3 → 1.4.4

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,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.4.4 (2026-04-08)
4
+
5
+ ### Export Profiles
6
+ - New profile-based export: Developer, Content, and AI Pipeline profiles
7
+ - Each profile filters to actionable data only — no raw database dumps
8
+ - Developer profile: technical issues, heading problems (own site only), orphan links, schema gaps
9
+ - Content profile: keyword gaps, long-tail opportunities, citability issues, content gaps
10
+ - AI Pipeline profile: structured JSON with all actionable sections for LLM consumption
11
+ - Heading export collapsed to per-page issue summaries (missing H1, duplicate H1, skipped levels)
12
+ - Empty sections automatically skipped in exports
13
+ - Profile picker UI in dashboard sidebar with format selector (MD, JSON, CSV, ZIP)
14
+
3
15
  ## 1.4.3 (2026-04-07)
4
16
 
5
17
  ### Dashboard: Export & Download
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "seo-intel",
3
- "version": "1.4.3",
3
+ "version": "1.4.4",
4
4
  "description": "Local Ahrefs-style SEO competitor intelligence. Crawl → SQLite → cloud analysis.",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -1525,6 +1525,20 @@ function buildHtmlTemplate(data, opts = {}) {
1525
1525
  .export-btn:hover { border-color: var(--accent-gold); color: var(--accent-gold); }
1526
1526
  .export-btn i { margin-right: 5px; font-size: 0.6rem; }
1527
1527
  .export-btn.active { border-color: var(--accent-gold); color: var(--accent-gold); background: rgba(232,213,163,0.06); }
1528
+ .profile-export-picker { position: relative; }
1529
+ .profile-export-trigger { display: flex; align-items: center; width: 100%; }
1530
+ .profile-export-menu {
1531
+ display: none;
1532
+ position: absolute;
1533
+ bottom: calc(100% + 4px);
1534
+ left: 0; right: 0;
1535
+ background: #1a1a1a;
1536
+ border: 1px solid var(--accent-gold);
1537
+ border-radius: var(--radius);
1538
+ padding: 10px;
1539
+ z-index: 50;
1540
+ box-shadow: 0 8px 24px rgba(0,0,0,0.5);
1541
+ }
1528
1542
  .draft-dropdown { position: relative; }
1529
1543
  .draft-trigger { display: flex; align-items: center; width: 100%; }
1530
1544
  .draft-menu {
@@ -2193,7 +2207,24 @@ function buildHtmlTemplate(data, opts = {}) {
2193
2207
  <i class="fa-solid fa-download"></i> Download
2194
2208
  </div>
2195
2209
  <div class="export-sidebar-btns">
2196
- <button class="export-btn download-all-btn" data-project="${project}"><i class="fa-solid fa-file-zipper"></i> Download All Reports (ZIP)</button>
2210
+ <div class="profile-export-picker" id="profilePicker${suffix}">
2211
+ <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>
2212
+ <div class="profile-export-menu">
2213
+ <div class="draft-menu-section">Profile</div>
2214
+ <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>
2215
+ <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>
2216
+ <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>
2217
+ <div class="draft-menu-section" style="margin-top:8px;">Format</div>
2218
+ <div style="display:flex;gap:6px;flex-wrap:wrap;">
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>
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>
2221
+ <label class="draft-option" style="flex:1;min-width:60px;"><input type="radio" name="exportFmt${suffix}" value="csv" /> <i class="fa-solid fa-table"></i> CSV</label>
2222
+ <label class="draft-option" style="flex:1;min-width:60px;"><input type="radio" name="exportFmt${suffix}" value="zip" /> <i class="fa-solid fa-file-zipper"></i> ZIP</label>
2223
+ </div>
2224
+ <button class="draft-generate-btn profile-download-btn" data-project="${project}" style="margin-top:10px;"><i class="fa-solid fa-download"></i> Download</button>
2225
+ </div>
2226
+ </div>
2227
+ <button class="export-btn download-all-btn" data-project="${project}" style="font-size:0.58rem;opacity:0.6;"><i class="fa-solid fa-file-zipper"></i> Raw Full Export (ZIP)</button>
2197
2228
  </div>
2198
2229
  <div style="position:relative;">
2199
2230
  <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;">
@@ -2592,8 +2623,40 @@ function buildHtmlTemplate(data, opts = {}) {
2592
2623
  }
2593
2624
  return;
2594
2625
  }
2626
+ // Profile export picker toggle
2627
+ var profTrigger = e.target.closest('.profile-export-trigger');
2628
+ if (profTrigger) {
2629
+ var picker = profTrigger.closest('.profile-export-picker');
2630
+ if (picker) {
2631
+ e.stopImmediatePropagation();
2632
+ var menu = picker.querySelector('.profile-export-menu');
2633
+ var wasVis = menu.style.display === 'block';
2634
+ document.querySelectorAll('.profile-export-menu').forEach(function(m) { m.style.display = 'none'; });
2635
+ menu.style.display = wasVis ? 'none' : 'block';
2636
+ return;
2637
+ }
2638
+ }
2639
+ // Profile download button
2640
+ var profDl = e.target.closest('.profile-download-btn');
2641
+ if (profDl) {
2642
+ var picker2 = profDl.closest('.profile-export-picker');
2643
+ if (picker2) {
2644
+ e.stopImmediatePropagation();
2645
+ var projP = profDl.getAttribute('data-project');
2646
+ var profVal = picker2.querySelector('input[name^="exportProfile"]:checked');
2647
+ var fmtVal = picker2.querySelector('input[name^="exportFmt"]:checked');
2648
+ var prof = profVal ? profVal.value : 'content';
2649
+ var fmt2 = fmtVal ? fmtVal.value : 'md';
2650
+ picker2.querySelector('.profile-export-menu').style.display = 'none';
2651
+ if (window.location.protocol.startsWith('http')) {
2652
+ window.location = '/api/export/download?project=' + encodeURIComponent(projP) + '&profile=' + encodeURIComponent(prof) + '&format=' + encodeURIComponent(fmt2);
2653
+ }
2654
+ return;
2655
+ }
2656
+ }
2595
2657
  // Outside click — close all open dropdowns
2596
2658
  document.querySelectorAll('.card-export.open').forEach(function(el) { el.classList.remove('open'); });
2659
+ document.querySelectorAll('.profile-export-menu').forEach(function(m) { m.style.display = 'none'; });
2597
2660
  }, true);
2598
2661
  }
2599
2662
 
package/server.js CHANGED
@@ -594,6 +594,7 @@ 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
597
598
 
598
599
  if (!project) { json(res, 400, { error: 'Missing project' }); return; }
599
600
 
@@ -607,6 +608,25 @@ async function handleRequest(req, res) {
607
608
 
608
609
  const SECTIONS = ['aeo', 'insights', 'technical', 'keywords', 'pages', 'watch', 'schemas', 'headings', 'links'];
609
610
 
611
+ // ── Profile definitions: which sections + which insight types matter ──
612
+ const PROFILES = {
613
+ dev: {
614
+ sections: ['technical', 'schemas', 'links', 'headings', 'watch', 'insights'],
615
+ insightTypes: ['technical_gap', 'quick_win', 'site_watch'],
616
+ label: 'Developer',
617
+ },
618
+ content: {
619
+ sections: ['insights', 'keywords', 'aeo'],
620
+ insightTypes: ['keyword_gap', 'long_tail', 'content_gap', 'new_page', 'keyword_inventor', 'citability_gap', 'positioning'],
621
+ label: 'Content',
622
+ },
623
+ 'ai-pipeline': {
624
+ sections: ['insights', 'aeo', 'technical', 'keywords', 'watch'],
625
+ insightTypes: null, // all types
626
+ label: 'AI Pipeline',
627
+ },
628
+ };
629
+
610
630
  function querySection(sec) {
611
631
  switch (sec) {
612
632
  case 'aeo': {
@@ -726,6 +746,103 @@ async function handleRequest(req, res) {
726
746
  }
727
747
  }
728
748
 
749
+ // ── Profile-aware filtering: strip raw dumps, keep actionable items ──
750
+ function filterForProfile(sec, data, prof) {
751
+ if (!prof || !data) return data;
752
+ const p = PROFILES[prof];
753
+ if (!p) return data;
754
+
755
+ switch (sec) {
756
+ case 'insights': {
757
+ if (!Array.isArray(data)) return data;
758
+ return p.insightTypes ? data.filter(r => p.insightTypes.includes(r._type)) : data;
759
+ }
760
+ case 'technical': {
761
+ if (!Array.isArray(data) || prof === 'ai-pipeline') return data;
762
+ // Dev profile: only pages with issues
763
+ return data.filter(r =>
764
+ r.status_code >= 400 || !r.has_canonical || !r.has_og_tags ||
765
+ !r.has_schema || !r.has_robots || !r.is_mobile_ok ||
766
+ (r.load_ms && r.load_ms > 3000) || (r.word_count != null && r.word_count < 100)
767
+ );
768
+ }
769
+ case 'headings': {
770
+ if (!Array.isArray(data)) return data;
771
+ // Own site only — group by page, return per-page issue summary
772
+ const ownOnly = data.filter(r => r.role === 'target' || r.role === 'owned');
773
+ const byPage = {};
774
+ for (const r of ownOnly) (byPage[r.url] ||= []).push(r);
775
+ const issues = [];
776
+ for (const [url, headings] of Object.entries(byPage)) {
777
+ const h1s = headings.filter(h => h.level === 1);
778
+ const levels = headings.map(h => h.level);
779
+ const problems = [];
780
+ if (h1s.length === 0) problems.push('missing H1');
781
+ else if (h1s.length > 1) problems.push(`${h1s.length}× H1`);
782
+ // Check for skipped levels (e.g. H1→H3 skips H2)
783
+ const unique = [...new Set(levels)].sort((a, b) => a - b);
784
+ for (let i = 1; i < unique.length; i++) {
785
+ if (unique[i] - unique[i - 1] > 1) {
786
+ problems.push(`skips H${unique[i - 1]}→H${unique[i]}`);
787
+ }
788
+ }
789
+ if (problems.length) {
790
+ const sequence = levels.map(l => `H${l}`).join(' → ');
791
+ issues.push({ url, domain: headings[0].domain, issues: problems.join(', '), sequence, heading_count: headings.length });
792
+ }
793
+ }
794
+ return issues;
795
+ }
796
+ case 'links': {
797
+ if (!Array.isArray(data)) return data;
798
+ // Only orphan pages (pages that are never a target) and broken anchors
799
+ const targetUrls = new Set(data.filter(l => l.is_internal).map(l => l.target_url));
800
+ const sourceUrls = new Set(data.map(l => l.source_url));
801
+ // Pages that link out but are never linked TO = orphan
802
+ const orphans = new Set([...sourceUrls].filter(u => !targetUrls.has(u)));
803
+ return data.filter(r => orphans.has(r.source_url) || !r.anchor_text);
804
+ }
805
+ case 'schemas': {
806
+ if (!Array.isArray(data) || prof !== 'dev') return data;
807
+ // Dev: pages missing schema are more useful — but we only have pages WITH schema here
808
+ // So return all (schema gaps come from technical section's has_schema=false)
809
+ return data;
810
+ }
811
+ case 'aeo': {
812
+ if (!Array.isArray(data)) return data;
813
+ if (prof === 'content') {
814
+ // Content: only low-scoring pages (needs improvement)
815
+ return data.filter(r => r.score < 60);
816
+ }
817
+ return data;
818
+ }
819
+ case 'keywords': {
820
+ if (!Array.isArray(data)) return data;
821
+ if (prof === 'content') {
822
+ // Content: only competitor-dominated keywords (role != target/owned)
823
+ const byKw = {};
824
+ for (const r of data) { (byKw[r.keyword] ||= []).push(r); }
825
+ const gapKws = new Set();
826
+ for (const [kw, rows] of Object.entries(byKw)) {
827
+ const hasTarget = rows.some(r => r.role === 'target' || r.role === 'owned');
828
+ const hasCompetitor = rows.some(r => r.role === 'competitor');
829
+ if (!hasTarget && hasCompetitor) gapKws.add(kw);
830
+ }
831
+ return data.filter(r => gapKws.has(r.keyword));
832
+ }
833
+ return data;
834
+ }
835
+ case 'watch': {
836
+ // Keep only errors + warnings, drop notices
837
+ if (data && data.events) {
838
+ return { ...data, events: data.events.filter(e => e.severity === 'error' || e.severity === 'warning') };
839
+ }
840
+ return data;
841
+ }
842
+ default: return data;
843
+ }
844
+ }
845
+
729
846
  function toCSV(rows) {
730
847
  if (!rows || (Array.isArray(rows) && !rows.length)) return '';
731
848
  const arr = Array.isArray(rows) ? rows : (rows.events || rows.pages || []);
@@ -834,26 +951,56 @@ async function handleRequest(req, res) {
834
951
  }
835
952
  }
836
953
 
837
- // Build response based on section + format
838
- const sections = section === 'all' ? SECTIONS : [section];
839
- if (section !== 'all' && !SECTIONS.includes(section)) {
954
+ // ── Resolve sections: profile overrides section=all ──
955
+ const validProfiles = Object.keys(PROFILES);
956
+ if (profile && !validProfiles.includes(profile)) {
957
+ json(res, 400, { error: `Invalid profile. Allowed: ${validProfiles.join(', ')}` });
958
+ return;
959
+ }
960
+ const resolvedSections = profile
961
+ ? PROFILES[profile].sections
962
+ : (section === 'all' ? SECTIONS : [section]);
963
+
964
+ if (!profile && section !== 'all' && !SECTIONS.includes(section)) {
840
965
  json(res, 400, { error: `Invalid section. Allowed: ${SECTIONS.join(', ')}, all` });
841
966
  return;
842
967
  }
843
968
 
969
+ // Helper: query + filter for profile
970
+ function getData(sec) {
971
+ const raw = querySection(sec);
972
+ return profile ? filterForProfile(sec, raw, profile) : raw;
973
+ }
974
+
975
+ function isEmpty(data) {
976
+ if (!data) return true;
977
+ if (Array.isArray(data)) return data.length === 0;
978
+ if (data.events) return data.events.length === 0;
979
+ return false;
980
+ }
981
+
982
+ const profileTag = profile ? `-${profile}` : '';
983
+ const profileLabel = profile ? PROFILES[profile].label : '';
984
+
844
985
  if (format === 'zip') {
845
- // ZIP: bundle all requested sections in all formats
846
986
  const entries = [];
847
- for (const sec of sections) {
848
- const data = querySection(sec);
849
- const baseName = `${project}-${sec}-${dateStr}`;
987
+ for (const sec of resolvedSections) {
988
+ const data = getData(sec);
989
+ if (isEmpty(data)) continue; // skip empty sections
990
+ const baseName = `${project}${profileTag}-${sec}-${dateStr}`;
850
991
  entries.push({ name: `${baseName}.json`, content: JSON.stringify(data, null, 2) });
851
992
  entries.push({ name: `${baseName}.md`, content: toMarkdown(sec, data, project) });
852
993
  const csv = toCSV(data);
853
994
  if (csv) entries.push({ name: `${baseName}.csv`, content: csv });
854
995
  }
996
+ if (!entries.length) {
997
+ json(res, 200, { message: 'No actionable data to export.' });
998
+ return;
999
+ }
855
1000
  const zipBuf = createZip(entries);
856
- const zipName = section === 'all' ? `${project}-full-export-${dateStr}.zip` : `${project}-${section}-${dateStr}.zip`;
1001
+ const zipName = profile
1002
+ ? `${project}-${profile}-export-${dateStr}.zip`
1003
+ : (section === 'all' ? `${project}-full-export-${dateStr}.zip` : `${project}-${section}-${dateStr}.zip`);
857
1004
  res.writeHead(200, {
858
1005
  'Content-Type': 'application/zip',
859
1006
  'Content-Disposition': `attachment; filename="${zipName}"`,
@@ -861,30 +1008,77 @@ async function handleRequest(req, res) {
861
1008
  });
862
1009
  res.end(zipBuf);
863
1010
  } else if (format === 'json') {
864
- const data = querySection(sections[0]);
865
- const fileName = `${project}-${sections[0]}-${dateStr}.json`;
866
- const content = JSON.stringify(data, null, 2);
867
- res.writeHead(200, {
868
- 'Content-Type': 'application/json',
869
- 'Content-Disposition': `attachment; filename="${fileName}"`,
870
- });
871
- res.end(content);
1011
+ if (profile) {
1012
+ // Profile JSON: merged object with all profile sections
1013
+ const result = { profile: profileLabel, project, date: dateStr, sections: {} };
1014
+ for (const sec of resolvedSections) {
1015
+ const data = getData(sec);
1016
+ if (!isEmpty(data)) result.sections[sec] = data;
1017
+ }
1018
+ const fileName = `${project}-${profile}-${dateStr}.json`;
1019
+ res.writeHead(200, {
1020
+ 'Content-Type': 'application/json',
1021
+ 'Content-Disposition': `attachment; filename="${fileName}"`,
1022
+ });
1023
+ res.end(JSON.stringify(result, null, 2));
1024
+ } else {
1025
+ const data = getData(resolvedSections[0]);
1026
+ const fileName = `${project}-${resolvedSections[0]}-${dateStr}.json`;
1027
+ res.writeHead(200, {
1028
+ 'Content-Type': 'application/json',
1029
+ 'Content-Disposition': `attachment; filename="${fileName}"`,
1030
+ });
1031
+ res.end(JSON.stringify(data, null, 2));
1032
+ }
872
1033
  } else if (format === 'csv') {
873
- const data = querySection(sections[0]);
874
- const fileName = `${project}-${sections[0]}-${dateStr}.csv`;
875
- res.writeHead(200, {
876
- 'Content-Type': 'text/csv; charset=utf-8',
877
- 'Content-Disposition': `attachment; filename="${fileName}"`,
878
- });
879
- res.end(toCSV(data));
1034
+ if (profile) {
1035
+ // Profile CSV: concatenate sections with headers
1036
+ let csv = '';
1037
+ for (const sec of resolvedSections) {
1038
+ const data = getData(sec);
1039
+ const secCsv = toCSV(data);
1040
+ if (secCsv) csv += `# ${sec}\n${secCsv}\n\n`;
1041
+ }
1042
+ const fileName = `${project}-${profile}-${dateStr}.csv`;
1043
+ res.writeHead(200, {
1044
+ 'Content-Type': 'text/csv; charset=utf-8',
1045
+ 'Content-Disposition': `attachment; filename="${fileName}"`,
1046
+ });
1047
+ res.end(csv || 'No actionable data.');
1048
+ } else {
1049
+ const data = getData(resolvedSections[0]);
1050
+ const fileName = `${project}-${resolvedSections[0]}-${dateStr}.csv`;
1051
+ res.writeHead(200, {
1052
+ 'Content-Type': 'text/csv; charset=utf-8',
1053
+ 'Content-Disposition': `attachment; filename="${fileName}"`,
1054
+ });
1055
+ res.end(toCSV(data));
1056
+ }
880
1057
  } else if (format === 'md') {
881
- const data = querySection(sections[0]);
882
- const fileName = `${project}-${sections[0]}-${dateStr}.md`;
883
- res.writeHead(200, {
884
- 'Content-Type': 'text/markdown; charset=utf-8',
885
- 'Content-Disposition': `attachment; filename="${fileName}"`,
886
- });
887
- res.end(toMarkdown(sections[0], data, project));
1058
+ if (profile) {
1059
+ // Profile Markdown: combined report
1060
+ let md = `# SEO Intel — ${profileLabel} Report\n\n- Project: ${project}\n- Date: ${dateStr}\n- Profile: ${profileLabel}\n\n`;
1061
+ for (const sec of resolvedSections) {
1062
+ const data = getData(sec);
1063
+ if (isEmpty(data)) continue;
1064
+ md += toMarkdown(sec, data, project).replace(/^# .+\n\n- Project:.+\n- Date:.+\n\n/, ''); // strip per-section header
1065
+ }
1066
+ if (md.split('\n').length < 8) md += '_No actionable data found. Run crawl + extract + analyze first._\n';
1067
+ const fileName = `${project}-${profile}-${dateStr}.md`;
1068
+ res.writeHead(200, {
1069
+ 'Content-Type': 'text/markdown; charset=utf-8',
1070
+ 'Content-Disposition': `attachment; filename="${fileName}"`,
1071
+ });
1072
+ res.end(md);
1073
+ } else {
1074
+ const data = getData(resolvedSections[0]);
1075
+ const fileName = `${project}-${resolvedSections[0]}-${dateStr}.md`;
1076
+ res.writeHead(200, {
1077
+ 'Content-Type': 'text/markdown; charset=utf-8',
1078
+ 'Content-Disposition': `attachment; filename="${fileName}"`,
1079
+ });
1080
+ res.end(toMarkdown(resolvedSections[0], data, project));
1081
+ }
888
1082
  } else {
889
1083
  json(res, 400, { error: 'Invalid format. Allowed: json, csv, md, zip' });
890
1084
  }