Web Scraping com BeautifulSoup e Selenium

Web Scraping com BeautifulSoup e Selenium

Web scraping com requests, BeautifulSoup, Selenium e Scrapy, do seletor CSS à paginação e ao monitor de preços — com os defeitos que passam calados: charset que corrompe o símbolo da moeda, milhar lido como decimal, página feita em JavaScript e o robots.txt com intervalo entre requisições.
Python

• • 21 min de leitura

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.txt do 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

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.

Comentários

Mais em Python

MLOps: Versionamento de Modelos, MLflow e Deploy de ML
MLOps: Versionamento de Modelos, MLflow e Deploy de ML

MLflow 3 na prática: experimentos, registro de modelos com aliases, API com…

Carreira: portfólio, GitHub, open source e o mercado Python
Carreira: portfólio, GitHub, open source e o mercado Python

O último artigo da série de Python: portfólio, README, contribuição open…

Logging, Monitoramento e Observabilidade
Logging, Monitoramento e Observabilidade

Logging estruturado em JSON, ContextVar, métricas com Prometheus, Sentry e…