Voltar ao blogue
Guias
Mihai MaximLast updated on May 12, 202625 min read

Python Extrair texto de HTML

Python Extrair texto de HTML
Resumo: Para extrair texto de HTML em Python, analise a marcação com um analisador de verdade (BeautifulSoup, lxml.htmlou html-text), remova scripts, estilos e elementos de interface do site, e, em seguida, normalize os espaços em branco e o Unicode antes de guardar. Este guia compara as principais bibliotecas, corrige as armadilhas comuns na limpeza e termina com um rastreador executável que escreve JSONL e ficheiros .txt .

Introdução

A maioria das equipas que querem extrair texto de HTML em Python começa com uma linha de código, depara-se com um obstáculo assim que uma página real aparece e, em seguida, passa uma tarde a descobrir que get_text() felizmente devolvem JavaScript, banners de cookies e 47 cópias da palavra «Subscrever». A solução não é uma biblioteca mágica diferente. É um fluxo de trabalho claro: analisar, limpar, extrair, normalizar, guardar.

O HTML é o código-fonte por trás de uma página web. Ele mistura o conteúdo real que se deseja — títulos, parágrafos, itens de lista — com marcação estrutural, scripts, estilos e metadados de que o navegador precisa, mas você não. O texto extraído é a parte visível e legível por humanos dessa página, com a marcação removida. Qualquer coisa que percorra o DOM — a árvore de nós que um analisador constrói a partir de HTML bruto — pode fazer isso se lhe for indicado quais os nós a manter.

Este guia destina-se a programadores Python, engenheiros de dados e profissionais de PLN que procuram código executável, predefinições sensatas e compromissos honestos. Iremos comparar as bibliotecas que realmente importam (BeautifulSoup, lxml.html além de html-text, Parsel e regex), criaremos auxiliares de limpeza e normalização que pode reutilizar e, em seguida, integraremos as peças num pequeno rastreador. Páginas renderizadas em JavaScript, armadilhas de codificação e uma tabela de resolução de problemas do tipo «sintoma-solução» serão abordadas ao longo do caminho.

O que significa realmente «extrair texto de HTML em Python»

Quando diz que quer extrair texto de HTML com Python, o que está realmente a dizer é: percorrer o documento analisado, manter os nós de texto visíveis e descartar tudo o resto. Os navegadores fazem isto implicitamente sempre que renderizam uma página. Como programadores, temos de ser explícitos.

Vale a pena definir alguns conceitos para que o resto do artigo faça sentido:

  • HTML é a fonte bruta: tags, atributos, estilos inline, scripts e metadados, além do conteúdo real inserido entre eles.
  • As tags são marcadores individuais como <p> e </p>. Elementos são tags mais tudo o que se encontra dentro delas.
  • O DOM (Document Object Model) é a árvore que um analisador constrói a partir dessa fonte. Cada elemento, atributo e nó de texto torna-se um nó na árvore.
  • O texto extraído é o conteúdo legível por humanos ao nível das folhas, títulos, parágrafos, itens de lista, rótulos, com a marcação removida.

A extração de texto funciona percorrendo esse DOM e recolhendo apenas os nós de texto, ignorando elementos como <script> e <style>. Bibliotecas diferentes expõem essa percussão de forma diferente, mas o modelo mental é o mesmo. Se mantiver na sua cabeça a análise, limpeza, extração e normalização como quatro etapas distintas, poderá alternar entre o BeautifulSoup, lxml, html-texte até mesmo pilhas que não sejam Python sem ter de reaprender o problema.

Também importa o motivo pelo qual está a extrair texto. Um índice de pesquisa pode tolerar uma única string plana. Um pipeline de ingestão de LLM geralmente quer que os parágrafos sejam preservados. Uma exportação de análise provavelmente quer que os títulos e o corpo do texto sejam separados. Decida isso logo no início, porque isso muda qual biblioteca e qual estratégia de extração fazem sentido.

Escolher uma biblioteca: BeautifulSoup, lxml, html-text, Parsel ou regex

Não existe uma única resposta «melhor» para extrair texto de HTML em Python, mas há boas opções padrão e más escolhas. Eis como as principais opções se alinham na prática.

BeautifulSoup (bs4) é o ponto de partida habitual. É tolerante com HTML danificado, tem uma superfície de API reduzida (find, find_all, select, get_text) e é amigável para leitores que nunca tiveram contacto com XPath. É a escolha certa para scraping ad hoc, protótipos e a maioria dos trabalhos de produção que não são limitados pela velocidade do analisador. As duas armadilhas em que as pessoas caem são esquecer-se de remover <script> e <style> antes de chamar get_text()e deixar o html.parser quando poderiam instalar lxml e passar 'lxml'.

lxml.html é a opção rápida, rigorosa e baseada em C. Utiliza a libxml2 nos bastidores, expõe tanto seletores CSS como XPath, e é a escolha certa quando se analisa milhares de páginas por minuto ou se necessita de uma intervenção precisa no DOM. A desvantagem é uma curva de aprendizagem ligeiramente mais acentuada e menor tolerância a marcação malformada do que o BeautifulSoup. De acordo com a documentação do lxml, este pode analisar HTML danificado através do seu html , mas o BeautifulSoup continua a ser mais flexível quando a entrada é verdadeiramente caótica.

html-text é um pequeno auxiliar que funciona em conjunto com lxml e produz texto simples e limpo com um tratamento sensato dos espaços em branco. É a escolha certa quando se pretende principalmente «texto legível a partir deste bloco» com um pós-processamento mínimo e não se necessita de consultas complexas. Por si só, não isola de forma fiável o corpo principal do artigo, pelo que combina bem com um <main> ou <article> seletor.

