intelwatch 1.3.2 → 1.6.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/README.md +74 -118
- package/ROADMAP-PREMIUM.md +17 -17
- package/package.json +2 -2
- package/patch_readme.js +30 -0
- package/src/ai/client.js +43 -2
- package/src/commands/compare.js +323 -0
- package/src/commands/discover.js +3 -3
- package/src/commands/profile.js +74 -16
- package/src/commands/setup.js +294 -0
- package/src/index.js +16 -4
- package/src/providers/annuaire-entreprises.js +83 -0
- package/src/providers/index.js +2 -0
- package/src/providers/registry.js +156 -50
- package/src/scrapers/annuaire-entreprises.js +461 -0
- package/src/scrapers/searxng-search.js +486 -0
- package/src/trackers/competitor.js +3 -3
- package/src/trackers/keyword.js +3 -3
- package/src/trackers/person.js +3 -3
- package/src/utils/fetcher.js +123 -4
- package/src/utils/parser.js +5 -3
- package/Endrix-Intelwatch-DueDil.pdf +0 -0
- package/export.pdf +0 -0
- package/profile-480254275.pdf +0 -0
- package/profile-775726417.pdf +0 -0
- package/profile-794598813.pdf +0 -0
- package/src/scrapers/brave-search.js +0 -281
package/src/commands/compare.js
CHANGED
|
@@ -1,8 +1,331 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
+
import Table from 'cli-table3';
|
|
2
3
|
import { getTracker, loadLatestSnapshot } from '../storage.js';
|
|
3
4
|
import { createTable, header, section, error, warn } from '../utils/display.js';
|
|
5
|
+
import { isSirenOrSiret } from '../providers/registry.js';
|
|
6
|
+
import { annuaireGetFullDossier } from '../scrapers/annuaire-entreprises.js';
|
|
7
|
+
import { pappersGetFullDossier, hasPappersKey } from '../scrapers/pappers.js';
|
|
8
|
+
import { isPro } from '../license.js';
|
|
9
|
+
|
|
10
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
function formatEuro(val) {
|
|
13
|
+
if (val == null) return null;
|
|
14
|
+
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 }).format(val);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function na() {
|
|
18
|
+
return chalk.gray('—');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function cellVal(val, formatter) {
|
|
22
|
+
if (val == null) return na();
|
|
23
|
+
return formatter ? formatter(val) : String(val);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function resultatCell(val) {
|
|
27
|
+
if (val == null) return na();
|
|
28
|
+
const fmt = formatEuro(val);
|
|
29
|
+
return val >= 0 ? chalk.green(fmt) : chalk.red(fmt);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Resolve which provider function to use for fetching a full dossier.
|
|
34
|
+
* Mirrors profile.js logic: Pappers (Pro + key) → Annuaire Entreprises (free).
|
|
35
|
+
*/
|
|
36
|
+
function resolveDossierFetcher() {
|
|
37
|
+
if (isPro() && hasPappersKey()) {
|
|
38
|
+
return { fetch: pappersGetFullDossier, name: 'pappers' };
|
|
39
|
+
}
|
|
40
|
+
return { fetch: annuaireGetFullDossier, name: 'annuaire-entreprises' };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Fetch a company dossier with Pappers → Annuaire Entreprises fallback.
|
|
45
|
+
* Returns { data, providerName, fromCache } or throws.
|
|
46
|
+
*/
|
|
47
|
+
async function fetchDossier(siren) {
|
|
48
|
+
const { fetch: fetchFn, name: providerName } = resolveDossierFetcher();
|
|
49
|
+
const result = await fetchFn(siren);
|
|
50
|
+
|
|
51
|
+
if (result.error && providerName === 'pappers' && /401|unauthorized|forbidden/i.test(result.error)) {
|
|
52
|
+
// Fallback to Annuaire Entreprises
|
|
53
|
+
const fallback = await annuaireGetFullDossier(siren);
|
|
54
|
+
if (fallback.error || !fallback.data) {
|
|
55
|
+
throw new Error(`Both providers failed: ${result.error} / ${fallback.error}`);
|
|
56
|
+
}
|
|
57
|
+
return { data: fallback.data, providerName: 'annuaire-entreprises', fromCache: fallback.fromCache };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (result.error || !result.data) {
|
|
61
|
+
throw new Error(result.error || `No data returned for SIREN ${siren}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return { data: result.data, providerName, fromCache: result.fromCache };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ── Company Profile Comparison ────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Compare two FR company profiles side-by-side.
|
|
71
|
+
* @param {string} siren1
|
|
72
|
+
* @param {string} siren2
|
|
73
|
+
*/
|
|
74
|
+
export async function runCompareCompanies(siren1, siren2) {
|
|
75
|
+
// Validate SIREN format
|
|
76
|
+
for (const s of [siren1, siren2]) {
|
|
77
|
+
const cleaned = s.trim();
|
|
78
|
+
if (!/^\d{9}(\d{5})?$/.test(cleaned)) {
|
|
79
|
+
error(`Invalid SIREN/SIRET: ${s}. Expected 9 digits (SIREN) or 14 digits (SIRET).`);
|
|
80
|
+
process.exit(1);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Normalize to 9-digit SIREN (strip SIRET establishment digits)
|
|
85
|
+
const s1 = siren1.trim().slice(0, 9);
|
|
86
|
+
const s2 = siren2.trim().slice(0, 9);
|
|
87
|
+
|
|
88
|
+
if (s1 === s2) {
|
|
89
|
+
error('Both SIRENs are identical — nothing to compare.');
|
|
90
|
+
process.exit(1);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
header(`📊 Company Comparison: ${s1} vs ${s2}`);
|
|
94
|
+
|
|
95
|
+
// ── Fetch both dossiers in parallel ──────────────────────────────────────
|
|
96
|
+
console.log(chalk.gray(' Fetching company profiles...'));
|
|
97
|
+
|
|
98
|
+
const [res1, res2] = await Promise.allSettled([
|
|
99
|
+
fetchDossier(s1),
|
|
100
|
+
fetchDossier(s2),
|
|
101
|
+
]);
|
|
102
|
+
|
|
103
|
+
if (res1.status === 'rejected') {
|
|
104
|
+
error(`Failed to fetch ${s1}: ${res1.reason.message}`);
|
|
105
|
+
process.exit(1);
|
|
106
|
+
}
|
|
107
|
+
if (res2.status === 'rejected') {
|
|
108
|
+
error(`Failed to fetch ${s2}: ${res2.reason.message}`);
|
|
109
|
+
process.exit(1);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const { data: d1, providerName: p1, fromCache: c1 } = res1.value;
|
|
113
|
+
const { data: d2, providerName: p2, fromCache: c2 } = res2.value;
|
|
114
|
+
|
|
115
|
+
// Provider info
|
|
116
|
+
const providerLabel = (name, cached) =>
|
|
117
|
+
name === 'annuaire-entreprises'
|
|
118
|
+
? chalk.cyan('Annuaire Entreprises (data.gouv.fr)')
|
|
119
|
+
: chalk.magenta('Pappers') + (cached ? chalk.gray(' (cache)') : '');
|
|
120
|
+
|
|
121
|
+
console.log(chalk.gray(` Provider 1: ${p1}${c1 ? ' (cache)' : ''}`));
|
|
122
|
+
console.log(chalk.gray(` Provider 2: ${p2}${c2 ? ' (cache)' : ''}`));
|
|
123
|
+
|
|
124
|
+
// ── Identity comparison table ─────────────────────────────────────────────
|
|
125
|
+
const i1 = d1.identity || {};
|
|
126
|
+
const i2 = d2.identity || {};
|
|
127
|
+
const name1 = i1.name || s1;
|
|
128
|
+
const name2 = i2.name || s2;
|
|
129
|
+
|
|
130
|
+
// Truncate long names for column headers
|
|
131
|
+
const col1 = name1.length > 30 ? name1.slice(0, 27) + '...' : name1;
|
|
132
|
+
const col2 = name2.length > 30 ? name2.slice(0, 27) + '...' : name2;
|
|
133
|
+
|
|
134
|
+
section('\n📋 Identité');
|
|
135
|
+
const identityTable = new Table({
|
|
136
|
+
head: ['Critère', chalk.bold.white(col1), chalk.bold.white(col2)].map(h =>
|
|
137
|
+
typeof h === 'string' && h !== 'Critère' ? h : chalk.cyan.bold(h)
|
|
138
|
+
),
|
|
139
|
+
style: { head: [], border: ['grey'] },
|
|
140
|
+
colAligns: ['left', 'left', 'left'],
|
|
141
|
+
colWidths: [20, 40, 40],
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const identityRows = [
|
|
145
|
+
['Nom', i1.name, i2.name],
|
|
146
|
+
['SIREN', i1.siren, i2.siren],
|
|
147
|
+
['NAF', i1.nafCode ? `${i1.nafCode} — ${i1.nafLabel || ''}`.trim() : null,
|
|
148
|
+
i2.nafCode ? `${i2.nafCode} — ${i2.nafLabel || ''}`.trim() : null],
|
|
149
|
+
['Forme juridique', i1.formeJuridique, i2.formeJuridique],
|
|
150
|
+
['Date création', i1.dateCreation, i2.dateCreation],
|
|
151
|
+
['Capital', i1.capital != null ? formatEuro(i1.capital) : null,
|
|
152
|
+
i2.capital != null ? formatEuro(i2.capital) : null],
|
|
153
|
+
['Effectifs', i1.effectifs, i2.effectifs],
|
|
154
|
+
['Statut', i1.status, i2.status],
|
|
155
|
+
['Ville', i1.ville, i2.ville],
|
|
156
|
+
];
|
|
157
|
+
|
|
158
|
+
for (const [label, v1, v2] of identityRows) {
|
|
159
|
+
// Color-code status
|
|
160
|
+
let c1 = cellVal(v1);
|
|
161
|
+
let c2 = cellVal(v2);
|
|
162
|
+
if (label === 'Statut') {
|
|
163
|
+
c1 = v1 === 'Actif' ? chalk.green(v1) : (v1 ? chalk.red(v1) : na());
|
|
164
|
+
c2 = v2 === 'Actif' ? chalk.green(v2) : (v2 ? chalk.red(v2) : na());
|
|
165
|
+
}
|
|
166
|
+
identityTable.push([chalk.white(label), c1, c2]);
|
|
167
|
+
}
|
|
168
|
+
console.log(identityTable.toString());
|
|
169
|
+
|
|
170
|
+
// ── Financial comparison table ─────────────────────────────────────────────
|
|
171
|
+
const f1 = (d1.financialHistory || []).slice(0, 5);
|
|
172
|
+
const f2 = (d2.financialHistory || []).slice(0, 5);
|
|
173
|
+
|
|
174
|
+
if (f1.length > 0 || f2.length > 0) {
|
|
175
|
+
section('\n💶 Historique financier');
|
|
176
|
+
const finTable = new Table({
|
|
177
|
+
head: ['Année', chalk.bold.white(col1), '', chalk.bold.white(col2), ''].map((h, i) =>
|
|
178
|
+
i === 0 ? chalk.cyan.bold(h) : h
|
|
179
|
+
),
|
|
180
|
+
style: { head: [], border: ['grey'] },
|
|
181
|
+
colAligns: ['left', 'right', 'right', 'right', 'right'],
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// Merge years from both companies
|
|
185
|
+
const allYears = [...new Set([
|
|
186
|
+
...f1.map(f => f.annee),
|
|
187
|
+
...f2.map(f => f.annee),
|
|
188
|
+
])].sort((a, b) => b - a);
|
|
189
|
+
|
|
190
|
+
const f1Map = Object.fromEntries(f1.map(f => [f.annee, f]));
|
|
191
|
+
const f2Map = Object.fromEntries(f2.map(f => [f.annee, f]));
|
|
192
|
+
|
|
193
|
+
for (const year of allYears) {
|
|
194
|
+
const a = f1Map[year] || {};
|
|
195
|
+
const b = f2Map[year] || {};
|
|
196
|
+
|
|
197
|
+
finTable.push([
|
|
198
|
+
chalk.white(year),
|
|
199
|
+
a.ca != null ? chalk.white(formatEuro(a.ca)) : na(),
|
|
200
|
+
a.resultat != null ? resultatCell(a.resultat) : na(),
|
|
201
|
+
b.ca != null ? chalk.white(formatEuro(b.ca)) : na(),
|
|
202
|
+
b.resultat != null ? resultatCell(b.resultat) : na(),
|
|
203
|
+
]);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
console.log(finTable.toString());
|
|
207
|
+
console.log(chalk.gray(' (CA | Résultat net) par année — vert = positif, rouge = négatif'));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ── Latest financials snapshot (clean side-by-side) ────────────────────────
|
|
211
|
+
const last1 = f1[0] || {};
|
|
212
|
+
const last2 = f2[0] || {};
|
|
213
|
+
|
|
214
|
+
section('\n💶 Dernier exercice');
|
|
215
|
+
const lastFinTable = new Table({
|
|
216
|
+
head: ['Indicateur', chalk.bold.white(col1), chalk.bold.white(col2)].map(h =>
|
|
217
|
+
typeof h === 'string' && !['Chiffre d\'affaires', 'Résultat net', 'Capitaux propres', 'Indicateur'].includes(h)
|
|
218
|
+
? h : chalk.cyan.bold(h)
|
|
219
|
+
),
|
|
220
|
+
style: { head: [], border: ['grey'] },
|
|
221
|
+
colAligns: ['left', 'right', 'right'],
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
lastFinTable.push([
|
|
225
|
+
chalk.white('Année'),
|
|
226
|
+
cellVal(last1.annee),
|
|
227
|
+
cellVal(last2.annee),
|
|
228
|
+
]);
|
|
229
|
+
lastFinTable.push([
|
|
230
|
+
chalk.white('Chiffre d\'affaires'),
|
|
231
|
+
cellVal(last1.ca, formatEuro),
|
|
232
|
+
cellVal(last2.ca, formatEuro),
|
|
233
|
+
]);
|
|
234
|
+
lastFinTable.push([
|
|
235
|
+
chalk.white('Résultat net'),
|
|
236
|
+
resultatCell(last1.resultat),
|
|
237
|
+
resultatCell(last2.resultat),
|
|
238
|
+
]);
|
|
239
|
+
lastFinTable.push([
|
|
240
|
+
chalk.white('Capitaux propres'),
|
|
241
|
+
last1.capitauxPropres != null ? (last1.capitauxPropres >= 0 ? chalk.white(formatEuro(last1.capitauxPropres)) : chalk.red(formatEuro(last1.capitauxPropres))) : na(),
|
|
242
|
+
last2.capitauxPropres != null ? (last2.capitauxPropres >= 0 ? chalk.white(formatEuro(last2.capitauxPropres)) : chalk.red(formatEuro(last2.capitauxPropres))) : na(),
|
|
243
|
+
]);
|
|
244
|
+
console.log(lastFinTable.toString());
|
|
245
|
+
|
|
246
|
+
// ── Dirigeants comparison ─────────────────────────────────────────────────
|
|
247
|
+
const dir1 = d1.dirigeants || [];
|
|
248
|
+
const dir2 = d2.dirigeants || [];
|
|
249
|
+
|
|
250
|
+
section('\n👔 Dirigeants');
|
|
251
|
+
const dirTable = new Table({
|
|
252
|
+
head: [chalk.cyan.bold('Rôle'), chalk.bold.white(col1), chalk.bold.white(col2)],
|
|
253
|
+
style: { head: [], border: ['grey'] },
|
|
254
|
+
colAligns: ['left', 'left', 'left'],
|
|
255
|
+
colWidths: [20, 40, 40],
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
// Show all dirigeants with their roles
|
|
259
|
+
const maxDir = Math.max(dir1.length, dir2.length, 1);
|
|
260
|
+
for (let i = 0; i < maxDir; i++) {
|
|
261
|
+
const d1p = dir1[i] || {};
|
|
262
|
+
const d2p = dir2[i] || {};
|
|
263
|
+
const role1 = d1p.role || '';
|
|
264
|
+
const role2 = d2p.role || '';
|
|
265
|
+
const name1str = d1p.nom ? [d1p.prenom, d1p.nom].filter(Boolean).join(' ') : '';
|
|
266
|
+
const name2str = d2p.nom ? [d2p.prenom, d2p.nom].filter(Boolean).join(' ') : '';
|
|
267
|
+
|
|
268
|
+
if (!name1str && !name2str) continue;
|
|
269
|
+
|
|
270
|
+
dirTable.push([
|
|
271
|
+
chalk.white(role1 || role2 || `Dirigeant ${i + 1}`),
|
|
272
|
+
name1str ? chalk.white(name1str) : na(),
|
|
273
|
+
name2str ? chalk.white(name2str) : na(),
|
|
274
|
+
]);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (dir1.length === 0 && dir2.length === 0) {
|
|
278
|
+
console.log(chalk.gray(' Aucun dirigeant connu pour les deux entreprises.'));
|
|
279
|
+
} else {
|
|
280
|
+
console.log(dirTable.toString());
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ── Delta indicators ───────────────────────────────────────────────────────
|
|
284
|
+
if (last1.ca != null && last2.ca != null && last1.ca !== 0 && last2.ca !== 0) {
|
|
285
|
+
section('\n📈 Indicateurs différentiels');
|
|
286
|
+
const ratio = (last1.ca / last2.ca);
|
|
287
|
+
const leader = last1.ca >= last2.ca ? name1 : name2;
|
|
288
|
+
const ratioStr = ratio >= 1
|
|
289
|
+
? `${(ratio).toFixed(2)}x plus grand`
|
|
290
|
+
: `${(1/ratio).toFixed(2)}x plus petit`;
|
|
291
|
+
|
|
292
|
+
console.log(chalk.white(` CA: ${leader} est ${ratioStr} en chiffre d'affaires.`));
|
|
293
|
+
|
|
294
|
+
if (last1.resultat != null && last2.resultat != null) {
|
|
295
|
+
const marge1 = last1.ca !== 0 ? ((last1.resultat / last1.ca) * 100).toFixed(1) : null;
|
|
296
|
+
const marge2 = last2.ca !== 0 ? ((last2.resultat / last2.ca) * 100).toFixed(1) : null;
|
|
297
|
+
if (marge1 && marge2) {
|
|
298
|
+
const m1Color = parseFloat(marge1) >= 0 ? chalk.green : chalk.red;
|
|
299
|
+
const m2Color = parseFloat(marge2) >= 0 ? chalk.green : chalk.red;
|
|
300
|
+
console.log(chalk.white(` Marge nette: ${name1} ${m1Color(marge1 + '%')} | ${name2} ${m2Color(marge2 + '%')}`));
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ── Footer ────────────────────────────────────────────────────────────────
|
|
306
|
+
console.log('');
|
|
307
|
+
const providerNote = p1 === 'annuaire-entreprises' || p2 === 'annuaire-entreprises'
|
|
308
|
+
? chalk.gray(' Données issues de l\'Annuaire Entreprises (data.gouv.fr) — gratuites mais limitées.\n Configurez PAPPERS_API_KEY pour des données complètes (UBO, BODACC, mandats).')
|
|
309
|
+
: '';
|
|
310
|
+
if (providerNote) console.log(providerNote);
|
|
311
|
+
console.log('');
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ── Tracker Comparison (original) ────────────────────────────────────────────
|
|
4
315
|
|
|
5
316
|
export function runCompare(id1, id2) {
|
|
317
|
+
// Detect SIREN/SIRET arguments → company comparison
|
|
318
|
+
if (isSirenOrSiret(id1) && isSirenOrSiret(id2)) {
|
|
319
|
+
return runCompareCompanies(id1, id2);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// If one is SIREN and the other is not → error
|
|
323
|
+
if (isSirenOrSiret(id1) || isSirenOrSiret(id2)) {
|
|
324
|
+
error('Both arguments must be SIREN/SIRET for company comparison, or tracker IDs for web tracker comparison.');
|
|
325
|
+
process.exit(1);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ── Original tracker comparison ──────────────────────────────────────────
|
|
6
329
|
const tracker1 = getTracker(id1);
|
|
7
330
|
const tracker2 = getTracker(id2);
|
|
8
331
|
|
package/src/commands/discover.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
import { fetch } from '../utils/fetcher.js';
|
|
3
3
|
import { load, extractMeta } from '../utils/parser.js';
|
|
4
|
-
import {
|
|
4
|
+
import { webSearch } from '../scrapers/searxng-search.js';
|
|
5
5
|
import { callAI, hasAIKey } from '../ai/client.js';
|
|
6
6
|
import { createTracker } from '../storage.js';
|
|
7
7
|
import { header, section, success, warn, error } from '../utils/display.js';
|
|
@@ -75,7 +75,7 @@ export async function runDiscover(url, options) {
|
|
|
75
75
|
|
|
76
76
|
for (const query of queries) {
|
|
77
77
|
await new Promise(r => setTimeout(r, 600));
|
|
78
|
-
const { results, error: searchError } = await
|
|
78
|
+
const { results, error: searchError } = await webSearch(query, { count: 20 });
|
|
79
79
|
if (searchError) {
|
|
80
80
|
warn(` Search error for "${query}": ${searchError}`);
|
|
81
81
|
continue;
|
|
@@ -105,7 +105,7 @@ export async function runDiscover(url, options) {
|
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
if (candidates.size === 0) {
|
|
108
|
-
warn('No competitors found. Check that
|
|
108
|
+
warn('No competitors found. Check that SEARXNG_URL or SERPER_API_KEY is set and the URL is reachable.');
|
|
109
109
|
return;
|
|
110
110
|
}
|
|
111
111
|
|
package/src/commands/profile.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
import Table from 'cli-table3';
|
|
3
3
|
import { pappersGetFullDossier, pappersSearchByName, pappersSearchSubsidiaries } from '../scrapers/pappers.js';
|
|
4
|
-
import {
|
|
4
|
+
import { annuaireGetFullDossier, annuaireSearchByName } from '../scrapers/annuaire-entreprises.js';
|
|
5
|
+
import { searchPressMentions } from '../scrapers/searxng-search.js';
|
|
5
6
|
import { analyzeSite } from '../scrapers/site-analyzer.js';
|
|
6
7
|
import { callAI, hasAIKey } from '../ai/client.js';
|
|
7
8
|
import { header, section, warn, error } from '../utils/display.js';
|
|
@@ -9,24 +10,49 @@ import { generatePDF } from '@recognity/pdf-report';
|
|
|
9
10
|
import { handleExport, formatForExport } from '../utils/export.js';
|
|
10
11
|
import { setLanguage, getLanguage, t, getPrompt } from '../utils/i18n.js';
|
|
11
12
|
import { isPro, printProUpgrade } from '../license.js';
|
|
13
|
+
import { hasPappersKey } from '../scrapers/pappers.js';
|
|
14
|
+
|
|
15
|
+
// ── Provider resolution: Pappers (Pro) → Annuaire Entreprises (free fallback) ──
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Resolve the FR company data provider.
|
|
19
|
+
* Returns { provider, providerName } where provider is the scraper module to use.
|
|
20
|
+
*/
|
|
21
|
+
function resolveFRProvider() {
|
|
22
|
+
if (isPro() && hasPappersKey()) {
|
|
23
|
+
return { providerName: 'pappers', searchByName: pappersSearchByName, getFullDossier: pappersGetFullDossier, searchSubsidiaries: pappersSearchSubsidiaries };
|
|
24
|
+
}
|
|
25
|
+
return { providerName: 'annuaire-entreprises', searchByName: annuaireSearchByName, getFullDossier: annuaireGetFullDossier, searchSubsidiaries: null };
|
|
26
|
+
}
|
|
12
27
|
|
|
13
28
|
export async function runMA(sirenOrName, options) {
|
|
14
29
|
const hasLicense = isPro();
|
|
15
30
|
const isPreview = !!options.preview;
|
|
31
|
+
const frProvider = resolveFRProvider();
|
|
32
|
+
const isFallbackProvider = frProvider.providerName === 'annuaire-entreprises';
|
|
16
33
|
|
|
17
34
|
// Set language from global option (passed from main program)
|
|
18
35
|
if (options.parent?.opts()?.lang) {
|
|
19
36
|
setLanguage(options.parent.opts().lang);
|
|
20
37
|
}
|
|
21
38
|
|
|
39
|
+
// ── Provider info ────────────────────────────────────────────────────────
|
|
40
|
+
if (isFallbackProvider) {
|
|
41
|
+
console.log(chalk.cyan(' ℹ Provider: Annuaire Entreprises (data.gouv.fr) — 100% gratuit'));
|
|
42
|
+
console.log(chalk.gray(' Données basiques Sirene (CA, effectifs, dirigeants, adresse, NAF).'));
|
|
43
|
+
console.log(chalk.gray(' UBO, BODACC, procédures collectives, mandats croisés non disponibles.'));
|
|
44
|
+
console.log('');
|
|
45
|
+
}
|
|
46
|
+
|
|
22
47
|
// ── License gate ───────────────────────────────────────────────────────────
|
|
23
|
-
|
|
48
|
+
// Annuaire Entreprises is free, so no license gate when using fallback
|
|
49
|
+
if (!isFallbackProvider && !hasLicense && !isPreview) {
|
|
24
50
|
printProUpgrade('Deep Profile Due Diligence');
|
|
25
51
|
console.log(chalk.gray(' Run with --preview for a limited preview (company identity + last year financials only).\n'));
|
|
26
52
|
process.exit(1);
|
|
27
53
|
}
|
|
28
54
|
|
|
29
|
-
if (isPreview && !hasLicense) {
|
|
55
|
+
if (!isFallbackProvider && isPreview && !hasLicense) {
|
|
30
56
|
console.log(chalk.yellow(' ⚡ PREVIEW MODE — Company identity + last year financials only'));
|
|
31
57
|
printProUpgrade('Full company profile');
|
|
32
58
|
}
|
|
@@ -36,7 +62,7 @@ export async function runMA(sirenOrName, options) {
|
|
|
36
62
|
|
|
37
63
|
if (!/^\d{9}$/.test(sirenOrName)) {
|
|
38
64
|
console.log(chalk.gray(` Searching for: "${sirenOrName}"...`));
|
|
39
|
-
const { results, error: searchErr } = await
|
|
65
|
+
const { results, error: searchErr } = await frProvider.searchByName(sirenOrName, { count: 1 });
|
|
40
66
|
if (searchErr || !results.length) {
|
|
41
67
|
error(`Company not found: ${searchErr || 'No results'}`);
|
|
42
68
|
process.exit(1);
|
|
@@ -48,10 +74,20 @@ export async function runMA(sirenOrName, options) {
|
|
|
48
74
|
|
|
49
75
|
// ── Fetch full dossier ─────────────────────────────────────────────────────
|
|
50
76
|
console.log(chalk.gray(' Loading company data...'));
|
|
51
|
-
const { data, error: dossierErr, fromCache } = await
|
|
77
|
+
const { data, error: dossierErr, fromCache } = await frProvider.getFullDossier(siren);
|
|
52
78
|
if (fromCache) console.log(chalk.gray(' ✓ Loaded from cache (0 API credits)'));
|
|
53
79
|
|
|
54
80
|
if (dossierErr || !data) {
|
|
81
|
+
// If Pappers returned 401, retry with Annuaire Entreprises fallback
|
|
82
|
+
if (!isFallbackProvider && dossierErr && /401|unauthorized|forbidden/i.test(dossierErr)) {
|
|
83
|
+
console.log(chalk.yellow(' ⚠ Pappers 401 — fallback vers l\'Annuaire Entreprises (data.gouv.fr)'));
|
|
84
|
+
const fallbackResult = await annuaireGetFullDossier(siren);
|
|
85
|
+
if (fallbackResult.error || !fallbackResult.data) {
|
|
86
|
+
error(`Failed to fetch dossier from both providers: ${dossierErr} / ${fallbackResult.error}`);
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
|
89
|
+
return renderDossier(fallbackResult.data, { ...options, isFallbackProvider: true, isPreview: true });
|
|
90
|
+
}
|
|
55
91
|
error(`Failed to fetch dossier: ${dossierErr || 'Unknown error'}`);
|
|
56
92
|
process.exit(1);
|
|
57
93
|
}
|
|
@@ -59,7 +95,8 @@ export async function runMA(sirenOrName, options) {
|
|
|
59
95
|
const { identity, financialHistory, consolidatedFinances, ubo, bodacc, dirigeants, representants, etablissements, proceduresCollectives } = data;
|
|
60
96
|
|
|
61
97
|
// ── Header ─────────────────────────────────────────────────────────────────
|
|
62
|
-
|
|
98
|
+
const providerTag = isFallbackProvider ? chalk.cyan(' [Annuaire Entreprises]') : '';
|
|
99
|
+
header(`🏢 Due Diligence Deep Profile — ${identity.name || siren}${providerTag}`);
|
|
63
100
|
|
|
64
101
|
// ── Company Identity ───────────────────────────────────────────────────────
|
|
65
102
|
section(' 📋 Identité');
|
|
@@ -77,7 +114,7 @@ export async function runMA(sirenOrName, options) {
|
|
|
77
114
|
if (identity.website) printRow('Site web', identity.website);
|
|
78
115
|
|
|
79
116
|
// ── Preview mode stops here (one year of financials) ──────────────────────
|
|
80
|
-
if (isPreview) {
|
|
117
|
+
if (isPreview || isFallbackProvider) {
|
|
81
118
|
const lastFin = financialHistory[0];
|
|
82
119
|
section(' 💶 Derniers résultats financiers (preview)');
|
|
83
120
|
if (lastFin) {
|
|
@@ -89,8 +126,29 @@ export async function runMA(sirenOrName, options) {
|
|
|
89
126
|
console.log(chalk.gray(' Données financières non disponibles.'));
|
|
90
127
|
}
|
|
91
128
|
console.log('');
|
|
92
|
-
|
|
129
|
+
if (isFallbackProvider) {
|
|
130
|
+
console.log(chalk.cyan(' ℹ Profil issu de l\'Annuaire Entreprises (data.gouv.fr) — données gratuites.'));
|
|
131
|
+
console.log(chalk.gray(' Pour les données complètes (UBO, BODACC, procédures, mandats), configurez PAPPERS_API_KEY.'));
|
|
132
|
+
} else {
|
|
133
|
+
console.log(chalk.yellow(` ⚡ Accédez au rapport complet avec Intelwatch Deep Profile : ${LICENSE_URL}`));
|
|
134
|
+
}
|
|
93
135
|
console.log('');
|
|
136
|
+
|
|
137
|
+
// When using fallback provider, only render available sections
|
|
138
|
+
if (isFallbackProvider) {
|
|
139
|
+
// Dirigeants (available from Annuaire)
|
|
140
|
+
if (dirigeants.length > 0) {
|
|
141
|
+
section(` 👔 Dirigeants (${dirigeants.length})`);
|
|
142
|
+
for (const d of dirigeants) {
|
|
143
|
+
const name = [d.prenom, d.nom].filter(Boolean).join(' ');
|
|
144
|
+
console.log('');
|
|
145
|
+
console.log(' ' + chalk.white.bold(name) + chalk.gray(` — ${d.role || '?'}`));
|
|
146
|
+
}
|
|
147
|
+
console.log('');
|
|
148
|
+
}
|
|
149
|
+
console.log(chalk.gray(' Sections non disponibles via Annuaire Entreprises: UBO, BODACC, procédures collectives, mandats croisés, filiales.'));
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
94
152
|
return;
|
|
95
153
|
}
|
|
96
154
|
|
|
@@ -309,9 +367,9 @@ export async function runMA(sirenOrName, options) {
|
|
|
309
367
|
// Additional M&A-focused search to catch acquisitions/deals (dorks: quality M&A sources only)
|
|
310
368
|
const MA_SITE_DORKS = '(site:fusacq.com OR site:cfnews.net OR site:lesechos.fr OR site:maddyness.com OR site:agefi.fr)';
|
|
311
369
|
try {
|
|
312
|
-
const {
|
|
370
|
+
const { webSearch } = await import('../scrapers/searxng-search.js');
|
|
313
371
|
await new Promise(r => setTimeout(r, 600));
|
|
314
|
-
const maSearch = await
|
|
372
|
+
const maSearch = await webSearch(`"${brandName}" (acquisition OR LBO OR rachat OR "levée de fonds" OR "entrée au capital" OR "prise de participation") ${MA_SITE_DORKS}`, { count: 10 });
|
|
315
373
|
for (const r of (maSearch.results || [])) {
|
|
316
374
|
const text = ((r.title || '') + ' ' + (r.snippet || '')).toLowerCase();
|
|
317
375
|
if (!text.includes(brandName.toLowerCase())) continue;
|
|
@@ -407,11 +465,11 @@ export async function runMA(sirenOrName, options) {
|
|
|
407
465
|
|
|
408
466
|
// Company website M&A articles via Brave (more reliable than crawling)
|
|
409
467
|
try {
|
|
410
|
-
const {
|
|
468
|
+
const { webSearch } = await import('../scrapers/searxng-search.js');
|
|
411
469
|
const domain = (() => { try { return new URL(companyDomain).hostname; } catch { return ''; } })();
|
|
412
470
|
if (domain) {
|
|
413
471
|
await new Promise(r => setTimeout(r, 600));
|
|
414
|
-
const siteSearch = await
|
|
472
|
+
const siteSearch = await webSearch(
|
|
415
473
|
`site:${domain} acquisition OR rapprochement OR capital OR croissance OR partenariat OR intègre`,
|
|
416
474
|
{ count: 10 },
|
|
417
475
|
);
|
|
@@ -438,9 +496,9 @@ export async function runMA(sirenOrName, options) {
|
|
|
438
496
|
|
|
439
497
|
// LinkedIn posts via Brave
|
|
440
498
|
try {
|
|
441
|
-
const {
|
|
499
|
+
const { webSearch } = await import('../scrapers/searxng-search.js');
|
|
442
500
|
await new Promise(r => setTimeout(r, 600));
|
|
443
|
-
const linkedinSearch = await
|
|
501
|
+
const linkedinSearch = await webSearch(
|
|
444
502
|
`site:linkedin.com "${brandName}" acquisition OR croissance OR chiffre OR recrutement OR partenariat`,
|
|
445
503
|
{ count: 10 },
|
|
446
504
|
);
|
|
@@ -547,7 +605,7 @@ export async function runMA(sirenOrName, options) {
|
|
|
547
605
|
let aiAnalysis = null;
|
|
548
606
|
if (options.ai) {
|
|
549
607
|
section(' 🤖 Analyse IA — Due Diligence');
|
|
550
|
-
if (!hasAIKey()) {
|
|
608
|
+
if (!hasAIKey() && !options.uncensored) {
|
|
551
609
|
warn(' No AI API key. Set OPENAI_API_KEY or ANTHROPIC_API_KEY.');
|
|
552
610
|
} else {
|
|
553
611
|
console.log(chalk.gray(' Generating AI due diligence analysis (JSON)...'));
|
|
@@ -818,7 +876,7 @@ OBLIGATOIRE :
|
|
|
818
876
|
- aiComment: 3-4 sentences. Compare deposited (62M€ 2024) vs announced/projected. Be specific. If multiple revenue targets exist (e.g. 100M€ and 300M€), explain both.
|
|
819
877
|
- aiComment: 3-4 sentences comparing deposited vs announced/projected, discussing growth sustainability and outlook`;
|
|
820
878
|
|
|
821
|
-
const raw = await callAI(systemPrompt, userPrompt, { maxTokens: 8192 });
|
|
879
|
+
const raw = await callAI(systemPrompt, userPrompt, { maxTokens: 8192, uncensored: options.uncensored });
|
|
822
880
|
aiAnalysis = extractAIJSON(raw);
|
|
823
881
|
|
|
824
882
|
// M&A History: Merging code-built events with AI events instead of overwriting
|