intelwatch 1.3.2 → 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.
@@ -0,0 +1,461 @@
1
+ /**
2
+ * Annuaire Entreprises / data.gouv scraper.
3
+ *
4
+ * Free, no API key required.
5
+ * API doc: https://api.recherche-entreprises.fr/docs
6
+ * Base URL: https://recherche-entreprises.api.gouv.fr
7
+ *
8
+ * Provides: SIREN, SIRET, dirigeants, adresse, NAF, effectifs,
9
+ * nature juridique, finances (CA, resultat_net), catégorie entreprise.
10
+ * Does NOT provide: UBO, BODACC, procédures collectives, consolidated finances,
11
+ * mandats croisés des dirigeants, etablissements multiples détaillés.
12
+ */
13
+
14
+ import axios from 'axios';
15
+ import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'fs';
16
+ import { join } from 'path';
17
+ import { homedir } from 'os';
18
+
19
+ const ANNUAIRE_API = 'https://recherche-entreprises.api.gouv.fr';
20
+
21
+ // ── Local cache (7 days, same TTL as Pappers) ────────────────────────────────
22
+ const CACHE_DIR = join(homedir(), '.intelwatch', 'cache', 'annuaire-entreprises');
23
+ const CACHE_TTL = 7 * 24 * 3600 * 1000;
24
+
25
+ function ensureCacheDir() {
26
+ if (!existsSync(CACHE_DIR)) mkdirSync(CACHE_DIR, { recursive: true });
27
+ }
28
+
29
+ function getCached(key) {
30
+ try {
31
+ const file = join(CACHE_DIR, `${key}.json`);
32
+ if (!existsSync(file)) return null;
33
+ const data = JSON.parse(readFileSync(file, 'utf8'));
34
+ if (Date.now() - data._cachedAt > CACHE_TTL) return null;
35
+ return data;
36
+ } catch { return null; }
37
+ }
38
+
39
+ function setCache(key, data) {
40
+ try {
41
+ ensureCacheDir();
42
+ writeFileSync(join(CACHE_DIR, `${key}.json`), JSON.stringify({ ...data, _cachedAt: Date.now() }));
43
+ } catch { /* silent */ }
44
+ }
45
+
46
+ // ── NAF code to label mapping (common codes) ─────────────────────────────────
47
+
48
+ const NAF_LABELS = {
49
+ '01': 'Agriculture', '02': 'Sylviculture', '10': 'Industries alimentaires',
50
+ '11': 'Boissons', '12': 'Tabac', '13': 'Textiles', '14': 'Habillement',
51
+ '15': 'Cuir', '16': 'Bois', '17': 'Papier', '18': 'Imprimerie',
52
+ '19': 'Coke et pétrole', '20': 'Produits chimiques', '21': 'Pharmacie',
53
+ '22': 'Caoutchouc/Plastique', '23': 'Autres minéraux non métalliques',
54
+ '24': 'Métallurgie', '25': 'Produits métalliques', '26': 'Informatique/Électronique',
55
+ '27': 'Équipements électriques', '28': 'Machines', '29': 'Automobiles',
56
+ '30': 'Autres transports', '31': 'Mobilier', '32': 'Autres industries',
57
+ '33': 'Réparation machines', '35': 'Énergie', '36': 'Eau', '37': 'Collecte déchets',
58
+ '38': 'Traitement déchets', '39': 'Dépollution', '41': 'Construction bâtiments',
59
+ '42': 'Génie civil', '43': 'Travaux spécialisés', '45': 'Commerce auto',
60
+ '46': 'Commerce de gros', '47': 'Commerce de détail', '49': 'Transport terrestre',
61
+ '50': 'Transport maritime', '51': 'Transport aérien', '52': 'Entreposage',
62
+ '53': 'Poste', '55': 'Hébergement', '56': 'Restauration', '58': 'Édition',
63
+ '59': 'Cinéma/Vidéo', '60': 'Programmation/TV', '61': 'Télécommunications',
64
+ '62': 'Programmation informatique', '63': 'Services information',
65
+ '64': 'Services financiers', '65': 'Assurance', '66': 'Activités auxiliaires finance',
66
+ '68': 'Immobilier', '69': 'Activités juridiques/comptables',
67
+ '70': 'Conseil de gestion', '71': 'Architecture/Ingénierie',
68
+ '72': 'Recherche scientifique', '73': 'Publicité/Conseil marketing',
69
+ '74': 'Conseil en design', '75': 'Activités vétérinaires',
70
+ '77': 'Location/bail', '78': 'Emploi', '79': 'Voyages',
71
+ '80': 'Sécurité/enquête', '81': 'Services bâtiments/paysage',
72
+ '82': 'Services administratifs', '84': 'Administration publique',
73
+ '85': 'Enseignement', '86': 'Santé humaine', '87': 'Hébergement médicalisé',
74
+ '88': 'Action sociale', '90': 'Arts', '91': 'Bibliothèques/Musées',
75
+ '92': 'Jeux/Loisirs', '93': 'Sports', '94': 'Associations',
76
+ '95': 'Réparation ménage', '96': 'Services personnels', '97': 'Ménages employeurs',
77
+ '98': 'Activités indifférenciées', '99': 'Extraterritorial',
78
+ };
79
+
80
+ // ── Effectif tranche mapping ────────────────────────────────────────────────
81
+
82
+ const EFFECTIF_TRANCHE_MAP = {
83
+ '00': '0 salarié',
84
+ '01': '1-2 salariés',
85
+ '02': '3-5 salariés',
86
+ '03': '6-9 salariés',
87
+ '11': '10-19 salariés',
88
+ '12': '20-49 salariés',
89
+ '21': '50-99 salariés',
90
+ '22': '100-249 salariés',
91
+ '31': '250-499 salariés',
92
+ '32': '500-999 salariés',
93
+ '41': '1 000-2 499 salariés',
94
+ '42': '2 500-4 999 salariés',
95
+ '51': '5 000-9 999 salariés',
96
+ '52': '10 000+ salariés',
97
+ 'NN': 'Inconnu',
98
+ };
99
+
100
+ // ── Nature juridique mapping (common codes) ─────────────────────────────────
101
+
102
+ const NATURE_JURIDIQUE_MAP = {
103
+ '1000': 'Entrepreneur individuel',
104
+ '2110': 'Indivision',
105
+ '2120': 'Société créée de fait',
106
+ '2210': 'Société en nom collectif (SNC)',
107
+ '2220': 'Société en commandite simple (SCS)',
108
+ '2250': 'Société en participation',
109
+ '2310': 'Société à responsabilité limitée (SARL)',
110
+ '2320': 'Société à responsabilité limitée simplifiée (EURL)',
111
+ '2385': 'SARL unipersonnelle',
112
+ '2410': 'Société anonyme (SA)',
113
+ '2420': 'Société anonyme à directoire',
114
+ '2450': 'Société par actions simplifiée (SAS)',
115
+ '2510': 'Société par actions simplifiée à associé unique (SASU)',
116
+ '2520': 'SAS unipersonnelle',
117
+ '2610': 'Société en commandite par actions (SCA)',
118
+ '2710': 'Groupement d\'intérêt économique (GIE)',
119
+ '2720': 'Groupement européen d\'intérêt économique (GEIE)',
120
+ '3110': 'Société civile (SC)',
121
+ '3150': 'Société civile immobilière (SCI)',
122
+ '3205': 'Société d\'exercice libéral (SEL)',
123
+ '3210': 'Société d\'exercice libéral à responsabilité limitée (SELARL)',
124
+ '3220': 'Société d\'exercice libéral par actions simplifiée (SELAS)',
125
+ '4110': 'Établissement public national',
126
+ '4120': 'Établissement public local',
127
+ '4140': 'Commune',
128
+ '4150': 'Département',
129
+ '4160': 'Région',
130
+ '4210': 'Syndicat intercommunal',
131
+ '5198': 'Société coopérative (SCOP)',
132
+ '5499': 'Société à responsabilité limitée (SARL)', // commonly mapped
133
+ '5710': 'Caisse d\'épargne',
134
+ '6202': 'Fonds commun de placement',
135
+ '6411': 'Société d\'investissement à capital variable (SICAV)',
136
+ '6511': 'Société de crédit agricole',
137
+ '6542': 'Caisse de crédit municipal',
138
+ '7112': 'Association déclarée',
139
+ '7120': 'Association non déclarée',
140
+ '7210': 'Syndicat de salariés',
141
+ '7220': 'Syndicat patronal',
142
+ '7312': 'Ordre professionnel',
143
+ '7412': 'Mutuelle',
144
+ '7499': 'Organisme social',
145
+ '8110': 'Établissement d\'enseignement privé',
146
+ '8150': 'Établissement sanitaire privé',
147
+ '8510': 'Régime général de la Sécurité sociale',
148
+ '8520': 'Régime agricole',
149
+ '9220': 'Société de la loi de 1901',
150
+ };
151
+
152
+ function getNafLabel(nafCode) {
153
+ if (!nafCode) return null;
154
+ const prefix = nafCode.split('.')[0];
155
+ return NAF_LABELS[prefix] || null;
156
+ }
157
+
158
+ function getEffectifLabel(trancheCode) {
159
+ if (!trancheCode) return null;
160
+ return EFFECTIF_TRANCHE_MAP[trancheCode] || `Tranche ${trancheCode}`;
161
+ }
162
+
163
+ function getNatureJuridiqueLabel(code) {
164
+ if (!code) return null;
165
+ return NATURE_JURIDIQUE_MAP[code] || `Code ${code}`;
166
+ }
167
+
168
+ /**
169
+ * Search companies by name on Annuaire Entreprises.
170
+ * @param {string} name
171
+ * @param {{ count?: number }} options
172
+ * @returns {Promise<{ results: Array, error: string|null }>}
173
+ */
174
+ export async function annuaireSearchByName(name, options = {}) {
175
+ try {
176
+ const resp = await axios.get(`${ANNUAIRE_API}/search`, {
177
+ params: {
178
+ q: name,
179
+ per_page: options.count || 10,
180
+ mtm_campaign: 'intelwatch',
181
+ },
182
+ timeout: 10000,
183
+ });
184
+ const results = (resp.data.results || []).map(r => formatSearchResult(r));
185
+ return { results, error: null, total_results: resp.data.total_results || 0 };
186
+ } catch (err) {
187
+ const msg = err.response?.status === 429
188
+ ? 'Rate limit exceeded (Annuaire Entreprises). Retry later.'
189
+ : err.message;
190
+ return { results: [], error: msg };
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Get company details by SIREN via Annuaire Entreprises search.
196
+ * Note: the API doesn't have a dedicated SIREN endpoint — we use the search endpoint
197
+ * with the SIREN as query, which returns exact match at position 0.
198
+ * @param {string} siren
199
+ * @returns {Promise<{ data: object|null, error: string|null }>}
200
+ */
201
+ export async function annuaireGetBySiren(siren) {
202
+ // Check cache
203
+ const cached = getCached(siren);
204
+ if (cached) return { data: cached, error: null, fromCache: true };
205
+
206
+ try {
207
+ const resp = await axios.get(`${ANNUAIRE_API}/search`, {
208
+ params: {
209
+ q: siren,
210
+ per_page: 1,
211
+ mtm_campaign: 'intelwatch',
212
+ },
213
+ timeout: 10000,
214
+ });
215
+
216
+ const results = resp.data.results || [];
217
+ if (results.length === 0) {
218
+ return { data: null, error: `SIREN ${siren} non trouvé sur l'Annuaire Entreprises.` };
219
+ }
220
+
221
+ const data = formatProfile(results[0]);
222
+ setCache(siren, data);
223
+ return { data, error: null };
224
+ } catch (err) {
225
+ if (err.response?.status === 404) {
226
+ return { data: null, error: `SIREN ${siren} non trouvé.` };
227
+ }
228
+ return { data: null, error: err.message };
229
+ }
230
+ }
231
+
232
+ /**
233
+ * Get full company dossier by SIREN.
234
+ * Returns a normalized structure compatible with the Pappers dossier format
235
+ * so that the profile command can render it seamlessly.
236
+ * @param {string} siren
237
+ * @returns {Promise<{ data: object|null, error: string|null, fromCache?: boolean }>}
238
+ */
239
+ export async function annuaireGetFullDossier(siren) {
240
+ const cached = getCached(`dossier_${siren}`);
241
+ if (cached) return { data: cached, error: null, fromCache: true };
242
+
243
+ try {
244
+ const resp = await axios.get(`${ANNUAIRE_API}/search`, {
245
+ params: {
246
+ q: siren,
247
+ per_page: 1,
248
+ mtm_campaign: 'intelwatch',
249
+ },
250
+ timeout: 10000,
251
+ });
252
+
253
+ const results = resp.data.results || [];
254
+ if (results.length === 0) {
255
+ return { data: null, error: `SIREN ${siren} non trouvé sur l'Annuaire Entreprises.` };
256
+ }
257
+
258
+ const raw = results[0];
259
+ const data = buildDossier(raw);
260
+ setCache(`dossier_${siren}`, data);
261
+ return { data, error: null };
262
+ } catch (err) {
263
+ return { data: null, error: err.message };
264
+ }
265
+ }
266
+
267
+ /**
268
+ * Quick lookup for competitor tracker (name → basic company info).
269
+ * @param {string} companyName
270
+ * @returns {Promise<object|null>}
271
+ */
272
+ export async function annuaireLookup(companyName) {
273
+ const { results, error } = await annuaireSearchByName(companyName, { count: 1 });
274
+ if (error || results.length === 0) return null;
275
+
276
+ const top = results[0];
277
+ const siren = top.siren;
278
+ if (!siren) return top;
279
+
280
+ const detail = await annuaireGetBySiren(siren);
281
+ if (detail.error || !detail.data) return top;
282
+ return detail.data;
283
+ }
284
+
285
+ // ── Formatters ───────────────────────────────────────────────────────────────
286
+
287
+ /**
288
+ * Format a search result (lightweight, for search listing).
289
+ */
290
+ function formatSearchResult(r) {
291
+ return {
292
+ siren: r.siren,
293
+ siret: r.siege?.siret || null,
294
+ nom_entreprise: r.nom_raison_sociale || r.nom_complet,
295
+ denomination: r.nom_raison_sociale || r.nom_complet,
296
+ date_creation: r.date_creation || null,
297
+ code_naf: r.activite_principale || null,
298
+ libelle_code_naf: r.activite_principale ? getNafLabel(r.activite_principale) : null,
299
+ forme_juridique: getNatureJuridiqueLabel(r.nature_juridique) || null,
300
+ siege: {
301
+ ville: r.siege?.libelle_commune || null,
302
+ code_postal: r.siege?.code_postal || null,
303
+ adresse: r.siege?.adresse || null,
304
+ },
305
+ tranche_effectif: getEffectifLabel(r.tranche_effectif_salarie) || null,
306
+ etat: r.etat_administratif === 'A' ? 'actif' : 'fermé',
307
+ categorie_entreprise: r.categorie_entreprise || null,
308
+ source: 'annuaire-entreprises',
309
+ };
310
+ }
311
+
312
+ /**
313
+ * Format a single company profile (for getProfile).
314
+ * Normalized to match the interface expected by the provider/consumers.
315
+ */
316
+ function formatProfile(r) {
317
+ const siege = r.siege || {};
318
+ const finances = r.finances || {};
319
+ const finYears = Object.keys(finances).sort((a, b) => b - a);
320
+
321
+ // Latest financials
322
+ const lastFinYear = finYears[0] || null;
323
+ const lastFin = lastFinYear ? finances[lastFinYear] : {};
324
+
325
+ return {
326
+ siren: r.siren,
327
+ siret: siege.siret || null,
328
+ name: r.nom_raison_sociale || r.nom_complet,
329
+ dateCreation: r.date_creation || null,
330
+ nafCode: r.activite_principale || null,
331
+ nafLabel: getNafLabel(r.activite_principale) || null,
332
+ formeJuridique: getNatureJuridiqueLabel(r.nature_juridique) || null,
333
+ effectifs: getEffectifLabel(r.tranche_effectif_salarie) || null,
334
+ adresse: [siege.numero_voie, siege.type_voie, siege.libelle_voie].filter(Boolean).join(' ') || null,
335
+ ville: siege.libelle_commune || null,
336
+ codePostal: siege.code_postal || null,
337
+ capital: r.capital_social ?? (r.capital ?? null),
338
+ capitalMonnaie: 'EUR',
339
+ website: null, // Not available in Annuaire API
340
+ status: r.etat_administratif === 'A' ? 'Actif' : 'Fermé',
341
+ categorieEntreprise: r.categorie_entreprise || null,
342
+ dirigeants: (r.dirigeants || []).filter(d => d.nom || d.prenoms).map(d => ({
343
+ nom: d.nom || null,
344
+ prenom: d.prenoms || null,
345
+ role: d.qualite || null,
346
+ dateNomination: null,
347
+ dateNaissance: d.annee_de_naissance || d.date_de_naissance || null,
348
+ nationalite: d.nationalite || null,
349
+ type: d.type_dirigeant || null,
350
+ mandats: [],
351
+ })),
352
+ ca: lastFin.ca ?? null,
353
+ caYear: lastFinYear ? parseInt(lastFinYear, 10) : null,
354
+ resultat: lastFin.resultat_net ?? null,
355
+ finances: finYears.slice(0, 5).map(year => ({
356
+ annee: parseInt(year, 10),
357
+ ca: finances[year].ca ?? null,
358
+ resultat: finances[year].resultat_net ?? null,
359
+ })),
360
+ source: 'annuaire-entreprises',
361
+ _fallback: true,
362
+ };
363
+ }
364
+
365
+ /**
366
+ * Build a full dossier compatible with the Pappers dossier structure.
367
+ * This allows the profile.js command to render data identically regardless
368
+ * of whether it came from Pappers or the Annuaire Entreprises fallback.
369
+ */
370
+ function buildDossier(r) {
371
+ const siege = r.siege || {};
372
+ const finances = r.finances || {};
373
+
374
+ // Identity block (matches Pappers identity structure)
375
+ const identity = {
376
+ siren: r.siren,
377
+ siret: siege.siret || null,
378
+ name: r.nom_raison_sociale || r.nom_complet,
379
+ dateCreation: r.date_creation || null,
380
+ nafCode: r.activite_principale || null,
381
+ nafLabel: getNafLabel(r.activite_principale) || null,
382
+ formeJuridique: getNatureJuridiqueLabel(r.nature_juridique) || null,
383
+ effectifs: getEffectifLabel(r.tranche_effectif_salarie) || null,
384
+ adresse: [siege.numero_voie, siege.type_voie, siege.libelle_voie].filter(Boolean).join(' ') || null,
385
+ ville: siege.libelle_commune || null,
386
+ codePostal: siege.code_postal || null,
387
+ capital: r.capital_social ?? (r.capital ?? null),
388
+ capitalMonnaie: 'EUR',
389
+ website: null,
390
+ status: r.etat_administratif === 'A' ? 'Actif' : 'Fermé',
391
+ dateRadiation: r.date_fermeture || null,
392
+ // Extra fields
393
+ objetSocial: null,
394
+ tvaIntra: null,
395
+ rcs: null,
396
+ greffe: null,
397
+ conventionCollective: null,
398
+ effectifTexte: getEffectifLabel(r.tranche_effectif_salarie) || null,
399
+ categorieEntreprise: r.categorie_entreprise || null,
400
+ };
401
+
402
+ // Financial history (sorted by year desc)
403
+ const finYears = Object.keys(finances).sort((a, b) => b - a);
404
+ const financialHistory = finYears.slice(0, 5).map(year => ({
405
+ annee: parseInt(year, 10),
406
+ ca: finances[year].ca ?? null,
407
+ resultat: finances[year].resultat_net ?? null,
408
+ capitauxPropres: null,
409
+ effectif: null,
410
+ ebitda: null,
411
+ margeEbitda: null,
412
+ dettesFinancieres: null,
413
+ tresorerie: null,
414
+ fondsPropres: null,
415
+ bfr: null,
416
+ ratioEndettement: null,
417
+ autonomieFinanciere: null,
418
+ rentabiliteFP: null,
419
+ margeNette: null,
420
+ capaciteAutofinancement: null,
421
+ }));
422
+
423
+ // Dirigeants (no mandats from Annuaire API)
424
+ const dirigeants = (r.dirigeants || []).filter(d => d.nom || d.prenoms).map(d => ({
425
+ nom: d.nom || null,
426
+ prenom: d.prenoms || null,
427
+ role: d.qualite || null,
428
+ dateNomination: null,
429
+ dateNaissance: d.annee_de_naissance || d.date_de_naissance || null,
430
+ nationalite: d.nationalite || null,
431
+ mandats: [],
432
+ }));
433
+
434
+ // Fields not available from Annuaire API — return empty but valid structures
435
+ const ubo = [];
436
+ const bodacc = [];
437
+ const proceduresCollectives = [];
438
+ const representants = [];
439
+ const etablissements = [];
440
+ const consolidatedFinances = [];
441
+
442
+ const result = {
443
+ identity,
444
+ financialHistory,
445
+ consolidatedFinances,
446
+ ubo,
447
+ bodacc,
448
+ dirigeants,
449
+ representants,
450
+ etablissements,
451
+ proceduresCollectives,
452
+ source: 'annuaire-entreprises',
453
+ _fallback: true,
454
+ _fallbackNote: 'Données issues de l\'Annuaire Entreprises (data.gouv.fr). Données financières limitées (CA, résultat net). UBO, BODACC, procédures collectives et mandats croisés non disponibles via cette source gratuite.',
455
+ };
456
+
457
+ return result;
458
+ }
459
+
460
+ // Re-export cache helpers for potential use by the provider
461
+ export { getCached, setCache };