Como Fazer Scraping do ZoomInfo em Grande Escala

Aprenda a fazer scraping das páginas de empresas do ZoomInfo, combinar domínios de CRM com perfis, e validar registros com Python e a API ZoomInfo Scraper da Bright Data.
4 min de leitura
How to Scrape - ZoomInfo

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

Página de desafio HUMAN Security do ZoomInfo, com o logo do ZoomInfo, o texto "Press & Hold to confirm you are a human (and not a bot)," e um botão de pressionar vazio.

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 curl ou python-requests foram 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:

Diagrama de fluxo do caminho de um scraper até uma página de empresa do ZoomInfo. As solicitações passam por um nó de saída de proxy opcional para 2 sistemas antibot. O Cloudflare retornou um 429 após cerca de 3 páginas por IP, e a HUMAN Security retornou um 403 quando a primeira solicitação não tinha referenciador externo. A origem do ZoomInfo retornou um 200 com os dados da empresa em JSON incorporado.

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:

Perfil de empresa do ZoomInfo para a Aviagen. O cabeçalho mostra Agricultura, Alabama, Estados Unidos, e 5K - 10K Funcionários, e a visão geral mostra sede, número de telefone, site, receita de $2,4 Bilhões, tags de setor, e um ZI Rank de 65.

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:

  • pic continha um conjunto parcialmente diferente de 9 funcionários.
  • copilotContent continha os mesmos 10 modelos de marketing em uma nova ordem.
  • intents continha 5 entradas, 2 das quais carregavam um valor randomLocation, 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:

Resultados de diretório do ZoomInfo para empresas agrícolas no Alabama. Uma tabela lista 9 empresas, incluindo Peco Foods e Aviagen, com colunas de setor, localização, receita e funcionários, e a paginação mostra páginas de 1 a 5.

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.

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:

Painel de controle da Bright Data para o scraper de informações de empresas Zoominfo. Discover by search_url está selecionado em Endpoints Disponíveis, com 4 URLs de diretório do ZoomInfo como entradas e os modos de scraper síncrono e assíncrono abaixo deles.

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_min e employees_max de clean, ou numberOfEmployees do 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.