Fazer scraping do ZoomInfo, uma plataforma de dados B2B, significa coletar os perfis de empresas que ela exibe sem login. Equipes de vendas e dados usam dados de empresas do ZoomInfo para enriquecer contas de CRM com campos de setor, número de funcionários, receita e tecnologia.
Sem um referenciador de outro site, todas as 32 primeiras solicitações que enviamos de novos IPs residenciais receberam a página de desafio “Press & Hold” do ZoomInfo. Em nossos testes, os IPs que conseguiram uma primeira página foram bloqueados após uma mediana de 3 páginas.
Este guia mostra como obter páginas de empresas em escala, combinar contas com perfis, extrair dados e decidir quando comprar um scraper do ZoomInfo.
Resumo
- Medimos cerca de 3 páginas por IP, então coletar 100 mil páginas do ZoomInfo exigiria mais de 33 mil sessões. Na 5ª solicitação, 12 de 14 sessões receberam um erro 429 do Cloudflare.
- O ZoomInfo manteve perfis de empresas duplicados. Uma busca por um domínio retornou 3 IDs de empresas, e um duplicado listava número de funcionários e receita diferentes.
- O JSON incorporado do ZoomInfo continha números não arredondados. O Web Unlocker retornou a página da Aviagen com 6.776 funcionários em seu JSON, enquanto o perfil mostrava de 5 mil a 10 mil.
- A API ZoomInfo Scraper da Bright Data cuida do desbloqueio para você. A partir de 1 URL de diretório, ela retornou 45 registros estruturados de empresas.
O que o ZoomInfo mostra sem login
As páginas públicas do ZoomInfo forneciam dados detalhados de empresas e dados básicos sobre pessoas. E-mails, números de telefone diretos, listas completas de contatos e a lista completa de tecnologias exigiam uma conta. Nosso scraper sem login coletou dados firmográficos como setor e número de funcionários. O site público tinha estes principais tipos de página:
| Tipo de página | Padrão de URL | O que pode mostrar |
|---|---|---|
| Perfil da empresa | /c/{slug}/{id} |
Setor, número de funcionários, estimativa de receita, sede, telefone, site, códigos SIC e NAICS, algumas tecnologias, financiamento e contagem de funcionários por nível de gestão |
| Diretório de empresas | /companies-search/{filters} |
9 empresas por página, filtradas por localização e setor |
| Perfil de pessoa | /p/{name}/{id} |
Um nome, um cargo e um empregador |
| Outras páginas | /pic/, /hq/, /tech/, /executives/, /competitors/ |
Diretórios de funcionários, detalhes da sede, empresas que usam uma tecnologia, executivos e concorrentes |
Páginas de pessoas, diretórios de funcionários e as listas de pessoas nas páginas de empresas contêm dados pessoais, que podem ser abrangidos pelo GDPR. As páginas com login estão sujeitas aos termos de serviço do ZoomInfo, que os titulares de contas aceitam. Nossos scripts coletam apenas registros públicos de empresas e descartam campos de pessoas.
Por que sua primeira solicitação ao ZoomInfo é bloqueada
O ZoomInfo executava 2 sistemas antibot na frente de suas páginas, e cada resposta bloqueada mostrava qual sistema a enviou. Um proxy entre seu scraper e o ZoomInfo também pode alterar a impressão digital TLS que esses sistemas veem. Em nossos testes, a primeira solicitação de uma sessão precisava de um referenciador externo, mas solicitações posteriores que carregavam os cookies da sessão não precisavam.
Cada sistema antibot tem sua própria assinatura
As solicitações para páginas do zoominfo.com, incluindo páginas de diretório, eram roteadas pelo Cloudflare e verificadas pela HUMAN Security, que se fundiu com a PerimeterX em 2022. Ambos os sistemas marcavam suas respostas bloqueadas com assinaturas distintas, e uma página real de empresa também tinha uma assinatura:
| Resposta de | Status e página | Sinal para ramificação |
|---|---|---|
| HUMAN Security | 403, “Access to this page has been denied” com um botão “Press & Hold” | O status 403 e px-captcha no corpo |
| Cloudflare | 429, “Just a moment…” | O cabeçalho de resposta cf-mitigated: challenge |
| Origem do ZoomInfo | 200, a página da empresa | <script id="ng-state"> com dados da empresa |
Ramifique com base nesses sinais, não em títulos de página. Páginas de empresas também continham _pxAppId, então sua presença não significa que a HUMAN bloqueou a solicitação. A documentação do Cloudflare diz que todo tipo de página de desafio carrega o cabeçalho cf-mitigated: challenge.
O JSON incorporado no script ng-state marcava as páginas reais de empresas de forma mais confiável. Trate uma resposta como bem-sucedida somente quando ela carregar esse JSON.
O que seu proxy faz à sua impressão digital TLS
Usamos uma zona de proxy residencial da Bright Data, uma configuração de proxy nomeada. A zona abriu sua própria conexão TLS com o site. Enviamos solicitações de 3 clientes para tls.peet.ws, um verificador de impressão digital TLS, primeiro sem proxy e depois através dessa zona:
| Cliente | JA4 sem proxy | JA4 através do proxy |
|---|---|---|
| curl_cffi imitando Chrome 150 | t13d1516h2_8daaf6152771_806a8c22fdea |
t13d1516h2_8daaf6152771_d8a2da3f94cd |
| curl_cffi imitando Firefox 147 | t13d1717h2_5b57614c22b0_3cbfd9057e0d |
t13d1516h2_8daaf6152771_02713d6af862 |
| curl 8.7.1 do sistema (LibreSSL) | t13d497h2_0d8feac7bc37_7395dae3b2f3 |
t13d1516h2_8daaf6152771_02713d6af862 |
Através do proxy, as 3 impressões digitais JA4 começaram com t13d1516h2_8daaf6152771, as mesmas 2 primeiras partes do curl_cffi imitando o Chrome. Essas partes codificam TLS 1.3, 15 conjuntos de cifras, 16 extensões e HTTP/2. Então o proxy apresentou uma impressão digital TLS semelhante ao Chrome para os 3 clientes. A imitação do Chrome também manteve sua impressão digital HTTP/2 através do proxy, então use-a para fazer tanto a impressão digital TLS quanto a HTTP/2 parecerem com as do Chrome.
Verifique qual salto encerra o TLS antes de gastar tempo com imitação de TLS. Se o proxy apenas tuneliza sua conexão, o site vê o próprio handshake do seu cliente. Em ambos os casos, a conexão TCP começa no nó de saída, cujo pacote SYN carrega seu próprio TTL, tamanho de janela e ordem de opções. Um User-Agent do macOS pode chegar através de uma pilha TCP do Windows, e um sistema antibot que lê impressões digitais TCP pode sinalizar essa incompatibilidade.
Como o ZoomInfo avalia a primeira solicitação
A página de desafio da HUMAN executava um script sensor no navegador, e o script definia cookies de risco como _px3. A primeira solicitação de um scraper não carrega nenhum desses cookies, então o site só pode avaliar a solicitação e a conexão em que ela chega.
Enviamos 32 solicitações de página de empresa sem cookies e sem referenciador de novos IPs residenciais dos EUA, e cada um dos 4 clientes enviou 8. Os clientes foram curl do sistema, curl com um conjunto completo de cabeçalhos do Chrome, curl_cffi imitando o Chrome e httpx sobre HTTP/2. Todas as 32 receberam o 403 da HUMAN e esta página “Press & Hold”:

O ZoomInfo retornou esta página de desafio para uma solicitação sem cookies e sem referenciador.
Através da zona de proxy, o Cloudflare viu o handshake TLS do proxy para os 4 clientes. De uma conexão doméstica, o Cloudflare viu o próprio handshake de cada cliente. As primeiras solicitações dos 6 clientes que testamos nessa conexão receberam o mesmo 403. Então, para nossas primeiras solicitações sem referenciador, o handshake TLS não mudou o resultado.
Um referenciador externo mudou o resultado. Solicitações sem cookies do curl_cffi imitando o Chrome tiveram sucesso em 16 de 18 vezes quando seu cabeçalho Referer nomeava outro site. Os referenciadores foram Google, Bing, DuckDuckGo, LinkedIn e example.com. Das 2 solicitações que falharam, 1 recebeu um HTTP 502.
Solicitações sem cookies com um cabeçalho sec-fetch-site: cross-site, mas sem o cabeçalho Referer, foram bloqueadas 3 de 3 vezes. Solicitações sem cookies com um referenciador de mesma origem também foram bloqueadas 3 de 3 vezes. Em nossos testes, uma primeira solicitação precisava parecer um clique de outro site, como um resultado de busca.
Com um referenciador do Google, a taxa de sucesso também mudou com a concorrência e com o User-Agent:
- Com 6 solicitações simultâneas através de IPs rotativos, 14 de 45 solicitações curl_cffi tiveram sucesso.
- Com 1 solicitação por vez e uma pausa de 6 segundos após cada uma, 13 de 20 solicitações tiveram sucesso, e 3 receberam um redirecionamento.
- Solicitações com User-Agent
curloupython-requestsforam bloqueadas 29 de 29 vezes. - Com 6 solicitações simultâneas, 4 de 15 solicitações de curl do sistema com User-Agent do Chrome tiveram sucesso.
Mesmo com 1 solicitação por vez, 7 de 20 solicitações falharam, então um scraper DIY ainda precisa de novas tentativas, novas sessões e monitoramento.
Quantas páginas do ZoomInfo cada IP recebe
Uma vez que uma sessão conseguia sua primeira página, o ZoomInfo servia apenas mais algumas páginas ao mesmo IP. Cada sessão mantinha 1 IP residencial através da configuração de sessão fixa do proxy e enviava um referenciador do Google na primeira solicitação. Depois contamos as páginas de empresas até a primeira solicitação bloqueada.
Em 14 sessões que conseguiram uma primeira página, a sessão mediana carregou 3 páginas de empresas antes de sua primeira solicitação bloqueada. Na 5ª solicitação, 12 das 14 tinham recebido um erro 429 do Cloudflare. Após o primeiro 429, toda solicitação posterior nessa sessão também recebia um 429. Nenhum dos 429s que inspecionamos carregava um cabeçalho Retry-After.
Nas sessões abaixo, um atraso de 2 ou 15 segundos entre páginas não mudou a contagem:
| Cliente | Atraso entre páginas | Resultado por solicitação |
|---|---|---|
| Cliente HTTP | 15 segundos | OK, OK, depois 429 nas próximas 8 |
| Cliente HTTP | 15 segundos | OK, OK, OK, depois 429 nas próximas 7 |
| Cliente HTTP | 2 segundos | OK, OK, OK, depois 429 nas próximas 7 |
| Chrome real | Tempo de carregamento da página | OK, OK, OK, depois “Just a moment…” nas próximas 5 |
Na sessão do Chrome, o Chrome com interface gráfica rodou com navigator.webdriver oculto, através de uma sessão fixa separada. Ele carregou as 3 primeiras páginas em 6 a 9 segundos cada. Em cada uma das próximas 5 páginas, o desafio do Cloudflare ainda aparecia após cerca de 15 segundos, embora o navegador tivesse um cookie de liberação, cf_clearance.
Nas sessões que mantiveram os cookies da primeira resposta, solicitações posteriores não precisavam de um referenciador externo. Uma solicitação posterior podia nomear a página anterior do ZoomInfo como seu referenciador, como um navegador faz.
Limpar os cookies não reiniciava a contagem. Das 4 sessões que os limparam antes de cada solicitação e enviaram um referenciador do Google a cada vez, 3 receberam um 429 após 3 páginas. Então o limite estava vinculado ao IP, não aos cookies.
Tópicos de fóruns dizem que os cookies antibot estão vinculados ao IP de saída, mas não conseguimos reproduzir isso com os cookies que nosso cliente HTTP recebeu. Movemos um conjunto de cookies com _pxhd, _cfuvid e contextual_signup_test de um IP para um novo, e 4 de 5 solicitações tiveram sucesso. Solicitações sem cookies de outros novos IPs ao mesmo tempo tiveram sucesso 2 de 5 vezes. Ambos os grupos enviaram um referenciador do Google.
Este diagrama mostra o caminho de um scraper até uma página de empresa e a resposta de cada sistema antibot:

Uma solicitação bloqueada não retorna dados de empresa, então uma taxa de bloqueio maior significa mais solicitações por registro.
Com cerca de 3 páginas por IP, coletar 100 mil páginas de empresas por mês exigiria mais de 33 mil sessões que conseguem uma primeira página. Toda primeira solicitação bloqueada significa mais uma sessão para iniciar. O Web Unlocker da Bright Data cuida dessas sessões para você.
Extraia os dados da empresa de uma página do ZoomInfo
As páginas de empresas do ZoomInfo eram aplicações Angular renderizadas no servidor. O servidor escrevia os dados da página em <script id="ng-state" type="application/json">. Esse script vem da API TransferState do Angular, que permite ao navegador reutilizar os dados sem buscá-los novamente. Para o Walmart, o script continha 84 KB de JSON, e seu objeto pageData tinha 42 chaves.
O JSON incorporado continha números mais precisos que a página visível. O perfil da Aviagen mostrava seu número de funcionários como um intervalo e sua receita arredondada para 1 casa decimal:

O perfil visível da Aviagen mostrava “5K – 10K” funcionários e “$2,4 Bilhões” em receita.
O JSON da mesma página continha os números não arredondados:
<script id="ng-state" type="application/json">
{"pageData": {
"companyId": 9047889,
"name": "Aviagen",
"numberOfEmployees": "6776",
"about": {"revenue": "2413885", ...},
...
}}
</script>
O ZoomInfo armazenava a receita em milhares de dólares, então o 2413885 da Aviagen significa $2.413.885.000. Compare o JSON incorporado de uma empresa com seu perfil visível para confirmar o multiplicador.
Outras páginas de empresas também mostravam intervalos, enquanto seu JSON incorporado continha números não arredondados. O bloco de perguntas frequentes na página do Walmart dizia “10K+” funcionários, enquanto o JSON continha 2.100.000. Para 5 pequenas empresas mostradas como “<$5 Milhões” em receita, as estimativas do JSON variaram de $366.000 a $4.474.000.
Alguns campos mudaram entre buscas mesmo quando os dados da empresa permaneceram os mesmos. Em 2 buscas da página do Walmart, com minutos de diferença, exatamente 3 chaves de pageData diferiram:
piccontinha um conjunto parcialmente diferente de 9 funcionários.copilotContentcontinha os mesmos 10 modelos de marketing em uma nova ordem.intentscontinha 5 entradas, 2 das quais carregavam um valorrandomLocation, e ambos os valores mudaram.
Com esses campos na página, aplicar hash em páginas inteiras pode fazer com que empresas inalteradas pareçam alteradas a cada rastreamento. Este analisador, salvo como parse_zoominfo.py, lê o JSON incorporado, converte as unidades, mantém IDs como strings e aplica hash somente aos campos da 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 # uma página de desafio, uma casca vazia, ou outro modelo
about = page.get("about") or {}
overview = page.get("overview") or {}
revenue_k = _int(about.get("revenue")) # armazenado em milhares de USD
domain = (page.get("url") or "").lower().removeprefix("www.")
return {
"id": str(page["companyId"]), # strings: IDs de pessoas transbordam int32
"name": page.get("name"),
"domain": domain or None,
"employees": _int(page.get("numberOfEmployees")), # contagem não arredondada
"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),
# Campos de pessoa (executives, pic, hierarchyOrgChart) são excluídos.
}
# intents, pic, e copilotContent mudaram entre 2 buscas da
# mesma página, então o hash os exclui.
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 retorna None para qualquer página sem dados de empresa, como uma página de desafio ou uma página vazia. Nas 2 buscas do Walmart, content_hash retornou o mesmo valor, b4ffe1810bbb1c8c, enquanto o SHA-256 de seu HTML bruto diferia.
Encontre URLs de empresas do ZoomInfo em escala
O robots.txt do ZoomInfo listava 1 sitemap, que incluía apenas páginas de marketing. Não encontramos sitemap para páginas de empresas ou pessoas, então você pode construir a lista de URLs a partir de 3 outras fontes.
Páginas de diretório
Cada URL /companies-search/ listava as empresas que correspondiam aos seus filtros de localização e setor, e um parâmetro de consulta pageNum selecionava a página. A listagem de agricultura do Alabama mostrava um total de 1.962 empresas. As páginas de 1 a 5 cada uma retornou a página que solicitamos. Solicitações para as páginas 6 e 10 retornaram HTTP 200 com as empresas da página 1 e pageNum: 1 no JSON incorporado.
Aqui está a primeira página dessa listagem:

