Comment Scraper ZoomInfo à grande échelle

Apprenez à scraper les pages d’entreprise ZoomInfo, faire correspondre les domaines CRM aux profils, et valider les enregistrements avec Python et l’API ZoomInfo Scraper de Bright Data.
4 min de lecture
How to Scrape - ZoomInfo

Scraper ZoomInfo, une plateforme de données B2B, consiste à collecter les profils d’entreprises qu’elle affiche sans connexion. Les équipes commerciales et data utilisent les données d’entreprise ZoomInfo pour enrichir les comptes CRM avec des champs d’industrie, d’effectifs, de revenus et de technologies.

Sans référent d’un autre site, les 32 premières requêtes envoyées depuis de nouvelles IPs résidentielles ont reçu la page de défi “Press & Hold” de ZoomInfo. Dans nos tests, les IPs ayant obtenu une première page ont été bloquées après une médiane de 3 pages.

Ce guide vous montre comment obtenir des pages d’entreprise à grande échelle, faire correspondre les comptes aux profils, extraire les données, et décider quand acheter un scraper ZoomInfo.

TL;DR

  • Nous avons mesuré environ 3 pages par IP, donc collecter 100K pages ZoomInfo nécessiterait plus de 33K sessions. À la 5ème requête, 12 sessions sur 14 ont reçu un Cloudflare 429.
  • ZoomInfo conservait des profils d’entreprise en double. Une recherche pour un domaine a renvoyé 3 identifiants d’entreprise, et un doublon affichait un effectif et un revenu différents.
  • Le JSON intégré de ZoomInfo contenait des nombres non arrondis. Web Unlocker a renvoyé la page d’Aviagen avec 6 776 employés dans son JSON, alors que le profil affichait 5K à 10K.
  • L’API ZoomInfo Scraper de Bright Data gère le déblocage pour vous. À partir d’1 URL de répertoire, elle a renvoyé 45 enregistrements d’entreprise structurés.

Ce que ZoomInfo affiche sans connexion

Les pages publiques de ZoomInfo fournissaient des données d’entreprise détaillées et des données de base sur les personnes. Les emails, numéros de téléphone directs, listes complètes de contacts et la liste complète des technologies nécessitaient un compte. Notre scraper déconnecté a collecté des données firmographiques telles que l’industrie et l’effectif. Le site public comportait ces principaux types de pages :

Type de page Modèle d’URL Ce qu’elle peut montrer
Profil d’entreprise /c/{slug}/{id} Industrie, effectif, estimation du revenu, siège social, téléphone, site web, codes SIC et NAICS, certaines technologies, financement, et nombre d’employés par niveau de gestion
Répertoire d’entreprises /companies-search/{filters} 9 entreprises par page, filtrées par localisation et industrie
Profil de personne /p/{name}/{id} Un nom, un titre de poste, et un employeur
Autres pages /pic/, /hq/, /tech/, /executives/, /competitors/ Répertoires d’employés, détails du siège social, entreprises utilisant une technologie, dirigeants, et concurrents

Les pages de personnes, les répertoires d’employés et les listes de personnes sur les pages d’entreprise contiennent des données personnelles, que le RGPD peut couvrir. Les pages connectées sont soumises aux conditions d’utilisation de ZoomInfo, que les titulaires de compte acceptent. Nos scripts collectent uniquement les enregistrements publics d’entreprise et abandonnent les champs relatifs aux personnes.

Pourquoi votre première requête vers ZoomInfo est bloquée

ZoomInfo exécutait 2 systèmes anti-bot devant ses pages, et chaque réponse bloquée indiquait quel système l’avait envoyée. Un proxy entre votre scraper et ZoomInfo peut aussi modifier l’empreinte TLS que ces systèmes voient. Dans nos tests, la première requête d’une session nécessitait un référent externe, mais les requêtes ultérieures portant les cookies de session n’en avaient pas besoin.

Chaque système anti-bot a sa propre signature

Les requêtes vers les pages zoominfo.com, y compris les pages de répertoire, étaient routées via Cloudflare et vérifiées par HUMAN Security, qui a fusionné avec PerimeterX en 2022. Les deux systèmes marquaient leurs réponses bloquées avec des signatures distinctes, et une vraie page d’entreprise avait aussi une signature :

Réponse de Statut et page Signal sur lequel se baser
HUMAN Security 403, “Access to this page has been denied” avec un bouton “Press & Hold” Le statut 403 et px-captcha dans le corps
Cloudflare 429, “Just a moment…” L’en-tête de réponse cf-mitigated: challenge
Origine de ZoomInfo 200, la page d’entreprise <script id="ng-state"> avec les données d’entreprise

Basez-vous sur ces signaux, pas sur les titres de page. Les pages d’entreprise contenaient aussi _pxAppId, donc sa présence ne signifie pas que HUMAN a bloqué la requête. La documentation Cloudflare indique que chaque type de page de défi porte l’en-tête cf-mitigated: challenge.

Le JSON intégré dans le script ng-state marquait les vraies pages d’entreprise de la manière la plus fiable. Ne traitez une réponse comme réussie que lorsqu’elle porte ce JSON.

Ce que votre proxy fait à votre empreinte TLS

Nous avons utilisé une zone de proxy résidentiel Bright Data, une configuration de proxy nommée. La zone ouvrait sa propre connexion TLS vers le site. Nous avons envoyé des requêtes depuis 3 clients vers tls.peet.ws, un vérificateur d’empreinte TLS, d’abord sans proxy puis via cette zone :

Client JA4 sans proxy JA4 via le proxy
curl_cffi imitant Chrome 150 t13d1516h2_8daaf6152771_806a8c22fdea t13d1516h2_8daaf6152771_d8a2da3f94cd
curl_cffi imitant Firefox 147 t13d1717h2_5b57614c22b0_3cbfd9057e0d t13d1516h2_8daaf6152771_02713d6af862
curl système 8.7.1 (LibreSSL) t13d497h2_0d8feac7bc37_7395dae3b2f3 t13d1516h2_8daaf6152771_02713d6af862

