intelwatch 1.3.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
 
@@ -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 { braveWebSearch } from '../scrapers/brave-search.js';
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 braveWebSearch(query, { count: 20 });
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 BRAVE_API_KEY is set and the URL is reachable.');
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
 
@@ -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 { searchPressMentions } from '../scrapers/brave-search.js';
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
- if (!hasLicense && !isPreview) {
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 pappersSearchByName(sirenOrName, { count: 1 });
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 pappersGetFullDossier(siren);
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
- header(`🏢 Due Diligence Deep Profile — ${identity.name || siren}`);
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
- console.log(chalk.yellow(` ⚡ Accédez au rapport complet avec Intelwatch Deep Profile : ${LICENSE_URL}`));
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 { braveWebSearch } = await import('../scrapers/brave-search.js');
370
+ const { webSearch } = await import('../scrapers/searxng-search.js');
313
371
  await new Promise(r => setTimeout(r, 600));
314
- const maSearch = await braveWebSearch(`"${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 });
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 { braveWebSearch: braveSearch3 } = await import('../scrapers/brave-search.js');
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 braveSearch3(
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 { braveWebSearch: braveSearch2 } = await import('../scrapers/brave-search.js');
499
+ const { webSearch } = await import('../scrapers/searxng-search.js');
442
500
  await new Promise(r => setTimeout(r, 600));
443
- const linkedinSearch = await braveSearch2(
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)...'));
@@ -799,6 +857,7 @@ OBLIGATOIRE :
799
857
  - ${getPrompt('strengthsWeaknessesRules')}
800
858
  - Minimum 5 concurrents de taille comparable (CA consolidé similaire, même code NAF ${identity.nafCode || ''})
801
859
  - Le score de santé doit être basé sur les finances CONSOLIDÉES si disponibles
860
+ - BE EXTREMELY CONCISE. Use bullet points and short sentences. Max 30 words per field.
802
861
  - Ne mentionne JAMAIS que la holding a peu d'employés comme faiblesse — c'est normal pour une holding, les employés sont dans les filiales
803
862
  - maHistory: The PRE-BUILT M&A TIMELINE above contains ALL entries with AUTHORITATIVE dates and types.
804
863
  RULES:
@@ -817,7 +876,7 @@ OBLIGATOIRE :
817
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.
818
877
  - aiComment: 3-4 sentences comparing deposited vs announced/projected, discussing growth sustainability and outlook`;
819
878
 
820
- const raw = await callAI(systemPrompt, userPrompt, { maxTokens: 3500 });
879
+ const raw = await callAI(systemPrompt, userPrompt, { maxTokens: 8192, uncensored: options.uncensored });
821
880
  aiAnalysis = extractAIJSON(raw);
822
881
 
823
882
  // M&A History: Merging code-built events with AI events instead of overwriting
@@ -944,8 +1003,9 @@ OBLIGATOIRE :
944
1003
  }
945
1004
  }
946
1005
 
1006
+ let pdfData = null;
947
1007
  // ── PDF export ──────────────────────────────────────────────────────────────
948
- if (options.format === 'pdf') {
1008
+ if (options.format === 'pdf' || options.export === 'pdf') {
949
1009
  const outputPath = options.output || `profile-${siren}.pdf`;
950
1010
  const fmtEuro = (n) => {
951
1011
  if (n == null) return '—';
@@ -964,22 +1024,24 @@ OBLIGATOIRE :
964
1024
  });
965
1025
  }
966
1026
 
