Cómo Scrapear ZoomInfo a Gran Escala

Aprende a scrapear páginas de empresa de ZoomInfo, vincular dominios de CRM a perfiles, y validar registros con Python y el Scraper API de ZoomInfo de Bright Data.
4 min de lectura
How to Scrape - ZoomInfo

Scrapear ZoomInfo, una plataforma de datos B2B, significa recolectar los perfiles de empresas que se muestran sin iniciar sesión. Los equipos de ventas y datos usan los datos de empresas de ZoomInfo para enriquecer cuentas de CRM con campos de industria, personal, ingresos y tecnología.

Sin un referente de otro sitio, las 32 primeras solicitudes que enviamos desde nuevas IPs residenciales recibieron la página de desafío “Press & Hold” de ZoomInfo. En nuestras pruebas, las IPs que obtuvieron una primera página fueron bloqueadas tras una mediana de 3 páginas.

Esta guía te muestra cómo obtener páginas de empresas a gran escala, vincular cuentas con perfiles, extraer datos y decidir cuándo comprar un scraper de ZoomInfo.

TL;DR

  • Medimos unas 3 páginas por IP, por lo que recolectar 100K páginas de ZoomInfo necesitaría más de 33K sesiones. Para la 5ª solicitud, 12 de 14 sesiones recibieron un 429 de Cloudflare.
  • ZoomInfo mantenía perfiles de empresa duplicados. Una búsqueda de un dominio devolvió 3 IDs de empresa, y un duplicado mostraba un personal e ingresos distintos.
  • El JSON incrustado de ZoomInfo contenía cifras sin redondear. Web Unlocker devolvió la página de Aviagen con 6,776 empleados en su JSON, mientras que el perfil mostraba de 5K a 10K.
  • El Scraper API de ZoomInfo de Bright Data gestiona el desbloqueo por ti. Desde 1 URL de directorio, devolvió 45 registros de empresa estructurados.

Qué muestra ZoomInfo sin iniciar sesión

Las páginas públicas de ZoomInfo ofrecían datos detallados de empresas y datos básicos de personas. Los correos, números de teléfono directos, listas completas de contactos y la lista completa de tecnologías requerían una cuenta. Nuestro scraper sin sesión recolectó datos firmográficos como industria y personal. El sitio público tenía estos tipos principales de página:

Tipo de página Patrón de URL Qué puede mostrar
Perfil de empresa /c/{slug}/{id} Industria, personal, estimación de ingresos, sede, teléfono, sitio web, códigos SIC y NAICS, algunas tecnologías, financiación, y conteo de empleados por nivel de gestión
Directorio de empresas /companies-search/{filters} 9 empresas por página, filtradas por ubicación e industria
Perfil de persona /p/{name}/{id} Un nombre, un puesto y un empleador
Otras páginas /pic/, /hq/, /tech/, /executives/, /competitors/ Directorios de empleados, detalles de sede, empresas que usan una tecnología, ejecutivos y competidores

Las páginas de personas, los directorios de empleados y las listas de personas en páginas de empresas contienen datos personales, que el GDPR puede cubrir. Las páginas con sesión iniciada están sujetas a los términos de servicio de ZoomInfo, que los titulares de cuenta aceptan. Nuestros scripts recolectan solo registros públicos de empresas y descartan campos de personas.

Por qué tu primera solicitud a ZoomInfo es bloqueada

ZoomInfo ejecutaba 2 sistemas antibot delante de sus páginas, y cada respuesta de bloqueo mostraba qué sistema la envió. Un proxy entre tu scraper y ZoomInfo también puede cambiar la huella TLS que esos sistemas ven. En nuestras pruebas, la primera solicitud de una sesión necesitaba un referente externo, pero las solicitudes posteriores que llevaban las cookies de la sesión no lo necesitaban.

Cada sistema antibot tiene su propia firma

Las solicitudes a páginas de zoominfo.com, incluidas las páginas de directorio, se enrutaban por Cloudflare y eran verificadas por HUMAN Security, que se fusionó con PerimeterX en 2022. Ambos sistemas marcaban sus respuestas de bloqueo con firmas distintas, y una página de empresa real también tenía una firma:

Respuesta de Estado y página Señal para ramificar
HUMAN Security 403, “Access to this page has been denied” con un botón “Press & Hold” El estado 403 y px-captcha en el cuerpo
Cloudflare 429, “Just a moment…” El encabezado de respuesta cf-mitigated: challenge
Origen de ZoomInfo 200, la página de empresa <script id="ng-state"> con datos de empresa

Ramifica según estas señales, no según los títulos de página. Las páginas de empresa también contenían _pxAppId, así que su presencia no significa que HUMAN bloqueó la solicitud. La documentación de Cloudflare indica que todo tipo de página de desafío lleva el encabezado cf-mitigated: challenge.