Via le proxy, les 3 empreintes JA4 commençaient par t13d1516h2_8daaf6152771, les mêmes 2 premières parties que curl_cffi imitant Chrome. Ces parties encodent TLS 1.3, 15 suites de chiffrement, 16 extensions, et HTTP/2. Le proxy présentait donc une empreinte TLS ressemblant à Chrome pour les 3 clients. L’imitation de Chrome a aussi conservé son empreinte HTTP/2 via le proxy, utilisez-la donc pour faire ressembler les empreintes TLS et HTTP/2 à celles de Chrome.

Vérifiez quel saut termine le TLS avant de passer du temps sur l’imitation TLS. Si le proxy ne fait que tunneliser votre connexion, le site voit la propre poignée de main de votre client. Dans les deux cas, la connexion TCP commence au niveau du nœud de sortie, dont le paquet SYN porte son propre TTL, sa taille de fenêtre, et son ordre d’options. Un User-Agent macOS peut arriver via une pile TCP Windows, et un système anti-bot qui lit les empreintes TCP peut signaler cette incohérence.

Comment ZoomInfo juge la première requête

La page de défi de HUMAN exécutait un script capteur dans le navigateur, et le script définissait des cookies de risque tels que _px3. La première requête d’un scraper ne porte aucun de ces cookies, donc le site ne peut juger que la requête et la connexion sur laquelle elle arrive.

Nous avons envoyé 32 requêtes de page d’entreprise sans cookies ni référent depuis de nouvelles IPs résidentielles américaines, chacun des 4 clients en envoyant 8. Les clients étaient curl système, curl avec un ensemble complet d’en-têtes Chrome, curl_cffi imitant Chrome, et httpx via HTTP/2. Les 32 ont reçu le 403 de HUMAN et cette page “Press & Hold” :

Page de défi HUMAN Security de ZoomInfo, avec le logo ZoomInfo, le texte « Press & Hold to confirm you are a human (and not a bot), » et un bouton de maintien vide.

ZoomInfo a renvoyé cette page de défi à une requête sans cookies et sans référent.

Via la zone de proxy, Cloudflare voyait la poignée de main TLS du proxy pour les 4 clients. Depuis une connexion domestique, Cloudflare voyait la propre poignée de main de chaque client. Les premières requêtes des 6 clients testés sur cette connexion ont toutes reçu le même 403. Donc pour nos premières requêtes sans référent, la poignée de main TLS n’a pas changé le résultat.

Un référent externe a changé le résultat. Les requêtes sans cookies de curl_cffi imitant Chrome ont réussi 16 fois sur 18 lorsque leur en-tête Referer nommait un autre site. Les référents étaient Google, Bing, DuckDuckGo, LinkedIn, et example.com. Des 2 requêtes échouées, 1 a reçu un HTTP 502.

Les requêtes sans cookies avec un en-tête sec-fetch-site: cross-site mais sans en-tête Referer ont été bloquées 3 fois sur 3. Les requêtes sans cookies avec un référent de même origine ont aussi été bloquées 3 fois sur 3. Dans nos tests, une première requête devait ressembler à un clic provenant d’un autre site, comme un résultat de recherche.

Avec un référent Google, le taux de réussite variait aussi selon la concurrence et le User-Agent :

  • À 6 requêtes simultanées via des IPs rotatives, 14 requêtes curl_cffi sur 45 ont réussi.
  • Avec 1 requête à la fois et une pause de 6 secondes après chacune, 13 requêtes sur 20 ont réussi, et 3 ont reçu une redirection.
  • Les requêtes avec un User-Agent curl ou python-requests ont été bloquées 29 fois sur 29.
  • À 6 requêtes simultanées, 4 requêtes curl système sur 15 avec un User-Agent Chrome ont réussi.

Même avec 1 requête à la fois, 7 requêtes sur 20 ont échoué, donc un scraper fait maison a quand même besoin de relances, de nouvelles sessions, et de surveillance.

Combien de pages ZoomInfo chaque IP obtient

Une fois qu’une session obtenait sa première page, ZoomInfo ne servait que quelques pages supplémentaires à la même IP. Chaque session conservait 1 IP résidentielle via le paramètre de session collante du proxy et envoyait un référent Google à la première requête. Nous avons ensuite compté les pages d’entreprise jusqu’à la première requête bloquée.

Sur 14 sessions ayant obtenu une première page, la session médiane a chargé 3 pages d’entreprise avant sa première requête bloquée. À la 5ème requête, 12 des 14 avaient reçu un Cloudflare 429. Après le premier 429, chaque requête ultérieure de cette session a aussi reçu un 429. Aucun des 429 examinés ne portait d’en-tête Retry-After.

Dans les sessions ci-dessous, un délai de 2 ou 15 secondes entre les pages n’a pas changé le nombre :

Client Délai entre les pages Résultat par requête
Client HTTP 15 secondes OK, OK, puis 429 sur les 8 suivants
Client HTTP 15 secondes OK, OK, OK, puis 429 sur les 7 suivants
Client HTTP 2 secondes OK, OK, OK, puis 429 sur les 7 suivants
Chrome réel Temps de chargement de page OK, OK, OK, puis “Just a moment…” sur les 5 suivants

Dans la session Chrome, Chrome en mode visible fonctionnait avec navigator.webdriver masqué, via une session collante séparée. Il a chargé les 3 premières pages en 6 à 9 secondes chacune. Sur chacune des 5 pages suivantes, le défi Cloudflare s’affichait encore après environ 15 secondes, bien que le navigateur ait un cookie de clearance, cf_clearance.

Dans les sessions ayant conservé les cookies de la première réponse, les requêtes ultérieures n’avaient pas besoin de référent externe. Une requête ultérieure pouvait nommer la page ZoomInfo précédente comme référent, comme le fait un navigateur.

Effacer les cookies ne réinitialisait pas le compteur. Sur 4 sessions qui les ont effacés avant chaque requête et envoyé un référent Google à chaque fois, 3 ont reçu un 429 après 3 pages. La limite était donc liée à l’IP, pas aux cookies.

