seo-intel 1.4.3 → 1.4.5
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 +19 -0
- package/package.json +1 -1
- package/reports/generate-html.js +64 -1
- package/server.js +249 -30
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.4.5 (2026-04-09)
|
|
4
|
+
|
|
5
|
+
### Export: actionable summaries only
|
|
6
|
+
- Technical export: per-page issue summary (own site only) — lists specific problems per URL
|
|
7
|
+
- Links export: per-page link issue summary (own site only) — orphan pages, missing anchors, excessive external links
|
|
8
|
+
- No more raw data dumps in profile exports — every row is an action item
|
|
9
|
+
|
|
10
|
+
## 1.4.4 (2026-04-08)
|
|
11
|
+
|
|
12
|
+
### Export Profiles
|
|
13
|
+
- New profile-based export: Developer, Content, and AI Pipeline profiles
|
|
14
|
+
- Each profile filters to actionable data only — no raw database dumps
|
|
15
|
+
- Developer profile: technical issues, heading problems (own site only), orphan links, schema gaps
|
|
16
|
+
- Content profile: keyword gaps, long-tail opportunities, citability issues, content gaps
|
|
17
|
+
- AI Pipeline profile: structured JSON with all actionable sections for LLM consumption
|
|
18
|
+
- Heading export collapsed to per-page issue summaries (missing H1, duplicate H1, skipped levels)
|
|
19
|
+
- Empty sections automatically skipped in exports
|
|
20
|
+
- Profile picker UI in dashboard sidebar with format selector (MD, JSON, CSV, ZIP)
|
|
21
|
+
|
|
3
22
|
## 1.4.3 (2026-04-07)
|
|
4
23
|
|
|
5
24
|
### Dashboard: Export & Download
|
package/package.json
CHANGED
package/reports/generate-html.js
CHANGED
|
@@ -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
|
-
<
|
|
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,128 @@ 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
|
+
// 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;
|
|
780
|
+
}
|
|
781
|
+
case 'headings': {
|
|
782
|
+
if (!Array.isArray(data)) return data;
|
|
783
|
+
// Own site only — group by page, return per-page issue summary
|
|
784
|
+
const ownOnly = data.filter(r => r.role === 'target' || r.role === 'owned');
|
|
785
|
+
const byPage = {};
|
|
786
|
+
for (const r of ownOnly) (byPage[r.url] ||= []).push(r);
|
|
787
|
+
const issues = [];
|
|
788
|
+
for (const [url, headings] of Object.entries(byPage)) {
|
|
789
|
+
const h1s = headings.filter(h => h.level === 1);
|
|
790
|
+
const levels = headings.map(h => h.level);
|
|
791
|
+
const problems = [];
|
|
792
|
+
if (h1s.length === 0) problems.push('missing H1');
|
|
793
|
+
else if (h1s.length > 1) problems.push(`${h1s.length}× H1`);
|
|
794
|
+
// Check for skipped levels (e.g. H1→H3 skips H2)
|
|
795
|
+
const unique = [...new Set(levels)].sort((a, b) => a - b);
|
|
796
|
+
for (let i = 1; i < unique.length; i++) {
|
|
797
|
+
if (unique[i] - unique[i - 1] > 1) {
|
|
798
|
+
problems.push(`skips H${unique[i - 1]}→H${unique[i]}`);
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
if (problems.length) {
|
|
802
|
+
const sequence = levels.map(l => `H${l}`).join(' → ');
|
|
803
|
+
issues.push({ url, domain: headings[0].domain, issues: problems.join(', '), sequence, heading_count: headings.length });
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
return issues;
|
|
807
|
+
}
|
|
808
|
+
case 'links': {
|
|
809
|
+
if (!Array.isArray(data)) return data;
|
|
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;
|
|
829
|
+
}
|
|
830
|
+
case 'schemas': {
|
|
831
|
+
if (!Array.isArray(data) || prof !== 'dev') return data;
|
|
832
|
+
// Dev: pages missing schema are more useful — but we only have pages WITH schema here
|
|
833
|
+
// So return all (schema gaps come from technical section's has_schema=false)
|
|
834
|
+
return data;
|
|
835
|
+
}
|
|
836
|
+
case 'aeo': {
|
|
837
|
+
if (!Array.isArray(data)) return data;
|
|
838
|
+
if (prof === 'content') {
|
|
839
|
+
// Content: only low-scoring pages (needs improvement)
|
|
840
|
+
return data.filter(r => r.score < 60);
|
|
841
|
+
}
|
|
842
|
+
return data;
|
|
843
|
+
}
|
|
844
|
+
case 'keywords': {
|
|
845
|
+
if (!Array.isArray(data)) return data;
|
|
846
|
+
if (prof === 'content') {
|
|
847
|
+
// Content: only competitor-dominated keywords (role != target/owned)
|
|
848
|
+
const byKw = {};
|
|
849
|
+
for (const r of data) { (byKw[r.keyword] ||= []).push(r); }
|
|
850
|
+
const gapKws = new Set();
|
|
851
|
+
for (const [kw, rows] of Object.entries(byKw)) {
|
|
852
|
+
const hasTarget = rows.some(r => r.role === 'target' || r.role === 'owned');
|
|
853
|
+
const hasCompetitor = rows.some(r => r.role === 'competitor');
|
|
854
|
+
if (!hasTarget && hasCompetitor) gapKws.add(kw);
|
|
855
|
+
}
|
|
856
|
+
return data.filter(r => gapKws.has(r.keyword));
|
|
857
|
+
}
|
|
858
|
+
return data;
|
|
859
|
+
}
|
|
860
|
+
case 'watch': {
|
|
861
|
+
// Keep only errors + warnings, drop notices
|
|
862
|
+
if (data && data.events) {
|
|
863
|
+
return { ...data, events: data.events.filter(e => e.severity === 'error' || e.severity === 'warning') };
|
|
864
|
+
}
|
|
865
|
+
return data;
|
|
866
|
+
}
|
|
867
|
+
default: return data;
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
729
871
|
function toCSV(rows) {
|
|
730
872
|
if (!rows || (Array.isArray(rows) && !rows.length)) return '';
|
|
731
873
|
const arr = Array.isArray(rows) ? rows : (rows.events || rows.pages || []);
|
|
@@ -834,26 +976,56 @@ async function handleRequest(req, res) {
|
|
|
834
976
|
}
|
|
835
977
|
}
|
|
836
978
|
|
|
837
|
-
//
|
|
838
|
-
const
|
|
839
|
-
if (
|
|
979
|
+
// ── Resolve sections: profile overrides section=all ──
|
|
980
|
+
const validProfiles = Object.keys(PROFILES);
|
|
981
|
+
if (profile && !validProfiles.includes(profile)) {
|
|
982
|
+
json(res, 400, { error: `Invalid profile. Allowed: ${validProfiles.join(', ')}` });
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
const resolvedSections = profile
|
|
986
|
+
? PROFILES[profile].sections
|
|
987
|
+
: (section === 'all' ? SECTIONS : [section]);
|
|
988
|
+
|
|
989
|
+
if (!profile && section !== 'all' && !SECTIONS.includes(section)) {
|
|
840
990
|
json(res, 400, { error: `Invalid section. Allowed: ${SECTIONS.join(', ')}, all` });
|
|
841
991
|
return;
|
|
842
992
|
}
|
|
843
993
|
|
|
994
|
+
// Helper: query + filter for profile
|
|
995
|
+
function getData(sec) {
|
|
996
|
+
const raw = querySection(sec);
|
|
997
|
+
return profile ? filterForProfile(sec, raw, profile) : raw;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
function isEmpty(data) {
|
|
1001
|
+
if (!data) return true;
|
|
1002
|
+
if (Array.isArray(data)) return data.length === 0;
|
|
1003
|
+
if (data.events) return data.events.length === 0;
|
|
1004
|
+
return false;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
const profileTag = profile ? `-${profile}` : '';
|
|
1008
|
+
const profileLabel = profile ? PROFILES[profile].label : '';
|
|
1009
|
+
|
|
844
1010
|
if (format === 'zip') {
|
|
845
|
-
// ZIP: bundle all requested sections in all formats
|
|
846
1011
|
const entries = [];
|
|
847
|
-
for (const sec of
|
|
848
|
-
const data =
|
|
849
|
-
|
|
1012
|
+
for (const sec of resolvedSections) {
|
|
1013
|
+
const data = getData(sec);
|
|
1014
|
+
if (isEmpty(data)) continue; // skip empty sections
|
|
1015
|
+
const baseName = `${project}${profileTag}-${sec}-${dateStr}`;
|
|
850
1016
|
entries.push({ name: `${baseName}.json`, content: JSON.stringify(data, null, 2) });
|
|
851
1017
|
entries.push({ name: `${baseName}.md`, content: toMarkdown(sec, data, project) });
|
|
852
1018
|
const csv = toCSV(data);
|
|
853
1019
|
if (csv) entries.push({ name: `${baseName}.csv`, content: csv });
|
|
854
1020
|
}
|
|
1021
|
+
if (!entries.length) {
|
|
1022
|
+
json(res, 200, { message: 'No actionable data to export.' });
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
855
1025
|
const zipBuf = createZip(entries);
|
|
856
|
-
const zipName =
|
|
1026
|
+
const zipName = profile
|
|
1027
|
+
? `${project}-${profile}-export-${dateStr}.zip`
|
|
1028
|
+
: (section === 'all' ? `${project}-full-export-${dateStr}.zip` : `${project}-${section}-${dateStr}.zip`);
|
|
857
1029
|
res.writeHead(200, {
|
|
858
1030
|
'Content-Type': 'application/zip',
|
|
859
1031
|
'Content-Disposition': `attachment; filename="${zipName}"`,
|
|
@@ -861,30 +1033,77 @@ async function handleRequest(req, res) {
|
|
|
861
1033
|
});
|
|
862
1034
|
res.end(zipBuf);
|
|
863
1035
|
} else if (format === 'json') {
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
1036
|
+
if (profile) {
|
|
1037
|
+
// Profile JSON: merged object with all profile sections
|
|
1038
|
+
const result = { profile: profileLabel, project, date: dateStr, sections: {} };
|
|
1039
|
+
for (const sec of resolvedSections) {
|
|
1040
|
+
const data = getData(sec);
|
|
1041
|
+
if (!isEmpty(data)) result.sections[sec] = data;
|
|
1042
|
+
}
|
|
1043
|
+
const fileName = `${project}-${profile}-${dateStr}.json`;
|
|
1044
|
+
res.writeHead(200, {
|
|
1045
|
+
'Content-Type': 'application/json',
|
|
1046
|
+
'Content-Disposition': `attachment; filename="${fileName}"`,
|
|
1047
|
+
});
|
|
1048
|
+
res.end(JSON.stringify(result, null, 2));
|
|
1049
|
+
} else {
|
|
1050
|
+
const data = getData(resolvedSections[0]);
|
|
1051
|
+
const fileName = `${project}-${resolvedSections[0]}-${dateStr}.json`;
|
|
1052
|
+
res.writeHead(200, {
|
|
1053
|
+
'Content-Type': 'application/json',
|
|
1054
|
+
'Content-Disposition': `attachment; filename="${fileName}"`,
|
|
1055
|
+
});
|
|
1056
|
+
res.end(JSON.stringify(data, null, 2));
|
|
1057
|
+
}
|
|
872
1058
|
} else if (format === 'csv') {
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
1059
|
+
if (profile) {
|
|
1060
|
+
// Profile CSV: concatenate sections with headers
|
|
1061
|
+
let csv = '';
|
|
1062
|
+
for (const sec of resolvedSections) {
|
|
1063
|
+
const data = getData(sec);
|
|
1064
|
+
const secCsv = toCSV(data);
|
|
1065
|
+
if (secCsv) csv += `# ${sec}\n${secCsv}\n\n`;
|
|
1066
|
+
}
|
|
1067
|
+
const fileName = `${project}-${profile}-${dateStr}.csv`;
|
|
1068
|
+
res.writeHead(200, {
|
|
1069
|
+
'Content-Type': 'text/csv; charset=utf-8',
|
|
1070
|
+
'Content-Disposition': `attachment; filename="${fileName}"`,
|
|
1071
|
+
});
|
|
1072
|
+
res.end(csv || 'No actionable data.');
|
|
1073
|
+
} else {
|
|
1074
|
+
const data = getData(resolvedSections[0]);
|
|
1075
|
+
const fileName = `${project}-${resolvedSections[0]}-${dateStr}.csv`;
|
|
1076
|
+
res.writeHead(200, {
|
|
1077
|
+
'Content-Type': 'text/csv; charset=utf-8',
|
|
1078
|
+
'Content-Disposition': `attachment; filename="${fileName}"`,
|
|
1079
|
+
});
|
|
1080
|
+
res.end(toCSV(data));
|
|
1081
|
+
}
|
|
880
1082
|
} else if (format === 'md') {
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
1083
|
+
if (profile) {
|
|
1084
|
+
// Profile Markdown: combined report
|
|
1085
|
+
let md = `# SEO Intel — ${profileLabel} Report\n\n- Project: ${project}\n- Date: ${dateStr}\n- Profile: ${profileLabel}\n\n`;
|
|
1086
|
+
for (const sec of resolvedSections) {
|
|
1087
|
+
const data = getData(sec);
|
|
1088
|
+
if (isEmpty(data)) continue;
|
|
1089
|
+
md += toMarkdown(sec, data, project).replace(/^# .+\n\n- Project:.+\n- Date:.+\n\n/, ''); // strip per-section header
|
|
1090
|
+
}
|
|
1091
|
+
if (md.split('\n').length < 8) md += '_No actionable data found. Run crawl + extract + analyze first._\n';
|
|
1092
|
+
const fileName = `${project}-${profile}-${dateStr}.md`;
|
|
1093
|
+
res.writeHead(200, {
|
|
1094
|
+
'Content-Type': 'text/markdown; charset=utf-8',
|
|
1095
|
+
'Content-Disposition': `attachment; filename="${fileName}"`,
|
|
1096
|
+
});
|
|
1097
|
+
res.end(md);
|
|
1098
|
+
} else {
|
|
1099
|
+
const data = getData(resolvedSections[0]);
|
|
1100
|
+
const fileName = `${project}-${resolvedSections[0]}-${dateStr}.md`;
|
|
1101
|
+
res.writeHead(200, {
|
|
1102
|
+
'Content-Type': 'text/markdown; charset=utf-8',
|
|
1103
|
+
'Content-Disposition': `attachment; filename="${fileName}"`,
|
|
1104
|
+
});
|
|
1105
|
+
res.end(toMarkdown(resolvedSections[0], data, project));
|
|
1106
|
+
}
|
|
888
1107
|
} else {
|
|
889
1108
|
json(res, 400, { error: 'Invalid format. Allowed: json, csv, md, zip' });
|
|
890
1109
|
}
|