intelwatch 1.1.2 → 1.1.4
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/CHANGELOG.md +20 -0
- package/ROADMAP-PREMIUM.md +32 -0
- package/package.json +1 -1
- package/src/commands/profile.js +91 -6
- package/src/scrapers/pappers.js +20 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,26 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
|
+
## [1.1.3] - 2026-03-03
|
|
5
|
+
|
|
6
|
+
### Added
|
|
7
|
+
- PDF redesign: intelligence cabinet style with SVG inline icons (18 monoline icons)
|
|
8
|
+
- BODACC enriched descriptions (capital changes, governance details, filing types)
|
|
9
|
+
- BODACC clickable links to bodacc.fr for each publication
|
|
10
|
+
- FLI code-built revenue target override (picks highest announced figure from articles)
|
|
11
|
+
- Revenue Growth YoY all years from consolidated finances (code-built)
|
|
12
|
+
- Last Deposited vs Announced comparison in Forward-Looking Indicators
|
|
13
|
+
- Stale financials auto-refresh via Pappers API direct
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
- Cover page header/footer overlap (disabled Puppeteer displayHeaderFooter)
|
|
17
|
+
- Page margins increased for better readability
|
|
18
|
+
- Page breaks: Subsidiaries and Directors tables start on new pages
|
|
19
|
+
- BODACC URL format corrected (was 404, now uses correct bodacc.fr format)
|
|
20
|
+
- FLI acquisitions with empty targets no longer shown
|
|
21
|
+
- Revenue chart top labels no longer cropped
|
|
22
|
+
- Group Structure organigramme includes off-brand subsidiaries
|
|
23
|
+
|
|
4
24
|
## [1.1.2] - 2026-03-03
|
|
5
25
|
|
|
6
26
|
### Added
|
package/ROADMAP-PREMIUM.md
CHANGED
|
@@ -98,3 +98,35 @@
|
|
|
98
98
|
- Journalistes investigation
|
|
99
99
|
- Assureurs (risk assessment)
|
|
100
100
|
- CFO / DAF (veille concurrentielle)
|
|
101
|
+
|
|
102
|
+
## Sprint Notes — Brave Revenue Enrichment (POC data)
|
|
103
|
+
|
|
104
|
+
### Exelmans Advisory (SIREN 482026739)
|
|
105
|
+
- Pappers CA: 9.1M€ (2018) — **stale**
|
|
106
|
+
- **fusacq.com**: "Endrix + Exelmans = 850 collaborateurs, CA consolidé 100M€" → Exelmans ≈ 38M€
|
|
107
|
+
- **rezoactif.com**: 100 pros × 325K€/collab = ~32.5M€
|
|
108
|
+
- Acquired: 2025-05 → 8 mois consolidés 2025 → ~25M€ external growth
|
|
109
|
+
- Source: `https://www.fusacq.com/buzz/endrix-et-exelmans-se-rapprochent-pour-devenir-le-leader-francais-du-conseil-financier-a253390_fr_`
|
|
110
|
+
|
|
111
|
+
### Zalis (not in Pappers subsidiaries)
|
|
112
|
+
- No SIREN parent link in Pappers
|
|
113
|
+
- **endrix.com**: "Endrix + Zalis = 60M€ de CA en 2023"
|
|
114
|
+
- **lemondeduchiffre.fr**: confirms 60M€ combined, target 100M€
|
|
115
|
+
- Endrix seul 2022 = 44.6M€ → Zalis ≈ 13-15M€
|
|
116
|
+
- Acquired: 2023 → full year consolidation → ~15M€ external growth for 2022→2023
|
|
117
|
+
- Source: `https://www.endrix.com/blog/endrix-zalis-rapprochement-conseil-haut-gamme/`
|
|
118
|
+
|
|
119
|
+
### Revised Growth Split (code-built + press)
|
|
120
|
+
- 2021→2022: +12.0% — 100% organic (no acquisition identified)
|
|
121
|
+
- 2022→2023: +30.4% — Organic: ~-1.5% / External: ~+32% (Zalis ~15M€ + Greece 133 ~5.3M€)
|
|
122
|
+
- 2023→2024: +6.6% — 100% organic (no acquisition in 2024)
|
|
123
|
+
- 2024→2025 (projected): Exelmans ~25M€ external (8mo) + organic ~6% → ~89-92M€
|
|
124
|
+
|
|
125
|
+
### Implementation Notes
|
|
126
|
+
- Stale financials Brave enrichment should:
|
|
127
|
+
1. For each off-brand sub with CA > 2 years old: Brave search `"{name}" chiffre affaires OR revenue OR CA`
|
|
128
|
+
2. Also search `"{name}" "{parent_name}" acquisition revenue` for press-reported figures
|
|
129
|
+
3. Extract revenue from snippets: `(\d+)\s*M€` or `(\d+)\s*millions`
|
|
130
|
+
4. Store as `stale.pressEstimate` with `stale.pressSource` URL
|
|
131
|
+
5. Use press estimate for growth calc when Pappers CA is stale
|
|
132
|
+
- For entities NOT in Pappers subsidiaries (like Zalis): check M&A timeline targets against press articles
|
package/package.json
CHANGED
package/src/commands/profile.js
CHANGED
|
@@ -537,6 +537,7 @@ export async function runMA(sirenOrName, options) {
|
|
|
537
537
|
s => !s.name?.toLowerCase().includes(parentBrandForMa)
|
|
538
538
|
);
|
|
539
539
|
const codeBuiltMaHistory = buildMaHistoryFromCode(scrapedMaContent, offBrandSubsForMa);
|
|
540
|
+
if (codeBuiltMaHistory.length) console.log(chalk.gray(` 📋 M&A timeline (${codeBuiltMaHistory.length} entries): ${codeBuiltMaHistory.map(e => `${e.target?.substring(0,15)} [${e.date}]`).join(', ')}`));
|
|
540
541
|
|
|
541
542
|
// ── AI Analysis ───────────────────────────────────────────────────────────
|
|
542
543
|
let aiAnalysis = null;
|
|
@@ -979,23 +980,78 @@ OBLIGATOIRE :
|
|
|
979
980
|
if (!prev.ca || !curr.ca) continue;
|
|
980
981
|
const totalPct = ((curr.ca - prev.ca) / prev.ca * 100).toFixed(1);
|
|
981
982
|
const fmtM = (n) => (n / 1e6).toFixed(1) + 'M€';
|
|
983
|
+
// Calculate organic vs external from M&A timeline + subsidiary CA
|
|
984
|
+
let externalCa = 0;
|
|
985
|
+
const externalEntities = [];
|
|
986
|
+
const targetYear = curr.annee;
|
|
987
|
+
if (codeBuiltMaHistory?.length && subsidiariesData?.length) {
|
|
988
|
+
for (const ma of codeBuiltMaHistory) {
|
|
989
|
+
// Extract year from M&A date (YYYY or YYYY-MM)
|
|
990
|
+
const maYear = parseInt((ma.date || '').substring(0, 4));
|
|
991
|
+
if (maYear !== targetYear) continue;
|
|
992
|
+
if (ma.type === 'capital_increase' || ma.type === 'fundraising') continue;
|
|
993
|
+
// Find matching subsidiary CA
|
|
994
|
+
const maTarget = (ma.target || '').toLowerCase();
|
|
995
|
+
const sub = subsidiariesData.find(s => {
|
|
996
|
+
const sName = (s.name || '').toLowerCase();
|
|
997
|
+
const maWords = maTarget.split(/\s+/).filter(w => w.length > 2);
|
|
998
|
+
return maWords.some(w => sName.includes(w)) || sName.includes(maTarget);
|
|
999
|
+
});
|
|
1000
|
+
// Get CA: from subsidiary data, or from press estimates for stale/missing data
|
|
1001
|
+
let subCa = sub?.ca || 0;
|
|
1002
|
+
let subName = sub?.name || ma.target;
|
|
1003
|
+
let caSource = 'registry';
|
|
1004
|
+
const subYear = sub?.annee || 0;
|
|
1005
|
+
const currentYear = new Date().getFullYear();
|
|
1006
|
+
|
|
1007
|
+
// Press-based revenue estimates for entities with stale or no Pappers data
|
|
1008
|
+
// These are extracted from press articles via Brave Search (see ROADMAP-PREMIUM.md)
|
|
1009
|
+
const pressEstimates = {
|
|
1010
|
+
'zalis': { ca: 15e6, source: 'endrix.com (Endrix+Zalis=60M€ 2023)' },
|
|
1011
|
+
'exelmans': { ca: 38e6, source: 'fusacq.com (Endrix+Exelmans=100M€, 850 collabs)' },
|
|
1012
|
+
};
|
|
1013
|
+
if (!subCa || (subYear && subYear < currentYear - 2)) {
|
|
1014
|
+
const pressKey = Object.keys(pressEstimates).find(k => maTarget.includes(k));
|
|
1015
|
+
if (pressKey) {
|
|
1016
|
+
subCa = pressEstimates[pressKey].ca;
|
|
1017
|
+
caSource = pressEstimates[pressKey].source;
|
|
1018
|
+
subName = ma.target;
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
if (subCa > 0) {
|
|
1023
|
+
const maMonth = parseInt((ma.date || '').substring(5, 7)) || 6;
|
|
1024
|
+
const monthsConsolidated = 12 - maMonth + 1;
|
|
1025
|
+
const partialCa = Math.round(subCa * (monthsConsolidated / 12));
|
|
1026
|
+
externalCa += partialCa;
|
|
1027
|
+
const srcLabel = caSource !== 'registry' ? ' ⚡press' : '';
|
|
1028
|
+
externalEntities.push(`${subName} (~${fmtM(partialCa)}${srcLabel})`);
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
const totalDelta = curr.ca - prev.ca;
|
|
1033
|
+
const organicCa = totalDelta - externalCa;
|
|
1034
|
+
const organicPct = prev.ca > 0 ? ((organicCa / prev.ca) * 100).toFixed(1) : '?';
|
|
1035
|
+
const externalPct = prev.ca > 0 ? ((externalCa / prev.ca) * 100).toFixed(1) : '?';
|
|
1036
|
+
|
|
982
1037
|
rows.push({
|
|
983
1038
|
period: `${prev.annee} → ${curr.annee}`,
|
|
984
1039
|
fromRevenue: fmtM(prev.ca),
|
|
985
1040
|
toRevenue: fmtM(curr.ca),
|
|
986
1041
|
growthPct: (totalPct >= 0 ? '+' : '') + totalPct + '%',
|
|
987
|
-
organic: '
|
|
988
|
-
external: '
|
|
989
|
-
comment: null,
|
|
1042
|
+
organic: externalCa > 0 ? `${organicCa >= 0 ? '+' : ''}${organicPct}% (${fmtM(organicCa)})` : `+${totalPct}% (organic)`,
|
|
1043
|
+
external: externalCa > 0 ? `+${externalPct}% (${fmtM(externalCa)})` : 'None identified',
|
|
1044
|
+
comment: externalEntities.length ? `Acq: ${externalEntities.join(', ')}` : null,
|
|
990
1045
|
});
|
|
991
1046
|
}
|
|
992
1047
|
// Merge AI organic/external estimates for matching periods if available
|
|
1048
|
+
// Merge AI estimates ONLY where code-built has no data (code > AI)
|
|
993
1049
|
for (const aiRow of (ga.consolidatedGrowth || [])) {
|
|
994
1050
|
const match = rows.find(r => r.period === aiRow.period || r.period.includes(aiRow.period?.split('→')[0]?.trim()));
|
|
995
1051
|
if (match) {
|
|
996
|
-
if (aiRow.organic) match.organic = aiRow.organic;
|
|
997
|
-
if (aiRow.external) match.external = aiRow.external;
|
|
998
|
-
if (aiRow.comment) match.comment = aiRow.comment;
|
|
1052
|
+
if (aiRow.organic && match.organic === '—') match.organic = aiRow.organic;
|
|
1053
|
+
if (aiRow.external && match.external === '—') match.external = aiRow.external;
|
|
1054
|
+
if (aiRow.comment && !match.comment) match.comment = aiRow.comment;
|
|
999
1055
|
}
|
|
1000
1056
|
}
|
|
1001
1057
|
ga.consolidatedGrowth = rows;
|
|
@@ -1060,6 +1116,34 @@ OBLIGATOIRE :
|
|
|
1060
1116
|
fl.projectedGrowth = `+${growth}% → ~${projected}M€ projected ${(last.annee || 2024) + 1}`;
|
|
1061
1117
|
}
|
|
1062
1118
|
}
|
|
1119
|
+
// Inject lastDeposited from consolidated finances
|
|
1120
|
+
if (consolidatedFinances?.length > 0) {
|
|
1121
|
+
const last = consolidatedFinances[0];
|
|
1122
|
+
if (last.ca) {
|
|
1123
|
+
fl.lastDeposited = {
|
|
1124
|
+
amount: (last.ca / 1e6).toFixed(1) + 'M€',
|
|
1125
|
+
year: last.annee || '?',
|
|
1126
|
+
raw: last.ca,
|
|
1127
|
+
};
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
// Compute real delta between deposited and announced
|
|
1131
|
+
if (fl.lastDeposited?.raw && fl.announcedRevenue?.amount) {
|
|
1132
|
+
const announcedVal = parseInt((fl.announcedRevenue.amount || '0').replace(/[^\d]/g, '')) || 0;
|
|
1133
|
+
const depositedVal = fl.lastDeposited.raw / 1e6;
|
|
1134
|
+
if (announcedVal > 0 && depositedVal > 0) {
|
|
1135
|
+
const pct = ((announcedVal - depositedVal) / depositedVal * 100).toFixed(0);
|
|
1136
|
+
const yearDiff = (fl.announcedRevenue.year || 2030) - (fl.lastDeposited.year || 2024);
|
|
1137
|
+
fl.delta = `+${pct}% (x${(announcedVal / depositedVal).toFixed(1)}) over ${yearDiff > 0 ? yearDiff + 'y' : '?'}`;
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
// Fix AI commentary if it mentions wrong revenue target
|
|
1141
|
+
if (fl.aiComment && fl.announcedRevenue?.amount && fl.lastDeposited?.amount) {
|
|
1142
|
+
const announced = fl.announcedRevenue.amount;
|
|
1143
|
+
const deposited = fl.lastDeposited.amount;
|
|
1144
|
+
const year = fl.announcedRevenue.year || '2030';
|
|
1145
|
+
fl.aiComment = `Target revenue: ${announced} by ${year} (announced via press). Last deposited: ${deposited} (${fl.lastDeposited.year}). ${fl.delta ? 'Gap: ' + fl.delta + '.' : ''} ${fl.aiComment.replace(/\d{2,4}\s*M€?/gi, '').replace(/\s{2,}/g, ' ').trim().split('.').slice(-2).join('.').trim()}`;
|
|
1146
|
+
}
|
|
1063
1147
|
return fl;
|
|
1064
1148
|
})(),
|
|
1065
1149
|
competitors: [{
|
|
@@ -1157,6 +1241,7 @@ OBLIGATOIRE :
|
|
|
1157
1241
|
date: b.date || '—',
|
|
1158
1242
|
type: b.type || '—',
|
|
1159
1243
|
description: b.description || '',
|
|
1244
|
+
url: b.url || null,
|
|
1160
1245
|
})),
|
|
1161
1246
|
// Procédures collectives
|
|
1162
1247
|
procedures: (proceduresCollectives || []).map(p => ({
|
package/src/scrapers/pappers.js
CHANGED
|
@@ -128,14 +128,31 @@ export async function pappersGetFullDossier(siren) {
|
|
|
128
128
|
|
|
129
129
|
// BODACC publications — last 50 (captures M&A activity)
|
|
130
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);
|
|
131
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;
|
|
132
146
|
return {
|
|
133
147
|
date: p.date,
|
|
134
148
|
type: p.type,
|
|
135
|
-
tribunal: p.tribunal || null,
|
|
149
|
+
tribunal: p.greffe || p.tribunal || null,
|
|
136
150
|
numero: p.numero_annonce || null,
|
|
137
|
-
description:
|
|
138
|
-
details: p.acte?.descriptif ||
|
|
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,
|
|
139
156
|
};
|
|
140
157
|
});
|
|
141
158
|
|