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 +12 -0
- package/package.json +1 -1
- package/reports/generate-html.js +64 -1
- package/server.js +224 -30
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
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,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
|
-
//
|
|
838
|
-
const
|
|
839
|
-
if (
|
|
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
|
|
848
|
-
const data =
|
|
849
|
-
|
|
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 =
|
|
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
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
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
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
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
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
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
|
}
|