seo-intel 1.4.9 → 1.5.0
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 +10 -0
- package/package.json +1 -1
- package/reports/generate-html.js +1 -1
- package/server.js +239 -513
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.5.0 (2026-04-10)
|
|
4
|
+
|
|
5
|
+
### Export: dashboard data, not raw DB dumps
|
|
6
|
+
- **Complete rewrite** of export endpoint — now exports the same processed data the dashboard shows
|
|
7
|
+
- Dev export: technical scorecard, quick wins, technical gaps, internal link stats, watch alerts
|
|
8
|
+
- Content export: keyword gaps, long-tails, new pages, content gaps, positioning, citability issues
|
|
9
|
+
- AI Pipeline: all actionable sections combined in structured JSON
|
|
10
|
+
- ~14 KB dev export instead of ~200 KB of competitor bloat
|
|
11
|
+
- No more raw link/heading/schema/keyword dumps — every item is an action
|
|
12
|
+
|
|
3
13
|
## 1.4.9 (2026-04-10)
|
|
4
14
|
|
|
5
15
|
### Security
|
package/package.json
CHANGED
package/reports/generate-html.js
CHANGED
|
@@ -40,7 +40,7 @@ function cardExportHtml(section, project) {
|
|
|
40
40
|
/**
|
|
41
41
|
* Gather all dashboard data for a single project
|
|
42
42
|
*/
|
|
43
|
-
function gatherProjectData(db, project, config) {
|
|
43
|
+
export function gatherProjectData(db, project, config) {
|
|
44
44
|
const targetDomain = config.target.domain;
|
|
45
45
|
const competitorDomains = config.competitors.map(c => c.domain);
|
|
46
46
|
const allDomains = [targetDomain, ...competitorDomains];
|
package/server.js
CHANGED
|
@@ -599,556 +599,282 @@ async function handleRequest(req, res) {
|
|
|
599
599
|
if (!project || !/^[a-z0-9_-]+$/i.test(project)) { json(res, 400, { error: 'Invalid project name' }); return; }
|
|
600
600
|
|
|
601
601
|
const { getDb } = await import('./db/db.js');
|
|
602
|
+
const { gatherProjectData } = await import('./reports/generate-html.js');
|
|
602
603
|
const db = getDb(join(__dirname, 'seo-intel.db'));
|
|
603
604
|
const configPath = join(__dirname, 'config', `${project}.json`);
|
|
604
605
|
const config = existsSync(configPath) ? JSON.parse(readFileSync(configPath, 'utf8')) : null;
|
|
606
|
+
if (!config) { json(res, 404, { error: `Project config not found: ${project}` }); return; }
|
|
605
607
|
|
|
606
608
|
const dateStr = new Date().toISOString().slice(0, 10);
|
|
607
609
|
const { createZip } = await import('./lib/export-zip.js');
|
|
608
610
|
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
};
|
|
629
|
-
|
|
630
|
-
function querySection(sec) {
|
|
631
|
-
switch (sec) {
|
|
632
|
-
case 'aeo': {
|
|
633
|
-
try {
|
|
634
|
-
return db.prepare(`
|
|
635
|
-
SELECT cs.score, cs.entity_authority, cs.structured_claims, cs.answer_density,
|
|
636
|
-
cs.qa_proximity, cs.freshness, cs.schema_coverage, cs.tier, cs.ai_intents,
|
|
637
|
-
p.url, p.title, p.word_count, d.domain, d.role
|
|
638
|
-
FROM citability_scores cs
|
|
639
|
-
JOIN pages p ON p.id = cs.page_id
|
|
640
|
-
JOIN domains d ON d.id = p.domain_id
|
|
641
|
-
WHERE d.project = ?
|
|
642
|
-
ORDER BY d.role ASC, cs.score ASC
|
|
643
|
-
`).all(project);
|
|
644
|
-
} catch { return []; }
|
|
645
|
-
}
|
|
646
|
-
case 'insights': {
|
|
647
|
-
try {
|
|
648
|
-
const rows = db.prepare(
|
|
649
|
-
`SELECT * FROM insights WHERE project = ? AND status = 'active' ORDER BY type, last_seen DESC`
|
|
650
|
-
).all(project);
|
|
651
|
-
return rows.map(r => {
|
|
652
|
-
try { return { ...JSON.parse(r.data), _type: r.type, _id: r.id, _first_seen: r.first_seen, _last_seen: r.last_seen }; }
|
|
653
|
-
catch { return { _type: r.type, _id: r.id, raw: r.data }; }
|
|
654
|
-
});
|
|
655
|
-
} catch { return []; }
|
|
656
|
-
}
|
|
657
|
-
case 'technical': {
|
|
658
|
-
try {
|
|
659
|
-
return db.prepare(`
|
|
660
|
-
SELECT p.url, p.status_code, p.word_count, p.load_ms, p.is_indexable, p.click_depth,
|
|
661
|
-
t.has_canonical, t.has_og_tags, t.has_schema, t.has_robots, t.is_mobile_ok,
|
|
662
|
-
d.domain, d.role
|
|
663
|
-
FROM pages p
|
|
664
|
-
JOIN domains d ON d.id = p.domain_id
|
|
665
|
-
LEFT JOIN technical t ON t.page_id = p.id
|
|
666
|
-
WHERE d.project = ?
|
|
667
|
-
ORDER BY d.domain, p.url
|
|
668
|
-
`).all(project);
|
|
669
|
-
} catch { return []; }
|
|
670
|
-
}
|
|
671
|
-
case 'keywords': {
|
|
672
|
-
try {
|
|
673
|
-
return db.prepare(`
|
|
674
|
-
SELECT k.keyword, d.domain, d.role, k.location, COUNT(*) as freq
|
|
675
|
-
FROM keywords k
|
|
676
|
-
JOIN pages p ON p.id = k.page_id
|
|
677
|
-
JOIN domains d ON d.id = p.domain_id
|
|
678
|
-
WHERE d.project = ?
|
|
679
|
-
GROUP BY k.keyword, d.domain
|
|
680
|
-
ORDER BY freq DESC
|
|
681
|
-
`).all(project);
|
|
682
|
-
} catch { return []; }
|
|
683
|
-
}
|
|
684
|
-
case 'pages': {
|
|
685
|
-
try {
|
|
686
|
-
return db.prepare(`
|
|
687
|
-
SELECT p.url, p.status_code, p.word_count, p.load_ms, p.is_indexable, p.click_depth,
|
|
688
|
-
p.title, p.meta_desc, p.published_date, p.modified_date,
|
|
689
|
-
p.crawled_at, p.first_seen_at, d.domain, d.role
|
|
690
|
-
FROM pages p
|
|
691
|
-
JOIN domains d ON d.id = p.domain_id
|
|
692
|
-
WHERE d.project = ?
|
|
693
|
-
ORDER BY d.domain, p.url
|
|
694
|
-
`).all(project);
|
|
695
|
-
} catch { return []; }
|
|
696
|
-
}
|
|
697
|
-
case 'watch': {
|
|
698
|
-
try {
|
|
699
|
-
const snap = db.prepare('SELECT * FROM watch_snapshots WHERE project = ? ORDER BY created_at DESC LIMIT 1').get(project);
|
|
700
|
-
if (!snap) return [];
|
|
701
|
-
const events = db.prepare('SELECT * FROM watch_events WHERE snapshot_id = ? ORDER BY severity, event_type').all(snap.id);
|
|
702
|
-
const pages = db.prepare('SELECT * FROM watch_page_states WHERE snapshot_id = ?').all(snap.id);
|
|
703
|
-
return { snapshot: snap, events, pages };
|
|
704
|
-
} catch { return []; }
|
|
705
|
-
}
|
|
706
|
-
case 'schemas': {
|
|
707
|
-
try {
|
|
708
|
-
return db.prepare(`
|
|
709
|
-
SELECT d.domain, d.role, p.url, ps.schema_type, ps.name, ps.description,
|
|
710
|
-
ps.rating, ps.rating_count, ps.price, ps.currency, ps.author,
|
|
711
|
-
ps.date_published, ps.date_modified
|
|
712
|
-
FROM page_schemas ps
|
|
713
|
-
JOIN pages p ON p.id = ps.page_id
|
|
714
|
-
JOIN domains d ON d.id = p.domain_id
|
|
715
|
-
WHERE d.project = ?
|
|
716
|
-
ORDER BY d.domain, ps.schema_type
|
|
717
|
-
`).all(project);
|
|
718
|
-
} catch { return []; }
|
|
611
|
+
// ── Gather dashboard data — same source as the HTML dashboard ──
|
|
612
|
+
const dash = gatherProjectData(db, project, config);
|
|
613
|
+
|
|
614
|
+
// ── Build export from dashboard data — exactly what the UI shows ──
|
|
615
|
+
function buildDashboardExport(dash, prof) {
|
|
616
|
+
const a = dash.latestAnalysis || {};
|
|
617
|
+
const sections = {};
|
|
618
|
+
|
|
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 + '%',
|
|
629
|
+
};
|
|
719
630
|
}
|
|
720
|
-
|
|
721
|
-
try {
|
|
722
|
-
return db.prepare(`
|
|
723
|
-
SELECT d.domain, d.role, p.url, h.level, h.text
|
|
724
|
-
FROM headings h
|
|
725
|
-
JOIN pages p ON p.id = h.page_id
|
|
726
|
-
JOIN domains d ON d.id = p.domain_id
|
|
727
|
-
WHERE d.project = ?
|
|
728
|
-
ORDER BY d.domain, p.url, h.level
|
|
729
|
-
`).all(project);
|
|
730
|
-
} catch { return []; }
|
|
731
|
-
}
|
|
732
|
-
case 'links': {
|
|
733
|
-
try {
|
|
734
|
-
return db.prepare(`
|
|
735
|
-
SELECT l.source_page_id, l.target_url, l.anchor_text, l.is_internal,
|
|
736
|
-
p.url as source_url, d.domain, d.role
|
|
737
|
-
FROM links l
|
|
738
|
-
JOIN pages p ON p.id = l.source_page_id
|
|
739
|
-
JOIN domains d ON d.id = p.domain_id
|
|
740
|
-
WHERE d.project = ?
|
|
741
|
-
ORDER BY d.domain, p.url
|
|
742
|
-
`).all(project);
|
|
743
|
-
} catch { return []; }
|
|
744
|
-
}
|
|
745
|
-
default: return [];
|
|
631
|
+
if (a.technical_gaps?.length) sections.technical_gaps = a.technical_gaps;
|
|
746
632
|
}
|
|
747
|
-
}
|
|
748
633
|
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
if (!p) return data;
|
|
634
|
+
// ── Quick Wins ──
|
|
635
|
+
if (!prof || prof === 'dev' || prof === 'ai-pipeline') {
|
|
636
|
+
if (a.quick_wins?.length) sections.quick_wins = a.quick_wins;
|
|
637
|
+
}
|
|
754
638
|
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
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
|
+
};
|
|
759
647
|
}
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
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;
|
|
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);
|
|
829
655
|
}
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
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
|
+
};
|
|
836
694
|
}
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
const
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
for (const kw of gapKws) {
|
|
851
|
-
const rows = byKw[kw];
|
|
852
|
-
const competitors = rows.map(r => r.domain).join(', ');
|
|
853
|
-
const topFreq = Math.max(...rows.map(r => r.freq));
|
|
854
|
-
gaps.push({ keyword: kw, used_by: competitors, frequency: topFreq });
|
|
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
|
+
};
|
|
855
708
|
}
|
|
856
|
-
return gaps.sort((a, b) => b.frequency - a.frequency);
|
|
857
709
|
}
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
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;
|
|
864
717
|
}
|
|
865
|
-
default: return data;
|
|
866
718
|
}
|
|
867
|
-
}
|
|
868
719
|
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
const keys = Object.keys(arr[0]);
|
|
874
|
-
const escape = (v) => {
|
|
875
|
-
if (v == null) return '';
|
|
876
|
-
const s = String(v);
|
|
877
|
-
return s.includes(',') || s.includes('"') || s.includes('\n') ? `"${s.replace(/"/g, '""')}"` : s;
|
|
878
|
-
};
|
|
879
|
-
return [keys.join(','), ...arr.map(r => keys.map(k => escape(r[k])).join(','))].join('\n');
|
|
720
|
+
// ── Crawl Stats ──
|
|
721
|
+
sections.crawl_stats = dash.crawlStats;
|
|
722
|
+
|
|
723
|
+
return sections;
|
|
880
724
|
}
|
|
881
725
|
|
|
882
|
-
function
|
|
726
|
+
function dashboardToMarkdown(sections, proj, prof) {
|
|
883
727
|
const date = new Date().toISOString().slice(0, 10);
|
|
884
|
-
const
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
for (const i of items) md += `| ${i.page || ''} | ${i.issue || ''} | ${i.fix || ''} | ${i.impact || ''} |\n`;
|
|
910
|
-
break;
|
|
911
|
-
case 'keyword_gap':
|
|
912
|
-
md += '| Keyword | Your Coverage | Competitor Coverage |\n|---------|--------------|--------------------|\n';
|
|
913
|
-
for (const i of items) md += `| ${i.keyword || ''} | ${i.your_coverage || i.target_count || 'none'} | ${i.competitor_coverage || i.competitor_count || ''} |\n`;
|
|
914
|
-
break;
|
|
915
|
-
case 'long_tail':
|
|
916
|
-
md += '| Phrase | Parent Keyword | Opportunity |\n|-------|----------------|-------------|\n';
|
|
917
|
-
for (const i of items) md += `| ${i.phrase || ''} | ${i.parent || i.keyword || ''} | ${i.opportunity || i.rationale || ''} |\n`;
|
|
918
|
-
break;
|
|
919
|
-
case 'new_page':
|
|
920
|
-
md += '| Title | Target Keyword | Rationale |\n|-------|----------------|----------|\n';
|
|
921
|
-
for (const i of items) md += `| ${i.title || ''} | ${i.target_keyword || ''} | ${i.rationale || ''} |\n`;
|
|
922
|
-
break;
|
|
923
|
-
case 'content_gap':
|
|
924
|
-
md += '| Topic | Gap | Suggestion |\n|-------|-----|------------|\n';
|
|
925
|
-
for (const i of items) md += `| ${i.topic || ''} | ${i.gap || ''} | ${i.suggestion || ''} |\n`;
|
|
926
|
-
break;
|
|
927
|
-
case 'technical_gap':
|
|
928
|
-
md += '| Issue | Affected | Recommendation |\n|-------|----------|----------------|\n';
|
|
929
|
-
for (const i of items) md += `| ${i.gap || i.issue || ''} | ${i.affected || i.pages || ''} | ${i.recommendation || i.fix || ''} |\n`;
|
|
930
|
-
break;
|
|
931
|
-
case 'citability_gap':
|
|
932
|
-
md += '| URL | Score | Weakest Signals |\n|-----|-------|----------------|\n';
|
|
933
|
-
for (const i of items) md += `| ${i.url || ''} | ${i.score ?? ''} | ${i.weak_signals || ''} |\n`;
|
|
934
|
-
break;
|
|
935
|
-
case 'keyword_inventor':
|
|
936
|
-
md += '| Phrase | Cluster | Search Potential |\n|-------|---------|------------------|\n';
|
|
937
|
-
for (const i of items) md += `| ${i.phrase || ''} | ${i.cluster || ''} | ${i.potential || i.volume || ''} |\n`;
|
|
938
|
-
break;
|
|
939
|
-
case 'site_watch':
|
|
940
|
-
md += '| URL | Event | Details |\n|-----|-------|--------|\n';
|
|
941
|
-
for (const i of items) md += `| ${i.url || ''} | ${i.event_type || ''} | ${i.details || ''} |\n`;
|
|
942
|
-
break;
|
|
943
|
-
default:
|
|
944
|
-
for (const i of items) {
|
|
945
|
-
md += `- ${i.phrase || i.keyword || i.title || i.page || i.message || JSON.stringify(i).slice(0, 120)}\n`;
|
|
946
|
-
}
|
|
947
|
-
}
|
|
948
|
-
md += '\n';
|
|
949
|
-
}
|
|
950
|
-
return md;
|
|
951
|
-
}
|
|
952
|
-
case 'technical': {
|
|
953
|
-
let md = header + '## Technical Audit\n\n| URL | Status | Words | Load ms | Canonical | OG | Schema | Robots | Mobile |\n|-----|--------|-------|---------|-----------|-----|--------|--------|--------|\n';
|
|
954
|
-
for (const r of data) {
|
|
955
|
-
md += `| ${r.url} | ${r.status_code} | ${r.word_count || 0} | ${r.load_ms || 0} | ${r.has_canonical ? 'Y' : 'N'} | ${r.has_og_tags ? 'Y' : 'N'} | ${r.has_schema ? 'Y' : 'N'} | ${r.has_robots ? 'Y' : 'N'} | ${r.is_mobile_ok ? 'Y' : 'N'} |\n`;
|
|
956
|
-
}
|
|
957
|
-
return md;
|
|
958
|
-
}
|
|
959
|
-
case 'keywords': {
|
|
960
|
-
// Profile exports return gap summary; raw exports return full matrix
|
|
961
|
-
if (data[0] && data[0].used_by !== undefined) {
|
|
962
|
-
let md = header + `## Keyword Gaps (${data.length})\n\nKeywords competitors use that you don't.\n\n| Keyword | Used By | Frequency |\n|---------|---------|----------|\n`;
|
|
963
|
-
for (const r of data.slice(0, 200)) {
|
|
964
|
-
md += `| ${r.keyword} | ${r.used_by} | ${r.frequency} |\n`;
|
|
965
|
-
}
|
|
966
|
-
if (data.length > 200) md += `\n_...and ${data.length - 200} more._\n`;
|
|
967
|
-
return md;
|
|
968
|
-
}
|
|
969
|
-
let md = header + '## Keyword Matrix\n\n| Keyword | Domain | Role | Location | Frequency |\n|---------|--------|------|----------|-----------|\n';
|
|
970
|
-
for (const r of data.slice(0, 500)) {
|
|
971
|
-
md += `| ${r.keyword} | ${r.domain} | ${r.role} | ${r.location || ''} | ${r.freq} |\n`;
|
|
972
|
-
}
|
|
973
|
-
if (data.length > 500) md += `\n_...and ${data.length - 500} more rows._\n`;
|
|
974
|
-
return md;
|
|
975
|
-
}
|
|
976
|
-
case 'pages': {
|
|
977
|
-
let md = header + '## Crawled Pages\n\n| URL | Status | Words | Title | Domain | Role |\n|-----|--------|-------|-------|--------|------|\n';
|
|
978
|
-
for (const r of data) {
|
|
979
|
-
md += `| ${r.url} | ${r.status_code} | ${r.word_count || 0} | ${(r.title || '').slice(0, 50)} | ${r.domain} | ${r.role} |\n`;
|
|
980
|
-
}
|
|
981
|
-
return md;
|
|
982
|
-
}
|
|
983
|
-
case 'watch': {
|
|
984
|
-
const snap = data.snapshot || {};
|
|
985
|
-
const events = data.events || [];
|
|
986
|
-
let md = header + `## Site Watch Snapshot\n\n- Health score: ${snap.health_score ?? 'N/A'}\n- Pages: ${snap.total_pages || 0}\n- Errors: ${snap.errors_count || 0} | Warnings: ${snap.warnings_count || 0} | Notices: ${snap.notices_count || 0}\n\n`;
|
|
987
|
-
if (events.length) {
|
|
988
|
-
md += '## Events\n\n| Type | Severity | URL | Details |\n|------|----------|-----|---------|\n';
|
|
989
|
-
for (const e of events) {
|
|
990
|
-
md += `| ${e.event_type} | ${e.severity} | ${e.url} | ${(e.details || '').slice(0, 80)} |\n`;
|
|
991
|
-
}
|
|
992
|
-
}
|
|
993
|
-
return md;
|
|
994
|
-
}
|
|
995
|
-
case 'schemas': {
|
|
996
|
-
let md = header + '## Schema Markup\n\n| Domain | URL | Type | Name | Rating | Price |\n|--------|-----|------|------|--------|-------|\n';
|
|
997
|
-
for (const r of data) {
|
|
998
|
-
md += `| ${r.domain} | ${r.url} | ${r.schema_type} | ${(r.name || '').slice(0, 40)} | ${r.rating || ''} | ${r.price ? r.currency + r.price : ''} |\n`;
|
|
999
|
-
}
|
|
1000
|
-
return md;
|
|
1001
|
-
}
|
|
1002
|
-
case 'headings': {
|
|
1003
|
-
let md = header + '## Heading Structure\n\n| Domain | URL | Level | Text |\n|--------|-----|-------|------|\n';
|
|
1004
|
-
for (const r of data.slice(0, 1000)) {
|
|
1005
|
-
md += `| ${r.domain} | ${r.url} | H${r.level} | ${(r.text || '').slice(0, 80)} |\n`;
|
|
1006
|
-
}
|
|
1007
|
-
if (data.length > 1000) md += `\n_...and ${data.length - 1000} more rows._\n`;
|
|
1008
|
-
return md;
|
|
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`;
|
|
730
|
+
|
|
731
|
+
const s = sections;
|
|
732
|
+
|
|
733
|
+
if (s.technical) {
|
|
734
|
+
md += `## Technical Scorecard\n\n`;
|
|
735
|
+
md += `- Overall: **${s.technical.score}/100**\n`;
|
|
736
|
+
md += `- H1: ${s.technical.h1_coverage} | Meta: ${s.technical.meta_coverage} | Schema: ${s.technical.schema_coverage} | Title: ${s.technical.title_coverage}\n\n`;
|
|
737
|
+
}
|
|
738
|
+
if (s.technical_gaps?.length) {
|
|
739
|
+
md += `## Technical Gaps (${s.technical_gaps.length})\n\n| Issue | Affected | Fix |\n|-------|----------|-----|\n`;
|
|
740
|
+
for (const g of s.technical_gaps) md += `| ${g.gap || g.issue || ''} | ${g.affected || g.pages || ''} | ${g.recommendation || g.fix || ''} |\n`;
|
|
741
|
+
md += '\n';
|
|
742
|
+
}
|
|
743
|
+
if (s.quick_wins?.length) {
|
|
744
|
+
md += `## Quick Wins (${s.quick_wins.length})\n\n| Page | Issue | Fix | Impact |\n|------|-------|-----|--------|\n`;
|
|
745
|
+
for (const w of s.quick_wins) md += `| ${w.page || ''} | ${w.issue || ''} | ${w.fix || ''} | ${w.impact || ''} |\n`;
|
|
746
|
+
md += '\n';
|
|
747
|
+
}
|
|
748
|
+
if (s.internal_links) {
|
|
749
|
+
md += `## Internal Links\n\n- Total links: ${s.internal_links.total_links}\n- Orphan pages: ${s.internal_links.orphan_pages}\n`;
|
|
750
|
+
if (s.internal_links.top_pages?.length) {
|
|
751
|
+
md += '\n| Page | Depth Score |\n|------|-------------|\n';
|
|
752
|
+
for (const p of s.internal_links.top_pages) md += `| ${p.url || p.label} | ${p.count} |\n`;
|
|
1009
753
|
}
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
754
|
+
md += '\n';
|
|
755
|
+
}
|
|
756
|
+
if (s.watch_summary) {
|
|
757
|
+
md += `## Site Watch\n\n- Health: **${s.watch_summary.health_score ?? 'N/A'}** | Errors: ${s.watch_summary.errors} | Warnings: ${s.watch_summary.warnings}\n\n`;
|
|
758
|
+
}
|
|
759
|
+
if (s.watch_alerts?.length) {
|
|
760
|
+
md += `### Alerts (${s.watch_alerts.length})\n\n| Type | Severity | URL | Details |\n|------|----------|-----|---------|\n`;
|
|
761
|
+
for (const e of s.watch_alerts) md += `| ${e.event_type} | ${e.severity} | ${e.url || ''} | ${(e.details || '').slice(0, 80)} |\n`;
|
|
762
|
+
md += '\n';
|
|
763
|
+
}
|
|
764
|
+
if (s.keyword_gaps?.length) {
|
|
765
|
+
md += `## Keyword Gaps (${s.keyword_gaps.length})\n\n| Keyword | Your Coverage | Competitor Coverage |\n|---------|--------------|--------------------|\n`;
|
|
766
|
+
for (const g of s.keyword_gaps) md += `| ${g.keyword || ''} | ${g.your_coverage || g.target_count || 'none'} | ${g.competitor_coverage || g.competitor_count || ''} |\n`;
|
|
767
|
+
md += '\n';
|
|
768
|
+
}
|
|
769
|
+
if (s.top_keyword_gaps?.length) {
|
|
770
|
+
md += `## Top Keyword Gaps\n\n| Keyword | Frequency | Your Count | Gap |\n|---------|-----------|------------|-----|\n`;
|
|
771
|
+
for (const g of s.top_keyword_gaps) md += `| ${g.keyword || ''} | ${g.total || ''} | ${g.target || 0} | ${g.gap || ''} |\n`;
|
|
772
|
+
md += '\n';
|
|
773
|
+
}
|
|
774
|
+
if (s.long_tails?.length) {
|
|
775
|
+
md += `## Long-tail Opportunities (${s.long_tails.length})\n\n| Phrase | Parent | Opportunity |\n|-------|--------|-------------|\n`;
|
|
776
|
+
for (const l of s.long_tails) md += `| ${l.phrase || ''} | ${l.parent || l.keyword || ''} | ${l.opportunity || l.rationale || ''} |\n`;
|
|
777
|
+
md += '\n';
|
|
778
|
+
}
|
|
779
|
+
if (s.new_pages?.length) {
|
|
780
|
+
md += `## New Pages to Create (${s.new_pages.length})\n\n| Title | Target Keyword | Rationale |\n|-------|----------------|----------|\n`;
|
|
781
|
+
for (const p of s.new_pages) md += `| ${p.title || ''} | ${p.target_keyword || ''} | ${p.rationale || ''} |\n`;
|
|
782
|
+
md += '\n';
|
|
783
|
+
}
|
|
784
|
+
if (s.content_gaps?.length) {
|
|
785
|
+
md += `## Content Gaps (${s.content_gaps.length})\n\n| Topic | Gap | Suggestion |\n|-------|-----|------------|\n`;
|
|
786
|
+
for (const g of s.content_gaps) md += `| ${g.topic || ''} | ${g.gap || ''} | ${g.suggestion || ''} |\n`;
|
|
787
|
+
md += '\n';
|
|
788
|
+
}
|
|
789
|
+
if (s.keyword_inventor?.length) {
|
|
790
|
+
md += `## Keyword Ideas (${s.keyword_inventor.length})\n\n| Phrase | Cluster | Potential |\n|-------|---------|----------|\n`;
|
|
791
|
+
for (const k of s.keyword_inventor.slice(0, 50)) md += `| ${k.phrase || ''} | ${k.cluster || ''} | ${k.potential || k.volume || ''} |\n`;
|
|
792
|
+
if (s.keyword_inventor.length > 50) md += `\n_...and ${s.keyword_inventor.length - 50} more._\n`;
|
|
793
|
+
md += '\n';
|
|
794
|
+
}
|
|
795
|
+
if (s.positioning) {
|
|
796
|
+
md += `## Positioning Strategy\n\n`;
|
|
797
|
+
if (s.positioning.open_angle) md += `**Open angle:** ${s.positioning.open_angle}\n\n`;
|
|
798
|
+
if (s.positioning.target_differentiator) md += `**Differentiator:** ${s.positioning.target_differentiator}\n\n`;
|
|
799
|
+
if (s.positioning.competitor_map) md += `**Competitor map:** ${s.positioning.competitor_map}\n\n`;
|
|
800
|
+
}
|
|
801
|
+
if (s.citability_summary) {
|
|
802
|
+
md += `## AI Citability\n\n- Average: **${s.citability_summary.avg_score ?? 'N/A'}/100** (${s.citability_summary.pages_scored} pages, ${s.citability_summary.pages_below_60} below 60)\n\n`;
|
|
803
|
+
}
|
|
804
|
+
if (s.citability_low_scores?.length) {
|
|
805
|
+
md += `### Pages Needing Improvement\n\n| Score | URL | Tier |\n|-------|-----|------|\n`;
|
|
806
|
+
for (const p of s.citability_low_scores) md += `| ${p.score} | ${p.url || ''} | ${p.tier || ''} |\n`;
|
|
807
|
+
md += '\n';
|
|
808
|
+
}
|
|
809
|
+
if (s.schema_types?.length) {
|
|
810
|
+
md += `## Schema Types (own site)\n\n| Type | Count |\n|------|-------|\n`;
|
|
811
|
+
for (const t of s.schema_types) md += `| ${t.type || t.schema_type || ''} | ${t.count || ''} |\n`;
|
|
812
|
+
md += '\n';
|
|
813
|
+
}
|
|
814
|
+
if (s.crawl_stats) {
|
|
815
|
+
md += `## Crawl Info\n\n- Last crawl: ${s.crawl_stats.lastCrawl || 'N/A'}\n- Extracted pages: ${s.crawl_stats.extractedPages || 0}\n`;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
return md;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
function toCSV(obj) {
|
|
822
|
+
// Flatten sections into CSV-friendly rows
|
|
823
|
+
const rows = [];
|
|
824
|
+
for (const [key, val] of Object.entries(obj)) {
|
|
825
|
+
if (Array.isArray(val)) {
|
|
826
|
+
for (const item of val) {
|
|
827
|
+
rows.push({ section: key, ...item });
|
|
1014
828
|
}
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
}
|
|
1018
|
-
default: {
|
|
1019
|
-
return header + '```json\n' + JSON.stringify(data, null, 2).slice(0, 10000) + '\n```\n';
|
|
829
|
+
} else if (val && typeof val === 'object') {
|
|
830
|
+
rows.push({ section: key, ...val });
|
|
1020
831
|
}
|
|
1021
832
|
}
|
|
833
|
+
if (!rows.length) return '';
|
|
834
|
+
const keys = [...new Set(rows.flatMap(r => Object.keys(r)))];
|
|
835
|
+
const escape = (v) => {
|
|
836
|
+
if (v == null) return '';
|
|
837
|
+
const s = String(v);
|
|
838
|
+
return s.includes(',') || s.includes('"') || s.includes('\n') ? `"${s.replace(/"/g, '""')}"` : s;
|
|
839
|
+
};
|
|
840
|
+
return [keys.join(','), ...rows.map(r => keys.map(k => escape(r[k])).join(','))].join('\n');
|
|
1022
841
|
}
|
|
1023
842
|
|
|
1024
|
-
// ──
|
|
1025
|
-
const validProfiles =
|
|
843
|
+
// ── Build and serve ──
|
|
844
|
+
const validProfiles = ['dev', 'content', 'ai-pipeline'];
|
|
1026
845
|
if (profile && !validProfiles.includes(profile)) {
|
|
1027
846
|
json(res, 400, { error: `Invalid profile. Allowed: ${validProfiles.join(', ')}` });
|
|
1028
847
|
return;
|
|
1029
848
|
}
|
|
1030
|
-
const resolvedSections = profile
|
|
1031
|
-
? PROFILES[profile].sections
|
|
1032
|
-
: (section === 'all' ? SECTIONS : [section]);
|
|
1033
|
-
|
|
1034
|
-
if (!profile && section !== 'all' && !SECTIONS.includes(section)) {
|
|
1035
|
-
json(res, 400, { error: `Invalid section. Allowed: ${SECTIONS.join(', ')}, all` });
|
|
1036
|
-
return;
|
|
1037
|
-
}
|
|
1038
|
-
|
|
1039
|
-
// Helper: query + filter for profile
|
|
1040
|
-
function getData(sec) {
|
|
1041
|
-
const raw = querySection(sec);
|
|
1042
|
-
return profile ? filterForProfile(sec, raw, profile) : raw;
|
|
1043
|
-
}
|
|
1044
|
-
|
|
1045
|
-
function isEmpty(data) {
|
|
1046
|
-
if (!data) return true;
|
|
1047
|
-
if (Array.isArray(data)) return data.length === 0;
|
|
1048
|
-
if (data.events) return data.events.length === 0;
|
|
1049
|
-
return false;
|
|
1050
|
-
}
|
|
1051
849
|
|
|
1052
|
-
const
|
|
1053
|
-
const profileLabel = profile ?
|
|
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';
|
|
1054
853
|
|
|
1055
|
-
if (format === '
|
|
854
|
+
if (format === 'json') {
|
|
855
|
+
const content = JSON.stringify({ profile: profileLabel, project, date: dateStr, ...sections }, null, 2);
|
|
856
|
+
const fileName = `${project}-${tag}-${dateStr}.json`;
|
|
857
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Disposition': `attachment; filename="${fileName}"` });
|
|
858
|
+
res.end(content);
|
|
859
|
+
} else if (format === 'md') {
|
|
860
|
+
const content = dashboardToMarkdown(sections, project, profile);
|
|
861
|
+
const fileName = `${project}-${tag}-${dateStr}.md`;
|
|
862
|
+
res.writeHead(200, { 'Content-Type': 'text/markdown; charset=utf-8', 'Content-Disposition': `attachment; filename="${fileName}"` });
|
|
863
|
+
res.end(content);
|
|
864
|
+
} else if (format === 'csv') {
|
|
865
|
+
const content = toCSV(sections);
|
|
866
|
+
const fileName = `${project}-${tag}-${dateStr}.csv`;
|
|
867
|
+
res.writeHead(200, { 'Content-Type': 'text/csv; charset=utf-8', 'Content-Disposition': `attachment; filename="${fileName}"` });
|
|
868
|
+
res.end(content || 'No data.');
|
|
869
|
+
} else if (format === 'zip') {
|
|
1056
870
|
const entries = [];
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
entries.push({ name: `${baseName}.json`, content: JSON.stringify(data, null, 2) });
|
|
1062
|
-
entries.push({ name: `${baseName}.md`, content: toMarkdown(sec, data, project) });
|
|
1063
|
-
const csv = toCSV(data);
|
|
1064
|
-
if (csv) entries.push({ name: `${baseName}.csv`, content: csv });
|
|
1065
|
-
}
|
|
1066
|
-
if (!entries.length) {
|
|
1067
|
-
json(res, 200, { message: 'No actionable data to export.' });
|
|
1068
|
-
return;
|
|
1069
|
-
}
|
|
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) });
|
|
873
|
+
const csv = toCSV(sections);
|
|
874
|
+
if (csv) entries.push({ name: `${project}-${tag}-${dateStr}.csv`, content: csv });
|
|
1070
875
|
const zipBuf = createZip(entries);
|
|
1071
|
-
|
|
1072
|
-
? `${project}-${profile}-export-${dateStr}.zip`
|
|
1073
|
-
: (section === 'all' ? `${project}-full-export-${dateStr}.zip` : `${project}-${section}-${dateStr}.zip`);
|
|
1074
|
-
res.writeHead(200, {
|
|
1075
|
-
'Content-Type': 'application/zip',
|
|
1076
|
-
'Content-Disposition': `attachment; filename="${zipName}"`,
|
|
1077
|
-
'Content-Length': zipBuf.length,
|
|
1078
|
-
});
|
|
876
|
+
res.writeHead(200, { 'Content-Type': 'application/zip', 'Content-Disposition': `attachment; filename="${project}-${tag}-${dateStr}.zip"`, 'Content-Length': zipBuf.length });
|
|
1079
877
|
res.end(zipBuf);
|
|
1080
|
-
} else if (format === 'json') {
|
|
1081
|
-
if (profile) {
|
|
1082
|
-
// Profile JSON: merged object with all profile sections
|
|
1083
|
-
const result = { profile: profileLabel, project, date: dateStr, sections: {} };
|
|
1084
|
-
for (const sec of resolvedSections) {
|
|
1085
|
-
const data = getData(sec);
|
|
1086
|
-
if (!isEmpty(data)) result.sections[sec] = data;
|
|
1087
|
-
}
|
|
1088
|
-
const fileName = `${project}-${profile}-${dateStr}.json`;
|
|
1089
|
-
res.writeHead(200, {
|
|
1090
|
-
'Content-Type': 'application/json',
|
|
1091
|
-
'Content-Disposition': `attachment; filename="${fileName}"`,
|
|
1092
|
-
});
|
|
1093
|
-
res.end(JSON.stringify(result, null, 2));
|
|
1094
|
-
} else {
|
|
1095
|
-
const data = getData(resolvedSections[0]);
|
|
1096
|
-
const fileName = `${project}-${resolvedSections[0]}-${dateStr}.json`;
|
|
1097
|
-
res.writeHead(200, {
|
|
1098
|
-
'Content-Type': 'application/json',
|
|
1099
|
-
'Content-Disposition': `attachment; filename="${fileName}"`,
|
|
1100
|
-
});
|
|
1101
|
-
res.end(JSON.stringify(data, null, 2));
|
|
1102
|
-
}
|
|
1103
|
-
} else if (format === 'csv') {
|
|
1104
|
-
if (profile) {
|
|
1105
|
-
// Profile CSV: concatenate sections with headers
|
|
1106
|
-
let csv = '';
|
|
1107
|
-
for (const sec of resolvedSections) {
|
|
1108
|
-
const data = getData(sec);
|
|
1109
|
-
const secCsv = toCSV(data);
|
|
1110
|
-
if (secCsv) csv += `# ${sec}\n${secCsv}\n\n`;
|
|
1111
|
-
}
|
|
1112
|
-
const fileName = `${project}-${profile}-${dateStr}.csv`;
|
|
1113
|
-
res.writeHead(200, {
|
|
1114
|
-
'Content-Type': 'text/csv; charset=utf-8',
|
|
1115
|
-
'Content-Disposition': `attachment; filename="${fileName}"`,
|
|
1116
|
-
});
|
|
1117
|
-
res.end(csv || 'No actionable data.');
|
|
1118
|
-
} else {
|
|
1119
|
-
const data = getData(resolvedSections[0]);
|
|
1120
|
-
const fileName = `${project}-${resolvedSections[0]}-${dateStr}.csv`;
|
|
1121
|
-
res.writeHead(200, {
|
|
1122
|
-
'Content-Type': 'text/csv; charset=utf-8',
|
|
1123
|
-
'Content-Disposition': `attachment; filename="${fileName}"`,
|
|
1124
|
-
});
|
|
1125
|
-
res.end(toCSV(data));
|
|
1126
|
-
}
|
|
1127
|
-
} else if (format === 'md') {
|
|
1128
|
-
if (profile) {
|
|
1129
|
-
// Profile Markdown: combined report
|
|
1130
|
-
let md = `# SEO Intel — ${profileLabel} Report\n\n- Project: ${project}\n- Date: ${dateStr}\n- Profile: ${profileLabel}\n\n`;
|
|
1131
|
-
for (const sec of resolvedSections) {
|
|
1132
|
-
const data = getData(sec);
|
|
1133
|
-
if (isEmpty(data)) continue;
|
|
1134
|
-
md += toMarkdown(sec, data, project).replace(/^# .+\n\n- Project:.+\n- Date:.+\n\n/, ''); // strip per-section header
|
|
1135
|
-
}
|
|
1136
|
-
if (md.split('\n').length < 8) md += '_No actionable data found. Run crawl + extract + analyze first._\n';
|
|
1137
|
-
const fileName = `${project}-${profile}-${dateStr}.md`;
|
|
1138
|
-
res.writeHead(200, {
|
|
1139
|
-
'Content-Type': 'text/markdown; charset=utf-8',
|
|
1140
|
-
'Content-Disposition': `attachment; filename="${fileName}"`,
|
|
1141
|
-
});
|
|
1142
|
-
res.end(md);
|
|
1143
|
-
} else {
|
|
1144
|
-
const data = getData(resolvedSections[0]);
|
|
1145
|
-
const fileName = `${project}-${resolvedSections[0]}-${dateStr}.md`;
|
|
1146
|
-
res.writeHead(200, {
|
|
1147
|
-
'Content-Type': 'text/markdown; charset=utf-8',
|
|
1148
|
-
'Content-Disposition': `attachment; filename="${fileName}"`,
|
|
1149
|
-
});
|
|
1150
|
-
res.end(toMarkdown(resolvedSections[0], data, project));
|
|
1151
|
-
}
|
|
1152
878
|
} else {
|
|
1153
879
|
json(res, 400, { error: 'Invalid format. Allowed: json, csv, md, zip' });
|
|
1154
880
|
}
|