intelwatch 1.1.0 → 1.1.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/src/index.js CHANGED
@@ -159,9 +159,11 @@ program
159
159
 
160
160
  program
161
161
  .command('profile <siren-or-name>')
162
- .description('M&A due diligence report for a French company (requires Pro license)')
162
+ .description('Deep company profile — due diligence report (requires Pro license)')
163
163
  .option('--preview', 'Run limited preview: company identity + last year financials only')
164
164
  .option('--ai', 'Generate an AI-powered due diligence summary (requires AI API key)')
165
+ .option('--format <type>', 'Output format: terminal (default) or pdf')
166
+ .option('--output <path>', 'Output file path for PDF')
165
167
  .action(async (sirenOrName, options) => {
166
168
  await runMA(sirenOrName, options);
167
169
  });
@@ -148,11 +148,24 @@ export async function searchPressMentions(brandName, options = {}) {
148
148
  }
149
149
  }
150
150
 
151
+ // ── Relevance filter: drop results that don't actually mention the brand ──
152
+ const brandLower = brandName.toLowerCase().trim();
153
+ const brandWords = brandLower.split(/\s+/);
154
+ const filtered = mentions.filter(m => {
155
+ const text = ((m.title || '') + ' ' + (m.snippet || '') + ' ' + (m.domain || '')).toLowerCase();
156
+ // Must contain the exact brand name OR all words of the brand
157
+ if (text.includes(brandLower)) return true;
158
+ if (brandWords.length > 1 && brandWords.every(w => text.includes(w))) return true;
159
+ // Fuzzy: allow 1 char difference for short names (e.g. "Endrix" vs "Endrick" should be EXCLUDED)
160
+ return false;
161
+ });
162
+
151
163
  return {
152
164
  brandName,
153
165
  checkedAt: new Date().toISOString(),
154
- mentions,
155
- mentionCount: mentions.length,
166
+ mentions: filtered,
167
+ mentionCount: filtered.length,
168
+ unfilteredCount: mentions.length,
156
169
  error: news.error || web.error || null,
157
170
  };
158
171
  }
@@ -1,4 +1,7 @@
1
1
  import axios from 'axios';
2
+ import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'fs';
3
+ import { join } from 'path';
4
+ import { homedir } from 'os';
2
5
 
3
6
  const PAPPERS_API = 'https://api.pappers.fr/v1';
4
7
  const PAPPERS_API_V2 = 'https://api.pappers.fr/v2';
@@ -27,7 +30,7 @@ export async function pappersSearchByName(name, options = {}) {
27
30
  },
28
31
  timeout: 10000,
29
32
  });
30
- return { results: resp.data.resultats || [], error: null };
33
+ return { results: resp.data.resultats || resp.data.entreprises || [], error: null };
31
34
  } catch (err) {
32
35
  return { results: [], error: err.message };
33
36
  }
@@ -78,6 +81,10 @@ export async function pappersLookup(companyName) {
78
81
  * and collective procedures.
79
82
  */
80
83
  export async function pappersGetFullDossier(siren) {
84
+ // Check cache first
85
+ const cached = getCached(siren);
86
+ if (cached) return { data: cached, error: null, fromCache: true };
87
+
81
88
  const apiKey = getApiKey();
82
89
  if (!apiKey) return { data: null, error: 'No PAPPERS_API_KEY set' };
83
90
 
@@ -96,6 +103,17 @@ export async function pappersGetFullDossier(siren) {
96
103
  resultat: f.resultat ?? null,
97
104
  capitauxPropres: f.capitaux_propres ?? null,
98
105
  effectif: f.effectif ?? null,
106
+ ebitda: f.excedent_brut_exploitation ?? null,
107
+ margeEbitda: f.taux_marge_EBITDA ?? null,
108
+ dettesFinancieres: f.dettes_financieres ?? null,
109
+ tresorerie: f.tresorerie ?? null,
110
+ fondsPropres: f.fonds_propres ?? null,
111
+ bfr: f.BFR ?? null,
112
+ ratioEndettement: f.ratio_endettement ?? null,
113
+ autonomieFinanciere: f.autonomie_financiere ?? null,
114
+ rentabiliteFP: f.rentabilite_fonds_propres ?? null,
115
+ margeNette: f.marge_nette ?? null,
116
+ capaciteAutofinancement: f.capacite_autofinancement ?? null,
99
117
  }));