O Parsel é a biblioteca com muitos seletores que alimenta o Scrapy. Destaca-se quando se pretende campos estruturados (título, preço, autor) via CSS ou XPath, não quando se pretende limpar uma parede de texto. No momento da redação deste artigo, o seu ritmo de lançamento público tem sido relativamente calmo, por isso verifique se a versão no PyPI ainda se adequa à sua pilha antes de a adotar para um novo projeto.

O Regex não é um analisador. Use-o para limpar strings já extraídas (NBSP, espaços em branco repetidos, aspas curvas) e aceite que qualquer tentativa de corresponder HTML aninhado com re irá falhar assim que a marcação se tornar complexa.

Tabela comparativa e regras de decisão

Biblioteca

Ideal para

Prós

Contras

Chamada típica

BeautifulSoup

A maioria das tarefas de scraping e análise

API flexível e fácil de usar, boa documentação

Mais lento do que o lxml em grandes volumes

BeautifulSoup(html, 'lxml').get_text(' ')

lxml.html

Grandes volumes, XPath, manipulação de DOM

Muito rápido, rigoroso, suporte a XPath

Menos tolerante com HTML corrompido

lxml.html.fromstring(html).text_content()

html-text

Texto simples e limpo com o mínimo de esforço

Heurísticas de espaços em branco e visibilidade integradas

Sem seleção de conteúdo por si só

html_text.extract_text(html)

Parsel

Extração de campos estruturados

CSS e XPath combinados, compatível com Scrapy

Ritmo de lançamento mais calmo, excessivo para texto simples

Selector(text=html).xpath('//p//text()')

Regex

Pequena limpeza em texto já extraído

Integrado, rápido em cadeias curtas

Falha em HTML aninhado ou inconsistente

re.sub(r'\s+', ' ', text)

Regras de decisão rápida: se é novo no scraping, comece com o BeautifulSoup. Se precisar apenas de texto limpo sem consultas, opte pelo html-text. Se estiver a analisar dezenas de milhares de páginas ou precisar de XPath, opte por lxml.html. Se precisar de campos preenchidos mais do que de texto, use o Parsel. Trate a regex como um zelador, nunca como um analisador.

Um exemplo de HTML reutilizável para cada exemplo

Todos os exemplos abaixo usam o mesmo trecho desorganizado para que possas comparar as bibliotecas de forma justa. Guarda-o como sample.html ou atribua-o a uma string:

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>How to brew filter coffee</title>
  <style>.ad{color:red}</style>
  <script>window.analytics={track:()=>{}}</script>
</head>
<body>
  <header><nav>Home &middot; Recipes &middot; About</nav></header>
  <aside class="ad">Buy our new grinder!</aside>
  <main>
    <article>
      <h1>How&nbsp;to brew filter coffee</h1>
      <p>Start with <strong>fresh beans</strong> ground medium-coarse.</p>
      <ul>
        <li>Use a 1:16 ratio.</li>
        <li>Bloom for 30&nbsp;seconds.</li>
      </ul>
      <p class="hidden">Secret affiliate link block.</p>
      <div aria-hidden="true">Hidden cookie banner copy.</div>
    </article>
  </main>
  <footer>&copy; 2026 Coffee Co. Privacy. Terms.</footer>
</body>
</html>

Tem os quatro problemas clássicos: uma tag de script e uma de estilo, elementos de layout (<header>, <nav>, <footer>, um anúncio <aside>), um espaço não separável dentro do texto e dois blocos ocultos (.hidden e [aria-hidden="true"]). Se uma biblioteca lidar com isto de forma limpa, irá lidar com a maior parte do que lhe for apresentado na prática.

Extrair texto com o BeautifulSoup (passo a passo)

O BeautifulSoup é o padrão por uma razão: a API é pequena, os modos de falha são óbvios e os mesmos quatro passos cobrem quase todas as tarefas de extração de texto de HTML em Python.

Instale o básico:

pip install beautifulsoup4 lxml requests

Incorporamos lxml como backend do analisador. O 'lxml' backend é geralmente considerado mais rápido e rigoroso do que o da biblioteca padrão html.parser, embora a margem exata dependa do tamanho da entrada e da estrutura do documento; faça um benchmark com os seus próprios dados, se for importante.

Passo 1: analise com um analisador de verdade. Nunca execute expressões regulares em HTML completo. Passe a marcação para o BeautifulSoup primeiro.

import requests
from bs4 import BeautifulSoup

resp = requests.get("https://example.com/coffee", timeout=20.0)
resp.raise_for_status()
soup = BeautifulSoup(resp.text, "lxml")

Passo 2: elimine o ruído óbvio. Scripts e estilos são puro ruído para a extração de texto. Elimine-os antes de mais nada, caso contrário, o seu conteúdo irá infiltrar-se diretamente na sua saída.

for tag in soup(["script", "style", "noscript"]):
    tag.decompose()

Use decompose() em vez de extract() ou unwrap() quando quiser que a tag e os seus filhos desapareçam. extract() remove o nó, mas mantém uma referência; unwrap() mantém o conteúdo. Para o ruído, decompose() é o que você quer.

Passo 3: extrair texto. get_text() transforma o DOM restante numa única cadeia de caracteres. Os dois argumentos importantes são separator e strip. Sem separador, o BeautifulSoup junta elementos inline adjacentes, pelo que <strong>fresh</strong>beans se tornaria freshbeans. Passe um espaço (ou uma nova linha) para manter as palavras separadas, e strip=True para remover espaços em branco por nó.

text = soup.get_text(separator=" ", strip=True)

