intelwatch 1.1.6 → 1.3.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.
@@ -1,21 +1,84 @@
1
1
  import { scrapeNewsMentions } from '../scrapers/google-news.js';
2
+ import { searchReddit, searchHackerNews } from '../scrapers/reddit-hn.js';
3
+ import { isPro, printProUpgrade } from '../license.js';
2
4
 
3
5
  export async function runBrandCheck(tracker) {
4
6
  const { brandName } = tracker;
5
7
 
6
- const mentionData = await scrapeNewsMentions(brandName);
8
+ // Fetch from all sources in parallel
9
+ const [mentionData, redditResults, hnResults] = await Promise.all([
10
+ scrapeNewsMentions(brandName),
11
+ searchReddit(brandName, { limit: 15, timeFilter: 'month' }).catch(() => []),
12
+ searchHackerNews(brandName, { limit: 15 }).catch(() => []),
13
+ ]);
14
+
15
+ // Convert Reddit results to mention format
16
+ const redditMentions = redditResults.map(r => ({
17
+ title: r.title,
18
+ url: r.url,
19
+ domain: 'reddit.com',
20
+ category: r.subreddit,
21
+ source: 'reddit',
22
+ sentiment: scoreSentiment(r.title + ' ' + r.selftext),
23
+ score: r.score,
24
+ numComments: r.numComments,
25
+ author: r.author,
26
+ date: r.createdAt,
27
+ }));
28
+
29
+ // Convert HN results to mention format
30
+ const hnMentions = hnResults.map(r => ({
31
+ title: r.title,
32
+ url: r.hnUrl,
33
+ domain: 'news.ycombinator.com',
34
+ category: 'hackernews',
35
+ source: 'hackernews',
36
+ sentiment: 'neutral',
37
+ score: r.points,
38
+ numComments: r.numComments,
39
+ author: r.author,
40
+ date: r.createdAt,
41
+ }));
42
+
43
+ const allMentions = [...(mentionData.mentions || []), ...redditMentions, ...hnMentions];
7
44
 
8
45
  return {
9
46
  type: 'brand',
10
47
  trackerId: tracker.id,
11
48
  brandName,
12
49
  checkedAt: new Date().toISOString(),
13
- mentions: mentionData.mentions,
14
- mentionCount: mentionData.mentionCount,
50
+ mentions: allMentions,
51
+ mentionCount: allMentions.length,
52
+ sources: {
53
+ googleNews: (mentionData.mentions || []).length,
54
+ reddit: redditMentions.length,
55
+ hackerNews: hnMentions.length,
56
+ },
15
57
  error: mentionData.error || null,
58
+ tier: isPro() ? 'pro' : 'free',
16
59
  };
17
60
  }
18
61
 
62
+ /**
63
+ * Simple sentiment scorer for Reddit/HN text.
64
+ */
65
+ function scoreSentiment(text) {
66
+ if (!text) return 'neutral';
67
+ const lower = text.toLowerCase();
68
+ const positive = ['great', 'awesome', 'excellent', 'love', 'best', 'amazing', 'good', 'fantastic', 'recommend', 'impressed'];
69
+ const negative = ['bad', 'terrible', 'worst', 'hate', 'awful', 'horrible', 'scam', 'avoid', 'disappointed', 'broken', 'bug'];
70
+
71
+ let score = 0;
72
+ for (const word of positive) { if (lower.includes(word)) score++; }
73
+ for (const word of negative) { if (lower.includes(word)) score--; }
74
+
75
+ if (score >= 2) return 'positive';
76
+ if (score === 1) return 'slightly_positive';
77
+ if (score <= -2) return 'negative';
78
+ if (score === -1) return 'slightly_negative';
79
+ return 'neutral';
80
+ }
81
+
19
82
  export function diffBrandSnapshots(prev, curr) {
20
83
  const changes = [];
21
84
 
@@ -1,7 +1,7 @@
1
1
  import { analyzeSite, analyzeKeyPages } from '../scrapers/site-analyzer.js';
2
2
  import { scrapeNewsMentions } from '../scrapers/google-news.js';
3
3
  import { searchPressMentions, extractRatingsFromResults } from '../scrapers/brave-search.js';
4
- import { pappersLookup, hasPappersKey } from '../scrapers/pappers.js';
4
+ import { lookupCompany, resolveProvider } from '../providers/registry.js';
5
5
  import { diffTechStacks } from '../utils/tech-detect.js';
6
6
  import { fetch } from '../utils/fetcher.js';
7
7
  import { load } from '../utils/parser.js';
@@ -83,14 +83,11 @@ export async function runCompetitorCheck(tracker) {
83
83
  }
84
84
  } catch {}