Des fils de discussion sur des forums disent que les cookies anti-bot sont liés à l’IP de sortie, mais nous n’avons pas pu reproduire cela avec les cookies reçus par notre client HTTP. Nous avons déplacé un pot de cookies avec _pxhd, _cfuvid, et contextual_signup_test d’une IP vers une nouvelle, et 4 requêtes sur 5 ont réussi. Les requêtes sans cookies depuis d’autres nouvelles IPs en même temps ont réussi 2 fois sur 5. Les deux groupes envoyaient un référent Google.

Ce diagramme montre le chemin d’un scraper vers une page d’entreprise et la réponse de chaque système anti-bot :

Diagramme de flux du chemin d'un scraper vers une page d'entreprise ZoomInfo. Les requêtes passent par un nœud de sortie proxy optionnel vers 2 systèmes anti-bot. Cloudflare a renvoyé un 429 après environ 3 pages par IP, et HUMAN Security a renvoyé un 403 lorsque la première requête n'avait pas de référent externe. L'origine de ZoomInfo a renvoyé un 200 avec les données d'entreprise en JSON intégré.

Une requête bloquée ne renvoie aucune donnée d’entreprise, donc un taux de blocage plus élevé signifie plus de requêtes par enregistrement.

À environ 3 pages par IP, collecter 100K pages d’entreprise par mois nécessiterait plus de 33K sessions obtenant une première page. Chaque première requête bloquée signifie une session supplémentaire à démarrer. Le Web Unlocker de Bright Data gère ces sessions pour vous.

Extraire les données d’entreprise d’une page ZoomInfo

Les pages d’entreprise de ZoomInfo étaient des applications Angular rendues côté serveur. Le serveur écrivait les données de la page dans <script id="ng-state" type="application/json">. Ce script provient de l’API TransferState d’Angular, qui permet au navigateur de réutiliser les données sans les récupérer à nouveau. Pour Walmart, le script contenait 84 Ko de JSON, et son objet pageData avait 42 clés.

Le JSON intégré contenait des nombres plus précis que la page visible. Le profil d’Aviagen affichait son effectif sous forme de plage et son revenu arrondi à 1 décimale :

Profil d'entreprise ZoomInfo pour Aviagen. L'en-tête affiche Agriculture, Alabama, États-Unis, et 5K - 10K Employés, et l'aperçu affiche le siège social, le numéro de téléphone, le site web, un revenu de 2,4 milliards $, des tags d'industrie, et un ZI Rank de 65.

Le profil visible d’Aviagen affichait “5K – 10K” employés et “2,4 milliards $” de revenu.

Le JSON de la même page contenait les nombres non arrondis :

<script id="ng-state" type="application/json">
{"pageData": {
  "companyId": 9047889,
  "name": "Aviagen",
  "numberOfEmployees": "6776",
  "about": {"revenue": "2413885", ...},
  ...
}}
</script>

ZoomInfo stockait le revenu en milliers de dollars, donc le 2413885 d’Aviagen signifie 2 413 885 000 $. Comparez le JSON intégré d’une entreprise avec son profil visible pour confirmer le multiplicateur.

D’autres pages d’entreprise affichaient aussi des plages, alors que leur JSON intégré contenait des nombres non arrondis. Le bloc FAQ de la page Walmart indiquait “10K+” employés, alors que le JSON contenait 2 100 000. Pour 5 petites entreprises affichées comme “<5 millions $” de revenu, les estimations JSON allaient de 366 000 $ à 4 474 000 $.

Certains champs changeaient entre les récupérations même lorsque les données d’entreprise restaient identiques. Sur 2 récupérations de la page Walmart, à quelques minutes d’intervalle, exactement 3 clés pageData différaient :

  • pic contenait un ensemble partiellement différent de 9 employés.
  • copilotContent contenait les 10 mêmes modèles marketing dans un nouvel ordre.
  • intents contenait 5 entrées, dont 2 portaient une valeur randomLocation, et les deux valeurs changeaient.

Avec ces champs dans la page, hacher des pages entières peut faire paraître des entreprises inchangées comme modifiées à chaque crawl. Ce parseur, enregistré sous parse_zoominfo.py, lit le JSON intégré, convertit les unités, conserve les IDs sous forme de chaînes, et hache uniquement les champs d’entreprise :

import hashlib
import json
import re
from urllib.parse import urljoin

STATE = re.compile(r'<script[^>]*id="ng-state"[^>]*>(.*?)</script>', re.S)
LD_JSON = re.compile(
    r'<script[^>]*type="application/ld\+json"[^>]*>(.*?)</script>', re.S
)


def _state(html: str) -> dict | None:
    match = STATE.search(html)
    return json.loads(match.group(1)) if match else None


def _int(value) -> int | None:
    return int(value) if value and str(value).isdigit() else None


def _zi_rank(html: str) -> int | None:
    for block in LD_JSON.findall(html):
        data = json.loads(block)
        if isinstance(data, dict) and data.get("@type") == "Organization":
            for prop in data.get("additionalProperty", []):
                if prop.get("name") == "ZI Rank":
                    return prop.get("value")
    return None


def parse_company(html: str) -> dict | None:
    state = _state(html)
    page = (state or {}).get("pageData") or {}
    if not page.get("companyId"):
        return None  # une page de défi, une coquille vide, ou un autre modèle
    about = page.get("about") or {}
    overview = page.get("overview") or {}
    revenue_k = _int(about.get("revenue"))  # stocké en milliers de USD
    domain = (page.get("url") or "").lower().removeprefix("www.")
    return {
        "id": str(page["companyId"]),  # chaînes : les IDs de personnes dépassent int32
        "name": page.get("name"),
        "domain": domain or None,
        "employees": _int(page.get("numberOfEmployees")),  # nombre non arrondi
        "revenue_usd": revenue_k * 1000 if revenue_k is not None else None,
        "founded": _int(page.get("foundingYear")),
        "ticker": overview.get("ticker"),
        "sic": overview.get("sic") or [],
        "naics": overview.get("naics") or [],
        "industries": [i["name"] for i in about.get("industries") or []],
        "hq": page.get("address"),
        "phone": about.get("phone"),
        "tech_used": sorted(t["name"] for t in page.get("techUsed") or []),
        "management_levels": {
            m["title"]: m["value"] for m in page.get("mgtl") or []
        },
        "zi_rank": _zi_rank(html),
        # Les champs de personnes (executives, pic, hierarchyOrgChart) sont exclus.
    }