A paginação mostrava 5 páginas. A coluna de receita estava em milhares de dólares, então o 2,4M da Aviagen corresponde aos $2,4 Bilhões em seu perfil.
Um paginador que verifica apenas códigos de status coletaria as empresas da página 1 novamente como novos resultados. Adicione esta proteção ao parse_zoominfo.py. Ela compara o número da página no JSON incorporado com o que você solicitou:
def directory_page(html: str, requested: int) -> list[str] | None:
state = _state(html) or {}
if state.get("pageNum") != requested:
return None # além das páginas acessíveis, o ZoomInfo serve a página 1 novamente
rows = (state.get("aiSearchResults") or {}).get("data") or []
return [urljoin("https://www.zoominfo.com", row["companyUrl"])
for row in rows]
directory_page retornou 9 URLs de empresas por página, e retornou None para a página 6. Então apenas 5 páginas desse filtro eram acessíveis, e continham cerca de 45 de suas 1.962 empresas. Para alcançar mais empresas, divida um filtro grande por estado dos EUA ou subsetor até que cada filtro menor tenha 45 empresas ou menos. A API ZoomInfo Scraper da Bright Data também pode rastrear páginas de diretório do ZoomInfo e retornar as empresas que elas listam como registros estruturados.
Resultados de busca para empresas que você já conhece
Uma consulta como site:zoominfo.com/c example.com, enviada através da API SERP da Bright Data, pode encontrar perfis candidatos para um domínio. Para brightdata.com, a consulta retornou 3 IDs de empresas diferentes. Mantenha o candidato cujo site corresponda ao domínio que você pesquisou.
Links entre páginas de empresas
Muitos perfis listavam empresas semelhantes, e a API ZoomInfo Scraper da Bright Data as retornou em um campo similar_companies. Um rastreador pode começar de uma lista de URLs semente e seguir esses links para encontrar mais empresas.
Perfis duplicados e IDs estáveis
Duplicatas existiam nos próprios dados do ZoomInfo, então qualquer uma das 3 fontes poderia retorná-las. Para brightdata.com, um perfil extra listava o endereço da rua do perfil principal, mas um domínio de site diferente. Suas estimativas de número de funcionários e receita também diferiam do perfil principal.
Uma junção por domínio perde essa duplicata, e uma junção por nome também pode corresponder a empresas não relacionadas. Uma busca por minimax.com retornou 9 perfis com Minimax no nome, e vários eram negócios não relacionados. Combine entidades pelo domínio ou endereço da sede, e revise apenas os perfis que correspondem em um, mas não no outro. Mantenha o ID numérico do ZoomInfo como chave primária.
Em uma URL de empresa, o slug podia mudar, mas o redirecionamento que rastreamos manteve o ID numérico. Em uma amostra, 3 de 20 URLs de empresas retornaram um redirecionamento 301, então cada uma precisava de mais uma solicitação através dos mesmos sistemas antibot. Para a entrada /c/walmart-inc/155353090, a Scraper API retornou a URL canônica, /c/walmart/155353090, que manteve o mesmo ID. O Web Unlocker seguiu o mesmo redirecionamento e retornou a página do Walmart.
3 formas de coletar dados do ZoomInfo em escala
Com 100 mil páginas de empresas por mês e cerca de 3 páginas por IP, um pipeline DIY exigiria mais de 33 mil sessões e trabalho contínuo de engenharia. As opções de extração de dados do ZoomInfo da Bright Data lidam com diferentes quantidades desse trabalho, de páginas desbloqueadas a registros prontos:
| Web Unlocker | API ZoomInfo Scraper | Conjunto de dados ZoomInfo | |
|---|---|---|---|
| Você envia | Uma URL de página | URLs de empresas ou diretórios | Um filtro |
| Você recebe | O HTML completo da página | Registros JSON estruturados | Registros prontos em um arquivo |
| Em nossos testes | As páginas de empresas chegaram com o JSON incorporado intacto | Os registros de empresas chegaram, e as URLs de diretório foram paginadas para nós | O arquivo de amostra mostrou o esquema completo |
| Entrega | Solicitações síncronas ou assíncronas | Snapshots através da API, armazenamento em nuvem, ou um webhook | Arquivos em uma atualização programada |
| Parsing | Seu | Incluído | Incluído |
A opção certa depende da saída que você precisa:
- Registros estruturados para sua própria lista de URLs. Use a Scraper API.
- Número de funcionários e receita não arredondados. Use o Web Unlocker com
parse_company. - Ampla cobertura, já coletada. Use o conjunto de dados.
Para páginas que precisam de cliques e rolagem, a Browser API da Bright Data executa um Chrome hospedado que você controla com Playwright ou Puppeteer. Para páginas de empresas, que não precisam de cliques, o Web Unlocker é a opção mais simples, porque retorna o HTML diretamente, sem navegador para controlar. Para agentes de IA, o servidor MCP ZoomInfo da Bright Data permite que clientes como Claude Desktop e Cursor coletem os mesmos perfis públicos de empresas.
Para começar, crie uma conta Bright Data e uma chave de API. Para o Web Unlocker, também crie uma zona, uma configuração nomeada do Web Unlocker, no painel de controle. Novas contas recebem créditos gratuitos para testar as APIs da Bright Data.
Os scripts precisam do Python 3.10 ou posterior. Os arquivos completos estão no repositório zoominfo-scraper da Bright Data no GitHub. Defina a chave e a zona uma vez:
pip install httpx
export BRIGHTDATA_API_KEY="your_api_key"
export BRIGHTDATA_UNLOCKER_ZONE="your_web_unlocker_zone"
Cada script lê a chave ao iniciar, então um script com uma chave ausente para antes de enviar qualquer solicitação.
O Web Unlocker retorna páginas que você analisa
O Web Unlocker é a API de desbloqueio da Bright Data. Você envia uma URL, e o Web Unlocker cuida de proxies, renderização de navegador e desafios antes de retornar a página. Em nossos testes, ele desbloqueou páginas de empresas do ZoomInfo e retornou o HTML com o JSON incorporado intacto. Então parse_company funciona nesse HTML sem alterações.
O Web Unlocker reporta o próprio status do alvo no cabeçalho de resposta x-brd-status-code e detalhes em x-brd-error, então um pipeline pode se ramificar com base no resultado. Por padrão, ele cobra apenas solicitações bem-sucedidas, e a documentação lista as configurações que alteram esse padrão.
Este buscador, salvo como fetch_pages.py, lida com 5 URLs por vez, tenta cada uma até 3 vezes, e salva os registros em 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,
)
# A API reporta o próprio resultado do alvo nesses 2 cabeçalhos.
target = resp.headers.get("x-brd-status-code")
record = parse_company(resp.text) if target == "200" else None
except (httpx.HTTPError, ValueError) as exc: # timeout ou 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"])
Com 3 URLs de empresas listadas uma por linha em urls.txt, python fetch_pages.py urls.txt imprimiu:
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
As 3 empresas foram salvas. Aqui estão os campos da empresa da entrada Aviagen em 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
}
O perfil da Aviagen mostrava esses valores de employees e revenue_usd como “5K – 10K” e “$2,4 Bilhões” em seu cabeçalho e visão geral.
A API ZoomInfo Scraper retorna registros
A API ZoomInfo Scraper da Bright Data cuida tanto do desbloqueio quanto do parsing. Seu endpoint Collect by URL recebe URLs de empresas, e Discover by search_url recebe URLs de diretório e coleta as empresas listadas em suas páginas. O painel de controle listava ambos os endpoints e oferecia modos síncrono e assíncrono:

Discover by search_url recebe URLs de diretório como essas e retorna as empresas listadas em suas páginas.
Para lotes, use o endpoint assíncrono /trigger, que executa o trabalho em segundo plano e retorna um ID de snapshot. O endpoint síncrono /scrape retorna registros diretamente para pequenos testes, e move trabalhos mais longos para um snapshot após seu limite de tempo de resposta.
Em nosso lote de 40 URLs, os primeiros registros chegaram em menos de um minuto. Para lotes grandes, deixe a API entregar resultados para armazenamento em nuvem ou um webhook. Ela envia cada snapshot quando a última entrada termina, então você não precisa consultar a API.
Discover by search_url cuida da paginação para você. Apenas da URL de diretório do Alabama, ela retornou 45 registros de empresas. Cada registro carregava a URL de diretório de onde veio. Para um filtro maior, envie uma URL de diretório para cada estado dos EUA ou subsetor.
O cliente scraper_api.py dispara um trabalho e consulta até que o snapshot esteja pronto, depois o baixa. Sua função clean converte o intervalo de funcionários em employees_min e employees_max e sinaliza intervalos de receita como “<$5 Milhões”. Também descarta os campos de liderança, organograma e CEO, que contêm dados sobre pessoas. Com --discover, o cliente envia URLs de diretório ao 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" # Informações de empresas 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 diretório: o trabalho cuida de sua paginação
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): # repetir após um erro de rede
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): # timeout ou arquivo parcial
pass
time.sleep(10) # aguarde até que o arquivo de snapshot esteja disponível
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"))
# O número de funcionários vem do intervalo 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"): # dados sobre pessoas
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)
# Mantenha linhas que carregam um id e nenhum erro.
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")
Com as 3 URLs de empresas de urls.txt como argumentos, o cliente imprimiu:
{"status": "ready", "records": 3, "errors": 0}
3 records saved to records.json
O snapshot continha os 3 registros, e o cliente os salvou em records.json. Cada registro carregava campos de empresa como receita, setor, tecnologias, site, sede e número de telefone.
O conjunto de dados ZoomInfo entrega registros já coletados
O conjunto de dados ZoomInfo entrega registros com os mesmos campos, já coletados e atualizados em uma programação. Verifique a página do conjunto de dados para a contagem atual de registros, frequência de atualização e preços, e inspecione sua amostra antes de comprar. Para decidir entre ele e uma assinatura do ZoomInfo, leia nossa comparação entre Bright Data e ZoomInfo.
Filtre antes de comprar. Os filtros do conjunto de dados permitem selecionar apenas os registros que você precisa, como páginas de empresas com URLs que incluem /c/. Descarte as colunas de liderança e organograma, que contêm dados sobre pessoas, a menos que você tenha uma base legal para elas.
Um conjunto de dados mantém registros como estavam no momento da coleta, e cada atualização os atualiza. Para escolher uma frequência de atualização, colete a mesma amostra de empresas duas vezes, com um mês de intervalo, e conte os registros que mudaram.
Combine domínios de CRM com perfis do ZoomInfo
Um CRM mantém domínios em vez de URLs do ZoomInfo. Este script, salvo como find_profiles.py, pesquisa no Google por cada domínio através da Bright Data. Ele busca os 3 principais perfis com fetch de fetch_pages.py e mantém o primeiro registro cujo domain corresponda ao domínio do CRM. Se a busca sem aspas não encontrar correspondência, o script pesquisa novamente com o domínio entre aspas:
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"]
# Uma zona de API SERP, se você tiver uma, caso contrário a zona do 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 encontrou perfis que a outra perdeu, então tente 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 analisados
except (httpx.HTTPError, ValueError): # timeout ou corpo vazio
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)
# Mantenha um perfil apenas quando seu site for o domínio do 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 domínios de CRM, python find_profiles.py aviagen.com pecofoods.com brightdata.com walmart.com imprimiu:
aviagen.com 9047889
pecofoods.com 82428957
brightdata.com 430599389
walmart.com no match
Para cada correspondência, o script retornou um registro completo de empresa. A busca sem aspas encontrou Aviagen e Bright Data, e a busca com aspas encontrou Peco Foods. O perfil do Walmart não apareceu nos resultados de nenhuma busca, então o script retornou nenhuma correspondência em vez de uma empresa errada. Envie domínios não correspondidos para uma busca por nome ou uma fila de revisão.
Valide cada registro antes que ele chegue ao seu CRM
Cada resposta que vimos do ZoomInfo e das APIs da Bright Data precisa de sua própria ação:
| Resposta | Significado | Ação |
|---|---|---|
200 com dados da empresa em ng-state |
Uma página real de empresa | Analisar e armazenar |
| Redirecionamento 301 | A URL mudou | Armazene a nova URL, e use o ID como chave |
403 com px-captcha no corpo |
A HUMAN bloqueou a solicitação | Tente novamente em uma nova sessão |
429 com cf-mitigated: challenge |
O limite de solicitações por IP do Cloudflare foi atingido | Rotacione o IP, e pare de tentar novamente nele |
| 429 sem cabeçalhos do Cloudflare | Limitação automática da Bright Data, acionada por uma baixa taxa de sucesso | Reduza a taxa de solicitações, e corrija as solicitações que falham |
| 502 sem assinatura antibot | Um erro temporário de upstream | Tente novamente com backoff |
Cabeçalho x-brd-status-code do Web Unlocker |
O próprio status do site alvo | Armazene resultados 200, e tente novamente os outros um número limitado de vezes |
Scraper API 202 de /scrape |
O trabalho foi movido para um snapshot | Consulte o ID do snapshot |
Página de diretório cujo pageNum é menor que o solicitado |
Além da última página acessível | Pare de paginar, e restrinja o filtro |
Verificações de status capturam páginas falhadas, e verificações de campo capturam registros ruins:
- Mantenha IDs como strings. Os IDs de pessoas do ZoomInfo nos dados que inspecionamos variaram de -2.135.143.530 a 17.773.369.573, então não cabem em um inteiro de 32 bits com sinal ou sem sinal.
- Leia o número de funcionários a partir de intervalos ou do JSON incorporado. Use
employees_mineemployees_maxdeclean, ounumberOfEmployeesdo JSON. - Marque intervalos de receita. Um valor como “<$5 Milhões” é um intervalo, e o JSON continha a estimativa.
- Divida códigos de setor. Valores como “54,541” carregavam um código de setor e um código de subsetor.
- Espere campos de CEO vazios. O ZoomInfo deixou o campo de CEO vazio em todas as páginas públicas que verificamos.
- Acompanhe as taxas de preenchimento por lote. Uma queda repentina na taxa de preenchimento de um campo pode significar que o ZoomInfo alterou um modelo.
Nosso guia sobre corrigir dados imprecisos de web scraping explica a limpeza de dados com mais profundidade.
Quanto custa o scraping do ZoomInfo em escala
As opções têm unidades de cobrança diferentes, e cada página de preços lista as taxas atuais:
| Opção | Cobrado por | Outros custos |
|---|---|---|
| Web Unlocker e seu analisador | Solicitações bem-sucedidas, com configurações padrão | Descoberta de URL, parsing, deduplicação e hospedagem |
| API ZoomInfo Scraper | Registros entregues | Deduplicação e armazenamento |
| Conjunto de dados ZoomInfo | Registros no arquivo | Deduplicação e armazenamento |
| Proxies residenciais e sua própria infraestrutura | Largura de banda | Sessões, novas tentativas, parsing, deduplicação, e a engenharia para executá-los |
As páginas de empresas que medimos tinham de 42 a 55 KB comprimidas, então 100 mil páginas usam cerca de 4 a 6 GB de largura de banda. Em volumes maiores, verifique cada página de preços para planos de volume.
Os proxies residenciais oferecem mais controle. Para páginas desse tamanho, sua conta de largura de banda é pequena comparada ao custo de engenharia. Com uma infraestrutura própria, você também paga por primeiras solicitações bloqueadas, novas tentativas, e as sessões extras a cerca de 3 páginas por IP. Você também precisa de um engenheiro que mantenha a infraestrutura quando o ZoomInfo alterar suas regras antibot ou estrutura de página.
Em uma entrevista no podcast IsraelTech, o CEO da Bright Data, Or Lenchner, disse: “Você precisa de nós quando precisa de escala repetidamente o tempo todo.” Para pequenas quantidades de dados, Lenchner aconselhou as equipes a coletá-los elas mesmas. Nossa comparação entre scraping gerenciado e DIY lista os custos de cada abordagem.
Considerações finais
As páginas do ZoomInfo continham dados firmográficos não arredondados como JSON incorporado, mas em nossos testes, os sistemas antibot verificavam cada primeira solicitação e bloqueavam IPs após cerca de 3 páginas. O JSON tornou a extração o trabalho menor, então em escala, sessões, descoberta de URL e deduplicação precisam do maior trabalho. Nossos scripts coletam apenas dados públicos de empresas, porque detalhes de contato exigiam uma conta e dados sobre pessoas podem exigir uma base legal.
O Web Unlocker e a API ZoomInfo Scraper da Bright Data cuidam das sessões, e o conjunto de dados ZoomInfo entrega registros que a Bright Data já coletou. Teste a API ZoomInfo Scraper em uma amostra de suas próprias contas, e compare os registros com seu CRM.
Perguntas frequentes
É possível fazer scraping do ZoomInfo?
Sim, para as páginas de empresas que carregam sem login. Detalhes de contato exigiam uma conta. As páginas públicas de empresas carregavam seus dados como JSON incorporado. Em nossos testes, o ZoomInfo bloqueou todas as 32 primeiras solicitações sem um referenciador de outro site, então comece toda nova sessão com um.
Como fazer scraping de dados do ZoomInfo?
Encontre a URL de perfil de cada empresa, busque a página pública, e leia os dados de seu JSON incorporado. Os IPs em nossos testes conseguiram uma mediana de 3 páginas, então planeje para muitas sessões. A API ZoomInfo Scraper da Bright Data cuida das sessões e do parsing para você.
Existe uma API do ZoomInfo?
Sim. O ZoomInfo vende acesso ao seu próprio banco de dados através de uma API. Para perfis públicos de empresas, a API ZoomInfo Scraper da Bright Data coleta essas páginas e retorna registros estruturados. O conjunto de dados ZoomInfo da Bright Data entrega registros com os mesmos campos, já coletados e atualizados em uma programação.
O ZoomInfo está desatualizado?
Alguns de seus registros podem estar desatualizados, então verifique-os antes que cheguem a um CRM. Nossa busca por um domínio retornou 3 IDs do ZoomInfo, e um era uma duplicata com um número de funcionários diferente. Deduplique, compare as taxas de preenchimento, e atualize com a frequência que seus campos mudam.
Posso usar o ZoomInfo gratuitamente?
Parcialmente. As páginas de perfil de empresas no zoominfo.com carregavam sem conta, embora seu HTML marcasse detalhes de contato e algumas seções como pagas. O ZoomInfo também oferecia um plano Lite gratuito com créditos mensais limitados. Novas contas da Bright Data recebem créditos gratuitos para testar a API ZoomInfo Scraper nessas páginas.
Existe um scraper gratuito para o ZoomInfo?
Sim. O repositório de código aberto zoominfo-scraper da Bright Data no GitHub tem scripts Python que usam as APIs da Bright Data. Em nossos testes, as solicitações com User-Agent curl ou python-requests foram bloqueadas 29 de 29 vezes, mesmo com um referenciador do Google. As APIs da Bright Data cuidam do desbloqueio para esses scripts.