> ## Documentation Index
> Fetch the complete documentation index at: https://docs.retriever.run/llms.txt
> Use this file to discover all available pages before exploring further.

# Synchroniser vos clients vers la liste d'exclusion

> Synchroniser automatiquement vos clients CRM vers les exclusions Retriever, sans jamais vider la liste par erreur.

Les exemples Node utilisent [le client Node commun](/recipes/client) (`retriever.mjs`).

**Problème** : le sourcing ne doit jamais proposer vos clients actuels.

**Solution** : une tâche planifiée ajoute les nouveaux domaines. Elle retire seulement les domaines qu'**elle** a ajoutés et qui ont quitté le CRM, avec des garde-fous.

```js theme={null}
import { call } from "./retriever.mjs";

const chunk = (array, size = 1000) =>
  Array.from({ length: Math.ceil(array.length / size) }, (_, i) => array.slice(i * size, (i + 1) * size));

/**
 * crmDomains : domaines actuels du CRM.
 * registry   : Set persistant (votre base) des domaines canoniques gérés par cette synchro.
 */
export async function syncCompanies(crmDomains, registry, { prune = false, maxPruneRatio = 0.2 } = {}) {
  if (crmDomains.length === 0) throw new Error("Export CRM vide : synchro annulée par sécurité.");

  // 1. Upsert. La réponse ne renvoie pas la forme canonique, on relit la liste ensuite.
  for (const domains of chunk([...new Set(crmDomains)])) {
    const { invalid } = await call("/v1/exclusions/companies", { method: "POST", body: { domains } });
    if (invalid.length) console.warn("Valeurs ignorées :", invalid); // [{ value, reason }]
  }

  // 2. Lire la liste canonique complète
  const current = new Set();
  for (let offset = 0; offset !== null; ) {
    const page = await call(`/v1/exclusions/companies?limit=1000&offset=${offset}`);
    page.items.forEach((item) => current.add(item.domain));
    offset = page.next_offset;
  }

  // 3. Même normalisation que le serveur : minuscules, sans protocole, chemin ni "www."
  const canonical = (value) => {
    try {
      const url = new URL(/^[a-z][a-z\d+.-]*:\/\//i.test(value.trim()) ? value.trim() : `https://${value.trim()}`);
      return url.hostname.toLowerCase().replace(/\.$/, "").replace(/^www\./, "");
    } catch {
      return null;
    }
  };
  const wanted = new Set(crmDomains.map(canonical).filter(Boolean));
  wanted.forEach((d) => current.has(d) && registry.add(d));

  // 4. Retrait optionnel, limité aux domaines gérés par la synchro
  const toRemove = [...registry].filter((d) => !wanted.has(d));
  if (!prune || toRemove.length === 0) return { removed: 0, pending: toRemove.length };
  if (toRemove.length > registry.size * maxPruneRatio) {
    throw new Error(`Retrait de ${toRemove.length}/${registry.size} domaines refusé : vérifiez l'export CRM.`);
  }
  let removed = 0;
  for (const domains of chunk(toRemove)) {
    removed += (await call("/v1/exclusions/companies/remove", { method: "POST", body: { domains } })).removed;
    domains.forEach((d) => registry.delete(d));
  }
  return { removed, pending: 0 };
}
```

Même logique pour les personnes avec `/v1/exclusions/people` et `linkedin_urls`.

**À savoir**

* L'upsert est idempotent : rejouer un lot après une coupure ne crée pas de doublon.
* Les exclusions ajoutées à la main dans l'app ne sont jamais retirées, car elles ne sont pas dans `registry`.
* Les valeurs invalides n'arrêtent pas le lot. Journalisez `invalid` pour corriger la source.
* Pour `people`, seules les URL `linkedin.com/in/…` sont acceptées.
* La liste appartient à l'**utilisateur** qui a créé la clé. Utilisez une clé du compte qui lance les runs de sourcing.