# intents, pic, et copilotContent ont changé entre 2 récupérations de la
# même page, donc le hachage les exclut.
STABLE = ("name", "domain", "employees", "revenue_usd", "industries",
          "hq", "phone", "sic", "naics", "ticker", "tech_used")


def content_hash(record: dict) -> str:
    stable = json.dumps({k: record.get(k) for k in STABLE}, sort_keys=True)
    return hashlib.sha256(stable.encode()).hexdigest()[:16]

parse_company renvoie None pour toute page sans données d’entreprise, comme une page de défi ou une page vide. Sur les 2 récupérations de Walmart, content_hash a renvoyé la même valeur, b4ffe1810bbb1c8c, alors que le SHA-256 de leur HTML brut différait.

Trouver les URLs d’entreprise ZoomInfo à grande échelle

Le robots.txt de ZoomInfo listait 1 sitemap, qui incluait uniquement des pages marketing. Nous n’avons trouvé aucun sitemap pour les pages d’entreprise ou de personne, vous pouvez donc construire la liste d’URLs à partir de 3 autres sources.

Pages de répertoire

Chaque URL /companies-search/ listait les entreprises correspondant à ses filtres de localisation et d’industrie, et un paramètre de requête pageNum sélectionnait la page. Le listing Alabama agriculture affichait un total de 1 962 entreprises. Les pages 1 à 5 ont chacune renvoyé la page demandée. Les requêtes pour les pages 6 et 10 ont renvoyé un HTTP 200 avec les entreprises de la page 1 et pageNum: 1 dans le JSON intégré.

Voici la première page de ce listing :

Résultats du répertoire ZoomInfo pour les entreprises d'agriculture en Alabama. Un tableau liste 9 entreprises, dont Peco Foods et Aviagen, avec des colonnes industrie, localisation, revenu et employés, et la pagination affiche les pages 1 à 5.

La pagination affichait 5 pages. La colonne revenu était en milliers de dollars, donc le 2,4M d’Aviagen correspond aux 2,4 milliards $ de son profil.

Un paginateur qui ne vérifie que les codes de statut collecterait à nouveau les entreprises de la page 1 comme nouveaux résultats. Ajoutez cette protection à parse_zoominfo.py. Elle compare le numéro de page dans le JSON intégré avec celui demandé :

def directory_page(html: str, requested: int) -> list[str] | None:
    state = _state(html) or {}
    if state.get("pageNum") != requested:
        return None  # au-delà des pages accessibles, ZoomInfo sert la page 1 à nouveau
    rows = (state.get("aiSearchResults") or {}).get("data") or []
    return [urljoin("https://www.zoominfo.com", row["companyUrl"])
            for row in rows]

directory_page renvoyait 9 URLs d’entreprise par page, et renvoyait None pour la page 6. Seules 5 pages de ce filtre étaient donc accessibles, et elles contenaient environ 45 de ses 1 962 entreprises. Pour atteindre plus d’entreprises, divisez un grand filtre par état américain ou sous-industrie jusqu’à ce que chaque filtre plus petit contienne 45 entreprises ou moins. L’API ZoomInfo Scraper de Bright Data peut aussi crawler les pages de répertoire ZoomInfo et renvoyer les entreprises qu’elles listent sous forme d’enregistrements structurés.

Résultats de recherche pour les entreprises que vous connaissez déjà

Une requête comme site:zoominfo.com/c example.com, envoyée via l’API SERP de Bright Data, peut trouver des profils candidats pour un domaine. Pour brightdata.com, la requête a renvoyé 3 identifiants d’entreprise différents. Conservez le candidat dont le site web correspond au domaine recherché.

De nombreux profils listaient des entreprises similaires, et l’API ZoomInfo Scraper de Bright Data les renvoyait dans un champ similar_companies. Un crawler peut partir d’une liste d’URLs de départ et suivre ces liens pour trouver plus d’entreprises.

Profils en double et IDs stables

Des doublons existaient dans les propres données de ZoomInfo, donc n’importe laquelle des 3 sources pouvait les renvoyer. Pour brightdata.com, un profil supplémentaire listait l’adresse du profil principal mais un domaine de site web différent. Ses estimations d’effectif et de revenu différaient aussi de celles du profil principal.

Une jointure sur le domaine manque ce doublon, et une jointure sur le nom peut aussi faire correspondre des entreprises non liées. Une recherche pour minimax.com a renvoyé 9 profils avec Minimax dans le nom, et plusieurs étaient des entreprises non liées. Faites correspondre les entités sur le domaine ou l’adresse du siège social, et ne vérifiez que les profils qui correspondent sur l’un mais pas sur l’autre. Conservez l’identifiant numérique de ZoomInfo comme clé primaire.

Dans une URL d’entreprise, le slug pouvait changer, mais la redirection que nous avons tracée conservait l’identifiant numérique. Dans un échantillon, 3 URLs d’entreprise sur 20 ont renvoyé une redirection 301, nécessitant donc une requête supplémentaire via les mêmes systèmes anti-bot. Pour l’entrée /c/walmart-inc/155353090, l’API Scraper a renvoyé l’URL canonique, /c/walmart/155353090, qui conservait le même ID. Web Unlocker a suivi la même redirection et renvoyé la page Walmart.

3 méthodes pour collecter les données ZoomInfo à grande échelle

À 100K pages d’entreprise par mois et environ 3 pages par IP, un pipeline fait maison nécessiterait plus de 33K sessions et un travail d’ingénierie continu. Les options d’extraction de données ZoomInfo de Bright Data gèrent différentes quantités de ce travail, des pages débloquées aux enregistrements prêts à l’emploi :

Web Unlocker API ZoomInfo Scraper Jeu de données ZoomInfo
Vous envoyez Une URL de page Des URLs d’entreprise ou de répertoire Un filtre
Vous obtenez Le HTML complet de la page Des enregistrements JSON structurés Des enregistrements prêts à l’emploi dans un fichier
Dans nos tests Les pages d’entreprise arrivaient avec le JSON intégré intact Les enregistrements d’entreprise arrivaient, et les URLs de répertoire étaient paginées pour nous Le fichier d’échantillon montrait le schéma complet
Livraison Requêtes synchrones ou asynchrones Snapshots via l’API, le stockage cloud, ou un webhook Fichiers sur un rafraîchissement programmé
Analyse La vôtre Incluse Incluse

