intelwatch 1.1.1 → 1.1.3

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.
@@ -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';
@@ -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,35 @@ 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
+ // Build rich description from all available fields
132
+ const parts = [];
133
+ if (p.description && p.description !== p.type) parts.push(p.description);
134
+ if (p.administration) parts.push(p.administration);
135
+ if (p.capital) parts.push(`Capital: ${(p.capital / 1e3).toFixed(0)}K€`);
136
+ if (p.date_cloture) parts.push(`Clôture: ${p.date_cloture}`);
137
+ if (p.type_depot) parts.push(p.type_depot);
138
+ if (p.activite) parts.push(p.activite);
139
+ const actes = (p.acte?.actes_publies || []).map(a => a.type_acte).filter(Boolean);
140
+ if (actes.length) parts.push(actes.join(', '));
141
+ // Build BODACC URL: format id:{letter}{parution}{annonce}
142
+ const bodaccLetter = p.bodacc || (p.type?.toLowerCase().includes('comptes') ? 'C' : 'B');
143
+ const bodaccUrl = p.numero_parution && p.numero_annonce
144
+ ? `https://www.bodacc.fr/pages/annonces-commerciales-detail/?q.id=id:${bodaccLetter}${p.numero_parution}${p.numero_annonce}`
145
+ : null;
146
+ return {
147
+ date: p.date,
148
+ type: p.type,
149
+ tribunal: p.greffe || p.tribunal || null,
150
+ numero: p.numero_annonce || null,
151
+ description: parts.length ? parts.join('. ') : p.type || null,
152
+ details: p.acte?.descriptif || null,
153
+ url: bodaccUrl,
154
+ capital: p.capital || null,
155
+ rcs: p.rcs || null,
156
+ };
157
+ });
119
158
 
120
159
  // Dirigeants with their mandats in other companies
