seo-intel 1.4.4 → 1.4.6

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 +14 -0
  2. package/package.json +1 -1
  3. package/server.js +81 -19
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.4.6 (2026-04-09)
4
+
5
+ ### Export: rich actionable content
6
+ - Insights export now renders type-specific tables (quick wins show issue + fix + impact, keyword gaps show coverage, etc.)
7
+ - Schema markup export scoped to own site only — no competitor schema dumps
8
+ - SKILL.md updated with export profiles documentation
9
+
10
+ ## 1.4.5 (2026-04-09)
11
+
12
+ ### Export: actionable summaries only
13
+ - Technical export: per-page issue summary (own site only) — lists specific problems per URL
14
+ - Links export: per-page link issue summary (own site only) — orphan pages, missing anchors, excessive external links
15
+ - No more raw data dumps in profile exports — every row is an action item
16
+
3
17
  ## 1.4.4 (2026-04-08)
4
18
 
5
19
  ### Export Profiles
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "seo-intel",
3
- "version": "1.4.4",
3
+ "version": "1.4.6",
4
4
  "description": "Local Ahrefs-style SEO competitor intelligence. Crawl → SQLite → cloud analysis.",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE",
package/server.js CHANGED
@@ -759,12 +759,24 @@ async function handleRequest(req, res) {
759
759
  }
760
760
  case 'technical': {
761
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
- );
762
+ // Own site only, per-page issue summary
763
+ const own = data.filter(r => r.role === 'target' || r.role === 'owned');
764
+ const issues = [];
765
+ for (const r of own) {
766
+ const problems = [];
767
+ if (r.status_code >= 400) problems.push(`HTTP ${r.status_code}`);
768
+ if (!r.has_canonical) problems.push('no canonical');
769
+ if (!r.has_og_tags) problems.push('no OG tags');
770
+ if (!r.has_schema) problems.push('no schema');
771
+ if (!r.has_robots) problems.push('no robots meta');
772
+ if (!r.is_mobile_ok) problems.push('not mobile-friendly');
773
+ if (r.load_ms && r.load_ms > 3000) problems.push(`slow (${r.load_ms}ms)`);
774
+ if (r.word_count != null && r.word_count < 100) problems.push(`thin content (${r.word_count} words)`);
775
+ if (problems.length) {
776
+ issues.push({ url: r.url, domain: r.domain, issues: problems.join(', '), status: r.status_code, load_ms: r.load_ms, word_count: r.word_count });
777
+ }
778
+ }
779
+ return issues;
768
780
  }
769
781
  case 'headings': {
770
782
  if (!Array.isArray(data)) return data;
@@ -795,18 +807,30 @@ async function handleRequest(req, res) {
795
807
  }
796
808
  case 'links': {
797
809
  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);
810
+ // Own site only, summarize to per-page link issues
811
+ const ownLinks = data.filter(r => r.role === 'target' || r.role === 'owned');
812
+ const internalTargets = new Set(ownLinks.filter(l => l.is_internal).map(l => l.target_url));
813
+ const byPage = {};
814
+ for (const r of ownLinks) (byPage[r.source_url] ||= []).push(r);
815
+ const issues = [];
816
+ for (const [url, links] of Object.entries(byPage)) {
817
+ const problems = [];
818
+ const noAnchor = links.filter(l => !l.anchor_text);
819
+ if (noAnchor.length) problems.push(`${noAnchor.length} links missing anchor text`);
820
+ if (!internalTargets.has(url)) problems.push('orphan page (no internal links point here)');
821
+ const extLinks = links.filter(l => !l.is_internal);
822
+ // flag if page has excessive external links
823
+ if (extLinks.length > 20) problems.push(`${extLinks.length} external links`);
824
+ if (problems.length) {
825
+ issues.push({ url, domain: links[0].domain, issues: problems.join(', '), total_links: links.length, internal: links.filter(l => l.is_internal).length, external: extLinks.length });
826
+ }
827
+ }
828
+ return issues;
804
829
  }