Passo 4: limpeza leve. Nesta altura, tem texto simples que ainda contém espaços em branco irregulares, espaços não separáveis e, possivelmente, várias linhas em branco. Deixe a normalização para um auxiliar dedicado (consulte a secção sobre normalização mais adiante) e mantenha este passo focado na extração.

Executar as quatro etapas na nossa amostra produz algo como:

Home · Recipes · About Buy our new grinder! How to brew filter coffee Start with fresh beans ground medium-coarse. Use a 1:16 ratio. Bloom for 30 seconds. Secret affiliate link block. Hidden cookie banner copy. © 2026 Coffee Co. Privacy. Terms.

Os scripts e estilos desapareceram, mas o layout, os anúncios e o conteúdo oculto ainda aparecem. Esse é o problema que as próximas secções resolvem.

Extrair texto limpo com lxml.html e html-text

Quando não precisas da facilidade de utilização do BeautifulSoup e queres velocidade, lxml.html mais html-text é uma combinação poderosa. lxml fornece-lhe a árvore analisada; html-text fornece-lhe texto bem normalizado a partir dela sem ter de escrever o seu próprio walker.

pip install lxml html-text

Uma versão mínima lxml.htmlda mesma extração fica assim:

import lxml.html

tree = lxml.html.fromstring(html_source)
for tag in tree.xpath("//script | //style | //noscript"):
    tag.drop_tree()
text = tree.text_content()

text_content() percorre o DOM e concatena nós de texto, mas não adiciona separadores entre elementos de nível de bloco. Títulos, parágrafos e itens de lista acabam por ficar colados uns aos outros. Essa é exatamente a lacuna html-text preenche.

import html_text

text = html_text.extract_text(html_source)

Internamente, html-text o parse com lxml, aplica algumas heurísticas em torno do conteúdo oculto (analisa padrões comuns, como display:none, aria-hiddene nomes de classes convencionais) e insere espaços em branco onde os elementos de nível de bloco criariam quebras visuais. O resultado é muito mais próximo do que um utilizador vê num navegador do que o text_content().

Vale a pena ser honesto quanto aos limites. html-textAs heurísticas de visibilidade do são baseadas em padrões, não na renderização do navegador. Estilos inline definidos via CSS numa folha de estilo externa, hidden ou alternâncias de testes A/B são invisíveis para um analisador estático. Se precisar de visibilidade com renderização real, necessita de um navegador sem interface gráfica, que abordaremos mais adiante.

html-text também não isola o artigo principal por si só. Ele emitirá alegremente a navegação e o rodapé se lhe fornecer a página completa. Combine-o com um <main> seletor <article> seletor (tree.cssselect('main')[0]) quando quiser uma saída apenas do corpo. Essa combinação, lxml para a seleção mais html-text para o dump de texto, é uma das formas mais limpas de extrair texto de HTML em Python em grande escala.

Quando (e apenas quando) usar expressões regulares para limpeza

De vez em quando, alguém publica «por que não posso simplesmente re.sub('<[^>]+>', '', html)?" e, a cada poucos meses, a resposta é a mesma: porque o HTML é aninhado, malformado e cheio de casos extremos que as expressões regulares não conseguem modelar. Os contra-exemplos clássicos são tags não fechadas, comentários com > dentro deles, blocos CDATA e atributos que contêm colchetes angulares entre aspas. Há também uma resposta famosa no Stack Overflow sobre o tema que vale um sorriso.

O padrão correto é: analise com um analisador real e, em seguida, deixe que as expressões regulares aperfeiçoem o texto simples resultante. Depois de o BeautifulSoup ou html-text te der uma string, a expressão regular serve para tarefas como:

import re
import unicodedata

text = unicodedata.normalize("NFKC", text)
text = text.replace("\u00a0", " ")          # NBSP -> space
text = re.sub(r"[\u2018\u2019]", "'", text) # smart single quotes
text = re.sub(r"[\u201c\u201d]", '"', text) # smart double quotes
text = re.sub(r"[ \t]+", " ", text)         # collapse runs of spaces
text = re.sub(r"\n{3,}", "\n\n", text)      # collapse blank-line runs

Coisas a evitar: remover tags com expressões regulares, extrair valores de atributos de HTML bruto com expressões regulares e dividir em < e > para «obter o texto». Essas técnicas funcionam numa demonstração escrita à mão, mas falham em produção. Se alguma vez se sentir tentado, escreva primeiro a versão baseada no analisador e só recorra à expressão regular na string já simplificada que este produz.

Limpar HTML do mundo real: navegação, rodapés, anúncios, banners de cookies, blocos ocultos

A saída que obtivemos do tutorial do BeautifulSoup ainda continha a navegação, um bloco de anúncios, um parágrafo oculto de afiliados, um aria-hidden banner de cookies e o rodapé. Nada disso é útil para indexação ou análise. Limpar isto antes da extração é a maior vantagem em termos de qualidade que pode obter quando extrai texto de HTML com Python.

O padrão é: analisar, remover scripts e estilos, remover elementos de layout, remover conteúdo oculto e, em seguida, chamar get_text().

from bs4 import BeautifulSoup

NOISE_TAGS = ["script", "style", "noscript", "template", "svg"]
CHROME_SELECTOR = (
    "header, footer, nav, aside, "
    ".cookie-banner, .cookie, .consent, .gdpr, "
    ".ad, .ads, .advert, .promo, .newsletter, "
    ".social-share, .related, .breadcrumbs"
)
HIDDEN_SELECTOR = (
    ".hidden, .visually-hidden, .sr-only, "
    "[aria-hidden='true'], [hidden], "
    "[style*='display:none'], [style*='visibility:hidden']"
)

def clean(soup):
    for tag in soup(NOISE_TAGS):
        tag.decompose()
    for tag in soup.select(CHROME_SELECTOR):
        tag.decompose()
    for tag in soup.select(HIDDEN_SELECTOR):
        tag.decompose()
    return soup

