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”:

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
curlopython-requestsfueron 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:

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:

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:
piccontenía un conjunto parcialmente distinto de 9 empleados.copilotContentcontenía las mismas 10 plantillas de marketing en un nuevo orden.intentscontenía 5 entradas, 2 de las cuales llevaban un valorrandomLocation, 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:

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.
Enlaces entre páginas de empresa
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:

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_minyemployees_maxdeclean, onumberOfEmployeesdel 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.