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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "seo-intel",
3
- "version": "1.4.8",
3
+ "version": "1.5.0",
4
4
  "description": "Local Ahrefs-style SEO competitor intelligence. Crawl → SQLite → cloud analysis.",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -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: 'Missing project' }); return; }
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
- const SECTIONS = ['aeo', 'insights', 'technical', 'keywords', 'pages', 'watch', 'schemas', 'headings', 'links'];
610
-
611
- // ── Profile definitions: which sections + which insight types matter ──
612
- const PROFILES = {
613
- dev: {
614
- sections: ['technical', '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
-
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
- 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 []; }
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
- // ── 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;
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
- 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)) return data;
762
- // Own site only, per-page issue summary
763
- const own = data.filter(r => r.role === 'target' || r.role === 'owned');
764
- const issues = [];
765
- for (const r of own) {
766
- const problems = [];
767
- if (r.status_code >= 400) problems.push(`HTTP ${r.status_code}`);
768
- if (!r.has_canonical) problems.push('no canonical');
769
- if (!r.has_og_tags) problems.push('no OG tags');
770
- if (!r.has_schema) problems.push('no schema');
771
- if (!r.has_robots) problems.push('no robots meta');
772
- if (!r.is_mobile_ok) problems.push('not mobile-friendly');
773
- if (r.load_ms && r.load_ms > 3000) problems.push(`slow (${r.load_ms}ms)`);
774
- if (r.word_count != null && r.word_count < 100) problems.push(`thin content (${r.word_count} words)`);
775
- if (problems.length) {
776
- issues.push({ url: r.url, domain: r.domain, issues: problems.join(', '), status: r.status_code, load_ms: r.load_ms, word_count: r.word_count });
777
- }
778
- }
779
- return issues;
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
- 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
- case 'schemas': return data; // raw only — not in any profile
831
- case 'aeo': {
832
- if (!Array.isArray(data)) return data;
833
- // Own site only, low-scoring pages that need work
834
- const ownAeo = data.filter(r => r.role === 'target' || r.role === 'owned');
835
- return ownAeo.filter(r => r.score < 60);
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
- case 'keywords': {
838
- if (!Array.isArray(data)) return data;
839
- // Only keyword gaps: competitor has it, you don't
840
- const byKw = {};
841
- for (const r of data) { (byKw[r.keyword] ||= []).push(r); }
842
- const gapKws = new Set();
843
- for (const [kw, rows] of Object.entries(byKw)) {
844
- const hasTarget = rows.some(r => r.role === 'target' || r.role === 'owned');
845
- const hasCompetitor = rows.some(r => r.role === 'competitor');
846
- if (!hasTarget && hasCompetitor) gapKws.add(kw);
847
- }
848
- // Return gap keywords with which competitors use them
849
- const gaps = [];
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
- case 'watch': {
859
- // Keep only errors + warnings, drop notices
860
- if (data && data.events) {
861
- return { ...data, events: data.events.filter(e => e.severity === 'error' || e.severity === 'warning') };
862
- }
863
- return data;
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
- function toCSV(rows) {
870
- if (!rows || (Array.isArray(rows) && !rows.length)) return '';
871
- const arr = Array.isArray(rows) ? rows : (rows.events || rows.pages || []);
872
- if (!arr.length) return '';
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 toMarkdown(sec, data, proj) {
726
+ function dashboardToMarkdown(sections, proj, prof) {
883
727
  const date = new Date().toISOString().slice(0, 10);
884
- const header = `# SEO Intel ${sec.charAt(0).toUpperCase() + sec.slice(1)} Export\n\n- Project: ${proj}\n- Date: ${date}\n\n`;
885
- if (!data || (Array.isArray(data) && !data.length)) return header + '_No data available._\n';
886
-
887
- switch (sec) {
888
- case 'aeo': {
889
- const targetRows = data.filter(r => r.role === 'target' || r.role === 'owned');
890
- const avg = targetRows.length ? Math.round(targetRows.reduce((a, r) => a + r.score, 0) / targetRows.length) : 0;
891
- let md = header + `## Summary\n\n- Pages scored: ${data.length}\n- Target average: ${avg}/100\n\n`;
892
- md += '## Page Scores\n\n| Score | Tier | URL | Title | Weakest Signals |\n|-------|------|-----|-------|-----------------|\n';
893
- for (const r of data) {
894
- const signals = ['entity_authority', 'structured_claims', 'answer_density', 'qa_proximity', 'freshness', 'schema_coverage'];
895
- const weakest = signals.sort((a, b) => (r[a] || 0) - (r[b] || 0)).slice(0, 2).map(s => s.replace(/_/g, ' ')).join(', ');
896
- md += `| ${r.score} | ${r.tier} | ${r.url} | ${(r.title || '').slice(0, 50)} | ${weakest} |\n`;
897
- }
898
- return md;
899
- }
900
- case 'insights': {
901
- let md = header + `## Active Insights (${data.length})\n\n`;
902
- const grouped = {};
903
- for (const r of data) { (grouped[r._type] ||= []).push(r); }
904
- for (const [type, items] of Object.entries(grouped)) {
905
- md += `### ${type.replace(/_/g, ' ')} (${items.length})\n\n`;
906
- switch (type) {
907
- case 'quick_win':
908
- md += '| Page | Issue | Fix | Impact |\n|------|-------|-----|--------|\n';
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
- case 'links': {
1011
- let md = header + '## Internal Links\n\n| Source | Target | Anchor |\n|--------|--------|--------|\n';
1012
- for (const r of data.filter(l => l.is_internal).slice(0, 1000)) {
1013
- md += `| ${r.source_url} | ${r.target_url} | ${(r.anchor_text || '').slice(0, 50)} |\n`;
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
- if (data.length > 1000) md += `\n_...and more rows._\n`;
1016
- return md;
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
- // ── Resolve sections: profile overrides section=all ──
1025
- const validProfiles = Object.keys(PROFILES);
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
- 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
- }
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
- 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
-
1052
- const profileTag = profile ? `-${profile}` : '';
1053
- const profileLabel = profile ? PROFILES[profile].label : '';
1054
-
1055
- if (format === 'zip') {
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
- for (const sec of resolvedSections) {
1058
- const data = getData(sec);
1059
- if (isEmpty(data)) continue; // skip empty sections
1060
- const baseName = `${project}${profileTag}-${sec}-${dateStr}`;
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
- const zipName = profile
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
- if (params.get('out')) args.push('--out', params.get('out'));
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'];