100
118
 
101
119
  // UBO — bénéficiaires effectifs
@@ -108,14 +126,18 @@ export async function pappersGetFullDossier(siren) {
108
126
  pourcentageVotes: b.pourcentage_votes ?? null,
109
127
  }));
110
128
 
111
- // BODACC publications — last 10
112
- const bodacc = (d.publications_bodacc || []).slice(0, 10).map(p => ({
113
- date: p.date,
114
- type: p.type,
115
- tribunal: p.tribunal || null,
116
- numero: p.numero_annonce || null,
117
- description: p.acte?.actes_publies?.[0]?.type_acte || p.type || null,
118
- }));
129
+ // BODACC publications — last 50 (captures M&A activity)
130
+ const bodacc = (d.publications_bodacc || []).slice(0, 50).map(p => {
131
+ const actes = (p.acte?.actes_publies || []).map(a => a.type_acte).filter(Boolean);
132
+ return {
133
+ date: p.date,
134
+ type: p.type,
135
+ tribunal: p.tribunal || null,
136
+ numero: p.numero_annonce || null,
137
+ description: actes.length ? actes.join(', ') : p.type || null,
138
+ details: p.acte?.descriptif || p.acte?.capital || null,
139
+ };
140
+ });
119
141
 
120
142
  // Dirigeants with their mandats in other companies