85
85
 
86
- // --- Pappers lookup for .fr domains ---
87
- let pappers = null;
88
- const hostname = new URL(url).hostname;
89
- if (hostname.endsWith('.fr') && hasPappersKey()) {
90
- try {
91
- pappers = await pappersLookup(brandName);
92
- } catch {}
93
- }
86
+ // --- Company data lookup (adapts to TLD: Pappers for .fr, OpenCorporates for international) ---
87
+ let companyData = null;
88
+ try {
89
+ companyData = await lookupCompany(brandName, url);
90
+ } catch {}
94
91
 
95
92
  return {
96
93
  type: 'competitor',
@@ -113,7 +110,9 @@ export async function runCompetitorCheck(tracker) {
113
110
  contentStats: siteData.contentStats,
114
111
  press,
115
112
  reputation,
116
- pappers,
113
+ companyData,
114
+ // Backward compat: keep 'pappers' key if data came from Pappers
115
+ pappers: companyData?.source === 'pappers' ? companyData : (companyData || null),
117
116
  };
118
117
  }
119
118
 
@@ -0,0 +1,190 @@
1
+ import chalk from 'chalk';
2
+
3
+ /**
4
+ * Global error handler for CLI
5
+ */
6
+ export function setupGlobalErrorHandler() {
7
+ // Handle uncaught exceptions
8
+ process.on('uncaughtException', (error) => {
9
+ console.error(chalk.red('\n❌ Fatal error occurred:'));
10
+ if (process.env.DEBUG_ERRORS) {
11
+ console.error(error.stack);
12
+ } else {
13
+ console.error(chalk.red(` ${error.message}`));
14
+ console.error(chalk.gray(' Run with DEBUG_ERRORS=1 for full stack trace'));
15
+ }
16
+ process.exit(1);
17
+ });
18
+
19
+ // Handle unhandled promise rejections
20
+ process.on('unhandledRejection', (reason, promise) => {
21
+ console.error(chalk.red('\n❌ Unhandled promise rejection:'));
22
+ if (process.env.DEBUG_ERRORS) {
23
+ console.error(reason);
24
+ } else {
25
+ console.error(chalk.red(` ${reason?.message || reason}`));
26
+ console.error(chalk.gray(' Run with DEBUG_ERRORS=1 for full details'));
27
+ }
28
+ process.exit(1);
29
+ });
30
+ }
31
+
32
+ /**
33
+ * Wrap async functions with error handling
34
+ */
35
+ export function withErrorHandling(fn) {
36
+ return async (...args) => {
37
+ try {
38
+ await fn(...args);
39
+ } catch (error) {
40
+ handleError(error);
41
+ process.exit(1);
42
+ }
43
+ };
44
+ }
45
+
46
+ /**
47
+ * Handle and format errors appropriately
48
+ */
49
+ export function handleError(error, context = '') {
50
+ // Guard against null/undefined/non-object errors
51
+ if (error == null) {
52
+ console.error(chalk.red(`\n❌ Unknown error${context ? ` in ${context}` : ''}`));
53
+ return;
54
+ }
55
+ if (typeof error === 'string') {
56
+ console.error(chalk.red(`\n❌ ${error}`));
57
+ return;
58
+ }
59
+
60
+ if (process.env.NODE_ENV === 'development' || process.env.DEBUG_ERRORS) {
61
+ console.error(chalk.red(`\n❌ Error${context ? ` in ${context}` : ''}:`));
62
+ console.error(error.stack || error);
63
+ return;
64
+ }
65
+
66
+ // Production error handling - user-friendly messages
67
+ const message = formatUserFriendlyError(error);
68
+ console.error(chalk.red(`\n❌ ${message}`));
69
+
70
+ if (error.code || error.status) {
71
+ console.error(chalk.gray(` Error code: ${error.code || error.status}`));
72
+ }
73
+
74
+ console.error(chalk.gray(' Run with DEBUG_ERRORS=1 for technical details'));
75
+ }
76
+
77
+ /**
78
+ * Convert technical errors to user-friendly messages
79
+ */
80
+ function formatUserFriendlyError(error) {
81
+ // Network errors
82
+ if (error.code === 'ENOTFOUND' || error.code === 'ECONNREFUSED') {
83
+ return 'Network error: Unable to connect. Check your internet connection.';
84
+ }
85
+
86
+ if (error.code === 'ETIMEDOUT' || error.message?.includes('timeout')) {
87
+ return 'Request timed out. The server took too long to respond.';
88
+ }
89
+
90
+ // HTTP errors
91
+ if (error.response?.status === 401) {
92
+ return 'Authentication failed. Check your API keys or credentials.';
93
+ }
94
+
95
+ if (error.response?.status === 403) {
96
+ return 'Access denied. You may not have permission for this resource.';
97
+ }
98
+
99
+ if (error.response?.status === 404) {
100
+ return 'Resource not found. The requested data may no longer exist.';
101
+ }
102
+
103
+ if (error.response?.status === 429) {
104
+ return 'Rate limited. Too many requests - please wait before trying again.';
105
+ }
106
+
107
+ if (error.response?.status >= 500) {
108
+ return 'Server error. The remote service is experiencing issues.';
109
+ }
110
+
111
+ // File system errors
112
+ if (error.code === 'ENOENT') {
113
+ return `File not found: ${error.path || 'unknown file'}`;
114
+ }
115
+
116
+ if (error.code === 'EACCES' || error.code === 'EPERM') {
117
+ return `Permission denied: ${error.path || 'access denied'}`;
118
+ }
119
+
120
+ // JSON parsing errors
121
+ if (error.name === 'SyntaxError' && error.message?.includes('JSON')) {
122
+ return 'Invalid JSON response. The server returned malformed data.';
123
+ }
124
+
125
+ // AI API errors
126
+ if (error.message?.includes('OpenAI') || error.message?.includes('Anthropic')) {
127
+ return `AI service error: ${error.message}`;
128
+ }
129
+
130
+ // Generic fallback
131
+ return error.message || 'An unexpected error occurred';
132
+ }
133
+
134
+ /**
135
+ * Validate required environment variables
136
+ */
137
+ export function validateEnvironment(required = []) {
138
+ const missing = required.filter(key => !process.env[key]);
139
+
140
+ if (missing.length > 0) {
141
+ console.error(chalk.red('\n❌ Missing required environment variables:'));
142
+ for (const key of missing) {
143
+ console.error(chalk.red(` - ${key}`));
144
+ }
145
+ console.error(chalk.gray('\nPlease set these variables and try again.'));
146
+ process.exit(1);
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Retry function with exponential backoff
152
+ */
153
+ export async function retry(fn, options = {}) {
154
+ const {
155
+ maxAttempts = 3,
156
+ baseDelay = 1000,
157
+ maxDelay = 10000,
158
+ backoffFactor = 2,
159
+ onRetry = () => {}
160
+ } = options;
161
+
162
+ let lastError;
163
+
164
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
165
+ try {
166
+ return await fn();
167
+ } catch (error) {
168
+ lastError = error;
169
+
170
+ if (attempt === maxAttempts) {
171
+ break;
172
+ }
173
+
174
+ // Don't retry on certain errors
175
+ if (error.response?.status === 401 || error.response?.status === 403) {
176
+ break;
177
+ }
178
+
179
+ const delay = Math.min(
180
+ baseDelay * Math.pow(backoffFactor, attempt - 1),
181
+ maxDelay
182
+ );
183
+
184
+ onRetry(error, attempt, delay);
185
+ await new Promise(resolve => setTimeout(resolve, delay));
186
+ }
187
+ }
188
+
189
+ throw lastError;
190
+ }
@@ -0,0 +1,323 @@
1
+ import { writeFileSync } from 'fs';
2
+ import { resolve } from 'path';
3
+ import * as XLSX from 'xlsx';
4
+ import { isPro, requirePro, getLimits, applyFreeLimit, printPaywallAndExit } from '../license.js';
5
+
6
+ // ── JSON Export ──────────────────────────────────────────────────────────────
7
+
8
+ /**
9
+ * Export data to JSON format.
10
+ * @param {any} data
11
+ * @param {string|null} outputPath
12
+ * @returns {string} Status message
13
+ */
14
+ export function exportToJSON(data, outputPath = null) {
15
+ const jsonStr = JSON.stringify(data, null, 2);
16
+
17
+ if (outputPath) {
18
+ writeFileSync(resolve(outputPath), jsonStr, 'utf8');
19
+ return `Exported to ${outputPath}`;
20
+ }
21
+
22
+ console.log(jsonStr);
23
+ return 'JSON output printed to console';
24
+ }
25
+
26
+ // ── CSV Export ────────────────────────────────────────────────────────────────
27
+
28
+ /**
29
+ * Escape a value for CSV (RFC 4180 compliant).
30
+ * @param {any} value
31
+ * @returns {string}
32
+ */
33
+ function escapeCSV(value) {
34
+ if (value == null) return '';
35
+ let str = typeof value === 'object'
36
+ ? (Array.isArray(value) ? value.join('; ') : JSON.stringify(value))
37
+ : String(value);
38
+ // Always quote if contains comma, double-quote, newline, or leading/trailing whitespace
39
+ if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r') || str !== str.trim()) {
40
+ str = `"${str.replace(/"/g, '""')}"`;
41
+ }
42
+ return str;
43
+ }
44
+
45
+ /**
46
+ * Export an array of objects to CSV.
47
+ * @param {Array<object>} data
48
+ * @param {string|null} outputPath
49
+ * @param {{ headers?: string[], separator?: string }} options
50
+ * @returns {string} Status message
51
+ */
52
+ export function exportToCSV(data, outputPath = null, options = {}) {
53
+ if (!Array.isArray(data)) {
54
+ throw new Error('CSV export requires an array of objects');
55
+ }
56
+
57
+ const sep = options.separator || ',';
58
+
59
+ if (data.length === 0) {
60
+ const empty = options.headers ? options.headers.join(sep) + '\n' : '';
61
+ if (outputPath) {
62
+ writeFileSync(resolve(outputPath), empty, 'utf8');
63
+ return `Empty CSV exported to ${outputPath}`;
64
+ }
65
+ console.log(empty);
66
+ return 'Empty CSV output';
67
+ }
68
+
69
+ const headers = options.headers || Object.keys(data[0]);
70
+ const rows = [headers.join(sep)];
71
+
72
+ for (const item of data) {
73
+ const row = headers.map(h => escapeCSV(item[h]));
74
+ rows.push(row.join(sep));
75
+ }
76
+
77
+ const csvStr = rows.join('\n') + '\n';
78
+
79
+ if (outputPath) {
80
+ writeFileSync(resolve(outputPath), csvStr, 'utf8');
81
+ return `CSV exported to ${outputPath}`;
82
+ }
83
+
84
+ console.log(csvStr);
85
+ return 'CSV output printed to console';
86
+ }
87
+
88
+ // ── XLS Export (xlsx format) ─────────────────────────────────────────────────
89
+
90
+ /**
91
+ * Export data to XLSX (Excel) format.
92
+ * @param {Array<object>} data — rows to export
93
+ * @param {string} outputPath — output file (.xlsx)
94
+ * @param {{ sheetName?: string, headers?: string[] }} options
95
+ * @returns {string} Status message
96
+ */
97
+ export function exportToXLS(data, outputPath, options = {}) {
98
+ if (!outputPath) throw new Error('XLS export requires an output path');
99
+ if (!Array.isArray(data)) throw new Error('XLS export requires an array of objects');
100
+
101
+ const sheetName = options.sheetName || 'Data';
102
+ const headers = options.headers || (data.length > 0 ? Object.keys(data[0]) : []);
103
+
104
+ // Build worksheet from array of arrays (header row + data rows)
105
+ const aoa = [headers];
106
+ for (const item of data) {
107
+ aoa.push(headers.map(h => {
108
+ const v = item[h];
109
+ if (v == null) return '';
110
+ if (Array.isArray(v)) return v.join('; ');
111
+ if (typeof v === 'object') return JSON.stringify(v);
112
+ return v;
113
+ }));
114
+ }
115
+
116
+ const wb = XLSX.utils.book_new();
117
+ const ws = XLSX.utils.aoa_to_sheet(aoa);
118
+
119
+ // Auto-size columns (approximate)
120
+ ws['!cols'] = headers.map((h, i) => {
121
+ let maxLen = h.length;
122
+ for (const row of aoa.slice(1)) {
123
+ const cellLen = String(row[i] || '').length;
124
+ if (cellLen > maxLen) maxLen = cellLen;
125
+ }
126
+ return { wch: Math.min(maxLen + 2, 60) };
127
+ });
128
+
129
+ XLSX.utils.book_append_sheet(wb, ws, sheetName);
130
+ XLSX.writeFile(wb, resolve(outputPath));
131
+ return `XLS exported to ${outputPath}`;
132
+ }
133
+
134
+ // ── PDF Export (via @recognity/pdf-report) ────────────────────────────────────
135
+
136
+ /**
137
+ * Export data to a PDF report.
138
+ * @param {object} data — structured report data
139
+ * @param {string} outputPath — output file (.pdf)
140
+ * @param {{ type?: string, title?: string, branding?: object }} options
141
+ * @returns {Promise<string>} Status message
142
+ */
143
+ export async function exportToPDF(data, outputPath, options = {}) {
144
+ if (!outputPath) throw new Error('PDF export requires an output path');
145
+
146
+ const { generatePDF } = await import('@recognity/pdf-report');
147
+ await generatePDF({
148
+ type: options.type || 'intel-report',
149
+ title: options.title || 'IntelWatch Report',
150
+ data,
151
+ output: resolve(outputPath),
152
+ branding: options.branding,
153
+ });
154
+
155
+ return `PDF exported to ${outputPath}`;
156
+ }
157
+
158
+ // ── Flatten Object ───────────────────────────────────────────────────────────
159
+
160
+ /**
161
+ * Flatten nested objects for tabular export.
162
+ * { user: { name: 'Jo' } } → { 'user.name': 'Jo' }
163
+ */
164
+ export function flattenObject(obj, prefix = '', result = {}) {
165
+ for (const key of Object.keys(obj)) {
166
+ const newKey = prefix ? `${prefix}.${key}` : key;
167
+ const val = obj[key];
168
+ if (val && typeof val === 'object' && !Array.isArray(val) && !(val instanceof Date)) {
169
+ flattenObject(val, newKey, result);
170
+ } else {
171
+ result[newKey] = val;
172
+ }
173
+ }
174
+ return result;
175
+ }
176
+
177
+ // ── Format for Export ────────────────────────────────────────────────────────
178
+
179
+ /**
180
+ * Format data for export based on command type.
181
+ * @param {any} data
182
+ * @param {string} commandType
183
+ * @returns {Array<object>}
184
+ */
185
+ export function formatForExport(data, commandType) {
186
+ switch (commandType) {
187
+ case 'check': return formatCheckData(data);
188
+ case 'digest': return formatDigestData(data);
189
+ case 'report': return formatReportData(data);
190
+ case 'profile': return formatProfileData(data);
191
+ case 'discover': return formatDiscoverData(data);
192
+ default: return Array.isArray(data) ? data : [data];
193
+ }
194
+ }
195
+
196
+ function formatCheckData(data) {
197
+ if (!Array.isArray(data)) return [data];
198
+ return data.map(item => ({
199
+ trackerId: item.id || item.trackerId,
200
+ name: item.name,
201
+ url: item.url,
202
+ type: item.type,
203
+ status: item.status || 'unknown',
204
+ lastCheck: item.lastCheck,
205
+ changes: Array.isArray(item.changes) ? item.changes.length : 0,
206
+ techStack: Array.isArray(item.techStack) ? item.techStack.join('; ') : '',
207
+ seoScore: item.seoScore || null,
208
+ sentiment: item.sentiment || null,
209
+ }));
210
+ }
211
+
212
+ function formatDigestData(data) {
213
+ if (!Array.isArray(data)) return [data];
214
+ return data.map(item => flattenObject({
215
+ tracker: { id: item.trackerId, name: item.name, type: item.type },
216
+ changes: {
217
+ total: item.changes?.length || 0,
218
+ critical: item.changes?.filter(c => c.severity === 'critical').length || 0,
219
+ major: item.changes?.filter(c => c.severity === 'major').length || 0,
220
+ minor: item.changes?.filter(c => c.severity === 'minor').length || 0,
221
+ },
222
+ summary: item.summary || '',
223
+ }));
224
+ }
225
+
226
+ function formatReportData(data) {
227
+ return Array.isArray(data) ? data : [data];
228
+ }
229
+
230
+ function formatProfileData(data) {
231
+ if (!data) return [];
232
+ const profile = Array.isArray(data) ? data[0] : data;
233
+ return [{
234
+ siren: profile.siren,
235
+ name: profile.name || profile.identity?.name,
236
+ legalForm: profile.identity?.formeJuridique,
237
+ nafCode: profile.identity?.nafCode,
238
+ nafLabel: profile.identity?.nafLabel,
239
+ creationDate: profile.identity?.dateCreation,
240
+ address: profile.identity?.adresse,
241
+ revenue: profile.financialHistory?.[0]?.revenue,
242
+ netIncome: profile.financialHistory?.[0]?.netIncome,
243
+ employees: profile.financialHistory?.[0]?.employees,
244
+ year: profile.financialHistory?.[0]?.year,
245
+ executiveSummary: profile.executiveSummary,
246
+ healthScore: profile.healthScore?.score,
247
+ riskLevel: profile.riskAssessment?.overall,
248
+ strengths: profile.strengths?.map(s => s.text || s).join('; ') || '',
249
+ weaknesses: profile.weaknesses?.map(w => w.text || w).join('; ') || '',
250
+ competitors: profile.competitors?.map(c => c.name).join('; ') || '',
251
+ subsidiariesCount: profile.subsidiaries?.length || 0,
252
+ groupRevenue: profile.groupStructure?.consolidatedRevenue,
253
+ }];
254
+ }
255
+
256
+ function formatDiscoverData(data) {
257
+ if (!Array.isArray(data)) return [data];
258
+ return data.map(item => ({
259
+ name: item.name || item.domain,
260
+ domain: item.domain,
261
+ url: item.url,
262
+ relevanceScore: item.relevanceScore || item.score,
263
+ description: item.description || item.snippet || '',
264
+ category: item.category || '',
265
+ }));
266
+ }
267
+
268
+ // ── Unified Export Handler ───────────────────────────────────────────────────
269
+
270
+ /**
271
+ * Handle --export flag for any command.
272
+ *
273
+ * Free tier: json, csv (capped at 50 rows)
274
+ * Pro tier: json, csv (unlimited), xls, pdf
275
+ *
276
+ * @param {string} format — 'json' | 'csv' | 'xls' | 'pdf'
277
+ * @param {any} data — data to export
278
+ * @param {{ output?: string, commandType?: string, pdfOptions?: object }} options
279
+ * @returns {Promise<string>} Status message
280
+ */
281
+ export async function handleExport(format, data, options = {}) {
282
+ const fmt = format.toLowerCase();
283
+ const limits = getLimits();
284
+
285
+ // Gate Pro-only formats — clean paywall exit
286
+ if (['xls', 'xlsx', 'excel', 'pdf'].includes(fmt)) {
287
+ if (!isPro()) {
288
+ printPaywallAndExit(`Export to ${fmt.toUpperCase()}`);
289
+ }
290
+ }
291
+
292
+ let formatted = options.commandType
293
+ ? formatForExport(data, options.commandType)
294
+ : (Array.isArray(data) ? data : [data]);
295
+
296
+ // Apply Free-tier row cap on CSV
297
+ if (fmt === 'csv' && Array.isArray(formatted)) {
298
+ formatted = applyFreeLimit(formatted, limits.csvMaxRows, 'CSV export rows');
299
+ }
300
+
301
+ switch (fmt) {
302
+ case 'json': {
303
+ const outPath = options.output?.replace(/\.[^.]+$/, '.json') || null;
304
+ return exportToJSON(formatted, outPath);
305
+ }
306
+ case 'csv': {
307
+ const outPath = options.output?.replace(/\.[^.]+$/, '.csv') || null;
308
+ return exportToCSV(formatted, outPath);
309
+ }
310
+ case 'xls':
311
+ case 'xlsx':
312
+ case 'excel': {
313
+ const outPath = options.output?.replace(/\.[^.]+$/, '.xlsx') || 'export.xlsx';
314
+ return exportToXLS(formatted, outPath);
315
+ }
316
+ case 'pdf': {
317
+ const outPath = options.output?.replace(/\.[^.]+$/, '.pdf') || 'export.pdf';
318
+ return exportToPDF(options.pdfData || data, outPath, options.pdfOptions || {});
319
+ }
320
+ default:
321
+ throw new Error(`Unsupported export format: ${format}. Use json, csv, xls, or pdf.`);
322
+ }
323
+ }