La bonne option dépend de la sortie dont vous avez besoin :

  • Enregistrements structurés pour votre propre liste d’URLs. Utilisez l’API Scraper.
  • Effectif et revenu non arrondis. Utilisez Web Unlocker avec parse_company.
  • Large couverture, déjà collectée. Utilisez le jeu de données.

Pour les pages nécessitant des clics et du défilement, l’API Browser de Bright Data exécute un Chrome hébergé que vous pilotez avec Playwright ou Puppeteer. Pour les pages d’entreprise, qui ne nécessitent aucun clic, Web Unlocker est l’option la plus simple, car il renvoie directement le HTML, sans navigateur à piloter. Pour les agents IA, le serveur MCP ZoomInfo de Bright Data permet à des clients comme Claude Desktop et Cursor de collecter les mêmes profils d’entreprise publics.

Pour commencer, créez un compte Bright Data et une clé API. Pour Web Unlocker, créez aussi une zone, une configuration Web Unlocker nommée, dans le panneau de contrôle. Les nouveaux comptes reçoivent des crédits gratuits pour tester les API de Bright Data.

Les scripts nécessitent Python 3.10 ou ultérieur. Les fichiers complets se trouvent dans le dépôt zoominfo-scraper de Bright Data sur GitHub. Définissez la clé et la zone une fois :

pip install httpx
export BRIGHTDATA_API_KEY="your_api_key"
export BRIGHTDATA_UNLOCKER_ZONE="your_web_unlocker_zone"

Chaque script lit la clé à son démarrage, donc un script avec une clé manquante s’arrête avant d’envoyer la moindre requête.

Web Unlocker renvoie des pages que vous analysez

Web Unlocker est l’API de déblocage de Bright Data. Vous envoyez une URL, et Web Unlocker gère les proxies, le rendu du navigateur, et les défis avant de renvoyer la page. Dans nos tests, il a débloqué les pages d’entreprise ZoomInfo et renvoyé le HTML avec le JSON intégré intact. Donc parse_company fonctionne sur ce HTML sans modification.

Web Unlocker rapporte le propre statut de la cible dans l’en-tête de réponse x-brd-status-code et les détails dans x-brd-error, donc un pipeline peut se baser sur le résultat. Par défaut, il facture uniquement les requêtes réussies, et la documentation liste les paramètres qui modifient ce défaut.

Ce récupérateur, enregistré sous fetch_pages.py, traite 5 URLs à la fois, essaie chacune jusqu’à 3 fois, et enregistre les enregistrements dans companies.jsonl :

import json
import os
import sys
import time
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone

import httpx

from parse_zoominfo import content_hash, parse_company

API = "https://api.brightdata.com/request"
KEY = os.environ["BRIGHTDATA_API_KEY"]
ZONE = os.environ["BRIGHTDATA_UNLOCKER_ZONE"]


def fetch(url: str, attempts: int = 3) -> dict:
    error = None
    for attempt in range(1, attempts + 1):
        try:
            resp = httpx.post(
                API,
                headers={"Authorization": f"Bearer {KEY}"},
                json={"zone": ZONE, "url": url, "format": "raw"},
                timeout=180,
            )
            # L'API rapporte le propre résultat de la cible dans ces 2 en-têtes.
            target = resp.headers.get("x-brd-status-code")
            record = parse_company(resp.text) if target == "200" else None
        except (httpx.HTTPError, ValueError) as exc:  # délai dépassé ou JSON invalide
            error = f"{type(exc).__name__}: {exc}"
        else:
            if record:
                record["fetched_at"] = datetime.now(timezone.utc).isoformat()
                record["hash"] = content_hash(record)
                return {"url": url, "ok": True, "attempts": attempt,
                        "record": record}
            error = resp.headers.get("x-brd-error") or f"target {target}"
        if attempt < attempts:
            time.sleep(2 ** attempt)
    return {"url": url, "ok": False, "attempts": attempts, "error": error}


if __name__ == "__main__":
    urls = [line.strip() for line in open(sys.argv[1]) if line.strip()]
    with ThreadPoolExecutor(max_workers=5) as pool, \
            open("companies.jsonl", "a") as good, \
            open("failures.jsonl", "a") as bad:
        for result in pool.map(fetch, urls):
            (good if result["ok"] else bad).write(json.dumps(result) + "\n")
            print("saved" if result["ok"] else "not saved", result["url"])

Avec 3 URLs d’entreprise listées une par ligne dans urls.txt, python fetch_pages.py urls.txt a affiché :

saved https://www.zoominfo.com/c/bright-data/430599389
saved https://www.zoominfo.com/c/peco-foods-inc/82428957
saved https://www.zoominfo.com/c/aviagen-group/9047889

Les 3 entreprises ont été enregistrées. Voici les champs d’entreprise de l’entrée Aviagen dans companies.jsonl :

{
  "id": "9047889",
  "name": "Aviagen",
  "domain": "aviagen.com",
  "employees": 6776,
  "revenue_usd": 2413885000,
  "founded": 1923,
  "ticker": null,
  "sic": ["02", "025"],
  "naics": ["11", "112"],
  "industries": ["Agriculture", "Animals & Livestock"],
  "hq": {
    "street": "920 Explorer Blvd NW",
    "city": "Huntsville",
    "state": "Alabama",
    "country": "United States",
    "zip": "35806"
  },
  "phone": "(256) 890-3800",
  "tech_used": ["ClickDimensions", "Freshservice", "PHP", "Zingfit"],
  "management_levels": {
    "C-Level": 14,
    "VP-Level": 28,
    "Director": 108,
    "Manager": 998,
    "Non-Manager": 1229,
    "Top Contacts": 1316
  },
  "zi_rank": 65
}

Le profil d’Aviagen affichait ces valeurs employees et revenue_usd comme “5K – 10K” et “2,4 milliards $” dans son en-tête et son aperçu.

