Como extrair resultados de busca do Google em Python
Por que requests + BeautifulSoup para de funcionar no Google, e uma configuração em Python que retorna SERP em JSON com paginação e tratamento de erros.

Todo projeto de scraping que envolve o Google começa do mesmo jeito: requests.get("https://www.google.com/search?q=..."), um pouco de BeautifulSoup, e um parser que funciona lindamente por uns dois dias.
Depois para de funcionar. Aqui está o porquê, e como é de fato uma configuração que continua funcionando.
Por que a versão DIY quebra
Quatro coisas diferentes quebram isso, geralmente nessa ordem:
- Bloqueio de IP. IPs de datacenter são limitados rapidamente, e depois recebem uma página de consentimento ou um CAPTCHA em vez de resultados. Rotacionar alguns proxies adia isso, não resolve.
- Instabilidade do markup. O markup de resultados do Google usa nomes de classe ofuscados e rotativos. Um seletor CSS que funciona hoje não é um contrato estável.
- Tipos de resultado. Links orgânicos são a parte fácil. Featured snippets, People Also Ask, knowledge panels, anúncios e local packs têm cada um sua própria estrutura, e a presença deles varia por busca e por país.
- A matemática da paginação. O Google não respeita mais o parâmetro
num=100, então “os 100 melhores resultados” agora significa dez requisições paginadas separadas, não uma. Qualquer estimativa de custo escrita antes dessa mudança está errada em 10x.
Nenhum desses é um problema difícil isoladamente. Juntos, viram uma esteira de manutenção: você acaba sendo dono de um pool de proxies e de um parser que precisa de atenção todo trimestre, para obter dados que não são o seu produto.
A versão que continua funcionando
Mande a busca para uma API que já tem os proxies e o parsing, e receba JSON de volta:
import requests
API_KEY = "faf_live_..."
def search(query, country="us", page=0):
response = requests.get(
"https://api.fastaf.co/v1/serp",
params={"query": query, "country": country, "page": page},
headers={"Authorization": f"Bearer {API_KEY}"},
timeout=60,
)
response.raise_for_status()
return response.json()
data = search("web scraping api")
for result in data["organic_results"]:
print(result["rank"], result["title"], result["link"])
Sem configuração de proxy, sem seletores. A resposta também traz people_also_ask, knowledge_graph, ads e related_searches quando a busca os gera, então você verifica a presença de uma chave em vez de escrever um parser para cada tipo de resultado.
Segmentação por país e idioma
Dados de ranking não significam nada sem uma localização. Passe o país explicitamente em vez de torcer para a requisição cair em algum lugar útil:
for country in ["us", "gb", "de", "it"]:
data = search("project management software", country=country)
top = data["organic_results"][0]
print(country, "->", top["link"])
A busca é feita a partir dessa localização, com o domínio do Google e os padrões de idioma correspondentes.
Coletando mais de uma página
Como os resultados vêm dez por página, trate a profundidade como um loop e lembre que cada página é uma requisição cobrada separadamente:
def search_deep(query, pages=3, country="us"):
results = []
for page in range(pages):
data = search(query, country=country, page=page)
results.extend(data.get("organic_results", []))
return results
top_30 = search_deep("headless browser", pages=3)
print(len(top_30), "results")
Tratando erros direito
Três códigos de status importam em produção, e tratar todos como “tenta de novo” é como você queima um saldo de créditos em uma tarde:
import time
def search_safe(query, **kwargs):
for attempt in range(3):
response = requests.get(
"https://api.fastaf.co/v1/serp",
params={"query": query, **kwargs},
headers={"Authorization": f"Bearer {API_KEY}"},
timeout=60,
)
if response.status_code == 200:
return response.json()
# Sem créditos: tentar de novo nunca vai resolver.
if response.status_code == 402:
raise RuntimeError("Sem créditos, recarregue antes de tentar de novo")
# Limite de concorrência: espere exatamente o tempo que a API pedir.
if response.status_code == 429:
time.sleep(int(response.headers.get("Retry-After", 1)))
continue
# Falha upstream: não é cobrada, seguro tentar de novo com backoff.
if response.status_code in (502, 503, 504):
time.sleep(2 ** attempt)
continue
error = response.json().get("error", {})
raise RuntimeError(f"{error.get('code')}: {error.get('message')}")
raise RuntimeError("Tentativas esgotadas")
A regra prática: 402 é definitivo, 429 significa desacelerar exatamente pelo tempo indicado em Retry-After, e 5xx pode ser tentado de novo porque requisições falhas não são cobradas.
Rodando buscas em paralelo
Pesquisa de palavras-chave significa centenas de buscas, e o limite que importa é a concorrência, não o volume total. Limite isso explicitamente:
from concurrent.futures import ThreadPoolExecutor
keywords = ["serp api", "web scraping api", "proxy rotation", "headless browser"]
# Mantenha isso igual ou abaixo da concorrência permitida pelo seu plano.
with ThreadPoolExecutor(max_workers=5) as pool:
for keyword, data in zip(keywords, pool.map(search_safe, keywords)):
print(keyword, len(data["organic_results"]))
Ir além da concorrência do seu plano não deixa o trabalho mais rápido, só converte requisições em 429.
Quanto custa
Uma busca custa 5 créditos, e cada resposta informa quanto foi gasto no header X-FastAF-Credits-Used, para você medir por funcionalidade em vez de descobrir só depois. Veja preços para os números por pacote, ou a referência da Google SERP API para a lista completa de parâmetros e respostas.