805
830
  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;
831
+ if (!Array.isArray(data)) return data;
832
+ // Own site only for all profiles
833
+ return data.filter(r => r.role === 'target' || r.role === 'owned');
810
834
  }
811
835
  case 'aeo': {
812
836
  if (!Array.isArray(data)) return data;
@@ -880,9 +904,47 @@ async function handleRequest(req, res) {
880
904
  for (const r of data) { (grouped[r._type] ||= []).push(r); }
881
905
  for (const [type, items] of Object.entries(grouped)) {
882
906
  md += `### ${type.replace(/_/g, ' ')} (${items.length})\n\n`;
883
- for (const item of items) {
884
- const desc = item.phrase || item.keyword || item.title || item.page || item.message || JSON.stringify(item).slice(0, 120);
885
- md += `- ${desc}\n`;
907
+ switch (type) {
908
+ case 'quick_win':
909
+ md += '| Page | Issue | Fix | Impact |\n|------|-------|-----|--------|\n';
910
+ for (const i of items) md += `| ${i.page || ''} | ${i.issue || ''} | ${i.fix || ''} | ${i.impact || ''} |\n`;
911
+ break;
912
+ case 'keyword_gap':
913
+ md += '| Keyword | Your Coverage | Competitor Coverage |\n|---------|--------------|--------------------|\n';
914
+ for (const i of items) md += `| ${i.keyword || ''} | ${i.your_coverage || i.target_count || 'none'} | ${i.competitor_coverage || i.competitor_count || ''} |\n`;
915
+ break;
916
+ case 'long_tail':
917
+ md += '| Phrase | Parent Keyword | Opportunity |\n|-------|----------------|-------------|\n';
918
+ for (const i of items) md += `| ${i.phrase || ''} | ${i.parent || i.keyword || ''} | ${i.opportunity || i.rationale || ''} |\n`;
919
+ break;
920
+ case 'new_page':
921
+ md += '| Title | Target Keyword | Rationale |\n|-------|----------------|----------|\n';
922
+ for (const i of items) md += `| ${i.title || ''} | ${i.target_keyword || ''} | ${i.rationale || ''} |\n`;
923
+ break;
924
+ case 'content_gap':
925
+ md += '| Topic | Gap | Suggestion |\n|-------|-----|------------|\n';
926
+ for (const i of items) md += `| ${i.topic || ''} | ${i.gap || ''} | ${i.suggestion || ''} |\n`;
927
+ break;
928
+ case 'technical_gap':
929
+ md += '| Issue | Affected | Recommendation |\n|-------|----------|----------------|\n';
930
+ for (const i of items) md += `| ${i.gap || i.issue || ''} | ${i.affected || i.pages || ''} | ${i.recommendation || i.fix || ''} |\n`;
931
+ break;
932
+ case 'citability_gap':
933
+ md += '| URL | Score | Weakest Signals |\n|-----|-------|----------------|\n';
934
+ for (const i of items) md += `| ${i.url || ''} | ${i.score ?? ''} | ${i.weak_signals || ''} |\n`;
935
+ break;
936
+ case 'keyword_inventor':
937
+ md += '| Phrase | Cluster | Search Potential |\n|-------|---------|------------------|\n';
938
+ for (const i of items) md += `| ${i.phrase || ''} | ${i.cluster || ''} | ${i.potential || i.volume || ''} |\n`;
939
+ break;
940
+ case 'site_watch':
941
+ md += '| URL | Event | Details |\n|-----|-------|--------|\n';
942
+ for (const i of items) md += `| ${i.url || ''} | ${i.event_type || ''} | ${i.details || ''} |\n`;
943
+ break;
944
+ default:
945
+ for (const i of items) {
946
+ md += `- ${i.phrase || i.keyword || i.title || i.page || i.message || JSON.stringify(i).slice(0, 120)}\n`;
947
+ }
886
948
  }
887
949
  md += '\n';
888
950
  }