# LintellO Prospect — Système de Crawl & Base Entreprises

> # ⛔ DOCUMENT PÉRIMÉ — NE PAS IMPLÉMENTER
>
> **Ce fichier est la base de réflexion initiale (04/07/2026). Il a été remplacé le
> 06/07/2026 par [`PROSPECT_CONFORMITE_ART28.md`](../docs/specs/prospect/conformite-art28.md), qui est
> le SEUL document de référence pour le build comme pour la conformité.**
>
> Il est conservé pour l'historique des décisions, pas pour être suivi. Sur trois points
> majeurs, **il dit l'inverse de la doctrine actuelle** :
>
> | | Ce document (périmé) | Doctrine actuelle |
> |---|---|---|
> | Posture juridique | LintellO **responsable de traitement**, base « intérêt légitime » (art. 6.1.f) | LintellO **sous-traitant** (art. 28), le client est responsable |
> | Données nominatives | table `CompanyContact` persistante, email chiffré AES-256 | **jamais stockées** — transit uniquement, purge après livraison |
> | Rétention | **3 ans**, rechargés à chaque activité du prospect (`lastActivityAt`) | aucune — purge à `delivered → purged`, filet 24-48 h |
>
> Concrètement : l'entité `CompanyContact` décrite au §3.5 **a été supprimée du code** au
> profit de `LeadWorkingContact` (éphémère, purgé). Le §9 (rétention 3 ans, purge à
> l'ancienneté, `lastActivityAt`) ne décrit rien d'existant. Apollo, cité comme cible
> d'enrichissement, a été écarté au profit de Dropcontact.
>
> ⚠️ **Enjeu juridique** : la posture de sous-traitant défendue devant l'avocat repose sur
> l'absence de conservation du nominatif. Un document interne affirmant l'inverse
> l'affaiblit — d'où ce bandeau plutôt qu'un simple classement.

> **Dernière mise à jour** : 04/07/2026
> **Statut** : ⛔ périmé — remplacé par `PROSPECT_CONFORMITE_ART28.md` (06/07/2026)
> **Fichiers liés** : `LintellO_Search.md` (L'dicO — dont ce système réutilise les patterns), `Project_Status.md`
> **Objectif business** : accumuler une base entreprises exploitable **maintenant**, pour alimenter la prospection de David (via Cowork + Apollo) → rentrer du cash pour financer LintellO. Le farming-produit et LintelloWeb viennent après.
---
## 1. Principe & Découplage
Ce système **automatise la recherche et le stockage d'entreprises** à partir d'une **typologie cible** (secteur / code NAF + géo + taille + signaux). Il est **totalement découplé** de L'dicO Search (`search_query` / `search_result`) : les deux ne partagent aucune donnée ni aucune clé.
**Pourquoi séparé :**
| | L'dicO Search (`search_*`) | LintellO Prospect (`prospect_*`) |
|---|---|---|
| Nature | Résultats de recherche (SERP) | Entités entreprises persistantes |
| Cycle de vie | Jetable, log analytique | Accumulé, enrichi dans le temps |
| Donnée perso | Aucune (anonymisé, pas de `user_id`) | Firmographie B2B (0 perso) **+ contacts** (perso, tardif) |
| Clé de dédup | `urlHash` (SHA-256 URL) | `siren` + `domain` |
**Règle d'or :** on part d'une typologie, Sirene fournit la **graine** (liste administrative), puis le **crawl produit les signaux** qui servent au tri. Le code NAF sélectionne *ce qu'on va chercher*, jamais *ce qu'on retient* — la sélection se fait sur les signaux crawlés.
---
## 2. Architecture
Réutilise l'infrastructure L'dicO (PostgreSQL sur VPS OVH `51.210.4.32`), mais dans une **base et un entity manager dédiés** pour isoler proprement (surtout les contacts, données perso).
```
┌────────────────────┐        ┌─────────────────────────────────────┐
│  Cowork (David)    │        │        VPS OVH — PostgreSQL         │
│  = orchestrateur   │        │                                     │
│                    │  API   │  DB "ldico"    →  search_query      │
│  - définit profils │◄──────►│  (existant)       search_result     │
│  - lit résultats   │  REST  │                                     │
│  - Apollo (emails) │        │  DB "prospect" →  target_profile    │
└────────────────────┘        │  (nouveau)        company           │
                              │                   company_signal    │
     Sources externes         │                   company_profile_  │
     - Recherche entreprises  │                     match            │
       (API gouv, gratuit)    │                   company_contact    │
     - Crawl HTTP sites       │                                     │
     - Mistral (extraction)   │  Worker: app:prospect:* (cron/     │
                              │          Messenger)                 │
                              └─────────────────────────────────────┘
```
### Doctrine — 3ᵉ entity manager
Ajouter un EM `prospect` à côté de `search` et `default`. **Rappel du piège documenté dans `LintellO_Search.md`** : l'ordre des EM dans `doctrine.yaml` est critique — les namespaces spécifiques (`App\Entity\Search`, `App\Entity\Prospect`) doivent être déclarés **AVANT** `App\Entity` (default), sinon Doctrine route vers le mauvais EM.
```yaml
# config/packages/doctrine.yaml
orm:
    entity_managers:
        search:      # existant — AVANT default
            connection: search
            mappings:
                Search: { dir: '%kernel.project_dir%/src/Entity/Search', prefix: 'App\Entity\Search' }
        prospect:    # NOUVEAU — AVANT default
            connection: prospect
            mappings:
                Prospect: { dir: '%kernel.project_dir%/src/Entity/Prospect', prefix: 'App\Entity\Prospect' }
        default:
            connection: default
            mappings:
                App: { dir: '%kernel.project_dir%/src/Entity', prefix: 'App\Entity' }
```
```bash
# .env.local
PROSPECT_DATABASE_URL="postgresql://prospect:<password>@51.210.4.32:5432/prospect"
```
> **Alternative plus simple** si tu ne veux pas d'une 2ᵉ base : réutiliser la connexion/EM `search` avec le namespace `App\Entity\Prospect` et un préfixe de table `prospect_`. On perd l'isolation physique des contacts — acceptable au début tant qu'on ne crawle **pas encore** de contacts (voir §9 et §11).
---
## 3. Modèle de données
Cinq entités. Les quatre premières ne contiennent **aucune donnée personnelle** (firmographie publique) → on peut crawler massivement dès maintenant sans risque RGPD. La 5ᵉ (`company_contact`) n'arrive qu'à la phase exploitation.
### 3.1 `TargetProfile` — la typologie (config-driven)
Le cœur réutilisable : on définit une typo une fois, le moteur ne change pas.
```php
// src/Entity/Prospect/TargetProfile.php
class TargetProfile
{
    private Uuid $id;
    private string $name;                     // "PME indus. Grand Est, process manuels"
    private array $nafCodes;                  // JSON ["25.62B", "28.99B"] — la GRAINE
    private array $regions;                    // JSON codes dép./région ["67","68","57"]
    private ?int $sizeMin;                     // effectif mini
    private ?int $sizeMax;                     // effectif maxi
    private ?\DateTimeImmutable $creationDateMin; // pour cibler le net-new
    private array $signalRules;                // JSON — signaux à crawler + poids (voir §6)
    private array $keywords;                   // JSON — affinage sémantique
    private string $status;                    // 'active' | 'paused'
    private \DateTimeImmutable $createdAt;
    private \DateTimeImmutable $updatedAt;
}
```
### 3.2 `Company` — l'entité résolue
```php
// src/Entity/Prospect/Company.php
class Company
{
    private Uuid $id;
    private ?string $siren;          // UNIQUE quand présent — clé métier Sirene
    private ?string $siret;
    private string $name;
    private ?string $domain;         // UNIQUE — clé de crawl + dédup
    private ?string $nafCode;
    private ?string $region;
    private ?string $city;
    private ?string $sizeBand;       // tranche effectif Sirene ("6-9", "10-19"…)
    private ?\DateTimeImmutable $creationDate;
    private ?string $dirigeantName;  // représentant légal (Sirene/INPI) — décideur par défaut TPE/PME
    private ?string $dirigeantRole;  // "Gérant", "Président"…
    private bool $isSoleTrader;      // EI / auto-entrepreneur → traiter comme donnée perso (§9)
    private string $source;          // 'sirene' | 'apollo' | 'manual'
    private string $crawlStatus;     // 'pending' | 'crawled' | 'failed' | 'no_site'
    private \DateTimeImmutable $firstSeenAt;
    private ?\DateTimeImmutable $lastCrawledAt;
}
```
Index : `UNIQUE(siren)`, `UNIQUE(domain)`, index sur `crawlStatus` (le worker balaie les `pending`).
### 3.3 `CompanySignal` — les faits crawlés, avec provenance
```php
// src/Entity/Prospect/CompanySignal.php
class CompanySignal
{
    private Uuid $id;
    private Company $company;        // ManyToOne
    private string $signalType;      // 'site_quality' | 'tech_stack' | 'hiring'
                                     // | 'has_ecommerce' | 'blog_active'
                                     // | 'recent_news' | 'social_presence' | 'no_ssl' ...
    private array $value;            // JSON flexible (ex: {"cms":"wix","https":false})
    private ?string $sourceUrl;      // PROVENANCE = le hook du futur mail
    private ?string $contentHash;    // SHA-256 (dédup, même pattern que urlHash)
    private ?float $confidence;      // confiance extraction Mistral (0-1)
    private \DateTimeImmutable $crawledAt;
}
```
### 3.4 `CompanyProfileMatch` — score par profil + machine à états
Une entreprise peut matcher plusieurs profils avec des scores différents. Le rapprochement porte l'état, pas la `Company`.
```php
// src/Entity/Prospect/CompanyProfileMatch.php
class CompanyProfileMatch
{
    private Uuid $id;
    private Company $company;         // ManyToOne
    private TargetProfile $profile;   // ManyToOne
    private float $score;
    private array $scoreBreakdown;    // JSON {"site_quality":0.4,"hiring":0.3,...}
    private ?string $angle;           // accroche retenue (issue du signal le plus fort)
    private string $state;            // machine à états (§7)
    private \DateTimeImmutable $matchedAt;
    private \DateTimeImmutable $updatedAt;
}
```
Index : `UNIQUE(company_id, target_profile_id)`, index sur `(target_profile_id, state, score)` pour que Cowork lise vite « les N meilleurs selected du profil X ».
### 3.5 `CompanyContact` — contacts (découverts dès le crawl, enrichis sur finalists)
Les contacts sont découverts dès le crawl (team page / dirigeant Sirene) → ce sont des **candidats sans email**. Seul l'enrichissement email (Dropcontact) attend les finalists (`state = selected`).
```php
// src/Entity/Prospect/CompanyContact.php
class CompanyContact
{
    private Uuid $id;
    private Company $company;         // ManyToOne
    private string $fullName;         // DONNÉE PERSO
    private ?string $role;
    private ?string $email;           // DONNÉE PERSO — chiffrer via EncryptionService (AES-256-GCM)
    private bool $emailVerified;
    private string $source;           // 'crawl_team_page' | 'sirene_dirigeant' | 'dropcontact'
    private ?string $sourceUrl;       // provenance page équipe = traçabilité + futur hook
    private string $legalBasis;       // 'legitimate_interest'
    private \DateTimeImmutable $addedAt;
    private ?\DateTimeImmutable $optOutAt;  // droit d'opposition
}
```
---
## 4. Flux de bout en bout
```
1. TargetProfile défini (David + Cowork, ou EasyAdmin)
        │
        ▼
2. SOURCING  ─ Recherche d'entreprises API (gratuit, État)
        │      filtre par nafCodes + regions + tranche effectif
        │      → crée les Company (source='sirene', crawlStatus='pending')
        │      → crée un CompanyProfileMatch (state='sourced')     ← LA GRAINE
        ▼
3. CRAWL     ─ worker balaie crawlStatus='pending'
        │      fetch domain (home + /contact + /mentions-legales + /recrutement + /blog)
        │      → CompanySignalExtractor (Mistral) → CompanySignal[]
        │      → ContactExtractor → CompanyContact (candidats team page)
        │      → crawlStatus='crawled'
        ▼
4. SCORING   ─ applique profile.signalRules aux signaux
        │      → CompanyProfileMatch.score + scoreBreakdown + angle
        │      → state='scored'
        ▼
5. SÉLECTION ─ top N par score → state='selected'
        │      → identification du contact en cascade :
        │        team page si trouvé, sinon dirigeant Sirene/INPI
        │
        ▼   [PHASE EXPLOITATION — plus tard, côté Cowork]
6. ENRICH    ─ Dropcontact (API) sur les 'selected' → email vérifié du contact retenu
        │      → state='enriched'   (paie-au-résultat : non trouvé = recrédité)
        ▼
7. (futur)   draft → staged → sent → replied
```
Étapes 1-5 = **le crawl qui accumule** (zéro perso, tourne en autonomie). Étapes 6+ = exploitation, déclenchées quand on lance une campagne.
---
## 5. Services & Commandes
| Composant | Rôle | Déclenchement |
|---|---|---|
| `CompanySourcingService` | Appelle Recherche d'entreprises API depuis un `TargetProfile`, crée les `Company` + `CompanyProfileMatch(sourced)` | `app:prospect:source --profile=<id>` |
| `CompanyCrawlerService` | Fetch le `domain` (pages clés), rate-limité, respecte `robots.txt`, User-Agent identifiable | worker `app:prospect:crawl` (balaie `pending`) |
| `CompanySignalExtractor` | **Réutilise le pattern `SearchClassifier`** : Mistral extrait des signaux structurés d'une page → `CompanySignal`. Reste 100% firmographique (zéro perso). | appelé par le crawler |
| `ContactExtractor` | Extrait noms + rôles des pages /à-propos, /equipe, /qui-sommes-nous → `CompanyContact` (candidats, sans email). Distinct de `CompanySignalExtractor`. | appelé par le crawler |
| `CompanyScoringService` | Applique `signalRules`, calcule `score` + `scoreBreakdown` + `angle` | `app:prospect:score --profile=<id>` |
| `ProspectApiController` | Surface REST que Cowork interroge (§8) | HTTP |
**Commandes CLI** (100% non-interactives, cohérent avec les conventions BlogWeb/L'dicO) :
```bash
php bin/console app:prospect:source  --profile=<uuid>   # étape 2 (graine Sirene)
php bin/console app:prospect:crawl   [--limit=200]      # étape 3 (worker, en boucle)
php bin/console app:prospect:score   --profile=<uuid>   # étape 4
php bin/console app:prospect:stats                       # coverage & distribution
```
**Source de sourcing — API Recherche d'entreprises** (`recherche-entreprises.api.gouv.fr`) : gratuite, sans clé, open data État. Filtres utiles : `activite_principale` (NAF), `departement`, `tranche_effectif_salarie`, `date_creation`. Retourne SIREN, dénomination, NAF, adresse, effectif, date de création. C'est le générateur de graine idéal (couvre toute la France, à jour).
---
## 6. Scoring config-driven (`signalRules`)
Tout le pouvoir de réutilisation est ici : changer de cible = changer le JSON `signalRules`, **le moteur ne bouge pas**.
```json
{
  "signals": [
    { "type": "site_quality",  "weight": 0.35, "target": "low",    "hook": true },
    { "type": "has_ecommerce", "weight": 0.20, "target": "absent" },
    { "type": "hiring",        "weight": 0.25, "target": "present", "hook": true },
    { "type": "tech_stack",    "weight": 0.20, "match": ["wix","wordpress-old"] }
  ],
  "minScoreToSelect": 0.6
}
```
- `weight` : poids dans le score agrégé (somme normalisée à 1).
- `target` / `match` : ce qui « compte » pour cette cible (un site *pauvre* est un bon signal ici).
- `hook: true` : ce signal peut fournir l'`angle` du mail (provenance = `sourceUrl`).
`CompanyScoringService` calcule `score = Σ(weight_i × signalPresent_i)`, remplit `scoreBreakdown` (traçable) et retient comme `angle` le signal `hook` au poids×présence le plus fort.
---
## 7. Machine à états (`CompanyProfileMatch.state`)
```
sourced → scored → selected → enriched → drafted → staged → sent → replied
                       └────────────► discarded (score < seuil ou pas d'angle)
```
- Idempotente, portée **par profil** (une même `Company` peut être `replied` sur un profil et `discarded` sur un autre).
- Dédup à l'entrée du sourcing sur `siren` (existant → on ne recrée pas, on met juste à jour le match).
- Un match sans `angle` exploitable ⇒ `discarded` plutôt qu'un mail générique (protège la délivrabilité future).
---
## 8. Intégration Cowork / Apollo (surface API)
Cowork **n'écrit pas dans la base** directement : il pilote via une API REST authentifiée (token). Le crawl, lui, tourne en autonomie sur le VPS.
```
POST /api/prospect/profiles/{id}/source     → lance la graine Sirene
GET  /api/prospect/profiles/{id}/companies  → liste scorée
        ?state=selected&minScore=0.6&limit=25
        (renvoie company + signals + angle + scoreBreakdown)
POST /api/prospect/companies/{id}/crawl     → force un (re)crawl ciblé
POST /api/prospect/companies/{id}/contacts  → Cowork écrit les contacts Apollo
                                              (phase exploitation)
GET  /api/prospect/stats                     → coverage, distribution des scores
```
**Répartition claire :**
- **Crawler / L'dicO** = firmographie + signaux web + contacts candidats (ce qu'on crawle). Tourne seul, accumule.
- **Dropcontact** = email vérifié du contact identifié (paie-au-résultat, RGPD-natif FR). Sur finalists seulement.
- **Pharow** = option de démarrage rapide (source FR + emails inclus) avant que le crawler tourne, ou pour cibler des rôles précis en grosse structure.
- **Cowork (moi)** = orchestration : je définis les profils avec toi, je lis les `selected`, je déclenche Dropcontact, je rédige, tu valides.

**Exclusion :** recherche LinkedIn automatisée exclue (CGU LinkedIn + précédent CNIL Kaspr) — manuel ponctuel seulement.
---
## 9. RGPD — Principes & Conformité complète

### 9.1 Principe directeur

| | Firmographie (Company, Signal) | Contact (personne) |
|---|---|---|
| Nature | Pas de donnée perso | Donnée perso, **même si publique** |
| Régime | Accumulation libre | Intérêt légitime B2B + obligations |
| Déclenche RGPD | Non | **Oui, dès le stockage** |

Le RGPD se déclenche à la **collecte/stockage** du contact, pas à l'exploitation. Public ≠ libre : « je transmets » n'efface pas la responsabilité de collecte.

**Phase crawl (§3.1-3.4) = quasi zéro risque.** Firmographie Sirene (open data) + signaux de sites publics = donnée B2B, base légale **intérêt légitime**. Accumulation libre dès maintenant.

**Deux réserves :**
1. **Entrepreneurs individuels / auto-entrepreneurs** : `siren` = une personne nommée. D'où le flag `Company.isSoleTrader` → mêmes protections que les contacts (minimisation, opt-out, rétention).
2. **Contacts (`CompanyContact`)** = données personnelles. Prospection B2B admise en France (CNIL) si le message est lié à la fonction pro, avec les obligations détaillées ci-dessous.

Dès qu'on extrait des personnes (team page ou dirigeant), la donnée perso entre au crawl, pas seulement à l'enrichissement. Ces personnes vont dans `CompanyContact` (table isolée, opt-out, rétention) ; `CompanySignal` reste sans donnée perso.

### 9.2 Champs RGPD sur `CompanyContact`

Champs à ajouter (en plus de `optOutAt`, `legalBasis`, `source`, `sourceUrl` déjà prévus) :

```php
private string $unsubToken;                     // unique, généré à la création
private ?\DateTimeImmutable $disclosureGivenAt; // preuve : info art.14 donnée
private ?\DateTimeImmutable $lastActivityAt;    // dernier ACTE du prospect → départ des 3 ans
```

### 9.3 Liste repoussoir (`SuppressionEntry`)

Opposition permanente, appliquée en dédup à chaque crawl de contact. L'email n'est jamais stocké en clair.

```php
// src/Entity/Prospect/SuppressionEntry.php
class SuppressionEntry
{
    private Uuid $id;
    private string $emailHash;              // SHA-256, pas l'email en clair
    private \DateTimeImmutable $optOutAt;   // conservé ≥ 3 ans
}
```

### 9.4 Log de transmission (`LeadTransmission`) — scénario B

Preuve du transfert conforme quand on fournit un lead à un client.

```php
// src/Entity/Prospect/LeadTransmission.php
class LeadTransmission
{
    private Uuid $id;
    private CompanyContact $contact;           // ManyToOne
    private string $recipient;                 // client / "self"
    private string $disclosureBlockVersion;    // version du bloc art.14 fourni
    private \DateTimeImmutable $transmittedAt;
}
```

### 9.5 Obligations branchées sur la machine à états

```
sourced → scored → selected → enriched → drafted → staged → sent → replied
```

| État | Action RGPD à câbler |
|---|---|
| **crawl → contact créé** | Générer `unsubToken`. Poser `legalBasis='legitimate_interest'`, `source`, `sourceUrl`. **Vérifier la liste repoussoir** : si `emailHash` présent → jeter, ne pas stocker. |
| **selected → enriched** | Email Dropcontact **chiffré AES-256** (`EncryptionService`). Pas d'email en clair en base. |
| **drafted** | Le template porte le **bloc art.14 complet** (§9.6) + le lien perso `unsubToken`. Pas juste le lien. |
| **staged → sent** | Au moment de l'envoi → écrire `disclosureGivenAt = now`. C'est la **preuve** d'avoir informé. |
| **replied** OU clic lien d'intérêt | `lastActivityAt = now` → **recharge le compteur 3 ans**. |
| **clic lien désinscription** | `optOutAt = now` + insert dans **SuppressionEntry** (permanent). Jamais de réinscription auto. |

### 9.6 Bloc article 14 (dans le 1ᵉʳ mail)

```
Vos coordonnées professionnelles proviennent de sources publiques
(base SIRENE de l'INSEE et le site de votre entreprise). Traitées par
Stratégie Digitale Conseil à des fins de prospection commerciale, sur
la base de l'intérêt légitime (art. 6.1.f RGPD), conservées 3 ans max.
Droits d'accès, rectification, opposition, suppression : [lien unsubToken]
ou [email]. Détail : [lien vers /confidentialite].
```

Adapter `source` à ce qui a servi pour CE contact. Version longue (identité, destinataires, droit de réclamation CNIL) sur `/confidentialite`.

### 9.7 Rétention — logique du cron de purge

```
Pour chaque CompanyContact :
  âge = now − lastActivityAt   (ou − addedAt si jamais d'activité)
  SI âge > 3 ans :
     → option A : suppression
     → option B : 1 mail de réengagement ("toujours intéressé ?")
                  · réponse/clic positif → lastActivityAt = now (reparti 3 ans)
                  · silence              → suppression
```

- **Pas de réinscription automatique.** Le compteur ne se recharge QUE sur acte du prospect.
- L'`optOutAt` en `SuppressionEntry` est **permanent** (≥ 3 ans), il survit à la purge.

### 9.8 Scénario A (maintenant) vs B (service LintellO plus tard)

| | A — je prospecte pour moi | B — je fournis le lead à un client |
|---|---|---|
| Responsable exploitation | Moi (bout en bout) | Le client, sur SON traitement |
| Ma responsabilité | Tout | **Collecte + transmission conforme** (ne disparaît pas) |
| À fournir avec le lead | — | Bloc art.14 complet + lien `unsubToken` |
| Preuve à garder | `disclosureGivenAt` | **+ `LeadTransmission`** (date, version bloc, destinataire) |
| En plus | — | Personnes informées que leurs données peuvent aller à des partenaires (secteur) |

**Point clé B** : fournir le bloc ne suffit pas, il faut **prouver** l'avoir fourni (`LeadTransmission`). Si le client n'applique pas → sa responsabilité. Si la transmission conforme n'est pas prouvable → la nôtre.

### 9.9 Registre des traitements (à tenir, 1 ligne)

| Finalité | Base légale | Données | Source | Durée | Sécurité |
|---|---|---|---|---|---|
| Prospection B2B | Intérêt légitime (6.1.f) | Firmographie + contacts pro | SIRENE + sites publics + Dropcontact | 3 ans / dernier contact | AES-256, base isolée |

### 9.10 Checklist d'implémentation RGPD

- [ ] `unsubToken` + `disclosureGivenAt` + `lastActivityAt` sur `CompanyContact`
- [ ] Table `SuppressionEntry` + check en dédup à chaque crawl de contact
- [ ] Bloc art.14 dans le template mail + section `/confidentialite`
- [ ] Page opt-out qui résout `unsubToken` → `optOutAt` + `SuppressionEntry`
- [ ] Cron rétention 3 ans (purge ou réengagement)
- [ ] `disclosureGivenAt` écrit à l'envoi
- [ ] (Scénario B) table `LeadTransmission`
- [ ] Registre des traitements (tableau, tenu à jour)
- [ ] Avant montée en cadence : passage DPO

Cohérent avec le positionnement souveraineté/RGPD : la base de prospection est aussi propre que le reste de LintellO.
---
## 10. Réutilisation de l'existant L'dicO
Tu ne réinventes presque rien — ce système est un cousin de L'dicO :
| Besoin Prospect | Brique L'dicO déjà en place |
|---|---|
| Extraction de signaux (Mistral) | Pattern `SearchClassifier` → `CompanySignalExtractor` |
| Dédup | `urlHash` (SHA-256) → `contentHash` sur signaux + `UNIQUE(domain)` |
| Isolation multi-base | Pattern `App\Entity\Search` + EM dédié → `App\Entity\Prospect` |
| Visualisation / pilotage | EasyAdmin CRUD (comme `SearchQueryCrudController`) sur les 5 entités |
| Stats | `getStatsByTheme()` / `getAiUsageRate()` → `getStatsByProfile()`, `getScoreDistribution()`, `getCrawlCoverage()` |
| Chiffrement contacts | `EncryptionService` AES-256-GCM (déjà utilisé pour email/billing) |
---
## 11. Roadmap MVP
Objectif : **crawler vite pour accumuler vite**. On code les phases 1-4 (zéro perso), on lance, la data s'accumule pendant qu'on branche l'exploitation.
| Phase | Livrable | Contenu | Perso ? |
|---|---|---|---|
| **P1** | Schéma + CRUD | 4 entités (hors contact) + migrations + EasyAdmin + EM `prospect` | Non |
| **P2** | Sourcing | `CompanySourcingService` + `app:prospect:source` (API Recherche d'entreprises) | Non |
| **P3** | Crawl + extraction | `CompanyCrawlerService` + `CompanySignalExtractor` (Mistral) + `ContactExtractor` + worker `app:prospect:crawl` | **Oui** (contacts team page) |
| **P4** | Scoring | `CompanyScoringService` + `app:prospect:score` + `signalRules` | Non |
| **P5** | Exploitation | API REST Cowork + enrichissement email Dropcontact | **Oui** |
Dès **P4 terminé**, on définit ton premier `TargetProfile` réel et on lance le crawl : la base commence à se remplir de sociétés scorées, prêtes à exploiter en P5.
---
## 12. Questions ouvertes
1. **1 base dédiée `prospect` ou tables `prospect_` dans `ldico` ?** (isolation contacts vs simplicité — cf. §2). Recommandation : base dédiée, mais démarrable en tables préfixées tant que P5 n'est pas là.
2. **Profondeur de crawl** : home seule, ou home + 4 pages clés ? (coût vs richesse des signaux). Recommandation : home + `/contact`, `/mentions-legales`, `/recrutement`, `/blog` si présents.
3. **Cadence du worker** : cron horaire, ou Messenger en continu ? (vitesse d'accumulation vs charge VPS).
4. **Détection tech_stack** : heuristiques HTML maison, ou tout déléguer à Mistral sur le HTML ? (précision vs tokens).
5. **Rétention contacts** : ~~purge à combien de mois sans engagement ?~~ → **Figé : 3 ans** depuis `lastActivityAt` (ou `addedAt`), conforme recommandations CNIL (§9.7).
---
## Historique du Document
| Date | Modification |
|------|--------------|
| 2026-07-03 | Création — spec crawl + base entreprises, découplée de L'dicO. Modèle 5 entités, flux Sirene→crawl→score→enrich, scoring config-driven, RGPD, réutilisation patterns L'dicO, roadmap MVP P1-P5. |
| 2026-07-03 | Ajout dirigeantName/Role sur Company, contacts découverts dès crawl (ContactExtractor), Dropcontact remplace Apollo, Pharow option rapide, LinkedIn auto exclu (CNIL Kaspr), RGPD ajusté pour perso au crawl. |
| 2026-07-04 | Addendum RGPD complet : SuppressionEntry (liste repoussoir), LeadTransmission (preuve scénario B), champs unsubToken/disclosureGivenAt/lastActivityAt sur CompanyContact, obligations par état machine, bloc art.14, rétention 3 ans, registre des traitements, checklist implémentation. |