El JSON incrustado en el script ng-state marcaba las páginas de empresa reales de forma más fiable. Trata una respuesta como exitosa solo cuando lleva este JSON.

Lo que tu proxy hace a tu huella TLS

Usamos una zona de proxy residencial de Bright Data, una configuración de proxy con nombre. La zona abrió su propia conexión TLS al sitio. Enviamos solicitudes desde 3 clientes a tls.peet.ws, un verificador de huellas TLS, primero sin proxy y luego a través de esa zona:

Cliente JA4 sin proxy JA4 con el proxy
curl_cffi imitando a Chrome 150 t13d1516h2_8daaf6152771_806a8c22fdea t13d1516h2_8daaf6152771_d8a2da3f94cd
curl_cffi imitando a Firefox 147 t13d1717h2_5b57614c22b0_3cbfd9057e0d t13d1516h2_8daaf6152771_02713d6af862
curl del sistema 8.7.1 (LibreSSL) t13d497h2_0d8feac7bc37_7395dae3b2f3 t13d1516h2_8daaf6152771_02713d6af862

A través del proxy, las 3 huellas JA4 comenzaban con t13d1516h2_8daaf6152771, las mismas primeras 2 partes que curl_cffi imitando a Chrome. Esas partes codifican TLS 1.3, 15 suites de cifrado, 16 extensiones y HTTP/2. Así que el proxy presentó una huella TLS similar a Chrome para los 3 clientes. La imitación de Chrome también mantuvo su huella HTTP/2 a través del proxy, así que úsala para que tanto la huella TLS como la HTTP/2 parezcan las de Chrome.

Verifica qué salto termina el TLS antes de invertir tiempo en imitación TLS. Si el proxy solo túnela tu conexión, el sitio ve el propio handshake de tu cliente. En ambos casos, la conexión TCP comienza en el nodo de salida, cuyo paquete SYN lleva su propio TTL, tamaño de ventana y orden de opciones. Un User-Agent de macOS puede llegar sobre una pila TCP de Windows, y un sistema antibot que lee huellas TCP puede marcar esa discrepancia.

Cómo evalúa ZoomInfo la primera solicitud

La página de desafío de HUMAN ejecutaba un script sensor en el navegador, y el script establecía cookies de riesgo como _px3. La primera solicitud de un scraper no lleva ninguna de esas cookies, así que el sitio solo puede evaluar la solicitud y la conexión por la que llega.

Enviamos 32 solicitudes de página de empresa sin cookies ni referente desde nuevas IPs residenciales de EE. UU., y cada uno de 4 clientes envió 8. Los clientes fueron curl del sistema, curl con un conjunto completo de encabezados de Chrome, curl_cffi imitando a Chrome, y httpx sobre HTTP/2. Las 32 recibieron el 403 de HUMAN y esta página “Press & Hold”:

Página de desafío HUMAN Security de ZoomInfo, con el logo de ZoomInfo, el texto 'Press & Hold to confirm you are a human (and not a bot)' y un botón de retención vacío.

ZoomInfo devolvió esta página de desafío a una solicitud sin cookies ni referente.

A través de la zona de proxy, Cloudflare vio el handshake TLS del proxy para los 4 clientes. Desde una conexión doméstica, Cloudflare vio el propio handshake de cada cliente. Las primeras solicitudes de los 6 clientes que probamos en esa conexión recibieron el mismo 403. Así que para nuestras primeras solicitudes sin referente, el handshake TLS no cambió el resultado.

Un referente externo cambió el resultado. Las solicitudes sin cookies de curl_cffi imitando a Chrome tuvieron éxito 16 de 18 veces cuando su encabezado Referer nombraba a otro sitio. Los referentes fueron Google, Bing, DuckDuckGo, LinkedIn y example.com. De las 2 solicitudes fallidas, 1 recibió un HTTP 502.

Las solicitudes sin cookies con un encabezado sec-fetch-site: cross-site pero sin encabezado Referer fueron bloqueadas 3 de 3 veces. Las solicitudes sin cookies con un referente del mismo origen también fueron bloqueadas 3 de 3 veces. En nuestras pruebas, una primera solicitud necesitaba parecer un clic desde otro sitio, como un resultado de búsqueda.

Con un referente de Google, la tasa de éxito también cambió según la concurrencia y el User-Agent:

  • Con 6 solicitudes concurrentes a través de IPs rotativas, 14 de 45 solicitudes curl_cffi tuvieron éxito.
  • Con 1 solicitud a la vez y una pausa de 6 segundos tras cada una, 13 de 20 solicitudes tuvieron éxito, y 3 recibieron una redirección.
  • Las solicitudes con un User-Agent de curl o python-requests fueron bloqueadas 29 de 29 veces.
  • Con 6 solicitudes concurrentes, 4 de 15 solicitudes de curl del sistema con un User-Agent de Chrome tuvieron éxito.

