seo-intel 1.4.8 → 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 +22 -0
- package/db/db.js +13 -1
- package/package.json +1 -1
- package/reports/generate-html.js +1 -1
- package/server.js +245 -515
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
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
|
+
|
|
13
|
+
## 1.4.9 (2026-04-10)
|
|
14
|
+
|
|
15
|
+
### Security
|
|
16
|
+
- Fixed arbitrary file write via `--out` query param in dashboard terminal API — write paths now server-controlled only
|
|
17
|
+
- Fixed path traversal in froggo config loader — project names validated to `[a-z0-9_-]`
|
|
18
|
+
- Added project name validation to export and terminal API endpoints
|
|
19
|
+
|
|
20
|
+
### URL Normalization
|
|
21
|
+
- Pages are now normalized before storage: fragments stripped (`/#pricing` → `/`), `index.html` collapsed
|
|
22
|
+
- Internal link targets also normalized for consistent orphan/link analysis
|
|
23
|
+
- Re-crawl to clean up existing fragment duplicates in your database
|
|
24
|
+
|
|
3
25
|
## 1.4.8 (2026-04-10)
|
|
4
26
|
|
|
5
27
|
### Export: own site only, zero competitor bloat
|
package/db/db.js
CHANGED
|
@@ -268,7 +268,19 @@ export function upsertDomain(db, { domain, project, role }) {
|
|
|
268
268
|
`).run(domain, project, role, now, now);
|
|
269
269
|
}
|
|
270
270
|
|
|
271
|
+
function normalizePageUrl(rawUrl) {
|
|
272
|
+
try {
|
|
273
|
+
const u = new URL(rawUrl);
|
|
274
|
+
u.hash = ''; // strip fragments (#pricing, #faq, etc.)
|
|
275
|
+
let path = u.pathname;
|
|
276
|
+
path = path.replace(/\/index\.html?$/i, '/'); // /en/index.html → /en/
|
|
277
|
+
u.pathname = path;
|
|
278
|
+
return u.toString();
|
|
279
|
+
} catch { return rawUrl; }
|
|
280
|
+
}
|
|
281
|
+
|
|
271
282
|
export function upsertPage(db, { domainId, url, statusCode, wordCount, loadMs, isIndexable, clickDepth = 0, publishedDate = null, modifiedDate = null, contentHash = null, title = null, metaDesc = null, bodyText = null }) {
|
|
283
|
+
url = normalizePageUrl(url);
|
|
272
284
|
const now = Date.now();
|
|
273
285
|
db.prepare(`
|
|
274
286
|
INSERT INTO pages (domain_id, url, crawled_at, first_seen_at, status_code, word_count, load_ms, is_indexable, click_depth, published_date, modified_date, content_hash, title, meta_desc, body_text)
|
|
@@ -350,7 +362,7 @@ export function insertLinks(db, sourceId, links) {
|
|
|
350
362
|
const stmt = db.prepare(`INSERT INTO links (source_id, target_url, anchor_text, is_internal) VALUES (?, ?, ?, ?)`);
|
|
351
363
|
db.exec('BEGIN');
|
|
352
364
|
try {
|
|
353
|
-
for (const l of links) stmt.run(sourceId, l.url, l.anchor, l.isInternal ? 1 : 0);
|
|
365
|
+
for (const l of links) stmt.run(sourceId, normalizePageUrl(l.url), l.anchor, l.isInternal ? 1 : 0);
|
|
354
366
|
db.exec('COMMIT');
|
|
355
367
|
} catch (e) { db.exec('ROLLBACK'); throw e; }
|
|
356
368
|
}
|
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
|
@@ -596,559 +596,285 @@ async function handleRequest(req, res) {
|
|
|
596
596
|
const format = url.searchParams.get('format') || 'json';
|
|
597
597
|
const profile = url.searchParams.get('profile'); // dev | content | ai-pipeline
|
|
598
598
|
|
|
599
|
-
if (!project) { json(res, 400, { error: '
|
|
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 []; }
|
|
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
|
+
};
|
|
705
630
|
}
|
|
706
|
-
|
|
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 []; }
|
|
719
|
-
}
|
|
720
|
-
case 'headings': {
|
|
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
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
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;
|
|
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
|
+
};
|
|
780
647
|
}
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
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
849
|
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
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
|
-
}
|
|
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';
|
|
1044
853
|
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
}
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
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
|
}
|
|
@@ -1164,6 +890,10 @@ async function handleRequest(req, res) {
|
|
|
1164
890
|
const params = url.searchParams;
|
|
1165
891
|
const command = params.get('command');
|
|
1166
892
|
const project = params.get('project') || '';
|
|
893
|
+
if (project && !/^[a-z0-9_-]+$/i.test(project)) {
|
|
894
|
+
json(res, 400, { error: 'Invalid project name' });
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
1167
897
|
|
|
1168
898
|
// Whitelist allowed commands
|
|
1169
899
|
const ALLOWED = ['crawl', 'extract', 'analyze', 'export-actions', 'competitive-actions',
|
|
@@ -1190,7 +920,7 @@ async function handleRequest(req, res) {
|
|
|
1190
920
|
if (params.get('type')) args.push('--type', params.get('type'));
|
|
1191
921
|
if (params.get('limit')) args.push('--limit', params.get('limit'));
|
|
1192
922
|
if (params.has('raw')) args.push('--raw');
|
|
1193
|
-
|
|
923
|
+
// --out is NOT passed from dashboard — write paths are server-controlled only (see auto-save below)
|
|
1194
924
|
|
|
1195
925
|
// Auto-save exports from dashboard to reports/
|
|
1196
926
|
const EXPORT_CMDS = ['export-actions', 'suggest-usecases', 'competitive-actions'];
|