L’API ZoomInfo Scraper renvoie des enregistrements

L’API ZoomInfo Scraper de Bright Data gère à la fois le déblocage et l’analyse. Son endpoint Collect by URL prend des URLs d’entreprise, et Discover by search_url prend des URLs de répertoire et collecte les entreprises listées sur leurs pages. Le panneau de contrôle listait les deux endpoints et proposait des modes synchrones et asynchrones :

Panneau de contrôle Bright Data pour le scraper d'informations d'entreprises Zoominfo. Discover by search_url est sélectionné sous Available Endpoints, avec 4 URLs de répertoire ZoomInfo comme entrées et les modes scraper synchrone et asynchrone en dessous.

Discover by search_url prend des URLs de répertoire comme celles-ci et renvoie les entreprises listées sur leurs pages.

Pour les lots, utilisez l’endpoint asynchrone /trigger, qui exécute la tâche en arrière-plan et renvoie un identifiant de snapshot. L’endpoint synchrone /scrape renvoie directement les enregistrements pour de petits tests, et il déplace les tâches plus longues vers un snapshot après sa limite de temps de réponse.

Dans notre lot de 40 URLs, les premiers enregistrements sont arrivés en moins d’une minute. Pour les gros lots, laissez l’API livrer les résultats vers le stockage cloud ou un webhook. Elle envoie chaque snapshot lorsque la dernière entrée se termine, donc vous n’interrogez pas l’API.

Discover by search_url gère la pagination pour vous. À partir de la seule URL de répertoire Alabama, il a renvoyé 45 enregistrements d’entreprise. Chaque enregistrement portait l’URL de répertoire dont il provenait. Pour un filtre plus large, envoyez une URL de répertoire pour chaque état américain ou sous-industrie.

Le client scraper_api.py déclenche une tâche et interroge jusqu’à ce que le snapshot soit prêt, puis le télécharge. Sa fonction clean convertit la plage d’employés en employees_min et employees_max et marque les plages de revenu telles que “<5 millions $”. Elle supprime aussi les champs de direction, d’organigramme, et de PDG, qui contiennent des données sur les personnes. Avec --discover, le client envoie des URLs de répertoire à Discover by search_url :

import json
import os
import re
import sys
import time

import httpx

BASE = "https://api.brightdata.com/datasets/v3"
DATASET_ID = "gd_m0ci4a4ivx3j5l6nx"  # Informations d'entreprises Zoominfo
HEADERS = {"Authorization": f"Bearer {os.environ['BRIGHTDATA_API_KEY']}"}


def trigger(urls: list[str], discover: bool = False, limit: int = 50) -> str:
    params = {"dataset_id": DATASET_ID, "include_errors": "true"}
    if discover:  # URLs de répertoire : la tâche gère leur pagination
        params |= {"type": "discover_new", "discover_by": "search_url",
                   "limit_per_input": limit}
    resp = httpx.post(f"{BASE}/trigger", params=params, headers=HEADERS,
                      json=[{"url": u} for u in urls], timeout=60)
    resp.raise_for_status()
    return resp.json()["snapshot_id"]


def wait(snapshot_id: str, every: int = 30) -> dict:
    while True:
        try:
            progress = httpx.get(f"{BASE}/progress/{snapshot_id}",
                                 headers=HEADERS, timeout=60).json()
        except (httpx.HTTPError, ValueError):  # réessayer après une erreur réseau
            progress = {}
        if progress.get("status") in ("ready", "failed"):
            return progress
        time.sleep(every)


def download(snapshot_id: str, tries: int = 30) -> list[dict]:
    for _ in range(tries):
        try:
            resp = httpx.get(f"{BASE}/snapshot/{snapshot_id}",
                             params={"format": "json"}, headers=HEADERS,
                             timeout=300)
            if resp.status_code == 200 and resp.content.strip():
                return resp.json()
        except (httpx.HTTPError, ValueError):  # délai dépassé ou fichier partiel
            pass
        time.sleep(10)  # attendre que le fichier snapshot soit disponible
    raise RuntimeError(f"snapshot {snapshot_id} is not available yet")


def employee_range(text: str | None) -> tuple[int | None, int | None]:
    # "11-50" -> (11, 50), "1K - 5K" -> (1000, 5000), "10K+" -> (10000, None)
    if not text:
        return None, None
    nums = [int(float(n) * (1000 if k else 1)) for n, k
            in re.findall(r"([\d.]+)\s*(K?)", text.replace(",", ""))]
    if not nums:
        return None, None
    if len(nums) == 1:
        return nums[0], None if "+" in text else nums[0]
    return nums[0], nums[1]


def clean(row: dict) -> dict:
    row["employees_min"], row["employees_max"] = employee_range(
        row.get("employees_text"))
    # L'effectif provient de la plage employees_text.
    row["employees"] = row["total_employees"] = None
    row["revenue_is_range"] = (row.get("revenue_text") or "").startswith("<")
    row["stock_symbol"] = (row.get("stock_symbol") or "").strip() or None
    row["id"] = str(row["id"])
    for field in ("leadership", "org_chart", "ceo"):  # données sur les personnes
        row.pop(field, None)
    return row


if __name__ == "__main__":
    urls = [a for a in sys.argv[1:] if a.startswith("http")]
    snapshot = trigger(urls, discover="--discover" in sys.argv)
    progress = wait(snapshot)
    summary = {k: progress.get(k) for k in ("status", "records", "errors")}
    print(json.dumps(summary))
    rows = download(snapshot)
    # Conserver les lignes qui portent un id et pas d'erreur.
    good = [clean(r) for r in rows if r.get("id") and not r.get("error")]
    bad = [r for r in rows if r.get("error") or not r.get("id")]
    json.dump(good, open("records.json", "w"), indent=2)
    json.dump(bad, open("errors.json", "w"), indent=2)
    print(f"{len(good)} records saved to records.json")

Avec les 3 URLs d’entreprise de urls.txt comme arguments, le client a affiché :

{"status": "ready", "records": 3, "errors": 0}
3 records saved to records.json