Incluso con 1 solicitud a la vez, 7 de 20 solicitudes fallaron, así que un scraper hecho por uno mismo aún necesita reintentos, nuevas sesiones y monitoreo.

Cuántas páginas de ZoomInfo obtiene cada IP

Una vez que una sesión obtenía su primera página, ZoomInfo servía solo unas pocas más a la misma IP. Cada sesión mantuvo 1 IP residencial mediante la configuración de sesión persistente del proxy y envió un referente de Google en la primera solicitud. Luego contamos páginas de empresa hasta la primera solicitud bloqueada.

De 14 sesiones que obtuvieron una primera página, la mediana cargó 3 páginas de empresa antes de su primera solicitud bloqueada. Para la 5ª solicitud, 12 de las 14 habían recibido un 429 de Cloudflare. Tras el primer 429, toda solicitud posterior en esa sesión también recibió un 429. Ninguno de los 429 que inspeccionamos llevaba un encabezado Retry-After.

En las sesiones a continuación, un retraso de 2 o 15 segundos entre páginas no cambió el conteo:

Cliente Retraso entre páginas Resultado por solicitud
Cliente HTTP 15 segundos OK, OK, luego 429 en las siguientes 8
Cliente HTTP 15 segundos OK, OK, OK, luego 429 en las siguientes 7
Cliente HTTP 2 segundos OK, OK, OK, luego 429 en las siguientes 7
Chrome real Tiempo de carga de página OK, OK, OK, luego “Just a moment…” en las siguientes 5

En la sesión de Chrome, Chrome con interfaz se ejecutó con navigator.webdriver oculto, mediante una sesión persistente separada. Cargó las primeras 3 páginas en 6 a 9 segundos cada una. En cada una de las siguientes 5 páginas, el desafío de Cloudflare seguía mostrándose tras unos 15 segundos, aunque el navegador tenía una cookie de aprobación, cf_clearance.

En las sesiones que mantuvieron las cookies de la primera respuesta, las solicitudes posteriores no necesitaban un referente externo. Una solicitud posterior podía nombrar la página anterior de ZoomInfo como su referente, como hace un navegador.

Borrar las cookies no reseteó el conteo. De 4 sesiones que las borraron antes de cada solicitud y enviaron un referente de Google cada vez, 3 recibieron un 429 tras 3 páginas. Así que el límite estaba vinculado a la IP, no a las cookies.

Hilos de foros dicen que las cookies antibot están vinculadas a la IP de salida, pero no pudimos reproducir eso con las cookies que recibió nuestro cliente HTTP. Movimos un almacén de cookies con _pxhd, _cfuvid y contextual_signup_test de una IP a una nueva, y 4 de 5 solicitudes tuvieron éxito. Las solicitudes sin cookies desde otras IPs nuevas al mismo tiempo tuvieron éxito 2 de 5 veces. Ambos grupos enviaron un referente de Google.

Este diagrama muestra la ruta de un scraper a una página de empresa y la respuesta de cada sistema antibot:

Diagrama de flujo de la ruta de un scraper a una página de empresa de ZoomInfo. Las solicitudes pasan por un nodo de salida de proxy opcional hacia 2 sistemas antibot. Cloudflare devolvió un 429 tras unas 3 páginas por IP, y HUMAN Security devolvió un 403 cuando la primera solicitud no tenía referente externo. El origen de ZoomInfo devolvió un 200 con los datos de empresa en JSON incrustado.

Una solicitud bloqueada no devuelve datos de empresa, así que una tasa de bloqueo más alta significa más solicitudes por registro.

A unas 3 páginas por IP, recolectar 100K páginas de empresa al mes necesitaría más de 33K sesiones que obtengan una primera página. Cada primera solicitud bloqueada significa una sesión más que iniciar. Web Unlocker de Bright Data gestiona estas sesiones por ti.

Extrae los datos de empresa de una página de ZoomInfo

Las páginas de empresa de ZoomInfo eran aplicaciones Angular renderizadas en servidor. El servidor escribía los datos de la página en <script id="ng-state" type="application/json">. Ese script proviene de la API TransferState de Angular, que permite al navegador reutilizar los datos sin volver a buscarlos. Para Walmart, el script contenía 84 KB de JSON, y su objeto pageData tenía 42 claves.

El JSON incrustado contenía cifras más precisas que la página visible. El perfil de Aviagen mostraba su personal como un rango y sus ingresos redondeados a 1 decimal:

