Nem toda informação que você precisa está disponível em uma API. Preços de produtos, dados públicos de portais governamentais, notícias, cotações, resultados de pesquisas — muitas vezes a única forma de obtê-los programaticamente é extraindo diretamente do HTML das páginas web. Web scraping é essa técnica, e Python tem ferramentas excelentes para isso: BeautifulSoup para páginas estáticas e Selenium para páginas que exigem JavaScript.
Considerações Éticas e Legais
Antes de qualquer código, algumas regras importantes:
- Sempre verifique o arquivo
robots.txtdo site — ele indica o que pode ou não ser coletado - Respeite os termos de serviço do site
- Não sobrecarregue servidores — adicione pausas entre requisições
- Prefira APIs oficiais quando disponíveis
- Dados pessoais coletados estão sujeitos à LGPD e ao GDPR
import requests
# Verificando robots.txt
resposta = requests.get("https://example.com/robots.txt", timeout=10) # example.com: reservado para exemplos
print(resposta.text)
# Ler o arquivo não basta: o robotparser da biblioteca padrão responde por URL
from urllib.robotparser import RobotFileParser
rp = RobotFileParser("https://news.ycombinator.com/robots.txt")
rp.read()
print(rp.can_fetch("MeuBot/1.0", "https://news.ycombinator.com/")) # True
print(rp.can_fetch("MeuBot/1.0", "https://news.ycombinator.com/x?id=1")) # False
print(rp.crawl_delay("MeuBot/1.0")) # 30 — segundos entre requisições
requests + BeautifulSoup: Páginas Estáticas
pip install requests beautifulsoup4 lxml
import requests
from bs4 import BeautifulSoup
def obter_soup(url: str, headers: dict = None) -> BeautifulSoup:
"""Faz requisição e retorna objeto BeautifulSoup."""
headers_padrao = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/120.0.0.0 Safari/537.36"
)
}
headers = {**headers_padrao, **(headers or {})}
resposta = requests.get(url, headers=headers, timeout=15)
resposta.raise_for_status()
# .content (bytes), não .text: o BeautifulSoup acha o charset na <meta>.
# Sem charset no cabeçalho, o requests decodifica como ISO-8859-1 e
# "£51.77" chega como "£51.77" (medido no books.toscrape.com)
return BeautifulSoup(resposta.content, "lxml")
# Exemplo com página de notícias
soup = obter_soup("https://news.ycombinator.com")
# Inspecionando a estrutura
print(soup.title.text) # título da página
h1 = soup.find("h1") # None: o Hacker News não tem <h1>
print(h1.text if h1 else "sem <h1>") # h1.text direto: AttributeError
print(soup.find("div", class_="content")) # None: find() não levanta erro
Seletores CSS e Navegação no DOM
from bs4 import BeautifulSoup
html = """
<html>
<body>
<div class="container">
<h1 id="titulo">Sistema Escolar</h1>
<ul class="lista-alunos">
<li class="aluno aprovado" data-id="1">
<span class="nome">Ana Silva</span>
<span class="nota">9.5</span>
</li>
<li class="aluno reprovado" data-id="2">
<span class="nome">Bruno Costa</span>
<span class="nota">4.0</span>
</li>
<li class="aluno aprovado" data-id="3">
<span class="nome">Carla Souza</span>
<span class="nota">8.5</span>
</li>
</ul>
<a href="/relatorio" class="btn">Ver Relatório</a>
<a href="/exportar" class="btn btn-primary">Exportar</a>
</div>
</body>
</html>
"""
soup = BeautifulSoup(html, "lxml")
# find() — primeiro elemento
titulo = soup.find("h1")
print(titulo.text) # Sistema Escolar
print(titulo["id"]) # titulo
# find_all() — todos os elementos
alunos = soup.find_all("li", class_="aluno")
print(len(alunos)) # 3
# Apenas aprovados
aprovados = soup.find_all("li", class_="aprovado")
# select() — seletores CSS completos
nomes = soup.select(".lista-alunos .nome")
notas = soup.select(".lista-alunos .nota")
for nome, nota in zip(nomes, notas):
print(f"{nome.text:15} — {nota.text}")
# select_one() — primeiro resultado
primeiro = soup.select_one(".aluno[data-id='1'] .nome")
print(primeiro.text) # Ana Silva
# Atributos
links = soup.find_all("a", class_="btn")
for link in links:
print(f"{link.text:20} → {link['href']}")
# Navegação
container = soup.find("div", class_="container")
print(container.find("h1").text) # filho direto
lista = container.find("ul")
primeiro_li = lista.find("li") # primeiro filho
proximo_li = primeiro_li.find_next_sibling("li") # irmão seguinte
print(proximo_li.find("span", class_="nome").text)
Exemplo Prático: Cotações de Moedas
A página de fechamento do dólar do Banco Central parece um alvo óbvio para o BeautifulSoup, mas o HTML que ela entrega não tem tabela nenhuma: medido, são 2.871 bytes, só a casca de uma aplicação que o JavaScript preenche depois no navegador. Um scraper escrito para ela devolve lista vazia, sem erro — o if not tabela: return cotacoes esconde exatamente isso. Os mesmos números estão numa API pública do próprio BCB, a PTAX, e é ela que se deve usar: é o "prefira APIs oficiais" da lista acima, na prática. Antes de raspar qualquer página, vale abrir a aba Rede das ferramentas do navegador e ver se os dados não chegam prontos em JSON.
import requests
from dataclasses import dataclass
from datetime import date
PTAX = ("https://olinda.bcb.gov.br/olinda/servico/PTAX/versao/v1/odata/"
"CotacaoDolarDia(dataCotacao=@dataCotacao)")
@dataclass
class Cotacao:
compra: float
venda: float
hora: str
def cotacao_dolar(dia: date) -> Cotacao | None:
"""Cotação PTAX do dólar; None em fim de semana e feriado."""
resposta = requests.get(PTAX, params={
"@dataCotacao": f"'{dia:%m-%d-%Y}'", # mês-dia-ano, entre aspas simples
"$format": "json",
}, timeout=15)
resposta.raise_for_status()
valores = resposta.json()["value"]
if not valores: # dia sem pregão: lista vazia
return None
v = valores[-1]
return Cotacao(v["cotacaoCompra"], v["cotacaoVenda"], v["dataHoraCotacao"])
print(cotacao_dolar(date(2026, 9, 24))) # Cotacao(compra=5.1789, venda=5.1795, ...)
print(cotacao_dolar(date(2026, 9, 20))) # None — domingo
Scraping com Paginação
import requests
from bs4 import BeautifulSoup
from dataclasses import dataclass
from typing import List, Optional
import time
@dataclass
class Livro:
titulo: str
preco: float
disponivel: bool
avaliacao: int
url: str
def extrair_livros_pagina(soup: BeautifulSoup, base_url: str) -> List[Livro]:
"""Extrai todos os livros de uma página."""
livros = []
estrelas = {"One": 1, "Two": 2, "Three": 3, "Four": 4, "Five": 5}
for artigo in soup.select("article.product_pod"):
titulo = artigo.find("h3").find("a")["title"]
preco_texto = artigo.find("p", class_="price_color").text
preco = float(preco_texto.replace("£", "").strip()) # sem o remendo do "Â": veja obter_soup
disponivel = "In stock" in artigo.find("p", class_="availability").text
avaliacao_cls = artigo.find("p", class_="star-rating")["class"][1]
avaliacao = estrelas.get(avaliacao_cls, 0)
link = artigo.find("h3").find("a")["href"]
livros.append(Livro(
titulo=titulo, preco=preco,
disponivel=disponivel, avaliacao=avaliacao,
url=f"{base_url}/{link}"
))
return livros
def coletar_todos_livros(max_paginas: int = 5) -> List[Livro]:
"""Percorre múltiplas páginas coletando livros."""
BASE = "https://books.toscrape.com/catalogue"
todos = []
pagina = 1
while pagina <= max_paginas:
if pagina == 1:
url = "https://books.toscrape.com/catalogue/page-1.html"
else:
url = f"{BASE}/page-{pagina}.html"
print(f"Coletando página {pagina}...")
try:
soup = obter_soup(url)
except Exception as e:
print(f" Erro na página {pagina}: {e}")
break
livros = extrair_livros_pagina(soup, BASE)
if not livros:
break
todos.extend(livros)
print(f" {len(livros)} livros coletados.")
# Verifica se há próxima página
proximo = soup.find("li", class_="next")
if not proximo:
break
pagina += 1
time.sleep(1) # pausa educada entre requisições
return todos
livros = coletar_todos_livros(max_paginas=3)
print(f"\nTotal coletado: {len(livros)} livros")
# Análise dos dados coletados
caros = sorted(livros, key=lambda l: l.preco, reverse=True)[:5]
bem_avaliados = [l for l in livros if l.avaliacao == 5]
print(f"\nTop 5 mais caros:")
for l in caros:
print(f" £{l.preco:.2f} — {l.titulo[:40]}")
print(f"\nCom 5 estrelas: {len(bem_avaliados)}")
Selenium: Páginas com JavaScript
Para páginas que carregam conteúdo dinamicamente via JavaScript — SPAs, resultados de busca, páginas com login:
pip install selenium webdriver-manager
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.chrome.options import Options
from webdriver_manager.chrome import ChromeDriverManager
from selenium.webdriver.chrome.service import Service
import time
def criar_driver(headless: bool = True) -> webdriver.Chrome:
"""Cria e configura o driver do Chrome."""
opcoes = Options()
if headless:
opcoes.add_argument("--headless") # sem interface gráfica
opcoes.add_argument("--no-sandbox")
opcoes.add_argument("--disable-dev-shm-usage")
opcoes.add_argument("--disable-gpu")
opcoes.add_argument("--window-size=1920,1080")
opcoes.add_argument(
"user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36"
)
servico = Service(ChromeDriverManager().install())
return webdriver.Chrome(service=servico, options=opcoes)
def aguardar_elemento(driver, by, seletor, timeout=10):
"""Aguarda elemento aparecer na página."""
return WebDriverWait(driver, timeout).until(
EC.presence_of_element_located((by, seletor))
)
def aguardar_clicavel(driver, by, seletor, timeout=10):
"""Aguarda elemento ficar clicável."""
return WebDriverWait(driver, timeout).until(
EC.element_to_be_clickable((by, seletor))
)
Selenium: Interações com Formulários
from selenium.common.exceptions import NoSuchElementException
def pesquisar_github(termo: str) -> list:
"""Pesquisa repositórios no GitHub."""
driver = criar_driver(headless=True)
resultados = []
try:
driver.get("https://github.com/search")
# Localiza e preenche campo de busca
campo = aguardar_elemento(driver, By.NAME, "q")
campo.clear()
campo.send_keys(termo)
campo.send_keys(Keys.RETURN)
time.sleep(2) # aguarda resultados carregarem
# Extrai resultados
itens = driver.find_elements(By.CSS_SELECTOR, "[data-testid='results-list'] > div")
for item in itens[:10]:
try:
nome = item.find_element(By.CSS_SELECTOR, "h3 a").text
link = item.find_element(By.CSS_SELECTOR, "h3 a").get_attribute("href")
try:
desc = item.find_element(By.CSS_SELECTOR, "p").text
except NoSuchElementException: # except: nu engoliria até o Ctrl+C
desc = ""
resultados.append({"nome": nome, "url": link, "descricao": desc})
except Exception:
continue
finally:
driver.quit()
return resultados
# Uso
repos = pesquisar_github("fastapi python")
for repo in repos[:5]:
print(f" {repo['nome']}")
if repo['descricao']:
print(f" {repo['descricao'][:60]}")
Selenium: Automação de Login
def automatizar_login(url_login: str, email: str, senha: str) -> webdriver.Chrome:
"""Realiza login em uma página e retorna o driver autenticado."""
driver = criar_driver(headless=False) # visível para debug
try:
driver.get(url_login)
# Campo de e-mail
campo_email = aguardar_elemento(driver, By.ID, "email")
campo_email.send_keys(email)
# Campo de senha
campo_senha = driver.find_element(By.ID, "password")
campo_senha.send_keys(senha)
# Botão de login
btn_login = aguardar_clicavel(driver, By.CSS_SELECTOR, "button[type='submit']")
btn_login.click()
# Aguarda redirecionamento
WebDriverWait(driver, 10).until(
EC.url_changes(url_login)
)
print(f"Login realizado. URL atual: {driver.current_url}")
return driver
except Exception as e:
driver.quit()
raise RuntimeError(f"Falha no login: {e}")
Selenium: Screenshots e Execução de JavaScript
def capturar_pagina(url: str, caminho_saida: str):
"""Captura screenshot de página completa."""
driver = criar_driver(headless=True)
try:
driver.get(url)
time.sleep(2) # aguarda carregar
# Redimensiona para capturar página inteira
altura = driver.execute_script(
"return document.body.scrollHeight"
)
driver.set_window_size(1920, altura)
driver.save_screenshot(caminho_saida)
print(f"Screenshot salvo: {caminho_saida}")
finally:
driver.quit()
def extrair_com_js(url: str) -> dict:
"""Extrai dados executando JavaScript na página."""
driver = criar_driver()
try:
driver.get(url)
time.sleep(2)
# Executa JS e retorna resultado
titulo = driver.execute_script("return document.title")
links = driver.execute_script("""
return Array.from(document.querySelectorAll('a'))
.map(a => ({texto: a.textContent.trim(), href: a.href}))
.filter(a => a.href.startsWith('https'))
.slice(0, 10);
""")
return {"titulo": titulo, "links": links}
finally:
driver.quit()
Scrapy: Para Projetos Maiores
Para scraping em escala — dezenas de sites, milhões de páginas:
pip install scrapy
scrapy startproject escola_scraper
# escola_scraper/spiders/livros.py
import scrapy
class LivrosSpider(scrapy.Spider):
name = "livros"
start_urls = ["https://books.toscrape.com"]
custom_settings = {
"DOWNLOAD_DELAY": 1,
"CONCURRENT_REQUESTS": 4,
"ROBOTSTXT_OBEY": True,
"FEEDS": {
"livros.json": {"format": "json", "encoding": "utf8",
"overwrite": True}, # sem isso, a 2ª execução anexa e o JSON quebra
}
}
def parse(self, response):
# Extrai livros da página atual
for livro in response.css("article.product_pod"):
yield {
"titulo": livro.css("h3 a::attr(title)").get(),
"preco": livro.css("p.price_color::text").get(),
"avaliacao": livro.css("p.star-rating::attr(class)").get(),
}
# Segue para próxima página
proxima = response.css("li.next a::attr(href)").get()
if proxima:
yield response.follow(proxima, self.parse)
scrapy crawl livros
Exemplo Completo: Monitor de Preços
import requests
from bs4 import BeautifulSoup
from dataclasses import dataclass
from pathlib import Path
import json
import re
import time
from datetime import datetime
@dataclass
class Produto:
nome: str
preco: float
url: str
coletado_em: str
def converter_preco(texto: str) -> float:
""""R$ 1.234,56" → 1234.56 e "£51.77" → 51.77: o último separador é o decimal."""
numero = re.sub(r"[^\d.,]", "", texto)
if numero.rfind(",") > numero.rfind("."): # vírgula decimal: 1.234,56
numero = numero.replace(".", "").replace(",", ".")
else: # ponto decimal: 1,234.56
numero = numero.replace(",", "")
return float(numero) # "1.234" sem centavos continua ambíguo: lê 1.234
def coletar_preco(url: str, seletor_preco: str, seletor_nome: str) -> Produto:
"""Coleta preço de um produto em qualquer loja."""
soup = obter_soup(url)
nome = soup.select_one(seletor_nome)
preco = soup.select_one(seletor_preco)
if not nome or not preco:
raise ValueError("Seletores não encontraram os elementos.")
valor = converter_preco(preco.text)
return Produto(
nome=nome.text.strip(),
preco=valor,
url=url,
coletado_em=datetime.now().isoformat()
)
class MonitorPrecos:
def __init__(self, arquivo_dados: str = "precos.json"):
self.arquivo = Path(arquivo_dados)
self.historico: dict = self._carregar()
def _carregar(self) -> dict:
if self.arquivo.exists():
return json.loads(self.arquivo.read_text(encoding="utf-8"))
return {}
def _salvar(self):
self.arquivo.write_text(
json.dumps(self.historico, indent=2, ensure_ascii=False),
encoding="utf-8"
)
def registrar(self, produto: Produto):
chave = produto.url
if chave not in self.historico:
self.historico[chave] = {
"nome": produto.nome,
"precos": [],
"minimo": produto.preco,
"maximo": produto.preco
}
entrada = self.historico[chave]
entrada["precos"].append({
"valor": produto.preco,
"data": produto.coletado_em
})
entrada["minimo"] = min(entrada["minimo"], produto.preco)
entrada["maximo"] = max(entrada["maximo"], produto.preco)
self._salvar()
# Alerta se preço caiu abaixo do mínimo histórico
precos_anteriores = [p["valor"] for p in entrada["precos"][:-1]]
if precos_anteriores and produto.preco < min(precos_anteriores):
print(f" 🔔 ALERTA: {produto.nome}")
print(f" Novo mínimo histórico: R$ {produto.preco:.2f}")
def relatorio(self):
print("\n=== Relatório de Preços ===\n")
for url, dados in self.historico.items():
precos = [p["valor"] for p in dados["precos"]]
atual = precos[-1] if precos else 0
print(f" {dados['nome'][:40]}")
print(f" Atual: R$ {atual:.2f}")
print(f" Mínimo: R$ {dados['minimo']:.2f} | Máximo: R$ {dados['maximo']:.2f}")
print(f" Coletas: {len(precos)}\n")
# Configuração de produtos a monitorar
PRODUTOS = [
{
"url": "https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html",
"seletor_preco": "p.price_color",
"seletor_nome": "h1"
},
]
monitor = MonitorPrecos()
for config in PRODUTOS:
try:
produto = coletar_preco(
config["url"],
config["seletor_preco"],
config["seletor_nome"]
)
monitor.registrar(produto)
print(f" Coletado: {produto.nome[:40]} — £{produto.preco:.2f}")
time.sleep(1)
except Exception as e:
print(f" Erro em {config['url']}: {e}")
monitor.relatorio()
Raspar uma página é baixar o HTML e escolher nele o que interessa. Com requests e BeautifulSoup, isso resolve o caso comum, de páginas que chegam prontas do servidor: find e select localizam elementos por tag, classe ou seletor CSS, e a paginação é um laço que segue o link de próxima página. Quando o conteúdo só aparece depois que o JavaScript roda, o Selenium dirige um navegador de verdade, com esperas explícitas no lugar de time.sleep; e, quando o volume cresce para milhares de páginas, o Scrapy assume a fila, a concorrência e a exportação. Antes de qualquer um deles, porém, vale conferir se os dados não estão numa API — foi o caso das cotações do Banco Central —, e aí o caminho é o de Consumindo APIs REST com requests e httpx.
Um scraper quebra de um jeito particular: quase sempre em silêncio. A codificação errada troca o símbolo da moeda por lixo, um separador de milhar mal interpretado multiplica o preço por cem, um seletor que deixou de existir devolve None e uma página montada por JavaScript devolve lista vazia. Por isso, vale conferir o resultado contra a página que o navegador mostra, falhar alto quando a extração vier vazia, e respeitar o que o site pede no robots.txt, inclusive o intervalo entre requisições.
Fontes e leituras recomendadas
- BeautifulSoup4 — documentação oficial — https://www.crummy.com/software/BeautifulSoup/bs4/doc/
- Selenium — documentação oficial — https://selenium-python.readthedocs.io/
- Scrapy — documentação oficial — https://docs.scrapy.org/en/latest/
- webdriver-manager — https://github.com/SergeyPirogov/webdriver_manager
- books.toscrape.com — site para prática — https://books.toscrape.com/
- quotes.toscrape.com — site para prática — https://quotes.toscrape.com/
- MITCHELL, Ryan. Web Scraping with Python. 3. ed. O'Reilly Media, 2024. — referência completa sobre scraping com Python, cobrindo BeautifulSoup, Scrapy e Selenium.
- LAWSON, Richard. Web Scraping with Python. Packt, 2015. — abordagem prática com foco em projetos reais.
Exercícios
Exercício 1
Um scraper de livraria inglesa usa BeautifulSoup(resposta.text, "lxml") e, para converter o preço, faz texto.replace("£", ""). Ele quebra com could not convert string to float: 'Â51.77'. Um colega "resolve" acrescentando .replace("Â", ""), e o erro some. Por que aparece um "Â" que não está na página, e por que o remendo é perigoso?
Ver resposta
✓ Resposta: O servidor manda Content-Type: text/html sem charset, e para esse caso o requests assume ISO-8859-1, a regra antiga do HTTP para texto. Medido no books.toscrape.com: resposta.encoding era ISO-8859-1, e o preço chegou como '£51.77'. A página está em UTF-8, em que "£" ocupa dois bytes, C2 A3; lidos como ISO-8859-1, esses dois bytes viram dois caracteres, "Â" e "£". O remendo apaga o sintoma de um caso só: qualquer outro caractere fora do ASCII — um "é" num título, um travessão — continua corrompido, e em silêncio, porque não quebra nenhuma conversão. A correção é entregar os bytes ao parser: com BeautifulSoup(resposta.content, "lxml"), ele encontra o charset na tag <meta> da página, e o preço veio '£51.77'. Quando for preciso usar .text, resposta.encoding = resposta.apparent_encoding antes da leitura pede ao requests que detecte a codificação pelo conteúdo.
Exercício 2
O monitor de preços funcionou por meses numa loja brasileira. Apontado para uma loja inglesa, ele registra "A Light in the Attic" por 5177.00, e dispara alerta de alta quando o preço real, £51.77, não mudou. A conversão é float(texto.replace("R$", "").replace("£", "").replace(".", "").replace(",", ".")). Explique o 5177 e escreva uma conversão que atenda às duas lojas.
Ver resposta
✓ Resposta: A conversão supõe o formato brasileiro, em que o ponto separa milhar e a vírgula separa decimal: ela apaga os pontos e troca a vírgula por ponto. Em "£51.77", o ponto é o decimal; apagado, sobra "5177". Medido com o próprio monitor: coletou £5177.00, cem vezes o preço. Nenhum erro foi levantado, porque "5177" é um número válido. Uma regra que atende aos dois formatos é olhar o último separador: se a última vírgula vem depois do último ponto, a vírgula é o decimal (1.234,56); caso contrário, o decimal é o ponto e as vírgulas são milhar (1,234.56). Medido com o converter_preco do artigo corrigido: "R$ 1.234,56" → 1234.56, "£51.77" → 51.77, "1,234.56" → 1234.56, "R$ 10,00" → 10.0. Ainda sobra um caso ambíguo, "1.234" sem centavos, que só a moeda da loja decide: o mais seguro é guardar a moeda e o formato na configuração de cada produto, em vez de adivinhar. E um preço que muda cem vezes de uma coleta para a outra deveria ser tratado como erro de extração, não como alerta.
Exercício 3
Um analista escreveu um scraper para a página de fechamento do dólar do Banco Central. No navegador, a tabela está lá. O script roda sem erro, todo dia, e o relatório diário sai com zero cotações há semanas; ninguém percebeu porque o código trata a ausência de tabela devolvendo lista vazia. O que o requests está recebendo, e qual é a solução?
Ver resposta
✓ Resposta: Uma página sem dados. O site é uma aplicação JavaScript: o HTML que o servidor entrega é uma casca com os scripts, e a tabela só é montada no navegador. Medido: a resposta tinha 2.871 bytes, cinco <script> e nenhum <table>. O BeautifulSoup não executa JavaScript, então o find("table") devolve None desde o primeiro dia, e o if not tabela: return cotacoes transformou o defeito num relatório vazio. A solução aqui não é o Selenium, e sim a fonte dos dados: o BCB publica a PTAX numa API pública, e a consulta de 24/09/2026 devolveu compra 5,1789 e venda 5,1795 em JSON. A API tem dois detalhes que custam tempo: a data vai no formato mês-dia-ano e entre aspas simples, e um dia sem pregão devolve lista vazia — medido com o domingo 20/09/2026 —, o que é diferente de erro. Mesmo com a API, a lição do relatório vazio vale: "nenhum dado" num dia útil deve falhar alto, com alerta, em vez de gerar um relatório com zero linhas.
Exercício 4
Um script coleta comentários do Hacker News com o User-Agent de um Chrome e time.sleep(1) entre as páginas, "para ser educado". O IP da empresa começa a receber erros e depois é bloqueado. O desenvolvedor diz que olhou o robots.txt, "e não proíbe nada do que eu acesso". O que ele deixou de ver, e como o script deveria consultar o arquivo?
Ver resposta
✓ Resposta: O robots.txt não diz só o que pode ser acessado, diz também em que ritmo. O do Hacker News começa com Crawl-delay: 30: trinta segundos entre requisições, e o script usava um. Além disso, a lista de Disallow é longa, e ler o arquivo a olho não garante que a URL de cada requisição esteja liberada. A biblioteca padrão resolve as duas coisas com urllib.robotparser: medido, can_fetch("MeuBot/1.0", "https://news.ycombinator.com/") deu True, uma URL de uma rota proibida deu False, e crawl_delay("MeuBot/1.0") devolveu 30. O script deve consultar o can_fetch antes de cada URL e usar o crawl_delay como pausa mínima. O User-Agent de navegador piora a situação: o robots.txt é aplicado por agente, e se passar por Chrome é justamente o que impede o site de tratar o robô como robô. O correto é um agente próprio, com um contato (MeuBot/1.0 (+https://empresa.example/bot)). E, no caso do Hacker News, existe uma API oficial, que torna o scraping desnecessário.
Exercício 5
Um spider do Scrapy, com "FEEDS": {"livros.json": {"format": "json"}}, roda toda noite pelo cron. Na primeira manhã, o painel mostra os livros; na segunda, o painel fica vazio, e o log do painel registra json.decoder.JSONDecodeError: Extra data. O spider não registrou nenhum erro. O que aconteceu com o arquivo?
Ver resposta
✓ Resposta: A segunda execução acrescentou uma nova lista JSON ao fim do arquivo, em vez de substituí-lo. Para arquivos locais, o feed do Scrapy anexa por padrão, o que funciona para formatos de linha, mas não para JSON, que precisa ser um único valor. Medido com o spider do artigo, duas execuções de uma página: o arquivo ficou com duas listas seguidas, e json.loads falhou com Extra data: line 22 column 2. O spider não vê erro nenhum, porque escrever deu certo; quem quebra é quem lê. Há duas correções. Com "overwrite": True na configuração do feed, cada execução substitui o arquivo — medido: 20 itens depois de duas execuções. Ou use o formato jsonlines, um objeto por linha, em que anexar é correto e o histórico se acumula de propósito. Também vale gravar num arquivo com a data no nome, ou num temporário renomeado ao final, para que o painel nunca leia um arquivo pela metade enquanto o spider ainda escreve.