121
160
  const dirigeants = (d.dirigeants || []).map(dir => ({
@@ -161,10 +200,53 @@ export async function pappersGetFullDossier(siren) {
161
200
  dateRadiation: d.date_radiation || null,
162
201
  };
163
202
 
164
- return {
165
- data: { identity, financialHistory, ubo, bodacc, dirigeants, proceduresCollectives, raw: d },
166
- error: null,
167
- };
203
+ // Consolidated financials (group level)
204
+ const consolidatedFinances = (d.finances_consolidees || []).slice(0, 5).map(f => ({
205
+ annee: f.annee,
206
+ ca: f.chiffre_affaires ?? null,
207
+ resultat: f.resultat ?? null,
208
+ capitauxPropres: f.capitaux_propres ?? null,
209
+ effectif: f.effectif ?? null,
210
+ ebitda: f.excedent_brut_exploitation ?? null,
211
+ margeEbitda: f.taux_marge_EBITDA ?? null,
212
+ dettesFinancieres: f.dettes_financieres ?? null,
213
+ tresorerie: f.tresorerie ?? null,
214
+ fondsPropres: f.fonds_propres ?? null,
215
+ bfr: f.BFR ?? null,
216
+ ratioEndettement: f.ratio_endettement ?? null,
217
+ autonomieFinanciere: f.autonomie_financiere ?? null,
218
+ rentabiliteFP: f.rentabilite_fonds_propres ?? null,
219
+ margeNette: f.marge_nette ?? null,
220
+ capaciteAutofinancement: f.capacite_autofinancement ?? null,
221
+ }));
222
+
223
+ // Representants (dirigeants + corporate entities with mandats)
224
+ const representants = (d.representants || []).map(r => ({
225
+ nom: r.nom_complet || r.denomination || [r.prenom, r.nom].filter(Boolean).join(' ') || '?',
226
+ qualite: r.qualite || '',
227
+ siren: r.siren || null,
228
+ personneMorale: !!r.personne_morale,
229
+ }));
230
+
231
+ // Etablissements
232
+ const etablissements = (d.etablissements || []).map(e => ({
233
+ siret: e.siret,
234
+ type: e.type_etablissement,
235
+ adresse: [e.adresse_ligne_1, e.code_postal, e.ville].filter(Boolean).join(' '),
236
+ actif: !e.etablissement_cesse,
237
+ }));
238
+
239
+ // Extra fields
240
+ identity.objetSocial = d.objet_social || null;
241
+ identity.tvaIntra = d.numero_tva_intracommunautaire || null;
242
+ identity.rcs = d.numero_rcs || null;
243
+ identity.greffe = d.greffe || null;
244
+ identity.conventionCollective = d.conventions_collectives?.[0]?.nom || null;
245
+ identity.effectifTexte = d.effectif || null;
246
+
247
+ const result = { identity, financialHistory, consolidatedFinances, ubo, bodacc, dirigeants, representants, etablissements, proceduresCollectives };
248
+ setCache(siren, result);
249
+ return { data: result, error: null };
168
250
  } catch (err) {
169
251
  return { data: null, error: err.message };
170
252
  }
@@ -216,3 +298,156 @@ function formatPappersDetail(d) {
216
298
  formeJuridique: d.forme_juridique || null,
217
299
  };
218
300
  }
301
+
302
+ /**
303
+ * Search for subsidiaries/related entities.
304
+ * Strategy 1: recherche-dirigeants — finds companies where the parent acts as corporate director.
305
+ * Strategy 2: name-search fallback.
306
+ * Returns array of entities with their latest financials, sorted by CA desc.
307
+ */
308
+ export async function pappersSearchSubsidiaries(parentName, parentSiren) {
309
+ const apiKey = getApiKey();
310
+ if (!apiKey) return { subsidiaries: [], error: 'No PAPPERS_API_KEY set' };
311
+
312
+ // Check cache for subsidiary search results
313
+ const subsCacheKey = `subs_${parentSiren}`;
314
+ const cachedSubs = getCached(subsCacheKey);
315
+ if (cachedSubs?.subsidiaries) {
316
+ return { subsidiaries: cachedSubs.subsidiaries, total: cachedSubs.total || cachedSubs.subsidiaries.length, error: null, fromCache: true };
317
+ }
318
+
319
+ const searchName = parentName.replace(/\s*(GRP|SAS|SARL|SA|SCI|EURL|GROUP|GROUPE|HOLDING|SNC|SASU)\s*/gi, ' ').trim();
320
+ const nameNorm = searchName.toLowerCase();
321
+
322
+ // ── Strategy 1: recherche-dirigeants ──────────────────────────────────────
323
+ // Finds companies where an entity named like the parent is listed as dirigeant
324
+ try {
325
+ const resp = await axios.get(`${PAPPERS_API}/recherche-dirigeants`, {
326
+ params: { api_token: apiKey, q: searchName, par_page: 20 },
327
+ timeout: 15000,
328
+ });
329
+
330
+ const resultats = resp.data.resultats || [];
331
+ const subsidiaryMap = new Map();
332
+
333
+ for (const r of resultats) {
334
+ // Only match the PARENT entity as dirigeant (by SIREN if available, else exact name)
335
+ const dirigeantSiren = r.siren || '';
336
+ const dirigeantName = (r.nom_entreprise || r.denomination || r.nom_complet || '').toLowerCase().trim();
337
+
338
+ // Strict filter: must be the parent SIREN, or exact parent name match
339
+ const isParent = (dirigeantSiren === parentSiren) || (dirigeantName === nameNorm) || (dirigeantName === searchName.toLowerCase());
340
+ if (!isParent) continue;
341
+
342
+ for (const e of (r.entreprises || [])) {
343
+ if (e.siren && e.siren !== parentSiren && !subsidiaryMap.has(e.siren)) {
344
+ subsidiaryMap.set(e.siren, e);
345
+ }
346
+ }
347
+ }
348
+
349
+ if (subsidiaryMap.size > 0) {
350
+ // Limit to 30 subsidiaries to save API credits
351
+ const entities = Array.from(subsidiaryMap.values()).slice(0, 30);
352
+ const subsidiaries = await fetchSubsidiaryDetails(apiKey, entities);
353
+ // Cache subsidiary search results
354
+ setCache(subsCacheKey, { subsidiaries, total: subsidiaryMap.size });
355
+ return { subsidiaries, total: subsidiaryMap.size, error: null };
356
+ }
357
+ } catch (_) {
358
+ // Fall through to name-search
359
+ }
360
+
361
+ // ── Strategy 2: name-search fallback ──────────────────────────────────────
362
+ try {
363
+ const resp = await axios.get(`${PAPPERS_API}/recherche`, {
364
+ params: { api_token: apiKey, q: searchName, par_page: 20 },
365
+ timeout: 15000,
366
+ });
367
+
368
+ const entities = (resp.data.entreprises || resp.data.resultats || [])
369
+ .filter(e => e.siren !== parentSiren);
370
+
371
+ const subsidiaries = await fetchSubsidiaryDetails(apiKey, entities);
372
+ // Cache fallback search results too
373
+ setCache(subsCacheKey, { subsidiaries, total: subsidiaries.length });
374
+ return { subsidiaries, error: null };
375
+ } catch (err) {
376
+ return { subsidiaries: [], error: err.message };
377
+ }
378
+ }
379
+
380
+ async function fetchSubsidiaryDetails(apiKey, entities) {
381
+ const subsidiaries = [];
382
+ for (const e of entities) {
383
+ try {
384
+ // Check cache first to save API credits
385
+ const cached = getCached(e.siren);
386
+ let d;
387
+ if (cached?.identity) {
388
+ // Reconstruct from cached full dossier
389
+ 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 })) || [] };
390
+ } else {
391
+ const det = await axios.get(`${PAPPERS_API}/entreprise`, {
392
+ params: { api_token: apiKey, siren: e.siren },
393
+ timeout: 10000,
394
+ });
395
+ d = det.data;
396
+ // Cache subsidiary data to avoid re-fetching
397
+ setCache(e.siren, {
398
+ 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 },
399
+ financialHistory: (d.finances || []).map(f => ({ ca: f.chiffre_affaires, resultat: f.resultat, annee: f.annee })),
400
+ _subCache: true,
401
+ });
402
+ }
403
+ const fin = (d.finances || [])[0] || {};
404
+ subsidiaries.push({
405
+ siren: e.siren,
406
+ name: d.nom_entreprise || d.denomination || e.nom_entreprise || '?',
407
+ naf: d.code_naf || '',
408
+ nafLabel: d.libelle_code_naf || '',
409
+ ville: d.siege?.ville || '',
410
+ effectif: d.effectif || '',
411
+ ca: fin.chiffre_affaires ?? null,
412
+ resultat: fin.resultat ?? null,
413
+ annee: fin.annee || null,
414
+ status: d.entreprise_cessee ? 'Cessée' : 'Active',
415
+ dateCreation: d.date_creation || null,
416
+ });
417
+ } catch (_) {
418
+ subsidiaries.push({
419
+ siren: e.siren,
420
+ name: e.nom_entreprise || e.denomination || '?',
421
+ ville: e.siege?.ville || '',
422
+ ca: null, resultat: null, effectif: '', annee: null, status: '?',
423
+ });
424
+ }
425
+ }
426
+ subsidiaries.sort((a, b) => (b.ca || 0) - (a.ca || 0));
427
+ return subsidiaries;
428
+ }
429
+
430
+ // ── Local cache to save API credits ──────────────────────────────────────────
431
+ const CACHE_DIR = join(homedir(), '.intelwatch', 'cache', 'pappers');
432
+ const CACHE_TTL = 7 * 24 * 3600 * 1000; // 7 days
433
+
434
+ function ensureCacheDir() {
435
+ if (!existsSync(CACHE_DIR)) mkdirSync(CACHE_DIR, { recursive: true });
436
+ }
437
+
438
+ export function getCached(siren) {
439
+ try {
440
+ const file = join(CACHE_DIR, `${siren}.json`);
441
+ if (!existsSync(file)) return null;
442
+ const data = JSON.parse(readFileSync(file, 'utf8'));
443
+ if (Date.now() - data._cachedAt > CACHE_TTL) return null;
444
+ return data;
445
+ } catch { return null; }
446
+ }
447
+
448
+ export function setCache(siren, data) {
449
+ try {
450
+ ensureCacheDir();
451
+ writeFileSync(join(CACHE_DIR, `${siren}.json`), JSON.stringify({ ...data, _cachedAt: Date.now() }));
452
+ } catch { /* silent */ }
453
+ }