Perfil de empresa de ZoomInfo para Aviagen. El encabezado muestra Agriculture, Alabama, United States y 5K - 10K Employees, y la vista general muestra sede, número de teléfono, sitio web, ingresos de $2.4 Billion, etiquetas de industria y un ZI Rank de 65.

El perfil visible de Aviagen mostraba “5K – 10K” empleados y “$2.4 Billion” en ingresos.

El JSON de la misma página contenía las cifras sin redondear:

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

ZoomInfo almacenaba los ingresos en miles de dólares, así que el 2413885 de Aviagen significa $2,413,885,000. Compara el JSON incrustado de una empresa con su perfil visible para confirmar el multiplicador.

Otras páginas de empresa también mostraban rangos, mientras su JSON incrustado contenía cifras sin redondear. El bloque de preguntas frecuentes en la página de Walmart decía “10K+” empleados, mientras el JSON contenía 2,100,000. Para 5 empresas pequeñas mostradas como “<$5 Million” en ingresos, las estimaciones del JSON iban de $366,000 a $4,474,000.

Algunos campos cambiaban entre descargas incluso cuando los datos de la empresa se mantenían iguales. En 2 descargas de la página de Walmart, con minutos de diferencia, exactamente 3 claves de pageData diferían:

  • pic contenía un conjunto parcialmente distinto de 9 empleados.
  • copilotContent contenía las mismas 10 plantillas de marketing en un nuevo orden.
  • intents contenía 5 entradas, 2 de las cuales llevaban un valor randomLocation, y ambos valores cambiaron.

Con estos campos en la página, calcular hash de páginas enteras puede hacer que empresas sin cambios parezcan cambiadas en cada rastreo. Este analizador, guardado como parse_zoominfo.py, lee el JSON incrustado, convierte las unidades, mantiene los IDs como cadenas y calcula hash solo de los campos de empresa:

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  # una página de desafío, un contenedor vacío, u otra plantilla
    about = page.get("about") or {}
    overview = page.get("overview") or {}
    revenue_k = _int(about.get("revenue"))  # almacenado en miles de USD
    domain = (page.get("url") or "").lower().removeprefix("www.")
    return {
        "id": str(page["companyId"]),  # cadenas: los IDs de persona desbordan int32
        "name": page.get("name"),
        "domain": domain or None,
        "employees": _int(page.get("numberOfEmployees")),  # conteo sin redondear
        "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),
        # Los campos de persona (executives, pic, hierarchyOrgChart) se excluyen.
    }


# intents, pic y copilotContent cambiaron entre 2 descargas de la
# misma página, así que el hash los excluye.
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 devuelve None para cualquier página sin datos de empresa, como una página de desafío o una página vacía. En las 2 descargas de Walmart, content_hash devolvió el mismo valor, b4ffe1810bbb1c8c, mientras el SHA-256 de su HTML crudo difería.

Encuentra URLs de empresas de ZoomInfo a gran escala

El robots.txt de ZoomInfo listaba 1 mapa del sitio, que incluía solo páginas de marketing. No encontramos mapa del sitio para páginas de empresa o persona, así que puedes construir la lista de URLs desde 3 otras fuentes.

Páginas de directorio

Cada URL /companies-search/ listaba las empresas que coincidían con sus filtros de ubicación e industria, y un parámetro de consulta pageNum seleccionaba la página. El listado de agricultura de Alabama mostraba un total de 1,962 empresas. Las páginas 1 a 5 devolvieron cada una la página que solicitamos. Las solicitudes para las páginas 6 y 10 devolvieron HTTP 200 con las empresas de la página 1 y pageNum: 1 en el JSON incrustado.

Aquí está la primera página de ese listado:

Resultados de directorio de ZoomInfo para empresas de agricultura en Alabama. Una tabla lista 9 empresas, incluidas Peco Foods y Aviagen, con columnas de industria, ubicación, ingresos y empleados, y la paginación muestra páginas 1 a 5.

La paginación mostraba 5 páginas. La columna de ingresos estaba en miles de dólares, así que el 2.4M de Aviagen coincide con los $2.4 Billion de su perfil.

Un paginador que solo verifica códigos de estado recolectaría de nuevo las empresas de la página 1 como resultados nuevos. Añade este resguardo a parse_zoominfo.py. Compara el número de página en el JSON incrustado con el que solicitaste:

def directory_page(html: str, requested: int) -> list[str] | None:
    state = _state(html) or {}
    if state.get("pageNum") != requested:
        return None  # más allá de las páginas alcanzables, ZoomInfo sirve la página 1 de nuevo
    rows = (state.get("aiSearchResults") or {}).get("data") or []
    return [urljoin("https://www.zoominfo.com", row["companyUrl"])
            for row in rows]