A ordem das operações é importante. Elimine primeiro os scripts e estilos, porque estes encontram-se frequentemente dentro dos elementos que está prestes a consultar, e removê-los primeiro mantém os seus seletores fiáveis. Em seguida, elimine o layout chrome por nome de tag. Os seletores de nome de classe vêm a seguir, porque são a parte mais frágil: cada site nomeia as coisas de forma diferente, e terá de ajustar esta lista por fonte.

Porquê decompose() e não extract()? decompose() elimina o nó e todos os seus filhos da árvore e liberta as suas referências. extract() remove o nó, mas devolve-o, o que é útil quando se pretende mover um nó para outro local, não quando se está a eliminar ruído. Para limpeza, utilize sempre decompose().

Após executar clean(soup) na nossa amostra e, em seguida, chamar soup.get_text(separator="\n", strip=True), obtém algo próximo do que um leitor realmente vê:

How to brew filter coffee
Start with fresh beans ground medium-coarse.
Use a 1:16 ratio.
Bloom for 30 seconds.

Esse é o objetivo: os títulos e parágrafos que interessam ao utilizador, com todo o código padrão descartado. Trate os seletores chrome e hidden acima como um kit inicial, não como uma lista completa; cada domínio que você rastrear irá adicionar uma ou duas novas classes que você precisará descartar.

Isolar o conteúdo principal com seletores e heurísticas de legibilidade

Remover o chrome funciona, mas a abordagem mais limpa quando a marcação está bem estruturada é extrair o conteúdo principal diretamente. O HTML moderno oferece-lhe três bons ganchos:

main = (
    soup.select_one("main")
    or soup.select_one("article")
    or soup.select_one("[role='main']")
)
if main is None:
    main = soup.body or soup
text = main.get_text(separator="\n", strip=True)

Essa escada de fallback, <main>, <article>, role="main", e depois <body>, cobre a maioria dos sites de conteúdo. Se também limpares a subárvore resultante com os seletores de chrome e ocultos da secção anterior, normalmente acabas com texto apenas no corpo sem teres de escrever regras personalizadas por site.

Quando a marcação é fraca (pense em modelos de CMS antigos sem tags semânticas), recorra ao readability-lxml ou trafilatura. Ambos aplicam heurísticas de densidade de texto: pontuam cada bloco pela proporção de texto em relação à marcação e pela densidade de links, e devolvem a região com a pontuação mais alta como o artigo principal. Nenhum deles é perfeito; ocasionalmente, podem selecionar uma secção de comentários ou ignorar uma nota na barra lateral. Trate-os como um recurso alternativo quando os seletores estruturais falham, não como o padrão.

Normalização de texto: espaços em branco, NBSP, quebras de linha e Unicode

A saída bruta do get_text() raramente é «limpa». Irá ver espaços não separáveis (\u00a0) onde esperava espaços reais, \r\n finais de linha em páginas criadas no Windows, sequências de três ou quatro linhas em branco provenientes de modelos generosos de CMS e, ocasionalmente, katakana de meia largura ou ligaduras cortesia do Unicode. Um pequeno normalizador dedicado corrige tudo isto de uma vez e poupa-lhe tempo de depuração mais tarde.

import re
import unicodedata

def normalize_text(text: str) -> str:
    # 1. Unicode-canonical form
    text = unicodedata.normalize("NFKC", text)
    # 2. NBSP and other exotic spaces -> regular space
    text = text.replace("\u00a0", " ").replace("\u200b", "")
    # 3. Normalize line endings
    text = text.replace("\r\n", "\n").replace("\r", "\n")
    # 4. Strip per-line whitespace
    lines = [line.strip() for line in text.split("\n")]
    # 5. Collapse internal runs of spaces and tabs
    lines = [re.sub(r"[ \t]+", " ", line) for line in lines]
    # 6. Collapse runs of blank lines down to one blank line
    out, blank_run = [], 0
    for line in lines:
        if line == "":
            blank_run += 1
            if blank_run <= 1:
                out.append(line)
        else:
            blank_run = 0
            out.append(line)
    return "\n".join(out).strip()

Algumas notas sobre o que cada passo lhe proporciona. unicodedata.normalize("NFKC", ...) colapsa os caracteres de compatibilidade para os seus equivalentes canónicos, pelo que o torna-se um A e ligaduras como tornam-se fi. A documentação do Python sobre o módulo unicodedata explica em detalhe o que cada forma faz.

Remover o NBSP antecipadamente é importante porque re.sub(r"\s+", ...) corresponde a \u00a0 no Python moderno, mas os tokenizadores e indexadores de pesquisa a jusante muitas vezes não o fazem. Normalizar os finais de linha impede que um único \r de danificar ficheiros JSONL. A compactação de sequências de espaços em branco mantém as quebras de parágrafo sem produzir páginas de linhas vazias.

Execute este auxiliar uma vez no final do seu pipeline, nunca dentro do loop por tag, e terá texto que as ferramentas a jusante podem realmente consumir.

Extração sensível à estrutura: parágrafos, títulos e listas como blocos

Uma única string plana serve para pesquisa e análises básicas, mas não é adequada para fragmentação de recuperação (RAG), resumo e qualquer coisa que se preocupe com hierarquia. Se o seu consumidor a jusante se beneficia de saber o que é um título em comparação com o corpo do texto, emita blocos tipados em vez de uma grande string.

BLOCK_TAGS = {"h1", "h2", "h3", "h4", "h5", "h6", "p", "li", "blockquote", "td", "pre"}