121
143
  const dirigeants = (d.dirigeants || []).map(dir => ({
@@ -161,10 +183,53 @@ export async function pappersGetFullDossier(siren) {
161
183
  dateRadiation: d.date_radiation || null,
162
184
  };
163
185
 
164
- return {
165
- data: { identity, financialHistory, ubo, bodacc, dirigeants, proceduresCollectives, raw: d },
166
- error: null,
167
- };
186
+ // Consolidated financials (group level)
187
+ const consolidatedFinances = (d.finances_consolidees || []).slice(0, 5).map(f => ({
188
+ annee: f.annee,
189
+ ca: f.chiffre_affaires ?? null,
190
+ resultat: f.resultat ?? null,
191
+ capitauxPropres: f.capitaux_propres ?? null,
192
+ effectif: f.effectif ?? null,
193
+ ebitda: f.excedent_brut_exploitation ?? null,
194
+ margeEbitda: f.taux_marge_EBITDA ?? null,
195
+ dettesFinancieres: f.dettes_financieres ?? null,
196
+ tresorerie: f.tresorerie ?? null,
197
+ fondsPropres: f.fonds_propres ?? null,
198
+ bfr: f.BFR ?? null,
199
+ ratioEndettement: f.ratio_endettement ?? null,
200
+ autonomieFinanciere: f.autonomie_financiere ?? null,
201
+ rentabiliteFP: f.rentabilite_fonds_propres ?? null,
202
+ margeNette: f.marge_nette ?? null,
203
+ capaciteAutofinancement: f.capacite_autofinancement ?? null,
204
+ }));
205
+
206
+ // Representants (dirigeants + corporate entities with mandats)
207
+ const representants = (d.representants || []).map(r => ({
208
+ nom: r.nom_complet || r.denomination || [r.prenom, r.nom].filter(Boolean).join(' ') || '?',
209
+ qualite: r.qualite || '',
210
+ siren: r.siren || null,
211
+ personneMorale: !!r.personne_morale,
212
+ }));
213
+
214
+ // Etablissements
215
+ const etablissements = (d.etablissements || []).map(e => ({
216
+ siret: e.siret,
217
+ type: e.type_etablissement,
218
+ adresse: [e.adresse_ligne_1, e.code_postal, e.ville].filter(Boolean).join(' '),
219
+ actif: !e.etablissement_cesse,
220
+ }));
221
+
222
+ // Extra fields
223
+ identity.objetSocial = d.objet_social || null;
224
+ identity.tvaIntra = d.numero_tva_intracommunautaire || null;
225
+ identity.rcs = d.numero_rcs || null;
226
+ identity.greffe = d.greffe || null;
227
+ identity.conventionCollective = d.conventions_collectives?.[0]?.nom || null;
228
+ identity.effectifTexte = d.effectif || null;
229
+
230
+ const result = { identity, financialHistory, consolidatedFinances, ubo, bodacc, dirigeants, representants, etablissements, proceduresCollectives };
231
+ setCache(siren, result);
232
+ return { data: result, error: null };
168
233
  } catch (err) {
169
234
  return { data: null, error: err.message };
170
235
  }
@@ -216,3 +281,156 @@ function formatPappersDetail(d) {
216
281
  formeJuridique: d.forme_juridique || null,
217
282
  };
218
283
  }
284
+
285
+ /**
286
+ * Search for subsidiaries/related entities.
287
+ * Strategy 1: recherche-dirigeants — finds companies where the parent acts as corporate director.
288
+ * Strategy 2: name-search fallback.
289
+ * Returns array of entities with their latest financials, sorted by CA desc.
290
+ */
291
+ export async function pappersSearchSubsidiaries(parentName, parentSiren) {
292
+ const apiKey = getApiKey();
293
+ if (!apiKey) return { subsidiaries: [], error: 'No PAPPERS_API_KEY set' };
294
+
295
+ // Check cache for subsidiary search results
296
+ const subsCacheKey = `subs_${parentSiren}`;
297
+ const cachedSubs = getCached(subsCacheKey);
298
+ if (cachedSubs?.subsidiaries) {
299
+ return { subsidiaries: cachedSubs.subsidiaries, total: cachedSubs.total || cachedSubs.subsidiaries.length, error: null, fromCache: true };
300
+ }
301
+
302
+ const searchName = parentName.replace(/\s*(GRP|SAS|SARL|SA|SCI|EURL|GROUP|GROUPE|HOLDING|SNC|SASU)\s*/gi, ' ').trim();
303
+ const nameNorm = searchName.toLowerCase();
304
+
305
+ // ── Strategy 1: recherche-dirigeants ──────────────────────────────────────
306
+ // Finds companies where an entity named like the parent is listed as dirigeant
307
+ try {
308
+ const resp = await axios.get(`${PAPPERS_API}/recherche-dirigeants`, {
309
+ params: { api_token: apiKey, q: searchName, par_page: 20 },
310
+ timeout: 15000,
311
+ });
312
+
313
+ const resultats = resp.data.resultats || [];
314
+ const subsidiaryMap = new Map();
315
+
316
+ for (const r of resultats) {
317
+ // Only match the PARENT entity as dirigeant (by SIREN if available, else exact name)
318
+ const dirigeantSiren = r.siren || '';
319
+ const dirigeantName = (r.nom_entreprise || r.denomination || r.nom_complet || '').toLowerCase().trim();
320
+
321
+ // Strict filter: must be the parent SIREN, or exact parent name match
322
+ const isParent = (dirigeantSiren === parentSiren) || (dirigeantName === nameNorm) || (dirigeantName === searchName.toLowerCase());
323
+ if (!isParent) continue;
324
+
325
+ for (const e of (r.entreprises || [])) {
326
+ if (e.siren && e.siren !== parentSiren && !subsidiaryMap.has(e.siren)) {
327
+ subsidiaryMap.set(e.siren, e);
328
+ }
329
+ }
330
+ }
331
+
332
+ if (subsidiaryMap.size > 0) {
333
+ // Limit to 30 subsidiaries to save API credits
334
+ const entities = Array.from(subsidiaryMap.values()).slice(0, 30);
335
+ const subsidiaries = await fetchSubsidiaryDetails(apiKey, entities);
336
+ // Cache subsidiary search results
337
+ setCache(subsCacheKey, { subsidiaries, total: subsidiaryMap.size });
338
+ return { subsidiaries, total: subsidiaryMap.size, error: null };
339
+ }
340
+ } catch (_) {
341
+ // Fall through to name-search
342
+ }
343
+
344
+ // ── Strategy 2: name-search fallback ──────────────────────────────────────
345
+ try {
346
+ const resp = await axios.get(`${PAPPERS_API}/recherche`, {
347
+ params: { api_token: apiKey, q: searchName, par_page: 20 },
348
+ timeout: 15000,
349
+ });
350
+
351
+ const entities = (resp.data.entreprises || resp.data.resultats || [])
352
+ .filter(e => e.siren !== parentSiren);
353
+
354
+ const subsidiaries = await fetchSubsidiaryDetails(apiKey, entities);
355
+ // Cache fallback search results too
356
+ setCache(subsCacheKey, { subsidiaries, total: subsidiaries.length });
357
+ return { subsidiaries, error: null };
358
+ } catch (err) {
359
+ return { subsidiaries: [], error: err.message };
360
+ }
361
+ }
362
+
363
+ async function fetchSubsidiaryDetails(apiKey, entities) {
364
+ const subsidiaries = [];
365
+ for (const e of entities) {
366
+ try {
367
+ // Check cache first to save API credits
368
+ const cached = getCached(e.siren);
369
+ let d;
370
+ if (cached?.identity) {
371
+ // Reconstruct from cached full dossier
372
+ d = { nom_entreprise: cached.identity.name, code_naf: cached.identity.nafCode, libelle_code_naf: cached.identity.nafLabel, siege: { ville: cached.identity.ville }, effectif: cached.identity.effectifTexte, entreprise_cessee: false, date_creation: cached.identity.dateCreation, finances: cached.financialHistory?.map(f => ({ chiffre_affaires: f.ca, resultat: f.resultat, annee: f.annee })) || [] };
373
+ } else {
374
+ const det = await axios.get(`${PAPPERS_API}/entreprise`, {
375
+ params: { api_token: apiKey, siren: e.siren },
376
+ timeout: 10000,
377
+ });
378
+ d = det.data;
379
+ // Cache subsidiary data to avoid re-fetching
380
+ setCache(e.siren, {
381
+ identity: { name: d.nom_entreprise, nafCode: d.code_naf, nafLabel: d.libelle_code_naf, ville: d.siege?.ville, effectifTexte: d.effectif, dateCreation: d.date_creation },
382
+ financialHistory: (d.finances || []).map(f => ({ ca: f.chiffre_affaires, resultat: f.resultat, annee: f.annee })),
383
+ _subCache: true,
384
+ });
385
+ }
386
+ const fin = (d.finances || [])[0] || {};
387
+ subsidiaries.push({
388
+ siren: e.siren,
389
+ name: d.nom_entreprise || d.denomination || e.nom_entreprise || '?',
390
+ naf: d.code_naf || '',
391
+ nafLabel: d.libelle_code_naf || '',
392
+ ville: d.siege?.ville || '',
393
+ effectif: d.effectif || '',
394
+ ca: fin.chiffre_affaires ?? null,
395
+ resultat: fin.resultat ?? null,
396
+ annee: fin.annee || null,
397
+ status: d.entreprise_cessee ? 'Cessée' : 'Active',
398
+ dateCreation: d.date_creation || null,
399
+ });
400
+ } catch (_) {
401
+ subsidiaries.push({
402
+ siren: e.siren,
403
+ name: e.nom_entreprise || e.denomination || '?',
404
+ ville: e.siege?.ville || '',
405
+ ca: null, resultat: null, effectif: '', annee: null, status: '?',
406
+ });
407
+ }
408
+ }
409
+ subsidiaries.sort((a, b) => (b.ca || 0) - (a.ca || 0));
410
+ return subsidiaries;
411
+ }
412
+
413
+ // ── Local cache to save API credits ──────────────────────────────────────────
414
+ const CACHE_DIR = join(homedir(), '.intelwatch', 'cache', 'pappers');
415
+ const CACHE_TTL = 7 * 24 * 3600 * 1000; // 7 days
416
+
417
+ function ensureCacheDir() {
418
+ if (!existsSync(CACHE_DIR)) mkdirSync(CACHE_DIR, { recursive: true });
419
+ }
420
+
421
+ export function getCached(siren) {
422
+ try {
423
+ const file = join(CACHE_DIR, `${siren}.json`);
424
+ if (!existsSync(file)) return null;
425
+ const data = JSON.parse(readFileSync(file, 'utf8'));
426
+ if (Date.now() - data._cachedAt > CACHE_TTL) return null;
427
+ return data;
428
+ } catch { return null; }
429
+ }
430
+
431
+ export function setCache(siren, data) {
432
+ try {
433
+ ensureCacheDir();
434
+ writeFileSync(join(CACHE_DIR, `${siren}.json`), JSON.stringify({ ...data, _cachedAt: Date.now() }));
435
+ } catch { /* silent */ }
436
+ }