directory_page devolvió 9 URLs de empresa por página, y devolvió None para la página 6. Así que solo 5 páginas de este filtro eran alcanzables, y contenían unas 45 de sus 1,962 empresas. Para alcanzar más empresas, divide un filtro grande por estado de EE. UU. o subindustria hasta que cada filtro más pequeño tenga 45 empresas o menos. El Scraper API de ZoomInfo de Bright Data también puede rastrear páginas de directorio de ZoomInfo y devolver las empresas que listan como registros estructurados.

Resultados de búsqueda para empresas que ya conoces

Una consulta como site:zoominfo.com/c example.com, enviada a través del API SERP de Bright Data, puede encontrar perfiles candidatos para un dominio. Para brightdata.com, la consulta devolvió 3 IDs de empresa distintos. Conserva el candidato cuyo sitio web coincide con el dominio que buscaste.

Muchos perfiles listaban empresas similares, y el Scraper API de ZoomInfo de Bright Data las devolvía en un campo similar_companies. Un rastreador puede comenzar desde una lista de URLs semilla y seguir estos enlaces para encontrar más empresas.

Perfiles duplicados e IDs estables

Existían duplicados en los propios datos de ZoomInfo, así que cualquiera de las 3 fuentes podía devolverlos. Para brightdata.com, un perfil adicional listaba la dirección de la sede del perfil principal pero un dominio de sitio web distinto. Sus estimaciones de personal e ingresos también diferían de las del perfil principal.

Una unión por dominio pierde ese duplicado, y una unión por nombre también puede coincidir con empresas no relacionadas. Una búsqueda de minimax.com devolvió 9 perfiles con Minimax en el nombre, y varios eran negocios no relacionados. Vincula entidades por dominio o dirección de sede, y revisa solo los perfiles que coinciden en uno pero no en el otro. Mantén el ID numérico de ZoomInfo como clave primaria.

En una URL de empresa, el slug podía cambiar, pero la redirección que rastreamos mantuvo el ID numérico. En una muestra, 3 de 20 URLs de empresa devolvieron una redirección 301, así que cada una necesitaba una solicitud más a través de los mismos sistemas antibot. Para la entrada /c/walmart-inc/155353090, el Scraper API devolvió la URL canónica, /c/walmart/155353090, que mantuvo el mismo ID. Web Unlocker siguió la misma redirección y devolvió la página de Walmart.

3 formas de recolectar datos de ZoomInfo a gran escala

Con 100K páginas de empresa al mes y unas 3 páginas por IP, una canalización hecha por uno mismo necesitaría más de 33K sesiones y trabajo de ingeniería continuo. Las opciones de extracción de datos de ZoomInfo de Bright Data gestionan distintas cantidades de ese trabajo, desde páginas desbloqueadas hasta registros listos para usar:

Web Unlocker Scraper API de ZoomInfo Conjunto de datos de ZoomInfo
Tú envías Una URL de página URLs de empresa o directorio Un filtro
Tú obtienes El HTML completo de la página Registros JSON estructurados Registros listos para usar en un archivo
En nuestras pruebas Las páginas de empresa llegaban con el JSON incrustado intacto Llegaban registros de empresa, y las URLs de directorio eran paginadas por nosotros El archivo de muestra mostraba el esquema completo
Entrega Solicitudes síncronas o asíncronas Instantáneas a través de la API, almacenamiento en la nube o un webhook Archivos con actualización programada
Parseo Tuyo Incluido Incluido

La opción correcta depende del resultado que necesites:

  • Registros estructurados para tu propia lista de URLs. Usa el Scraper API.
  • Personal e ingresos sin redondear. Usa Web Unlocker con parse_company.
  • Amplia cobertura, ya recolectada. Usa el conjunto de datos.

Para páginas que necesitan clics y desplazamiento, el Browser API de Bright Data ejecuta un Chrome alojado que manejas con Playwright o Puppeteer. Para páginas de empresa, que no necesitan clics, Web Unlocker es la opción más simple, porque devuelve el HTML directamente, sin navegador que manejar. Para agentes de IA, el servidor MCP de ZoomInfo de Bright Data permite a clientes como Claude Desktop y Cursor recolectar los mismos perfiles públicos de empresa.

Para empezar, crea una cuenta de Bright Data y una clave de API. Para Web Unlocker, también crea una zona, una configuración de Web Unlocker con nombre, en el panel de control. Las cuentas nuevas reciben créditos gratuitos para probar las APIs de Bright Data.

Los scripts necesitan Python 3.10 o posterior. Los archivos completos están en el repositorio zoominfo-scraper de Bright Data en GitHub. Configura la clave y la zona una vez:

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

