intelwatch 1.2.0 → 1.3.2
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 +30 -0
- package/Endrix-Intelwatch-DueDil.pdf +0 -0
- package/RELEASE.md +15 -0
- package/export.pdf +0 -0
- package/package.json +3 -2
- package/profile-480254275.pdf +0 -0
- package/profile-775726417.pdf +0 -0
- package/profile-794598813.pdf +0 -0
- package/src/ai/client.js +39 -1
- package/src/commands/profile.js +58 -48
- package/src/commands/report.js +11 -13
- package/src/index.js +30 -4
- 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 +10 -0
- package/src/utils/export.js +221 -99
package/src/license.js
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Freemium license gate for Intelwatch.
|
|
3
|
+
*
|
|
4
|
+
* License check order:
|
|
5
|
+
* 1. process.env.INTELWATCH_PRO_KEY
|
|
6
|
+
* 2. ~/.intelwatch-license (file containing license key)
|
|
7
|
+
*
|
|
8
|
+
* Pro features:
|
|
9
|
+
* - XLS / PDF export
|
|
10
|
+
* - Unlimited CSV rows (Free: capped at 50)
|
|
11
|
+
* - Unlimited Reddit/HN results (Free: capped at 5)
|
|
12
|
+
* - Full Pappers company profile (Free: --preview only)
|
|
13
|
+
* - Full brand mention history
|
|
14
|
+
*
|
|
15
|
+
* The key is validated as a non-empty string. Actual server-side
|
|
16
|
+
* validation can be added later (e.g. license.recognity.fr/verify).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
|
20
|
+
import { join } from 'path';
|
|
21
|
+
import { homedir } from 'os';
|
|
22
|
+
import chalk from 'chalk';
|
|
23
|
+
|
|
24
|
+
const LICENSE_FILE = join(homedir(), '.intelwatch-license');
|
|
25
|
+
const LICENSE_FILE_ALT = join(homedir(), '.intelwatch-pro');
|
|
26
|
+
const LICENSE_URL = 'https://recognity.fr/tools/intelwatch';
|
|
27
|
+
|
|
28
|
+
// ── Limits ───────────────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
export const FREE_LIMITS = {
|
|
31
|
+
csvMaxRows: 50,
|
|
32
|
+
redditMaxResults: 5,
|
|
33
|
+
hnMaxResults: 5,
|
|
34
|
+
pappersFullProfile: false,
|
|
35
|
+
exportFormats: ['json', 'csv'],
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const PRO_LIMITS = {
|
|
39
|
+
csvMaxRows: Infinity,
|
|
40
|
+
redditMaxResults: 100,
|
|
41
|
+
hnMaxResults: 100,
|
|
42
|
+
pappersFullProfile: true,
|
|
43
|
+
exportFormats: ['json', 'csv', 'xls', 'xlsx', 'excel', 'pdf'],
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// ── Cache ────────────────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
let _cachedKey = undefined;
|
|
49
|
+
|
|
50
|
+
function readLicenseKey() {
|
|
51
|
+
if (_cachedKey !== undefined) return _cachedKey;
|
|
52
|
+
|
|
53
|
+
// 1. Environment variable
|
|
54
|
+
if (process.env.INTELWATCH_PRO_KEY) {
|
|
55
|
+
_cachedKey = process.env.INTELWATCH_PRO_KEY.trim();
|
|
56
|
+
return _cachedKey;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// 2. Legacy env var (backward compat with profile.js)
|
|
60
|
+
if (process.env.INTELWATCH_LICENSE_KEY) {
|
|
61
|
+
_cachedKey = process.env.INTELWATCH_LICENSE_KEY.trim();
|
|
62
|
+
return _cachedKey;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// 3. License file (~/.intelwatch-license or ~/.intelwatch-pro)
|
|
66
|
+
for (const file of [LICENSE_FILE, LICENSE_FILE_ALT]) {
|
|
67
|
+
if (existsSync(file)) {
|
|
68
|
+
try {
|
|
69
|
+
const content = readFileSync(file, 'utf8').trim();
|
|
70
|
+
if (content) {
|
|
71
|
+
_cachedKey = content;
|
|
72
|
+
return _cachedKey;
|
|
73
|
+
}
|
|
74
|
+
} catch {
|
|
75
|
+
// Ignore read errors
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
_cachedKey = null;
|
|
81
|
+
return _cachedKey;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── Public API ───────────────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Check if the user has a Pro license.
|
|
88
|
+
*/
|
|
89
|
+
export function isPro() {
|
|
90
|
+
return !!readLicenseKey();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Get the current license key (or null).
|
|
95
|
+
*/
|
|
96
|
+
export function getLicenseKey() {
|
|
97
|
+
return readLicenseKey();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Get the limits for the current tier.
|
|
102
|
+
*/
|
|
103
|
+
export function getLimits() {
|
|
104
|
+
return isPro() ? PRO_LIMITS : FREE_LIMITS;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Assert that a Pro feature is available. Throws if not.
|
|
109
|
+
* @param {string} featureName — human-readable feature name for the error message
|
|
110
|
+
*/
|
|
111
|
+
export function requirePro(featureName) {
|
|
112
|
+
if (isPro()) return;
|
|
113
|
+
const msg = `${featureName} requires an Intelwatch Pro license.`;
|
|
114
|
+
const err = new Error(msg);
|
|
115
|
+
err.code = 'LICENSE_REQUIRED';
|
|
116
|
+
err.featureName = featureName;
|
|
117
|
+
throw err;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Print a Pro upgrade message to stderr (non-blocking, doesn't throw).
|
|
122
|
+
* @param {string} featureName
|
|
123
|
+
*/
|
|
124
|
+
export function printProUpgrade(featureName) {
|
|
125
|
+
console.error('');
|
|
126
|
+
console.error(chalk.yellow(` ⚡ ${featureName} — Pro Feature`));
|
|
127
|
+
console.error(chalk.gray(` Upgrade to Intelwatch Pro for full access.`));
|
|
128
|
+
console.error(chalk.gray(` ${LICENSE_URL}`));
|
|
129
|
+
console.error(chalk.gray(` Set INTELWATCH_PRO_KEY or create ~/.intelwatch-license`));
|
|
130
|
+
console.error('');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Gate a Pro feature: if not Pro, print upgrade message and return false.
|
|
135
|
+
* @param {string} featureName
|
|
136
|
+
* @returns {boolean} true if Pro, false otherwise
|
|
137
|
+
*/
|
|
138
|
+
export function gatePro(featureName) {
|
|
139
|
+
if (isPro()) return true;
|
|
140
|
+
printProUpgrade(featureName);
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Truncate an array to Free-tier limit with a warning.
|
|
146
|
+
* @param {Array} data
|
|
147
|
+
* @param {number} freeLimit
|
|
148
|
+
* @param {string} featureName
|
|
149
|
+
* @returns {Array}
|
|
150
|
+
*/
|
|
151
|
+
export function applyFreeLimit(data, freeLimit, featureName) {
|
|
152
|
+
if (!Array.isArray(data)) return data;
|
|
153
|
+
if (isPro() || data.length <= freeLimit) return data;
|
|
154
|
+
|
|
155
|
+
console.error(chalk.yellow(` ⚠️ Free tier: showing ${freeLimit}/${data.length} results. Upgrade to Pro for unlimited ${featureName}.`));
|
|
156
|
+
return data.slice(0, freeLimit);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Save a license key to ~/.intelwatch-pro and refresh the cache.
|
|
161
|
+
* @param {string} key
|
|
162
|
+
*/
|
|
163
|
+
export function saveLicenseKey(key) {
|
|
164
|
+
const trimmed = (key || '').trim();
|
|
165
|
+
if (!trimmed) {
|
|
166
|
+
throw new Error('License key cannot be empty.');
|
|
167
|
+
}
|
|
168
|
+
writeFileSync(LICENSE_FILE_ALT, trimmed + '\n', 'utf8');
|
|
169
|
+
_cachedKey = undefined; // bust cache
|
|
170
|
+
return LICENSE_FILE_ALT;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Print a clean paywall block and exit the process (non-throwing).
|
|
175
|
+
* Use this instead of requirePro when you want a user-friendly exit.
|
|
176
|
+
* @param {string} featureName
|
|
177
|
+
*/
|
|
178
|
+
export function printPaywallAndExit(featureName) {
|
|
179
|
+
console.error('');
|
|
180
|
+
console.error(chalk.red(' 🔒 This is a Pro feature!'));
|
|
181
|
+
console.error(chalk.yellow(` "${featureName}" requires an Intelwatch Pro license.`));
|
|
182
|
+
console.error('');
|
|
183
|
+
console.error(chalk.gray(' Upgrade at ') + chalk.cyan.underline(LICENSE_URL));
|
|
184
|
+
console.error(chalk.gray(' Then run: ') + chalk.white('intelwatch auth <key>'));
|
|
185
|
+
console.error('');
|
|
186
|
+
process.exit(0);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Reset cached license (for testing).
|
|
191
|
+
*/
|
|
192
|
+
export function _resetCache() {
|
|
193
|
+
_cachedKey = undefined;
|
|
194
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Apollo Provider — International company enrichment (BYOK).
|
|
3
|
+
*
|
|
4
|
+
* API: https://apolloapi.io/developers
|
|
5
|
+
* Requires APOLLO_API_KEY.
|
|
6
|
+
*
|
|
7
|
+
* Provides:
|
|
8
|
+
* - Domain → company enrichment (name, sector, employee count, revenue, tech)
|
|
9
|
+
* - Good for .com / international where Pappers doesn't work
|
|
10
|
+
* - Complements Clearbit with similar enrichment capabilities
|
|
11
|
+
*
|
|
12
|
+
* All Apollo queries are Pro-only (license gate in registry.js).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { fetch } from '../utils/fetcher.js';
|
|
16
|
+
|
|
17
|
+
const APOLLO_API = 'https://api.apollo.io/v1';
|
|
18
|
+
|
|
19
|
+
function getApiKey() {
|
|
20
|
+
return process.env.APOLLO_API_KEY || null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const apolloProvider = {
|
|
24
|
+
name: 'apollo',
|
|
25
|
+
country: 'INTL',
|
|
26
|
+
description: 'Apollo.io — Domain-based company enrichment (sector, size, tech, revenue)',
|
|
27
|
+
|
|
28
|
+
isAvailable() {
|
|
29
|
+
return !!getApiKey();
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Search companies by name or domain.
|
|
34
|
+
* @param {string} query
|
|
35
|
+
* @param {{ count?: number }} options
|
|
36
|
+
* @returns {Promise<{ results: Array, error: string|null }>}
|
|
37
|
+
*/
|
|
38
|
+
async search(query, options = {}) {
|
|
39
|
+
const apiKey = getApiKey();
|
|
40
|
+
if (!apiKey) {
|
|
41
|
+
return { results: [], error: 'APOLLO_API_KEY not set.' };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
const body = {
|
|
46
|
+
q_organization_name: query,
|
|
47
|
+
per_page: Math.min(options.count || 10, 25),
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const resp = await fetch(`${APOLLO_API}/mixed_companies/search`, {
|
|
51
|
+
method: 'POST',
|
|
52
|
+
headers: {
|
|
53
|
+
'Content-Type': 'application/json',
|
|
54
|
+
'Cache-Control': 'no-cache',
|
|
55
|
+
'X-Api-Key': apiKey,
|
|
56
|
+
},
|
|
57
|
+
data: JSON.stringify(body),
|
|
58
|
+
timeout: 15000,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const data = typeof resp.data === 'string' ? JSON.parse(resp.data) : resp.data;
|
|
62
|
+
const orgs = data?.organizations || [];
|
|
63
|
+
|
|
64
|
+
const results = orgs.map(c => ({
|
|
65
|
+
name: c.name,
|
|
66
|
+
domain: c.primary_domain,
|
|
67
|
+
sector: c.industry,
|
|
68
|
+
employeeRange: c.estimated_num_employees ? `${c.estimated_num_employees}` : null,
|
|
69
|
+
estimatedRevenue: c.annual_revenue_printed,
|
|
70
|
+
description: c.short_description,
|
|
71
|
+
foundedYear: c.founded_year,
|
|
72
|
+
location: [c.city, c.state, c.country].filter(Boolean).join(', '),
|
|
73
|
+
country: c.country,
|
|
74
|
+
logo: c.logo_url,
|
|
75
|
+
url: c.website_url || (c.primary_domain ? `https://${c.primary_domain}` : null),
|
|
76
|
+
linkedin: c.linkedin_url,
|
|
77
|
+
phone: c.phone,
|
|
78
|
+
source: 'apollo',
|
|
79
|
+
}));
|
|
80
|
+
|
|
81
|
+
return { results, error: null };
|
|
82
|
+
} catch (err) {
|
|
83
|
+
return { results: [], error: err.message };
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Get company profile by domain.
|
|
89
|
+
* @param {string} domain — e.g. "stripe.com"
|
|
90
|
+
* @param {{ preview?: boolean }} options
|
|
91
|
+
* @returns {Promise<{ data: object|null, error: string|null }>}
|
|
92
|
+
*/
|
|
93
|
+
async getProfile(domain, options = {}) {
|
|
94
|
+
const apiKey = getApiKey();
|
|
95
|
+
if (!apiKey) {
|
|
96
|
+
return { data: null, error: 'APOLLO_API_KEY not set.' };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
const cleanDomain = domain.replace(/^https?:\/\//, '').replace(/^www\./, '').split('/')[0];
|
|
101
|
+
|
|
102
|
+
const resp = await fetch(`${APOLLO_API}/organizations/enrich`, {
|
|
103
|
+
method: 'GET',
|
|
104
|
+
headers: {
|
|
105
|
+
'Cache-Control': 'no-cache',
|
|
106
|
+
'X-Api-Key': apiKey,
|
|
107
|
+
},
|
|
108
|
+
params: { domain: cleanDomain },
|
|
109
|
+
timeout: 15000,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const data = typeof resp.data === 'string' ? JSON.parse(resp.data) : resp.data;
|
|
113
|
+
const c = data?.organization;
|
|
114
|
+
|
|
115
|
+
if (!c) {
|
|
116
|
+
return { data: null, error: 'Company not found on Apollo' };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const profile = {
|
|
120
|
+
name: c.name,
|
|
121
|
+
legalName: c.name,
|
|
122
|
+
domain: c.primary_domain || cleanDomain,
|
|
123
|
+
sector: c.industry,
|
|
124
|
+
subIndustry: c.sub_industry,
|
|
125
|
+
employeeRange: c.estimated_num_employees ? `${c.estimated_num_employees}` : null,
|
|
126
|
+
estimatedRevenue: c.annual_revenue_printed,
|
|
127
|
+
raised: c.total_funding_printed,
|
|
128
|
+
description: c.short_description,
|
|
129
|
+
foundedYear: c.founded_year,
|
|
130
|
+
location: [c.city, c.state, c.country].filter(Boolean).join(', '),
|
|
131
|
+
country: c.country,
|
|
132
|
+
logo: c.logo_url,
|
|
133
|
+
techStack: c.current_technologies?.map(t => t.name) || [],
|
|
134
|
+
tags: c.keywords || [],
|
|
135
|
+
url: c.website_url || `https://${cleanDomain}`,
|
|
136
|
+
linkedin: c.linkedin_url,
|
|
137
|
+
twitter: c.twitter_url,
|
|
138
|
+
facebook: c.facebook_url,
|
|
139
|
+
phone: c.phone,
|
|
140
|
+
source: 'apollo',
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
return { data: profile, error: null };
|
|
144
|
+
} catch (err) {
|
|
145
|
+
return { data: null, error: err.message };
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* No subsidiary data from Apollo.
|
|
151
|
+
*/
|
|
152
|
+
async getSubsidiaries() {
|
|
153
|
+
return {
|
|
154
|
+
subsidiaries: [],
|
|
155
|
+
error: 'Apollo does not provide subsidiary data.',
|
|
156
|
+
};
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Quick lookup by domain.
|
|
161
|
+
*/
|
|
162
|
+
async lookup(companyNameOrDomain) {
|
|
163
|
+
if (!this.isAvailable()) return null;
|
|
164
|
+
const domain = companyNameOrDomain.includes('.')
|
|
165
|
+
? companyNameOrDomain
|
|
166
|
+
: `${companyNameOrDomain.toLowerCase().replace(/\s+/g, '')}.com`;
|
|
167
|
+
const result = await this.getProfile(domain, { preview: true });
|
|
168
|
+
return result.data;
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
export default apolloProvider;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clearbit Provider — International company enrichment (BYOK).
|
|
3
|
+
*
|
|
4
|
+
* API: https://dashboard.clearbit.com/docs
|
|
5
|
+
* Requires CLEARBIT_API_KEY.
|
|
6
|
+
*
|
|
7
|
+
* Provides:
|
|
8
|
+
* - Domain → company enrichment (name, sector, employee count, revenue range, tech stack)
|
|
9
|
+
* - Good for .com / international where Pappers doesn't work
|
|
10
|
+
* - Complements OpenCorporates (OC = registry data, Clearbit = market data)
|
|
11
|
+
*
|
|
12
|
+
* This is a scaffold ready for BYOK integration.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { fetch } from '../utils/fetcher.js';
|
|
16
|
+
|
|
17
|
+
const CLEARBIT_API = 'https://company.clearbit.com/v2';
|
|
18
|
+
|
|
19
|
+
function getApiKey() {
|
|
20
|
+
return process.env.CLEARBIT_API_KEY || null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const clearbitProvider = {
|
|
24
|
+
name: 'clearbit',
|
|
25
|
+
country: 'INTL',
|
|
26
|
+
description: 'Clearbit — Domain-based company enrichment (sector, size, tech, revenue)',
|
|
27
|
+
|
|
28
|
+
isAvailable() {
|
|
29
|
+
return !!getApiKey();
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Search is not the primary Clearbit use case — use domain enrichment instead.
|
|
34
|
+
* Falls back to domain lookup if query looks like a domain.
|
|
35
|
+
*/
|
|
36
|
+
async search(query, options = {}) {
|
|
37
|
+
// Clearbit's primary API is domain-based, not name-based
|
|
38
|
+
if (query.includes('.')) {
|
|
39
|
+
// Looks like a domain — use enrichment
|
|
40
|
+
const profile = await this.getProfile(query);
|
|
41
|
+
if (profile.data) {
|
|
42
|
+
return { results: [profile.data], error: null };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
results: [],
|
|
48
|
+
error: 'Clearbit requires a domain for lookup. Try with a domain (e.g., company.com).',
|
|
49
|
+
};
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Get company profile by domain.
|
|
54
|
+
* @param {string} domain — e.g. "stripe.com"
|
|
55
|
+
* @param {{ preview?: boolean }} options
|
|
56
|
+
* @returns {Promise<{ data: object|null, error: string|null }>}
|
|
57
|
+
*/
|
|
58
|
+
async getProfile(domain, options = {}) {
|
|
59
|
+
const apiKey = getApiKey();
|
|
60
|
+
if (!apiKey) {
|
|
61
|
+
return { data: null, error: 'CLEARBIT_API_KEY not set.' };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
// Clean domain
|
|
66
|
+
const cleanDomain = domain.replace(/^https?:\/\//, '').replace(/^www\./, '').split('/')[0];
|
|
67
|
+
|
|
68
|
+
const resp = await fetch(`${CLEARBIT_API}/companies/find?domain=${cleanDomain}`, {
|
|
69
|
+
headers: {
|
|
70
|
+
Authorization: `Bearer ${apiKey}`,
|
|
71
|
+
Accept: 'application/json',
|
|
72
|
+
},
|
|
73
|
+
timeout: 15000,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
if (resp.status === 404) {
|
|
77
|
+
return { data: null, error: 'Company not found on Clearbit' };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const c = typeof resp.data === 'string' ? JSON.parse(resp.data) : resp.data;
|
|
81
|
+
|
|
82
|
+
const profile = {
|
|
83
|
+
name: c.name,
|
|
84
|
+
legalName: c.legalName,
|
|
85
|
+
domain: c.domain,
|
|
86
|
+
sector: c.category?.sector,
|
|
87
|
+
industry: c.category?.industry,
|
|
88
|
+
subIndustry: c.category?.subIndustry,
|
|
89
|
+
employeeRange: c.metrics?.employeesRange,
|
|
90
|
+
estimatedRevenue: c.metrics?.estimatedAnnualRevenue,
|
|
91
|
+
raised: c.metrics?.raised,
|
|
92
|
+
description: c.description,
|
|
93
|
+
foundedYear: c.foundedYear,
|
|
94
|
+
location: c.geo ? `${c.geo.city}, ${c.geo.country}` : null,
|
|
95
|
+
country: c.geo?.country,
|
|
96
|
+
logo: c.logo,
|
|
97
|
+
techStack: c.tech || [],
|
|
98
|
+
tags: c.tags || [],
|
|
99
|
+
url: `https://${c.domain}`,
|
|
100
|
+
linkedin: c.linkedin?.handle ? `https://linkedin.com/company/${c.linkedin.handle}` : null,
|
|
101
|
+
twitter: c.twitter?.handle ? `https://twitter.com/${c.twitter.handle}` : null,
|
|
102
|
+
facebook: c.facebook?.handle ? `https://facebook.com/${c.facebook.handle}` : null,
|
|
103
|
+
source: 'clearbit',
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
return { data: profile, error: null };
|
|
107
|
+
} catch (err) {
|
|
108
|
+
return { data: null, error: err.message };
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* No subsidiary data from Clearbit.
|
|
114
|
+
*/
|
|
115
|
+
async getSubsidiaries() {
|
|
116
|
+
return {
|
|
117
|
+
subsidiaries: [],
|
|
118
|
+
error: 'Clearbit does not provide subsidiary data.',
|
|
119
|
+
};
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Quick lookup by domain.
|
|
124
|
+
*/
|
|
125
|
+
async lookup(companyNameOrDomain) {
|
|
126
|
+
if (!this.isAvailable()) return null;
|
|
127
|
+
// Best used with domains
|
|
128
|
+
const domain = companyNameOrDomain.includes('.')
|
|
129
|
+
? companyNameOrDomain
|
|
130
|
+
: `${companyNameOrDomain.toLowerCase().replace(/\s+/g, '')}.com`;
|
|
131
|
+
const result = await this.getProfile(domain, { preview: true });
|
|
132
|
+
return result.data;
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
export default clearbitProvider;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider auto-registration.
|
|
3
|
+
*
|
|
4
|
+
* Import this module once at CLI startup to register all company data providers.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* import './providers/index.js'; // registers all
|
|
8
|
+
* import { resolveProvider } from './providers/registry.js'; // use
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { registerProvider } from './registry.js';
|
|
12
|
+
import pappersProvider from './pappers.js';
|
|
13
|
+
import opencorporatesProvider from './opencorporates.js';
|
|
14
|
+
import clearbitProvider from './clearbit.js';
|
|
15
|
+
import apolloProvider from './apollo.js';
|
|
16
|
+
|
|
17
|
+
registerProvider('pappers', pappersProvider);
|
|
18
|
+
registerProvider('opencorporates', opencorporatesProvider);
|
|
19
|
+
registerProvider('clearbit', clearbitProvider);
|
|
20
|
+
registerProvider('apollo', apolloProvider);
|
|
21
|
+
|
|
22
|
+
export {
|
|
23
|
+
detectCountry,
|
|
24
|
+
resolveProvider,
|
|
25
|
+
searchCompany,
|
|
26
|
+
getCompanyProfile,
|
|
27
|
+
getSubsidiaries,
|
|
28
|
+
lookupCompany,
|
|
29
|
+
listProviders,
|
|
30
|
+
} from './registry.js';
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenCorporates Provider — International company data.
|
|
3
|
+
*
|
|
4
|
+
* API: https://api.opencorporates.com/v0.4/
|
|
5
|
+
* Free tier: 500 req/month, basic data (name, jurisdiction, status, incorporation date)
|
|
6
|
+
* Pro tier (BYOK): full data, officers, filings, statements
|
|
7
|
+
*
|
|
8
|
+
* This is a scaffold with real API integration for search and basic profile.
|
|
9
|
+
* Full enrichment (officers, filings) can be added incrementally.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { fetch } from '../utils/fetcher.js';
|
|
13
|
+
|
|
14
|
+
const OC_API = 'https://api.opencorporates.com/v0.4';
|
|
15
|
+
|
|
16
|
+
function getApiKey() {
|
|
17
|
+
return process.env.OPENCORPORATES_API_KEY || null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const opencorporatesProvider = {
|
|
21
|
+
name: 'opencorporates',
|
|
22
|
+
country: 'INTL',
|
|
23
|
+
description: 'OpenCorporates — International company registry (180+ jurisdictions)',
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* OpenCorporates has a free tier (no key needed, 500/month).
|
|
27
|
+
* With API key → higher limits + more data.
|
|
28
|
+
*/
|
|
29
|
+
isAvailable() {
|
|
30
|
+
// Always available (free tier exists), but limited without key
|
|
31
|
+
return true;
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Search companies by name.
|
|
36
|
+
* @param {string} query
|
|
37
|
+
* @param {{ count?: number, jurisdiction?: string }} options
|
|
38
|
+
* @returns {Promise<{ results: Array, error: string|null }>}
|
|
39
|
+
*/
|
|
40
|
+
async search(query, options = {}) {
|
|
41
|
+
try {
|
|
42
|
+
const params = new URLSearchParams({ q: query });
|
|
43
|
+
if (options.jurisdiction) params.set('jurisdiction_code', options.jurisdiction);
|
|
44
|
+
if (options.count) params.set('per_page', String(Math.min(options.count, 30)));
|
|
45
|
+
const apiKey = getApiKey();
|
|
46
|
+
if (apiKey) params.set('api_token', apiKey);
|
|
47
|
+
|
|
48
|
+
const resp = await fetch(`${OC_API}/companies/search?${params}`, {
|
|
49
|
+
headers: { Accept: 'application/json' },
|
|
50
|
+
timeout: 15000,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const data = typeof resp.data === 'string' ? JSON.parse(resp.data) : resp.data;
|
|
54
|
+
const companies = data?.results?.companies || [];
|
|
55
|
+
|
|
56
|
+
const results = companies.map(({ company: c }) => ({
|
|
57
|
+
name: c.name,
|
|
58
|
+
companyNumber: c.company_number,
|
|
59
|
+
jurisdiction: c.jurisdiction_code,
|
|
60
|
+
status: c.current_status,
|
|
61
|
+
incorporationDate: c.incorporation_date,
|
|
62
|
+
companyType: c.company_type,
|
|
63
|
+
registeredAddress: c.registered_address_in_full,
|
|
64
|
+
url: c.opencorporates_url,
|
|
65
|
+
source: 'opencorporates',
|
|
66
|
+
}));
|
|
67
|
+
|
|
68
|
+
return { results, error: null };
|
|
69
|
+
} catch (err) {
|
|
70
|
+
return { results: [], error: err.message };
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Get company profile by jurisdiction + company number.
|
|
76
|
+
* @param {string} identifier — format: "jurisdiction/company_number" e.g. "gb/12345678"
|
|
77
|
+
* @param {{ preview?: boolean }} options
|
|
78
|
+
* @returns {Promise<{ data: object|null, error: string|null }>}
|
|
79
|
+
*/
|
|
80
|
+
async getProfile(identifier, options = {}) {
|
|
81
|
+
try {
|
|
82
|
+
// Identifier can be "gb/12345678" or just a company number
|
|
83
|
+
const path = identifier.includes('/') ? identifier : `us_${identifier}`;
|
|
84
|
+
const params = new URLSearchParams();
|
|
85
|
+
const apiKey = getApiKey();
|
|
86
|
+
if (apiKey) params.set('api_token', apiKey);
|
|
87
|
+
|
|
88
|
+
const resp = await fetch(`${OC_API}/companies/${path}?${params}`, {
|
|
89
|
+
headers: { Accept: 'application/json' },
|
|
90
|
+
timeout: 15000,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const data = typeof resp.data === 'string' ? JSON.parse(resp.data) : resp.data;
|
|
94
|
+
const c = data?.results?.company;
|
|
95
|
+
|
|
96
|
+
if (!c) return { data: null, error: 'Company not found' };
|
|
97
|
+
|
|
98
|
+
const profile = {
|
|
99
|
+
name: c.name,
|
|
100
|
+
companyNumber: c.company_number,
|
|
101
|
+
jurisdiction: c.jurisdiction_code,
|
|
102
|
+
status: c.current_status,
|
|
103
|
+
incorporationDate: c.incorporation_date,
|
|
104
|
+
dissolutionDate: c.dissolution_date,
|
|
105
|
+
companyType: c.company_type,
|
|
106
|
+
registeredAddress: c.registered_address_in_full,
|
|
107
|
+
previousNames: (c.previous_names || []).map(pn => pn.company_name),
|
|
108
|
+
agentName: c.agent_name,
|
|
109
|
+
agentAddress: c.agent_address,
|
|
110
|
+
url: c.opencorporates_url,
|
|
111
|
+
source: 'opencorporates',
|
|
112
|
+
// Officers and filings available with Pro key
|
|
113
|
+
officerCount: c.number_of_employees || null,
|
|
114
|
+
...(options.preview ? {} : {
|
|
115
|
+
officers: (c.officers || []).map(o => ({
|
|
116
|
+
name: o.officer?.name,
|
|
117
|
+
position: o.officer?.position,
|
|
118
|
+
startDate: o.officer?.start_date,
|
|
119
|
+
endDate: o.officer?.end_date,
|
|
120
|
+
})),
|
|
121
|
+
filings: (c.filings || []).slice(0, 20).map(f => ({
|
|
122
|
+
title: f.filing?.title,
|
|
123
|
+
date: f.filing?.date,
|
|
124
|
+
type: f.filing?.filing_type,
|
|
125
|
+
url: f.filing?.opencorporates_url,
|
|
126
|
+
})),
|
|
127
|
+
}),
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
return { data: profile, error: null };
|
|
131
|
+
} catch (err) {
|
|
132
|
+
return { data: null, error: err.message };
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Subsidiary lookup — not directly supported by OC free tier.
|
|
138
|
+
* Returns empty with a note.
|
|
139
|
+
*/
|
|
140
|
+
async getSubsidiaries(parentName, parentId, options = {}) {
|
|
141
|
+
return {
|
|
142
|
+
subsidiaries: [],
|
|
143
|
+
error: 'OpenCorporates does not provide subsidiary data in the free tier. Use a jurisdiction-specific provider for subsidiary analysis.',
|
|
144
|
+
};
|
|
145
|
+
},
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Quick lookup for competitor tracker.
|
|
149
|
+
*/
|
|
150
|
+
async lookup(companyName) {
|
|
151
|
+
const result = await this.search(companyName, { count: 1 });
|
|
152
|
+
if (result.results.length > 0) {
|
|
153
|
+
return result.results[0];
|
|
154
|
+
}
|
|
155
|
+
return null;
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
export default opencorporatesProvider;
|