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.
- package/CHANGELOG-DRAFT.md +44 -0
- package/CHANGELOG.md +105 -1
- package/Endrix-Intelwatch-DueDil.pdf +0 -0
- package/RELEASE.md +15 -0
- package/bin/intelwatch.js +6 -1
- package/package.json +3 -2
- package/src/commands/check.js +52 -1
- package/src/commands/digest.js +40 -1
- package/src/commands/profile.js +164 -34
- package/src/commands/report.js +25 -1
- package/src/index.js +36 -3
- package/src/license.js +194 -0
- package/src/providers/apollo.js +172 -0
- package/src/providers/clearbit.js +136 -0
- package/src/providers/index.js +30 -0
- package/src/providers/opencorporates.js +159 -0
- package/src/providers/pappers.js +75 -0
- package/src/providers/registry.js +531 -0
- package/src/scrapers/reddit-hn.js +161 -0
- package/src/trackers/brand.js +66 -3
- package/src/trackers/competitor.js +9 -10
- package/src/utils/error-handler.js +190 -0
- package/src/utils/export.js +323 -0
- package/src/utils/i18n.js +153 -0
package/src/trackers/brand.js
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
14
|
-
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 {
|
|
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
|
-
// ---
|
|
87
|
-
let
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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
|
+
}
|