Cada script lee la clave al iniciarse, así que un script con una clave faltante se detiene antes de enviar cualquier solicitud.

Web Unlocker devuelve páginas que tú parseas

Web Unlocker es el API de desbloqueo de Bright Data. Envías una URL, y Web Unlocker gestiona proxies, renderizado de navegador y desafíos antes de devolver la página. En nuestras pruebas, desbloqueó páginas de empresa de ZoomInfo y devolvió el HTML con el JSON incrustado intacto. Así que parse_company funciona en ese HTML sin cambios.

Web Unlocker reporta el propio estado del objetivo en el encabezado de respuesta x-brd-status-code y detalles en x-brd-error, así que una canalización puede ramificarse según el resultado. Por defecto, factura solo las solicitudes exitosas, y la documentación lista las configuraciones que cambian este valor predeterminado.

Este recolector, guardado como fetch_pages.py, gestiona 5 URLs a la vez, intenta cada una hasta 3 veces, y guarda los registros en 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,
            )
            # La API reporta el propio resultado del objetivo en estos 2 encabezados.
            target = resp.headers.get("x-brd-status-code")
            record = parse_company(resp.text) if target == "200" else None
        except (httpx.HTTPError, ValueError) as exc:  # tiempo de espera o JSON inválido
            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"])

Con 3 URLs de empresa listadas una por línea en urls.txt, python fetch_pages.py urls.txt imprimió:

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

Las 3 empresas fueron guardadas. Aquí están los campos de empresa de la entrada de Aviagen en 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
}

El perfil de Aviagen mostraba estos valores de employees y revenue_usd como “5K – 10K” y “$2.4 Billion” en su encabezado y vista general.

El Scraper API de ZoomInfo devuelve registros

El Scraper API de ZoomInfo de Bright Data gestiona tanto el desbloqueo como el parseo. Su endpoint Collect by URL toma URLs de empresa, y Discover by search_url toma URLs de directorio y recolecta las empresas listadas en sus páginas. El panel de control listaba ambos endpoints y ofrecía modos síncrono y asíncrono:

Panel de control de Bright Data para el scraper de información de empresas de Zoominfo. Discover by search_url está seleccionado bajo Available Endpoints, con 4 URLs de directorio de ZoomInfo como entradas y los modos de scraper síncrono y asíncrono debajo.

Discover by search_url toma URLs de directorio como estas y devuelve las empresas listadas en sus páginas.

Para lotes, usa el endpoint asíncrono /trigger, que ejecuta la tarea en segundo plano y devuelve un ID de instantánea. El endpoint síncrono /scrape devuelve registros directamente para pruebas pequeñas, y traslada tareas más largas a una instantánea tras su límite de tiempo de respuesta.

En nuestro lote de 40 URLs, los primeros registros llegaron en menos de un minuto. Para lotes grandes, deja que la API entregue resultados a almacenamiento en la nube o un webhook. Envía cada instantánea cuando la última entrada termina, así que no necesitas sondear la API.

Discover by search_url gestiona la paginación por ti. Solo desde la URL de directorio de Alabama, devolvió 45 registros de empresa. Cada registro llevaba la URL de directorio de la que provenía. Para un filtro más grande, envía una URL de directorio por cada estado de EE. UU. o subindustria.

El cliente scraper_api.py activa una tarea y sondea hasta que la instantánea está lista, luego la descarga. Su función clean convierte el rango de empleados en employees_min y employees_max y marca rangos de ingresos como “<$5 Million”. También descarta los campos de liderazgo, organigrama y CEO, que contienen datos de personas. Con --discover, el cliente envía URLs de directorio a 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"  # Información de empresas de 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 directorio: la tarea gestiona su paginación
        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):  # reintentar tras un error de red
            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):  # tiempo de espera o archivo parcial
            pass
        time.sleep(10)  # esperar hasta que el archivo de instantánea esté 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"))
    # El personal proviene del rango de 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"):  # datos de personas
        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)
    # Mantener filas que llevan un id y ningún error.
    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")

Con las 3 URLs de empresa de urls.txt como argumentos, el cliente imprimió:

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

La instantánea contenía los 3 registros, y el cliente los guardó en records.json. Cada registro llevaba campos de empresa como ingresos, industria, tecnologías, sitio web, sede y número de teléfono.

El conjunto de datos de ZoomInfo entrega registros ya recolectados

El conjunto de datos de ZoomInfo entrega registros con los mismos campos, ya recolectados y actualizados según un programa. Revisa la página del conjunto de datos para el conteo actual de registros, frecuencia de actualización y precios, e inspecciona su muestra antes de comprar. Para decidir entre este y una suscripción de ZoomInfo, lee nuestra comparación de Bright Data y ZoomInfo.