def extract_blocks(soup):
    blocks = []
    for el in soup.find_all(list(BLOCK_TAGS)):
        text = el.get_text(separator=" ", strip=True)
        if not text:
            continue
        kind = "heading" if el.name.startswith("h") else "body"
        blocks.append({
            "kind": kind,
            "tag": el.name,
            "text": text,
        })
    return blocks

No nosso artigo de exemplo, isto produz algo como:

[
  {"kind": "heading", "tag": "h1", "text": "How to brew filter coffee"},
  {"kind": "body",    "tag": "p",  "text": "Start with fresh beans ground medium-coarse."},
  {"kind": "body",    "tag": "li", "text": "Use a 1:16 ratio."},
  {"kind": "body",    "tag": "li", "text": "Bloom for 30 seconds."},
]

Porquê dar-se ao trabalho? Três razões. Primeiro, um segmentador LLM pode manter os títulos com os parágrafos seguintes em vez de os dividir. Segundo, as consultas analíticas podem contar os títulos separadamente do corpo do texto, o que é importante para auditorias de conteúdo. Terceiro, pode juntar os títulos num esboço (# How to brew filter coffee) e manter o corpo por baixo, o que lhe dá uma saída ao estilo Markdown de graça.

Se precisar de preservar a ordem e o aninhamento (um título e os seus parágrafos descendentes como uma secção), itere usando soup.descendants e agrupar blocos sempre que encontrar uma tag de título. A estrutura é barata de manter e cara de reconstruir mais tarde, por isso capture-a uma vez na altura da extração.

Mini-projeto de ponta a ponta: rastrear, extrair, normalizar e guardar

Hora de juntar tudo. O script abaixo rastreia uma secção paginada de um site, extrai texto limpo por página, normaliza-o e escreve um registo JSONL por página, além de um ficheiro .txt . Ele usa um único requests.Session, segue o Next link de paginação e pára num max_pages.

import json
import re
import time
import unicodedata
from pathlib import Path
from urllib.parse import urljoin

import requests
from bs4 import BeautifulSoup

HEADERS = {
    "User-Agent": "text-extractor/1.0 (+contact@example.com)",
    "Accept": "text/html,application/xhtml+xml",
}
NOISE_TAGS = ["script", "style", "noscript", "template", "svg"]
CHROME = "header, footer, nav, aside, .cookie-banner, .ad, .related, .newsletter"
HIDDEN = ".hidden, [aria-hidden='true'], [hidden]"

def fetch_soup(session, url):
    resp = session.get(url, headers=HEADERS, timeout=20.0)
    resp.raise_for_status()
    if resp.encoding is None or resp.encoding.lower() == "iso-8859-1":
        resp.encoding = resp.apparent_encoding
    return BeautifulSoup(resp.text, "lxml")

def clean(soup):
    for tag in soup(NOISE_TAGS):
        tag.decompose()
    for tag in soup.select(CHROME):
        tag.decompose()
    for tag in soup.select(HIDDEN):
        tag.decompose()
    return soup

def main_subtree(soup):
    return (
        soup.select_one("main")
        or soup.select_one("article")
        or soup.select_one("[role='main']")
        or soup.body
        or soup
    )

def normalize_text(text: str) -> str:
    text = unicodedata.normalize("NFKC", text)
    text = text.replace("\u00a0", " ").replace("\u200b", "")
    text = text.replace("\r\n", "\n").replace("\r", "\n")
    text = "\n".join(line.strip() for line in text.split("\n"))
    text = re.sub(r"[ \t]+", " ", text)
    text = re.sub(r"\n{3,}", "\n\n", text)
    return text.strip()

def extract(soup):
    cleaned = clean(soup)
    body = main_subtree(cleaned)
    title = soup.title.get_text(strip=True) if soup.title else ""
    raw = body.get_text(separator="\n", strip=True)
    return title, normalize_text(raw)

def crawl(start_url: str, out_dir: Path, max_pages: int = 25):
    out_dir.mkdir(parents=True, exist_ok=True)
    jsonl_path = out_dir / "pages.jsonl"
    session = requests.Session()
    url, count = start_url, 0
    with jsonl_path.open("w", encoding="utf-8") as out:
        while url and count < max_pages:
            try:
                soup = fetch_soup(session, url)
            except requests.RequestException as exc:
                print(f"[skip] {url}: {exc}")
                break
            title, text = extract(soup)
            record = {"url": url, "title": title, "text": text}
            out.write(json.dumps(record, ensure_ascii=False) + "\n")
            (out_dir / f"page-{count:03d}.txt").write_text(text, encoding="utf-8")
            next_link = soup.select_one("ul.pager li.next a")
            url = urljoin(url, next_link["href"]) if next_link else None
            count += 1
            time.sleep(1.0)  # be polite
    return count

if __name__ == "__main__":
    pages = crawl(
        start_url="https://example.com/blog/",
        out_dir=Path("out"),
        max_pages=10,
    )
    print(f"Saved {pages} pages")

As partes são deliberadamente pequenas. Troque fetch_soup por um fetcher do Playwright quando chegar a páginas renderizadas em JavaScript. Troque o seletor de paginação pelo que o seu site de destino utilizar. Troque o gravador JSONL por uma inserção no SQLite se quiser um armazenamento pesquisável. O padrão — analisar, limpar, extrair, normalizar, guardar — permanece idêntico.

Dois pequenos detalhes que vale a pena manter. O fetch_soup() ajudante aplica um tempo limite de 20 segundos para a solicitação e recorre a apparent_encoding quando o servidor retorna o padrão iso-8859-1. Ambos são baratos de adicionar agora e difíceis de adaptar posteriormente. O time.sleep(1.0) entre páginas é o comportamento mínimo de cortesia; para rastreamento sério, consulte a secção de escalabilidade abaixo.

Formatos de saída: JSONL vs CSV vs texto simples vs base de dados

Adapte o formato de armazenamento ao consumidor, não ao que digitou inicialmente.

  • JSONL (um objeto JSON por linha) é o padrão para pipelines de scraping. É transmissível, apenas de adição, fácil de inspecionar head -n 1 pages.jsonl | jq .e tolerante a formatos de registo em evolução. Use-o quando os registos tiverem vários campos ou uma estrutura aninhada.
  • O CSV é adequado quando os consumidores a jusante são folhas de cálculo, pandas ou ferramentas de BI. Opte por um esquema plano com colunas previsíveis e escreva com csv.DictWriter para não ter de colocar nada entre aspas manualmente.
  • Texto simples (.txt) por página é ideal para NLP, indexação de pesquisa e ingestão de LLM. Um ficheiro por documento mantém tudo compatível com o Git e permite-lhe processar páginas em paralelo sem qualquer estrutura de registos.
  • SQLite ou DuckDB são a escolha certa quando se querem consultas ad hoc («quantas páginas mencionam espresso?») ou junções com outras tabelas. Ambos vêm como uma base de dados de ficheiro único, sem qualquer configuração.

Na prática, o pipeline acima grava JSONL e por página .txt simultaneamente. O JSONL é o seu índice de metadados; os .txt ficheiros são o que se passa para a fase seguinte.

Armadilhas de codificação, conjunto de caracteres e marcação incorreta

Erros de codificação são a segunda razão mais comum pela qual um pipeline Python de extração de texto de HTML gera lixo. Os sintomas clássicos são é onde esperava é, caracteres de substituição () no meio de parágrafos, ou o temido UnicodeDecodeError em resp.text.

A causa principal é quase sempre que requests padrão para iso-8859-1 porque a resposta não tinha um conjunto de caracteres no seu Content-Type cabeçalho. A requests documentação refere isto: quando não é especificada nenhuma codificação, iso-8859-1 é assumido. Substitua-o:

resp = session.get(url, timeout=20.0)
if resp.encoding is None or resp.encoding.lower() == "iso-8859-1":
    resp.encoding = resp.apparent_encoding  # chardet-style sniff
html = resp.text

Para bytes brutos, descodifique explicitamente e passe errors="replace" para manter o fluxo em andamento mesmo com entradas incorretas:

html = resp.content.decode("utf-8", errors="replace")

Depois, há a própria marcação incorreta. lxml é rigoroso; irá silenciosamente ignorar ou reequilibrar partes de entradas gravemente malformadas. O BeautifulSoup com a configuração padrão html.parser é mais tolerante, mas mais lento. Se os seus dados forem uma mistura de HTML limpo e sujo, experimente BeautifulSoup(html, "html5lib"), que é o backend mais tolerante e segue o mesmo algoritmo de análise que os navegadores usam. A desvantagem é a velocidade: html5lib é visivelmente mais lento do que lxml em documentos grandes, por isso reserve-o para a minoria malformada.

Tratamento de páginas renderizadas por JavaScript

Mais cedo ou mais tarde, irá buscar uma página, descarregar resp.texte encontrar um <div id="root"> onde deveria estar o conteúdo. O site está a renderizar o seu conteúdo do lado do cliente com React, Vue ou similares, e requests não executa JavaScript. Nenhuma extração, por mais inteligente que seja, resolverá isso.

Três opções realistas:

  1. Procure um endpoint pré-renderizado ou de API. Muitos SPAs são alimentados a partir de uma API JSON que o navegador chama ao carregar. Abra o DevTools, observe a guia Rede e, muitas vezes, encontrará um endpoint estruturado que retorna exatamente o que precisa, sem qualquer análise de HTML.
  2. Execute um navegador headless. Playwright, Pyppeteer, e Selenium todos iniciam motores de navegador reais (Chromium, Firefox, WebKit) que executam JavaScript. A contrapartida é a complexidade e o uso de recursos: cada página custa-lhe um separador num navegador real, o que é ordens de magnitude mais caro do que uma requests chamada.
  3. Use uma API de scraping que retorne HTML renderizado. Os serviços que tratam da renderização headless por si aceitam um URL e devolvem o DOM final como uma string, que se encaixa diretamente no pipeline do BeautifulSoup acima. Você abre mão de algum controlo sobre as configurações do navegador; ganha uma infraestrutura mais simples e um rendimento consistente.

Um fetcher Playwright mínimo fica assim:

from playwright.sync_api import sync_playwright

def fetch_rendered(url: str) -> str:
    with sync_playwright() as p:
        browser = p.chromium.launch(headless=True)
        page = browser.new_page()
        page.goto(url, wait_until="networkidle", timeout=30_000)
        html = page.content()
        browser.close()
    return html

Incorpore isso na fetch_soup etapa do miniprojeto (analisar o html com o BeautifulSoup) e o resto do pipeline permanece inalterado. O ciclo de análise, limpeza, extração e normalização não se importa com a origem do HTML.

Escalabilidade, anti-bot e fiabilidade: quando a obtenção é o verdadeiro gargalo

Assim que a sua extração funcionar em algumas páginas, o gargalo passa da análise para a recuperação. Os sites limitam a sua taxa de acesso, os IPs dos centros de dados são bloqueados, aparecem CAPTCHAs e o mesmo seletor que funcionava ontem não devolve nada hoje porque a página está a identificar o seu cliente.

Uma lista de verificação prática de fiabilidade para a camada de obtenção:

  • Respeite robots.txt e os termos de serviço do site. urllib.robotparser lê-os por si.
  • Defina tempos de espera realistas (15-30 segundos para conexão + leitura) para que uma conexão travada não bloqueie toda a execução.
  • Tente novamente com recuo exponencial nos códigos 429, 502, 503 e 504. tenacity ou urllib3.util.Retry resolva isto com algumas linhas de configuração.
  • Use cabeçalhos realistas. Um User-Agent que identifique o seu bot, além de um Accept e Accept-Language cabeçalho evita as regras de deteção mais simplistas.
  • Limite por host. Um único requests.Session com um time.sleep entre pedidos é o mínimo; o rastreamento simultâneo requer um contador de tokens por host.
  • Alterne os IPs quando estiver a lidar com um volume significativo. Os proxies residenciais parecem tráfego de utilizador comum; os IPs de centros de dados são sinalizados por predefinição em muitos sites de grande dimensão.

Se gerir tudo isso internamente não é onde quer gastar o tempo de engenharia, uma API de busca hospedada pode assumir a rotação de proxies, a resolução de CAPTCHAs e a lógica de repetição de tentativas por trás de um único ponto de extremidade, enquanto mantém o BeautifulSoup ou lxml código de análise inalterados. Esse é o modelo em que a WebScrapingAPI se baseia: envias o URL, recebes o HTML renderizado (ou JSON estruturado) e o teu pipeline de extração continua a ser Python.

Seja qual for a rota que escolher, separe as responsabilidades de forma clara. Mantenha o fetcher num módulo e o extrator noutro. Depois, pode trocar requests o Playwright por uma API hospedada sem alterar o código de análise.

Referência entre linguagens: extração em Ruby, JavaScript e C# num único local

As linguagens mudam, as bibliotecas mudam, mas a mentalidade de extração não. O mesmo ciclo de análise, limpeza, extração e normalização transfere-se entre pilhas. Aqui está o equivalente ao passo a passo do BeautifulSoup em três outros ecossistemas, útil se trabalha numa equipa poliglota ou se está a decidir em que linguagem padronizar.

Ruby com Nokogiri. O Nokogiri é o analisador HTML padrão no mundo Ruby e desempenha o mesmo papel que o BeautifulSoup ou lxml desempenha em Python.

require "nokogiri"
require "open-uri"

doc = Nokogiri::HTML(URI.open("https://example.com/coffee"))
doc.search("script, style, header, footer, nav, aside").each(&:remove)
text = doc.text.gsub(/\s+/, " ").strip
puts text

JavaScript com cheerio. O Cheerio implementa uma API ao estilo jQuery sobre um analisador HTML rápido. jsdom é a alternativa mais pesada quando também precisa de APIs DOM e renderização com suporte a CSS.

import * as cheerio from "cheerio";

const html = await (await fetch("https://example.com/coffee")).text();
const $ = cheerio.load(html);
$("script, style, header, footer, nav, aside").remove();
const text = $("main, article, body").first().text().replace(/\s+/g, " ").trim();
console.log(text);

C# com HtmlAgilityPack. O padrão é o mesmo; a API é mais prolixa.

using HtmlAgilityPack;

var web = new HtmlWeb();
var doc = web.Load("https://example.com/coffee");
var junk = doc.DocumentNode.SelectNodes("//script|//style|//header|//footer|//nav|//aside");
if (junk != null) foreach (var n in junk) n.Remove();
var text = System.Text.RegularExpressions.Regex.Replace(
    doc.DocumentNode.InnerText, @"\s+", " ").Trim();
Console.WriteLine(text);

Cada um destes trechos segue os mesmos quatro passos da versão Python: analisar, eliminar ruído óbvio (scripts, estilos, chrome), extrair texto da árvore restante e colapsar espaços em branco. Se internalizar o loop, mudar de linguagem torna-se um exercício de sintaxe, não uma reformulação.

Lista de verificação para resolução de problemas em resultados de extração confusos

Quando se extrai texto de HTML com Python em ambientes reais, a saída raramente é perfeita na primeira tentativa. Esta tabela relaciona os sintomas que se observam com as soluções que realmente funcionam.

Sintoma na saída

Causa provável

Solução

Código-fonte JavaScript ou CSS no texto

<script> e <style> não removida antes da extração

for t in soup(['script','style','noscript']): t.decompose()

Palavras coladas (freshbeans)

Faltando separator em get_text()

soup.get_text(separator=' ', strip=True)

Espaços estranhos ou  artefatos

NBSP e incompatibilidade de codificação

unicodedata.normalize('NFKC', ...) e text.replace('\u00a0', ' ')

A página parece vazia, sem corpo de texto

SPA renderizada por JavaScript

Use o Playwright, um endpoint pré-renderizado ou uma API de scraping

A navegação, o rodapé ou os anúncios aparecem na saída

Elementos de interface do site não removidos

soup.select('header, footer, nav, aside, .ad').decompose()

Página inteira como texto, sem isolamento do artigo

Extração a partir de <body> em vez de <main>

soup.select_one('main, article, [role=main]').get_text()

Mojibake (é para é)

requests padrão para ISO-8859-1

resp.encoding = resp.apparent_encoding antes resp.text

Três linhas em branco entre parágrafos

Modelos CMS, não normalizados

`re.sub(r' {3,}', '

', texto)`

UnicodeDecodeError em resp.text

Codec errado ou fluxo truncado

resp.content.decode('utf-8', errors='replace')

Trabalhe de cima para baixo: elimine primeiro os scripts e os estilos, depois o chrome e, por fim, a codificação. A grande maioria dos erros do tipo «a minha extração está corrompida» está numa das quatro primeiras linhas.

Pontos-chave

  • A forma fiável de extrair texto de HTML em Python é um ciclo de quatro passos: analise com um analisador a sério, limpe o ruído óbvio e o chrome do site, extraia texto do que resta e normalize os espaços em branco e o Unicode.
  • Comece com o BeautifulSoup para quase tudo. Mude para lxml.html além html-text quando precisar de velocidade ou de um tratamento padrão mais limpo dos espaços em branco. Use o Parsel para campos estruturados, não para limpeza de texto simples.
  • Nunca execute expressões regulares em HTML completo. Analise primeiro e, em seguida, use expressões regulares para refinar a cadeia de caracteres simples resultante (NBSP, aspas curvas, espaços em branco colapsados).
  • Isola o artigo principal com <main>, <article>, ou [role="main"] antes de extrair. Recorra a heurísticas do tipo readability apenas quando a marcação não tiver ganchos semânticos.
  • requests não pode executar JavaScript. Para páginas renderizadas pelo cliente, mude o fetcher para um navegador headless ou uma API de renderização; o código de análise permanece o mesmo.
  • Guarde os metadados como JSONL e os corpos por página como .txt. A combinação oferece-lhe um índice transmissível, além de texto pronto para o pipeline, sem ter de fazer o commit numa base de dados demasiado cedo.

Recursos relacionados da WebScrapingAPI

Perguntas frequentes

Qual é a diferença entre o BeautifulSoup, o lxml, o html-text e o Parsel para a extração de texto?

O BeautifulSoup é flexível e adequado para principiantes; lxml.html é rápido e rigoroso, com suporte completo a XPath; html-text baseia-se lxml para produzir texto limpo e legível com espaços em branco sensatos; o Parsel é focado em seletores para extrair campos estruturados, como preços ou autores. Diferentes formas do mesmo problema: escolha o BeautifulSoup, a menos que um dos outros tenha uma funcionalidade de que precise especificamente.

Como extrair apenas o texto principal do artigo e ignorar a navegação, os anúncios e os rodapés?

Selecione primeiro a subárvore principal: tente soup.select_one("main"), depois "article", depois "[role='main']", e recorra a soup.body. Dentro dessa subárvore, remova anúncios, blocos de publicações relacionadas, widgets de partilha e quaisquer elementos ocultos por seletor CSS. Quando a marcação não tiver ganchos semânticos, bibliotecas como readability-lxml ou trafilatura classifique os blocos por densidade de texto e devolva o melhor candidato.

Por que razão o meu texto extraído contém código JavaScript ou CSS, e como posso evitar isso?

Isso significa que chamou get_text() antes de remover <script> e <style> . O analisador trata o seu conteúdo como nós de texto normais. Percorra essas tags e chame .decompose() em cada uma delas antes da extração. Adicione <noscript> e <template> à mesma lista enquanto estiver a fazer isso; ambas podem vazar marcação ou texto de fallback para a sua saída.

Como extrair texto de uma página renderizada por JavaScript em que o requests retorna um corpo HTML vazio?

Ou recupere a API subjacente que a página utiliza (verifique o separador Rede nas DevTools), ou renderize a página com um navegador headless como o Playwright, Selenium ou Pyppeteer. Assim que tiver a cadeia de caracteres HTML renderizada, o resto do seu pipeline de extração é idêntico. Uma API de renderização hospedada funciona da mesma forma se não quiser executar navegadores por conta própria.

Devo usar expressões regulares para extrair texto de HTML em Python?

Não como um analisador. As expressões regulares não conseguem lidar de forma fiável com tags aninhadas, elementos não fechados, comentários com colchetes angulares ou CDATA. Utilize um verdadeiro analisador HTML para simplificar o documento primeiro e, em seguida, aplique expressões regulares à string simples resultante para tarefas pequenas, como colapsar espaços em branco, normalizar caracteres de aspas ou substituir espaços não separáveis.

Conclusão e próximos passos

A razão pela qual extrair texto de HTML em Python parece mais difícil do que deveria é que a maioria dos tutoriais pára no soup.get_text(). O fluxo de trabalho real tem quatro etapas: analisar, limpar, extrair, normalizar e uma quinta etapa (guardar) assim que o integrar num pipeline. Interiorize esse ciclo e a escolha da biblioteca torna-se uma nota de rodapé: BeautifulSoup para a maioria das tarefas, lxml.html além de html-text quando precisar de velocidade e predefinições mais limpas, o Parsel quando quiser campos estruturados, um navegador sem interface gráfica quando o JavaScript estiver a atrapalhar.

A partir daqui, os próximos passos naturais são o rastreamento em escala (paginação, limitação educada, deduplicação), familiarizar-se com seletores e XPath, e decidir quando recorrer a analisadores sensíveis à estrutura, como o Parsel, ou a heurísticas de legibilidade. Cada um é um buraco de coelho à parte, mas todos assentam no mesmo ciclo de extração.

Se a camada de obtenção é o que o está a atrasar (bloqueios, CAPTCHAs, renderização de JS), vale a pena experimentar a WebScrapingAPI como um fetcher pronto a usar: envie um URL, receba o HTML renderizado de volta e deixe o seu código de extração em Python fazer o resto. Comece de forma simples com o BeautifulSoup, analise o desempenho quando deixar de escalar e só então recorra às ferramentas mais pesadas.

Sobre o autor
Mihai Maxim, Desenvolvedor Full Stack @ WebScrapingAPI
Mihai MaximDesenvolvedor Full Stack

Mihai Maxim é um programador Full Stack na WebScrapingAPI, contribuindo em todas as áreas do produto e ajudando a criar ferramentas e funcionalidades fiáveis para a plataforma.

Comece a construir

Pronto para expandir a sua recolha de dados?

Junte-se a mais de 2.000 empresas que utilizam a WebScrapingAPI para extrair dados da Web à escala empresarial, sem quaisquer custos de infraestrutura.