Le snapshot contenait les 3 enregistrements, et le client les a enregistrés dans records.json. Chaque enregistrement portait des champs d’entreprise tels que le revenu, l’industrie, les technologies, le site web, le siège social, et le numéro de téléphone.

Le jeu de données ZoomInfo livre des enregistrements déjà collectés

Le jeu de données ZoomInfo livre des enregistrements avec les mêmes champs, déjà collectés et rafraîchis selon un calendrier. Vérifiez la page du jeu de données pour le nombre actuel d’enregistrements, la fréquence de rafraîchissement, et les tarifs, et examinez son échantillon avant d’acheter. Pour choisir entre lui et un abonnement ZoomInfo, lisez notre comparaison de Bright Data et ZoomInfo.

Filtrez avant d’acheter. Les filtres du jeu de données vous permettent de sélectionner uniquement les enregistrements dont vous avez besoin, comme les pages d’entreprise dont les URLs incluent /c/. Supprimez les colonnes de direction et d’organigramme, qui contiennent des données sur les personnes, sauf si vous avez une base légale pour les conserver.

Un jeu de données contient des enregistrements tels qu’ils étaient au moment de la collecte, et chaque rafraîchissement les met à jour. Pour choisir une fréquence de rafraîchissement, collectez le même échantillon d’entreprises deux fois, à un mois d’intervalle, et comptez les enregistrements qui ont changé.

Faire correspondre les domaines CRM aux profils ZoomInfo

Un CRM contient des domaines plutôt que des URLs ZoomInfo. Ce script, enregistré sous find_profiles.py, recherche sur Google chaque domaine via Bright Data. Il récupère les 3 premiers profils avec fetch depuis fetch_pages.py et conserve le premier enregistrement dont le domain correspond au domaine CRM. Si la recherche sans guillemets ne trouve aucune correspondance, le script recherche à nouveau avec le domaine entre guillemets :

import json
import os
import re
import sys
from concurrent.futures import ThreadPoolExecutor
from urllib.parse import quote_plus

import httpx

from fetch_pages import fetch

API = "https://api.brightdata.com/request"
KEY = os.environ["BRIGHTDATA_API_KEY"]
# Une zone API SERP si vous en avez une, sinon la zone Web Unlocker.
ZONE = (os.environ.get("BRIGHTDATA_SERP_ZONE")
        or os.environ["BRIGHTDATA_UNLOCKER_ZONE"])
PROFILE = re.compile(r"https://www\.zoominfo\.com/c/[^/?#]+/\d+")
# Chaque requête trouvait des profils que l'autre manquait, donc essayez les deux.
QUERIES = ("site:zoominfo.com/c {}", 'site:zoominfo.com/c "{}"')


def candidates(query: str, top: int = 3) -> list[str]:
    url = f"https://www.google.com/search?q={quote_plus(query)}&brd_json=1"
    for _ in range(3):
        try:
            resp = httpx.post(
                API,
                headers={"Authorization": f"Bearer {KEY}"},
                json={"zone": ZONE, "url": url, "format": "raw"},
                timeout=120,
            )
            results = resp.json().get("organic", [])  # résultats analysés
        except (httpx.HTTPError, ValueError):  # délai dépassé ou corps vide
            continue
        links = [row.get("link", "") for row in results]
        found = (PROFILE.match(link) for link in links)
        return list(dict.fromkeys(m.group(0) for m in found if m))[:top]
    return []


def find_profile(domain: str) -> dict | None:
    for query in QUERIES:
        for url in candidates(query.format(domain)):
            result = fetch(url)
            # Ne conserver un profil que lorsque son site web est le domaine CRM.
            if result["ok"] and result["record"]["domain"] == domain:
                return result["record"]
    return None


if __name__ == "__main__":
    domains = [d.strip().lower().removeprefix("www.") for d in sys.argv[1:]]
    with ThreadPoolExecutor(max_workers=5) as pool, \
            open("matches.jsonl", "a") as out:
        records = pool.map(find_profile, domains)
        for domain, record in zip(domains, records, strict=True):
            out.write(json.dumps({"domain": domain, "record": record}) + "\n")
            print(domain, record["id"] if record else "no match")

Pour 4 domaines CRM, python find_profiles.py aviagen.com pecofoods.com brightdata.com walmart.com a affiché :

aviagen.com 9047889
pecofoods.com 82428957
brightdata.com 430599389
walmart.com no match

Pour chaque correspondance, le script a renvoyé un enregistrement d’entreprise complet. La recherche sans guillemets a trouvé Aviagen et Bright Data, et la recherche avec guillemets a trouvé Peco Foods. Le profil de Walmart n’apparaissait dans aucun des résultats de recherche, donc le script a renvoyé aucune correspondance plutôt qu’une mauvaise entreprise. Envoyez les domaines non correspondants vers une recherche par nom ou une file de révision.

Valider chaque enregistrement avant qu’il n’atteigne votre CRM

Chaque réponse que nous avons vue de ZoomInfo et des API de Bright Data nécessite sa propre action :

Réponse Signification Action
200 avec données d’entreprise dans ng-state Une vraie page d’entreprise Analyser et stocker
Redirection 301 L’URL a changé Stocker la nouvelle URL, et utiliser l’ID comme clé
403 avec px-captcha dans le corps HUMAN a bloqué la requête Réessayer sur une nouvelle session
429 avec cf-mitigated: challenge La limite de requêtes par IP de Cloudflare a été atteinte Faire tourner l’IP, et arrêter de réessayer dessus
429 sans en-têtes Cloudflare Limitation automatique de Bright Data, déclenchée par un faible taux de réussite Réduire le taux de requêtes, et corriger les requêtes qui échouent
502 sans signature anti-bot Une erreur temporaire en amont Réessayer avec un délai croissant
En-tête Web Unlocker x-brd-status-code Le propre statut du site cible Stocker les résultats 200, et réessayer les autres un nombre limité de fois
Scraper API 202 depuis /scrape La tâche a été déplacée vers un snapshot Interroger l’identifiant du snapshot
Page de répertoire dont le pageNum est inférieur à celui demandé Au-delà de la dernière page accessible Arrêter la pagination, et restreindre le filtre