Filtra antes de comprar. Los filtros del conjunto de datos te permiten seleccionar solo los registros que necesitas, como páginas de empresa con URLs que incluyen /c/. Descarta las columnas de liderazgo y organigrama, que contienen datos de personas, a menos que tengas una base legal para ellas.

Un conjunto de datos mantiene registros como estaban al momento de la recolección, y cada actualización los renueva. Para elegir una frecuencia de actualización, recolecta la misma muestra de empresas dos veces, con un mes de diferencia, y cuenta los registros que cambiaron.

Vincula dominios de CRM a perfiles de ZoomInfo

Un CRM contiene dominios en lugar de URLs de ZoomInfo. Este script, guardado como find_profiles.py, busca en Google cada dominio a través de Bright Data. Obtiene los 3 primeros perfiles con fetch de fetch_pages.py y conserva el primer registro cuyo domain coincide con el dominio del CRM. Si la búsqueda sin comillas no encuentra coincidencia, el script busca de nuevo con el dominio entre comillas:

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"]
# Una zona de SERP API si tienes una, de lo contrario la zona de 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+")
# Cada consulta encontró perfiles que la otra omitió, así que prueba ambas.
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", [])  # resultados parseados
        except (httpx.HTTPError, ValueError):  # tiempo de espera o cuerpo vacío
            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)
            # Conservar un perfil solo cuando su sitio web es el dominio del 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")

Para 4 dominios de CRM, python find_profiles.py aviagen.com pecofoods.com brightdata.com walmart.com imprimió:

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

Para cada coincidencia, el script devolvió un registro de empresa completo. La búsqueda sin comillas encontró Aviagen y Bright Data, y la búsqueda con comillas encontró Peco Foods. El perfil de Walmart no apareció en los resultados de ninguna búsqueda, así que el script devolvió sin coincidencia en lugar de una empresa incorrecta. Envía dominios sin coincidencia a una búsqueda por nombre o una cola de revisión.

Valida cada registro antes de que llegue a tu CRM

Cada respuesta que vimos de ZoomInfo y de las APIs de Bright Data necesita su propia acción:

Respuesta Significado Acción
200 con datos de empresa en ng-state Una página de empresa real Parsear y almacenar
Redirección 301 La URL cambió Almacenar la nueva URL, y usar el ID como clave
403 con px-captcha en el cuerpo HUMAN bloqueó la solicitud Reintentar en una sesión nueva
429 con cf-mitigated: challenge Se alcanzó el límite de solicitudes por IP de Cloudflare Rotar la IP, y dejar de reintentar en ella
429 sin encabezados de Cloudflare Auto-regulación de Bright Data, activada por una tasa de éxito baja Reducir la tasa de solicitudes, y arreglar las que fallan
502 sin firma antibot Un error temporal del origen Reintentar con retroceso
Encabezado x-brd-status-code de Web Unlocker El propio estado del sitio objetivo Almacenar resultados 200, y reintentar los demás un número limitado de veces
202 del Scraper API desde /scrape La tarea pasó a una instantánea Sondear el ID de instantánea
Página de directorio cuyo pageNum es menor al solicitado Más allá de la última página alcanzable Detener la paginación, y reducir el filtro

Las verificaciones de estado detectan páginas fallidas, y las verificaciones de campo detectan registros defectuosos:

  • Mantén los IDs como cadenas. Los IDs de persona de ZoomInfo en los datos que inspeccionamos iban de -2,135,143,530 a 17,773,369,573, así que no encajan ni en un entero de 32 bits con signo ni sin signo.
  • Lee el personal de rangos o del JSON incrustado. Usa employees_min y employees_max de clean, o numberOfEmployees del JSON.
  • Marca los rangos de ingresos. Un valor como “<$5 Million” es un rango, y el JSON contenía la estimación.
  • Divide los códigos de industria. Valores como “54,541” llevaban un código de sector y un código de subsector.
  • Espera campos de CEO vacíos. ZoomInfo dejaba el campo de CEO vacío en cada página pública que revisamos.
  • Rastrea las tasas de llenado por lote. Una caída repentina en la tasa de llenado de un campo puede significar que ZoomInfo cambió una plantilla.

Nuestra guía para corregir datos inexactos de web scraping explica la limpieza de datos con mayor profundidad.

Cuánto cuesta scrapear ZoomInfo a gran escala

Las opciones tienen unidades de facturación distintas, y cada página de precios lista las tarifas actuales:

Opción Facturado por Otros costos
Web Unlocker y tu propio analizador Solicitudes exitosas, con configuración predeterminada Descubrimiento de URLs, parseo, deduplicación y hospedaje
Scraper API de ZoomInfo Registros entregados Deduplicación y almacenamiento
Conjunto de datos de ZoomInfo Registros en el archivo Deduplicación y almacenamiento
Proxies residenciales y tu propia infraestructura Ancho de banda Sesiones, reintentos, parseo, deduplicación, y la ingeniería para ejecutarlos

Las páginas de empresa que medimos tenían de 42 a 55 KB comprimidos, así que 100K páginas usan unos 4 a 6 GB de ancho de banda. Para volúmenes más altos, revisa cada página de precios para planes por volumen.

Los proxies residenciales te dan el mayor control. Para páginas de este tamaño, su factura de ancho de banda es pequeña comparada con el costo de ingeniería. Con una infraestructura hecha por uno mismo, también pagas por primeras solicitudes bloqueadas, reintentos y las sesiones extra a unas 3 páginas por IP. También necesitas un ingeniero que mantenga la infraestructura cuando ZoomInfo cambie sus reglas antibot o la estructura de página.

En una entrevista del podcast IsraelTech, el CEO de Bright Data Or Lenchner dijo: “Nos necesitas cuando necesitas escala repetidamente todo el tiempo”. Para cantidades pequeñas de datos, Lenchner aconsejó a los equipos recolectarla ellos mismos. Nuestra comparación de scraping gestionado y hecho por uno mismo lista los costos de cada enfoque.

Reflexiones finales

Las páginas de ZoomInfo contenían datos firmográficos sin redondear como JSON incrustado, pero en nuestras pruebas, los sistemas antibot verificaban cada primera solicitud y bloqueaban IPs tras unas 3 páginas. El JSON hizo de la extracción la tarea menor, así que a gran escala, las sesiones, el descubrimiento de URLs y la deduplicación necesitan más trabajo. Nuestros scripts recolectan solo datos públicos de empresas, porque los detalles de contacto requerían una cuenta y los datos de personas pueden requerir una base legal.

Web Unlocker y el Scraper API de ZoomInfo de Bright Data gestionan las sesiones, y el conjunto de datos de ZoomInfo entrega registros que Bright Data ya recolectó. Prueba el Scraper API de ZoomInfo con una muestra de tus propias cuentas, y compara los registros con tu CRM.

Preguntas frecuentes

¿Se puede scrapear ZoomInfo?

Sí, para las páginas de empresa que cargan sin iniciar sesión. Los detalles de contacto requerían una cuenta. Las páginas públicas de empresa llevaban sus datos como JSON incrustado. En nuestras pruebas, ZoomInfo bloqueó las 32 primeras solicitudes sin un referente de otro sitio, así que inicia cada sesión nueva con uno.

¿Cómo scrapear datos de ZoomInfo?

Encuentra la URL del perfil de cada empresa, obtén la página pública, y lee los datos de su JSON incrustado. Las IPs en nuestras pruebas obtuvieron una mediana de 3 páginas, así que planifica muchas sesiones. El Scraper API de ZoomInfo de Bright Data gestiona las sesiones y el parseo por ti.

¿Existe un API de ZoomInfo?

Sí. ZoomInfo vende acceso a su propia base de datos a través de un API. Para perfiles públicos de empresa, el Scraper API de ZoomInfo de Bright Data recolecta esas páginas y devuelve registros estructurados. El conjunto de datos de ZoomInfo de Bright Data entrega registros con los mismos campos, ya recolectados y actualizados según un programa.

¿Está desactualizado ZoomInfo?

Algunos de sus registros pueden estar desactualizados, así que verifícalos antes de que lleguen a un CRM. Nuestra búsqueda de un dominio devolvió 3 IDs de ZoomInfo, y uno era un duplicado con un personal distinto. Deduplica, compara tasas de llenado, y actualiza tan seguido como cambien tus campos.

¿Puedo usar ZoomInfo gratis?

En parte. Las páginas de perfil de empresa en zoominfo.com cargaban sin una cuenta, aunque su HTML marcaba detalles de contacto y algunas secciones como de pago. ZoomInfo también ofrecía un plan Lite gratuito con créditos mensuales limitados. Las cuentas nuevas de Bright Data reciben créditos gratuitos para probar el Scraper API de ZoomInfo en esas páginas.

¿Existe un scraper de ZoomInfo gratuito?

Sí. El repositorio de código abierto zoominfo-scraper de Bright Data en GitHub tiene scripts de Python que usan las APIs de Bright Data. En nuestras pruebas, las solicitudes con un User-Agent de curl o python-requests fueron bloqueadas 29 de 29 veces, incluso con un referente de Google. Las APIs de Bright Data gestionan el desbloqueo para estos scripts.