967
- const pdfData = {
1027
+ pdfData = {
968
1028
  aiSummary: aiAnalysis?.executiveSummary || null,
969
1029
  groupStructure: (() => {
970
1030
  const gs = aiAnalysis?.groupStructure || {};
971
- // Override subsidiaries with real data — top 7 by CA, mixing branded + off-brand
972
- if (subsidiariesData?.length) {
973
- gs.subsidiaries = subsidiariesData
974
- .filter(s => s.ca && s.ca > 0)
975
- .sort((a, b) => (b.ca || 0) - (a.ca || 0))
976
- .slice(0, 7)
977
- .map(s => ({ entity: s.name, revenue: `${(s.ca / 1e6).toFixed(1)} M€${s.annee ? ' (' + s.annee + ')' : ''}` }));
1031
+ // Combine AI subsidiaries with real data
1032
+ const pappersSubs = (subsidiariesData || [])
1033
+ .filter(s => s.ca && s.ca > 0)
1034
+ .sort((a, b) => (b.ca || 0) - (a.ca || 0))
1035
+ .slice(0, 7)
1036
+ .map(s => ({ entity: s.name, revenue: `${(s.ca / 1e6).toFixed(1)} M€${s.annee ? ' (' + s.annee + ')' : ''}` }));
1037
+
1038
+ if (pappersSubs.length > 0) {
1039
+ gs.subsidiaries = pappersSubs;
978
1040
  }
979
1041
  return gs;
980
1042
  })(),
981
1043
  aiCompetitors: aiAnalysis?.competitors || [],
982
- maHistory: aiAnalysis?.maHistory || [],
1044
+ maHistory: (aiAnalysis?.maHistory?.length ? aiAnalysis.maHistory : codeBuiltMaHistory) || [],
983
1045
  riskAssessment: aiAnalysis?.riskAssessment || null,
984
1046
  healthScore: aiAnalysis?.healthScore || null,
985
1047
  growthAnalysis: (() => {
@@ -1210,7 +1272,7 @@ OBLIGATOIRE :
1210
1272
  siren: r.siren,
1211
1273
  })),
1212
1274
  // Etablissements
1213
- etablissements: (etablissements || []).map(e => ({
1275
+ etablissements: (etablissements || []).filter(e => e.actif !== false).map(e => ({
1214
1276
  siret: e.siret,
1215
1277
  type: e.type,
1216
1278
  address: e.adresse,
@@ -1254,7 +1316,7 @@ OBLIGATOIRE :
1254
1316
  bodacc: (bodacc || []).slice(0, 15).map(b => ({
1255
1317
  date: b.date || '—',
1256
1318
  type: b.type || '—',
1257
- description: b.description || '',
1319
+ description: (b.description && b.description.length > 140) ? b.description.substring(0, 140) + '...' : (b.description || ''),
1258
1320
  url: b.url || null,
1259
1321
  })),
1260
1322
  // Procédures collectives
package/src/index.js CHANGED
@@ -138,10 +138,10 @@ program
138
138
  // ─── compare ──────────────────────────────────────────────────────────────────
139
139
 
140
140
  program
141
- .command('compare <tracker1> <tracker2>')
142
- .description('Side-by-side comparison of two competitor trackers')
143
- .action((id1, id2) => {
144
- runCompare(id1, id2);
141
+ .command('compare <id1> <id2>')
142
+ .description('Side-by-side comparison of two competitor trackers OR two company profiles (SIREN/SIRET)')
143
+ .action(async (id1, id2) => {
144
+ await runCompare(id1, id2);
145
145
  });
146
146
 
147
147
  // ─── ai-summary ───────────────────────────────────────────────────────────────
@@ -173,6 +173,7 @@ program
173
173
  .description('Deep company profile — due diligence report (requires Pro license)')
174
174
  .option('--preview', 'Run limited preview: company identity + last year financials only')
175
175
  .option('--ai', 'Generate an AI-powered due diligence summary (requires AI API key)')
176
+ .option('--uncensored', 'Run AI analysis on a local Ollama instance (uncensored OSINT)')
176
177
  .option('--format <type>', 'Output format: terminal (default) or pdf')
177
178
  .option('--output <path>', 'Output file path for PDF')
178
179
  .option('--export <format>', 'Export structured data (json, csv, xls, pdf)')