Les vérifications de statut détectent les pages échouées, et les vérifications de champs détectent les mauvais enregistrements :

  • Conservez les IDs sous forme de chaînes. Les IDs de personnes ZoomInfo dans les données examinées allaient de -2 135 143 530 à 17 773 369 573, ils ne rentrent donc ni dans un entier 32 bits signé ni non signé.
  • Lisez l’effectif à partir des plages ou du JSON intégré. Utilisez employees_min et employees_max depuis clean, ou numberOfEmployees depuis le JSON.
  • Marquez les plages de revenu. Une valeur comme “<5 millions $” est une plage, et le JSON contenait l’estimation.
  • Divisez les codes d’industrie. Des valeurs comme “54,541” portaient un code de secteur et un code de sous-secteur.
  • Attendez-vous à des champs PDG vides. ZoomInfo laissait le champ PDG vide sur chaque page publique vérifiée.
  • Suivez les taux de remplissage par lot. Une chute soudaine du taux de remplissage d’un champ peut signifier que ZoomInfo a changé un modèle.

Notre guide pour corriger les données de web scraping inexactes explique le nettoyage des données plus en détail.

Ce que coûte le scraping ZoomInfo à grande échelle

Les options ont différentes unités de facturation, et chaque page de tarification liste les taux actuels :

Option Facturé sur Autres coûts
Web Unlocker et votre parseur Requêtes réussies, avec paramètres par défaut Découverte d’URLs, analyse, déduplication, et hébergement
API ZoomInfo Scraper Enregistrements livrés Déduplication et stockage
Jeu de données ZoomInfo Enregistrements dans le fichier Déduplication et stockage
Proxys résidentiels et votre propre stack Bande passante Sessions, relances, analyse, déduplication, et l’ingénierie pour les faire fonctionner

Les pages d’entreprise mesurées faisaient 42 à 55 Ko compressées, donc 100K pages utilisent environ 4 à 6 Go de bande passante. Pour des volumes plus élevés, consultez chaque page de tarification pour les plans de volume.

Les proxys résidentiels vous donnent le plus de contrôle. Pour des pages de cette taille, leur facture de bande passante est faible comparée au coût d’ingénierie. Avec une stack fait maison, vous payez aussi pour les premières requêtes bloquées, les relances, et les sessions supplémentaires à environ 3 pages par IP. Vous avez aussi besoin d’un ingénieur qui maintient la stack lorsque ZoomInfo change ses règles anti-bot ou sa structure de page.

Dans une interview podcast IsraelTech, le PDG de Bright Data Or Lenchner a déclaré : “Vous avez besoin de nous quand vous avez besoin d’échelle de manière répétée tout le temps.” Pour de petites quantités de données, Lenchner a conseillé aux équipes de les collecter elles-mêmes. Notre comparaison du scraping géré et fait maison liste les coûts de chaque approche.

Réflexions finales

Les pages de ZoomInfo contenaient des données firmographiques non arrondies sous forme de JSON intégré, mais dans nos tests, les systèmes anti-bot vérifiaient chaque première requête et bloquaient les IPs après environ 3 pages. Le JSON rendait l’extraction la tâche la plus facile, donc à grande échelle, les sessions, la découverte d’URLs, et la déduplication nécessitent le plus de travail. Nos scripts collectent uniquement les données publiques d’entreprise, car les détails de contact nécessitaient un compte et les données sur les personnes peuvent nécessiter une base légale.

Le Web Unlocker et l’API ZoomInfo Scraper de Bright Data gèrent les sessions, et le jeu de données ZoomInfo livre des enregistrements que Bright Data a déjà collectés. Testez l’API ZoomInfo Scraper sur un échantillon de vos propres comptes, et comparez les enregistrements avec votre CRM.

FAQ

Peut-on scraper ZoomInfo ?

Oui, pour les pages d’entreprise qui se chargent sans connexion. Les détails de contact nécessitaient un compte. Les pages d’entreprise publiques portaient leurs données sous forme de JSON intégré. Dans nos tests, ZoomInfo a bloqué les 32 premières requêtes sans référent d’un autre site, démarrez donc chaque nouvelle session avec un référent.

Comment scraper les données de ZoomInfo ?

Trouvez l’URL du profil de chaque entreprise, récupérez la page publique, et lisez les données depuis son JSON intégré. Les IPs dans nos tests obtenaient une médiane de 3 pages, prévoyez donc de nombreuses sessions. L’API ZoomInfo Scraper de Bright Data gère les sessions et l’analyse pour vous.

Existe-t-il une API ZoomInfo ?

Oui. ZoomInfo vend l’accès à sa propre base de données via une API. Pour les profils d’entreprise publics, l’API ZoomInfo Scraper de Bright Data collecte ces pages et renvoie des enregistrements structurés. Le jeu de données ZoomInfo de Bright Data livre des enregistrements avec les mêmes champs, déjà collectés et rafraîchis selon un calendrier.

ZoomInfo est-il dépassé ?

Certains de ses enregistrements peuvent être dépassés, vérifiez-les donc avant qu’ils n’atteignent un CRM. Notre recherche pour un domaine a renvoyé 3 identifiants ZoomInfo, et l’un était un doublon avec un effectif différent. Dédupliquez, comparez les taux de remplissage, et rafraîchissez aussi souvent que vos champs changent.

Puis-je utiliser ZoomInfo gratuitement ?

Partiellement. Les pages de profil d’entreprise sur zoominfo.com se chargeaient sans compte, bien que leur HTML marque les détails de contact et certaines sections comme payantes. ZoomInfo proposait aussi un plan Lite gratuit avec des crédits mensuels limités. Les nouveaux comptes Bright Data reçoivent des crédits gratuits pour tester l’API ZoomInfo Scraper sur ces pages.

Existe-t-il un scraper ZoomInfo gratuit ?

Oui. Le dépôt open-source zoominfo-scraper de Bright Data sur GitHub contient des scripts Python qui utilisent les API de Bright Data. Dans nos tests, les requêtes avec un User-Agent curl ou python-requests ont été bloquées 29 fois sur 29, même avec un référent Google. Les API de Bright Data gèrent le déblocage pour ces scripts.