En resumen: Para extraer texto de un archivo HTML en Python, analiza el código con un analizador sintáctico adecuado (BeautifulSoup,lxml.htmlohtml-text), elimina scripts, estilos y elementos de interfaz del sitio, y luego normaliza los espacios en blanco y el Unicode antes de guardar. Esta guía compara las principales bibliotecas, soluciona los errores habituales de limpieza y termina con un rastreador ejecutable que genera archivos JSONL y.txt.
Introducción
La mayoría de los equipos que quieren extraer texto de HTML con Python empiezan con una línea de código, se topan con un muro en cuanto aparece una página real y luego se pasan una tarde descubriendo que get_text() devuelve alegremente JavaScript, banners de cookies y 47 copias de la palabra «Suscribirse». La solución no es otra biblioteca mágica. Es un flujo de trabajo claro: analizar, limpiar, extraer, normalizar, guardar.
El HTML es el código fuente que hay detrás de una página web. Mezcla el contenido real que te interesa —encabezados, párrafos, elementos de lista— con el marcado estructural, los scripts, los estilos y los metadatos que el navegador necesita, pero tú no. El texto extraído es la parte visible y legible para el ser humano de esa página, una vez eliminado el marcado. Cualquier cosa que recorra el DOM —el árbol de nodos que un analizador sintáctico construye a partir del HTML sin procesar— puede hacer esto si le indicas qué nodos debe conservar.
Esta guía está dirigida a desarrolladores de Python, ingenieros de datos y profesionales del PLN que buscan código ejecutable, valores predeterminados sensatos y compensaciones honestas. Compararemos las bibliotecas que realmente importan (BeautifulSoup, lxml.html además de html-text, Parsel y expresiones regulares), crearemos utilidades de limpieza y normalización que podrás reutilizar y, a continuación, conectaremos las piezas en un pequeño rastreador. A lo largo del proceso, trataremos las páginas renderizadas en JavaScript, las trampas de la codificación y una tabla de resolución de problemas con síntomas y soluciones.
Qué significa realmente «extraer texto de HTML con Python»
Cuando dices que quieres extraer texto de HTML con Python, lo que realmente estás diciendo es: recorrer el documento analizado, conservar los nodos de texto visibles y descartar todo lo demás. Los navegadores hacen esto de forma implícita cada vez que renderizan una página. Como desarrolladores, tenemos que ser explícitos.
Vale la pena aclarar algunas definiciones para que el resto del artículo tenga sentido:
- El HTML es el código fuente sin procesar: etiquetas, atributos, estilos en línea, scripts y metadatos, además del contenido real que se encuentra entre ellos.
- Las etiquetas son marcadores individuales como
<p>y</p>. Los elementos son etiquetas más todo lo que hay dentro de ellas. - El DOM (Modelo de Objetos de Documento) es el árbol que un analizador sintáctico construye a partir de ese código fuente. Cada elemento, atributo y nodo de texto se convierte en un nodo del árbol.
- El texto extraído es el contenido legible para el ser humano a nivel de hoja: encabezados, párrafos, elementos de lista, etiquetas, sin el marcado.
La extracción de texto funciona recorriendo ese DOM y recopilando solo los nodos de texto, omitiendo elementos como <script> y <style>. Las diferentes bibliotecas exponen este recorrido de forma distinta, pero el modelo mental es el mismo. Si mantienes en tu cabeza el análisis, la limpieza, la extracción y la normalización como cuatro pasos distintos, puedes pasar de BeautifulSoup, lxml, html-texte incluso pilas que no sean de Python sin tener que volver a aprender el problema.
También importa por qué estás extrayendo texto. Un índice de búsqueda puede tolerar una sola cadena plana. Un proceso de ingestión de LLM suele requerir que se conserven los párrafos. Una exportación de análisis probablemente requiera que los encabezados y el cuerpo del texto estén separados. Decídelo pronto, porque eso determina qué biblioteca y qué estrategia de extracción tienen sentido.
Elección de una biblioteca: BeautifulSoup, lxml, html-text, Parsel o regex
No hay una única respuesta «óptima» para extraer texto de HTML en Python, pero hay buenas opciones predeterminadas y malas elecciones. A continuación se muestra cómo se comparan las principales opciones en la práctica.
BeautifulSoup (bs4) suele ser el punto de partida habitual. Es tolerante con el HTML dañado, tiene una superficie de API reducida (find, find_all, select, get_text) y es fácil de usar para quienes nunca han trabajado con XPath. Es la elección adecuada para el scraping ad hoc, los prototipos y la mayoría de los trabajos de producción que no se ven limitados por la velocidad del analizador. Los dos errores más comunes son olvidarse de eliminar <script> y <style> antes de llamar a get_text(), y dejar el html.parser cuando podrían instalar lxml y pasar 'lxml'.
lxml.html es la opción rápida, estricta y basada en C. Utiliza libxml2 en segundo plano, expone tanto selectores CSS como XPath, y es a lo que se recurre cuando se analizan miles de páginas por minuto o se necesita una manipulación precisa del DOM. La contrapartida es una curva de aprendizaje ligeramente más pronunciada y menos tolerancia ante el marcado mal formado que BeautifulSoup. Según la documentación de lxml, puede analizar HTML dañado a través de su html , pero BeautifulSoup sigue siendo más indulgente cuando la entrada es realmente caótica.
html-text es una pequeña herramienta auxiliar que se ejecuta sobre lxml y genera texto plano limpio con un manejo sensato de los espacios en blanco. Es la elección acertada cuando lo que se busca principalmente es «texto legible a partir de este blob» con un posprocesamiento mínimo, y no se necesitan consultas complejas. Por sí solo, no aísla de forma fiable el cuerpo principal del artículo, por lo que combina bien con un <main> o <article> selector.
Parsel es la biblioteca con gran cantidad de selectores que impulsa a Scrapy. Destaca cuando se quieren campos estructurados (título, precio, autor) mediante CSS o XPath, no cuando se quiere limpiar un muro de texto. En el momento de escribir este artículo, su ritmo de lanzamientos públicos ha sido relativamente tranquilo, así que comprueba que la versión en PyPI sigue siendo adecuada para tu pila antes de adoptarla para un nuevo proyecto.
Regex no es un analizador. Úsalo para limpiar cadenas ya extraídas (NBSP, espacios en blanco repetidos, comillas tipográficas) y acepta que cualquier intento de hacer coincidir HTML anidado con re fracasará en cuanto el marcado se complique.
Tabla comparativa y reglas de decisión
|
Biblioteca |
Ideal para |
Ventajas |
Contras |
Llamada típica |
|---|---|---|---|---|
|
BeautifulSoup |
La mayoría de los trabajos de scraping y análisis sintáctico |
Flexible, API sencilla, buena documentación |
Más lento que lxml con grandes volúmenes |
|
|
|
Grandes volúmenes, XPath, manipulación de DOM |
Muy rápido, estricto, compatible con XPath |
Menos tolerante con el HTML dañado |
|
|
|
Texto plano limpio con un esfuerzo mínimo |
Heurísticas de espacios en blanco y visibilidad integradas |
No permite seleccionar contenido por sí solo |
|
|
Parsel |
Extracción de campos estructurados |
Combinación de CSS y XPath, compatible con Scrapy |
Ritmo de lanzamiento más lento, excesivo para texto sin formato |
|
|
Regex |
Pequeña limpieza del texto ya extraído |
Integrado, rápido con cadenas cortas |
Falla con HTML anidado o inconsistente |
|
Reglas de decisión rápida: si eres nuevo en el scraping, empieza con BeautifulSoup. Si solo necesitas texto limpio sin consultas, opta por html-text. Si vas a analizar decenas de miles de páginas o necesitas XPath, pasa a lxml.html. Si necesitas campos de entrada más que texto, usa Parsel. Trata las expresiones regulares como un limpiador, nunca como un analizador.
Un fragmento de HTML reutilizable para cada ejemplo
Todos los ejemplos que siguen utilizan el mismo fragmento desordenado para que puedas comparar las bibliotecas de forma imparcial. Guárdalo como sample.html o asígnalo a una cadena:
<!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 · Recipes · About</nav></header>
<aside class="ad">Buy our new grinder!</aside>
<main>
<article>
<h1>How 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 seconds.</li>
</ul>
<p class="hidden">Secret affiliate link block.</p>
<div aria-hidden="true">Hidden cookie banner copy.</div>
</article>
</main>
<footer>© 2026 Coffee Co. Privacy. Terms.</footer>
</body>
</html>Tiene los cuatro problemas clásicos: una etiqueta de script y otra de estilo, elementos de diseño (<header>, <nav>, <footer>, un anuncio <aside>), un espacio no separable dentro del texto y dos bloques ocultos (.hidden y [aria-hidden="true"]). Si una biblioteca gestiona esto correctamente, podrá manejar la mayor parte de lo que le eches en el mundo real.
Extracción de texto con BeautifulSoup (paso a paso)
BeautifulSoup es la opción predeterminada por una razón: la API es pequeña, los modos de fallo son obvios y los mismos cuatro pasos cubren casi todas las tareas de extracción de texto de HTML en Python.
Instala lo básico:
pip install beautifulsoup4 lxml requestsIncorporamos lxml como backend del analizador. El 'lxml' backend se considera generalmente más rápido y estricto que el de la biblioteca estándar html.parser, aunque la diferencia exacta depende del tamaño de la entrada y la estructura del documento; haz pruebas de rendimiento con tus propios datos si te importa.
Paso 1: analiza con un analizador real. Nunca ejecutes expresiones regulares sobre HTML completo. Pasa primero el marcado a BeautifulSoup.
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")Paso 2: descarta el ruido evidente. Los scripts y los estilos son puro ruido para la extracción de texto. Elimínalos antes que nada, de lo contrario su contenido se filtrará directamente en tu salida.
for tag in soup(["script", "style", "noscript"]):
tag.decompose()Utilice decompose() en lugar de extract() o unwrap() cuando quieras eliminar la etiqueta y sus elementos secundarios. extract() elimina el nodo, pero sigues conservando una referencia; unwrap() mantiene el contenido. Para el ruido, decompose() es lo que quieres.
Paso 3: extrae el texto. get_text() Aplanar el DOM restante en una sola cadena. Los dos argumentos que importan son separator y strip. Sin separador, BeautifulSoup une los elementos en línea adyacentes, por lo que <strong>fresh</strong>beans se convertiría en freshbeans. Pasa un espacio (o un salto de línea) para mantener las palabras separadas, y strip=True para recortar los espacios en blanco por nodo.
text = soup.get_text(separator=" ", strip=True)Paso 4: limpieza ligera. En este punto tienes texto sin formato que aún contiene espacios en blanco extraños, espacios no separables y, posiblemente, varias líneas en blanco. Deja la normalización para una función auxiliar dedicada (consulta la sección de normalización más adelante) y centra este paso en la extracción.
Al aplicar los cuatro pasos a nuestra muestra, se obtiene algo como esto:
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.Los scripts y los estilos han desaparecido, pero el diseño, los anuncios y el contenido oculto siguen apareciendo. Ese es el problema que resuelven las siguientes secciones.
Extracción de texto limpio con lxml.html y html-text
Cuando no necesites la facilidad de uso de BeautifulSoup y busques velocidad, lxml.html además html-text es una combinación muy eficaz. lxml te proporciona el árbol analizado; html-text te proporciona texto bien normalizado a partir de él sin tener que escribir tu propio recorrido.
pip install lxml html-textUna versión mínima lxml.htmlde la misma extracción tiene este aspecto:
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() recorre el DOM y concatena los nodos de texto, pero no añade separadores entre los elementos de nivel de bloque. Los encabezados, los párrafos y los elementos de la lista acaban pegados entre sí. Esa es precisamente la laguna html-text cubre.
import html_text
text = html_text.extract_text(html_source)Internamente, html-text analiza con lxml, aplica algunas heurísticas en torno al contenido oculto (analiza patrones comunes como display:none, aria-hiddeny los nombres de clase convencionales) e inserta espacios en blanco donde los elementos de nivel de bloque crearían visualmente saltos. El resultado es mucho más parecido a lo que ve el usuario en un navegador que el text_content().
Vale la pena ser honesto sobre los límites. html-textLas heurísticas de visibilidad de se basan en patrones, no en la representación del navegador. Los estilos en línea definidos mediante CSS en una hoja de estilos externa, los hidden o los conmutadores de pruebas A/B son invisibles para un analizador estático. Si necesitas la visibilidad tal y como se renderiza realmente, necesitas un navegador sin interfaz gráfica, algo que trataremos más adelante.
html-text tampoco aísla el artículo principal por sí solo. Emitirá sin problemas la navegación y el pie de página si le pasas la página completa. Combínalo con un <main> o <article> selector (tree.cssselect('main')[0]) cuando quieras una salida solo del cuerpo. Esa combinación, lxml para la selección más html-text para el volcado de texto, es una de las formas más limpias de extraer texto de HTML a gran escala en Python.
Cuándo (y solo cuándo) usar expresiones regulares para la limpieza
Cada pocos meses alguien publica «¿por qué no puedo simplemente re.sub('<[^>]+>', '', html)?» y, cada pocos meses, la respuesta es la misma: porque el HTML está anidado, mal formado y lleno de casos extremos que las expresiones regulares no pueden modelar. Los contraejemplos clásicos son las etiquetas sin cerrar, los comentarios con > dentro, bloques CDATA y atributos que contienen corchetes angulares entre comillas. También hay una famosa respuesta de Stack Overflow sobre el tema que merece una sonrisa.
El patrón correcto es: analizar con un analizador real y, a continuación, dejar que las expresiones regulares pulan el texto plano resultante. Una vez que BeautifulSoup o html-text te haya dado una cadena, las expresiones regulares están bien para tareas 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 runsCosas que hay que evitar: eliminar etiquetas con expresiones regulares, extraer valores de atributos de HTML sin procesar con expresiones regulares y dividir por < y > para «obtener el texto». Eso funciona en una demo escrita a mano, pero falla en producción. Si alguna vez te sientes tentado, escribe primero la versión basada en un analizador y recurre a expresiones regulares solo sobre la cadena ya plana que este produce.
Limpieza de HTML del mundo real: navegación, pies de página, anuncios, banners de cookies, bloques ocultos
El resultado que obtuvimos del tutorial de BeautifulSoup aún contenía la navegación, un bloque de anuncios, un párrafo oculto de afiliados, un aria-hidden banner de cookies y el pie de página. Nada de eso es útil para la indexación o el análisis. Limpiar esto antes de la extracción es la mayor mejora de calidad que puedes conseguir al extraer texto de HTML con Python.
El patrón es: analizar, eliminar scripts y estilos, eliminar elementos de diseño, eliminar contenido oculto y, a continuación, llamar a 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 soupEl orden de las operaciones importa. Elimina primero los scripts y los estilos porque a menudo se encuentran dentro de los elementos que vas a consultar, y eliminarlos primero mantiene la fiabilidad de tus selectores. A continuación, elimina los elementos de diseño por nombre de etiqueta. Los selectores de nombre de clase vienen después, porque son la parte más frágil: cada sitio nombra las cosas de forma diferente, y tendrás que ajustar esta lista según la fuente.
¿Por qué decompose() y no extract()? decompose() elimina el nodo y todos sus hijos del árbol y libera sus referencias. extract() elimina el nodo pero lo devuelve, lo cual es útil cuando quieres mover un nodo a otro lugar, no cuando estás eliminando ruido. Para la limpieza, siempre decompose().
Después de ejecutar clean(soup) en nuestra muestra y llamar a soup.get_text(separator="\n", strip=True), obtienes algo parecido a lo que ve realmente un lector:
How to brew filter coffee
Start with fresh beans ground medium-coarse.
Use a 1:16 ratio.
Bloom for 30 seconds.Ese es el objetivo: los encabezados y párrafos que le importan al usuario, con todo el texto repetitivo descartado. Considera los selectores de elementos de interfaz y ocultos anteriores como un kit de inicio, no como una lista definitiva; cada dominio que rastrees añadirá una o dos clases nuevas que deberás eliminar.
Aislar el contenido principal con selectores y heurísticas de legibilidad
Eliminar el «chrome» funciona, pero el enfoque más limpio cuando el marcado está bien estructurado es extraer el contenido principal directamente. El HTML moderno te ofrece tres buenos puntos de apoyo:
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)Esa escalera de respaldo, <main>, <article>, role="main", y luego <body>, cubre la mayoría de los sitios de contenido. Si además limpias el subárbol resultante con los selectores de elementos de interfaz y ocultos de la sección anterior, normalmente obtendrás solo el texto del cuerpo sin necesidad de escribir reglas personalizadas para cada sitio.
Cuando el marcado es deficiente (piensa en plantillas antiguas de CMS sin etiquetas semánticas), recurre a readability-lxml o trafilatura. Ambos aplican heurísticas de densidad de texto: puntúan cada bloque según la proporción de texto respecto al marcado y la densidad de enlaces, y devuelven la región con mayor puntuación como artículo principal. Ninguno es perfecto; en ocasiones extraerán una sección de comentarios o pasarán por alto una nota al margen. Trátalos como un recurso de reserva cuando fallen los selectores estructurales, no como el método predeterminado.
Normalización del texto: espacios en blanco, NBSP, saltos de línea y Unicode
La salida sin procesar de get_text() rara vez es «limpia». Verás espacios no separables (\u00a0) donde esperabas espacios reales, \r\n finales de línea en páginas creadas en Windows, secuencias de tres o cuatro líneas en blanco procedentes de plantillas generosas de CMS y, ocasionalmente, katakana de medio ancho o ligaduras cortesía de Unicode. Un pequeño normalizador específico soluciona todo esto de una vez y te ahorra tiempo de depuración más adelante.
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()Algunas notas sobre lo que te aporta cada paso. unicodedata.normalize("NFKC", ...) colapsa los caracteres de compatibilidad en sus equivalentes canónicos, por lo que el A se convierte en un A y las ligaduras como fi se convierten en fi. La documentación de Python sobre el módulo unicodedata explica en detalle lo que hace cada forma.
Es importante eliminar los NBSP cuanto antes porque re.sub(r"\s+", ...) coincide \u00a0 en Python moderno, pero los tokenizadores y los indexadores de búsqueda posteriores a menudo no lo hacen. Normalizar los finales de línea evita que un solo \r de romper los archivos JSONL. Colapsar las secuencias de espacios en blanco mantiene los saltos de párrafo sin generar páginas de líneas vacías.
Ejecuta esta ayuda una vez al final de tu proceso, nunca dentro del bucle por etiqueta, y obtendrás un texto que las herramientas posteriores podrán realmente procesar.
Extracción con reconocimiento de estructura: párrafos, encabezados y listas como bloques
Una sola cadena plana está bien para la búsqueda y el análisis aproximado, pero no es adecuada para la fragmentación de la recuperación (RAG), la síntesis y cualquier cosa que tenga en cuenta la jerarquía. Si a tu consumidor posterior le beneficia saber qué es un encabezado frente al cuerpo del texto, genera bloques tipificados en lugar de una gran cadena.
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 blocksEn nuestro artículo de ejemplo, esto produce 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."},
]¿Por qué molestarse? Tres razones. En primer lugar, un fragmentador de LLM puede mantener los encabezados con los párrafos siguientes en lugar de separarlos. En segundo lugar, las consultas analíticas pueden contar los encabezados por separado del cuerpo del texto, lo cual es importante para las auditorías de contenido. En tercer lugar, puedes unir los encabezados en un esquema (# How to brew filter coffee) y mantener el cuerpo debajo, lo que te proporciona una salida al estilo Markdown de forma gratuita.
Si necesitas conservar el orden y el anidamiento (un encabezado y sus párrafos descendientes como una sección), itera utilizando soup.descendants y agrupa bloques cada vez que encuentres una etiqueta de encabezado. Mantener la estructura es barato, pero reconstruirla más tarde es costoso, así que capturala una vez en el momento de la extracción.
Mini proyecto de principio a fin: rastrear, extraer, normalizar y guardar
Es hora de ponerlo todo junto. El script que aparece a continuación rastrea una sección paginada de un sitio web, extrae el texto limpio por página, lo normaliza y escribe un registro JSONL por página, además de un archivo .txt . Utiliza un único requests.Session, sigue el Next enlace de paginación y se detiene en un 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")Las piezas son deliberadamente pequeñas. Cambia fetch_soup por un fetcher de Playwright cuando te encuentres con páginas renderizadas en JavaScript. Cambia el selector de paginación por el que utilice tu sitio de destino. Cambia el escritor JSONL por una inserción en SQLite si quieres un almacenamiento consultable. El patrón —analizar, limpiar, extraer, normalizar, guardar— sigue siendo idéntico.
Hay dos pequeños detalles que vale la pena conservar. El fetch_soup() ayudante aplica un tiempo de espera de 20 segundos para la solicitud y recurre a apparent_encoding cuando el servidor devuelve el valor predeterminado iso-8859-1. Ambos son fáciles de añadir ahora y difíciles de adaptar más adelante. El time.sleep(1.0) entre páginas es el comportamiento mínimo de cortesía; para un rastreo en profundidad, consulta la sección de escalabilidad más abajo.
Formatos de salida: JSONL frente a CSV frente a texto sin formato frente a base de datos
Adapta el formato de almacenamiento al usuario final, no a lo primero que se te haya ocurrido.
- JSONL (un objeto JSON por línea) es el valor predeterminado para las cadenas de procesamiento de datos extraídos. Se puede transmitir, es de solo adición, fácil de inspeccionar
head -n 1 pages.jsonl | jq .y tolerante con la evolución de las formas de los registros. Úsalo cuando los registros tengan múltiples campos o una estructura anidada. - CSV es adecuado cuando los consumidores posteriores son hojas de cálculo, pandas o herramientas de BI. Limítate a un esquema plano con columnas predecibles y escribe con
csv.DictWriterpara no tener que poner comillas a nada manualmente. - El texto sin formato (
.txt) por página es ideal para PLN, indexación de búsquedas e ingestión de LLM. Un archivo por documento facilita el uso de Git y permite procesar páginas en paralelo sin necesidad de estructurar los registros. - SQLite o DuckDB son la mejor opción si quieres realizar consultas ad hoc («¿cuántas páginas mencionan el espresso?») o uniones con otras tablas. Ambos se distribuyen como una base de datos de un solo archivo sin necesidad de configuración.
En la práctica, el proceso anterior escribe JSONL y por página .txt simultáneamente. JSONL es tu índice de metadatos; los .txt archivos son lo que se pasa a la siguiente etapa.
Trampas de codificación, juego de caracteres y marcado defectuoso
Los errores de codificación son la segunda causa más común por la que un proceso de Python para extraer texto de HTML genera basura. Los síntomas clásicos son é que, donde esperabas é, caracteres de sustitución (�) en medio de los párrafos, o el temido UnicodeDecodeError en resp.text.
La causa principal es casi siempre que requests se ha establecido por defecto iso-8859-1 porque la respuesta carecía de un charset en su Content-Type encabezado. La requests documentación lo señala: cuando no se especifica ninguna codificación, iso-8859-1 se asume . Anúlalo:
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.textPara bytes sin procesar, decodifica explícitamente y pasa errors="replace" para que el proceso continúe incluso con entradas erróneas:
html = resp.content.decode("utf-8", errors="replace")Luego está el propio marcado defectuoso. lxml es estricto; omitirá o reequilibrará silenciosamente partes de entradas con graves errores de formato. BeautifulSoup con la configuración predeterminada html.parser es más tolerante, pero más lento. Si tus datos son una mezcla de HTML limpio y sucio, prueba BeautifulSoup(html, "html5lib"), que es el backend más tolerante y sigue el mismo algoritmo de análisis que utilizan los navegadores. La contrapartida es la velocidad: html5lib es notablemente más lento que lxml en documentos grandes, así que resérvalo para la minoría malformada.
Manejo de páginas renderizadas con JavaScript
Tarde o temprano, recuperarás una página, la volcará resp.texty te encontrarás con un <div id="root"> donde debería estar el contenido. El sitio está renderizando su contenido del lado del cliente con React, Vue o similares, y requests no ejecuta JavaScript. Ninguna extracción por muy ingeniosa que sea solucionará eso.
Tres opciones realistas:
- Busca un punto final prerenderizado o de API. Muchas aplicaciones de página única (SPA) se alimentan de una API JSON a la que el navegador recurre al cargarse. Abre DevTools, observa la pestaña Red y a menudo encontrarás un punto final estructurado que devuelve exactamente lo que necesitas sin necesidad de analizar el HTML.
- Ejecuta un navegador sin interfaz gráfica.
Playwright,Pyppeteer, ySeleniumtodos ejecutan motores de navegador reales (Chromium, Firefox, WebKit) que ejecutan JavaScript. La contrapartida es la complejidad y el uso de recursos: cada página te cuesta una pestaña en un navegador real, lo cual es mucho más costoso que unarequestsllamada. - Utiliza una API de scraping que devuelva HTML renderizado. Los servicios que gestionan el renderizado sin interfaz gráfica por ti aceptan una URL y devuelven el DOM final como una cadena, que encaja directamente en el flujo de BeautifulSoup anterior. Renuncias a cierto control sobre la configuración del navegador; a cambio, obtienes una infraestructura más sencilla y un rendimiento constante.
Un fetcher mínimo de Playwright tiene este aspecto:
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 htmlIncorpóralo al fetch_soup paso del miniproyecto (analizar el resultado devuelto html con BeautifulSoup) y el resto del proceso no cambia. El bucle de análisis, limpieza, extracción y normalización no tiene en cuenta de dónde proviene el HTML.
Escalabilidad, antibots y fiabilidad: cuando la recuperación es el verdadero cuello de botella
Una vez que la extracción funciona en unas cuantas páginas, el cuello de botella pasa del análisis a la recuperación. Los sitios te limitan la velocidad, las IP de los centros de datos se bloquean, aparecen CAPTCHAs y el mismo selector que funcionaba ayer no devuelve nada hoy porque la página está identificando a tu cliente.
Una lista de verificación práctica de fiabilidad para la capa de obtención:
- Respeta
robots.txty los términos de servicio del sitio.urllib.robotparserte los lee por ti. - Establece tiempos de espera realistas (15-30 segundos para conectar + leer) para que una conexión atascada no bloquee toda la ejecución.
- Vuelve a intentarlo con retroceso exponencial en los códigos 429, 502, 503 y 504.
tenacityourllib3.util.Retrygestiona esto con unas pocas líneas de configuración. - Utiliza encabezados realistas. Uno
User-Agentque identifique a tu bot, además de unAcceptyAccept-Languageevita las reglas de detección más simplistas. - Limita el tráfico por host. Un único
requests.Sessioncon untime.sleepentre solicitudes es el mínimo; el rastreo simultáneo necesita un contador de tokens por host. - Rota las IP cuando manejes un volumen considerable. Los proxies residenciales parecen tráfico de usuario normal; las IP de centros de datos son marcadas por defecto en muchos sitios web grandes.
Si gestionar todo eso internamente no es en lo que quieres invertir tiempo de ingeniería, una API de recuperación alojada puede encargarse de la rotación de proxies, la resolución de CAPTCHA y la lógica de reintentos detrás de un único punto final, mientras mantienes el código de análisis de BeautifulSoup o lxml código de análisis sin cambios. Ese es el modelo en el que se basa WebScrapingAPI: envías la URL, recibes el HTML renderizado (o JSON estructurado) y tu canal de extracción sigue siendo Python.
Sea cual sea la ruta que elijas, separa las responsabilidades de forma clara. Mantén el fetcher en un módulo y el extractor en otro. Así podrás cambiar requests Playwright por una API alojada sin tocar el código de análisis.
Referencia multilinguaje: extracción en Ruby, JavaScript y C# en un solo lugar
Los lenguajes cambian, las bibliotecas cambian, pero la mentalidad de la extracción no. El mismo ciclo de análisis, limpieza, extracción y normalización se transfiere entre pilas. Aquí tienes el equivalente al tutorial de BeautifulSoup en otros tres ecosistemas, útil si trabajas en un equipo multilingüe o si estás decidiendo en qué lenguaje estandarizar.
Ruby con Nokogiri. Nokogiri es el analizador HTML estándar en el mundo de Ruby y desempeña el mismo papel que BeautifulSoup o lxml en 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 textJavaScript con cheerio. Cheerio implementa una API al estilo jQuery sobre un rápido analizador de HTML. jsdom es la alternativa más pesada cuando también necesitas API DOM y renderizado compatible con 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# con HtmlAgilityPack. El patrón es el mismo; la API es más prolija.
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 uno de estos fragmentos sigue los mismos cuatro pasos que la versión de Python: analizar, eliminar el ruido evidente (scripts, estilos, elementos de interfaz), extraer el texto del árbol restante y colapsar los espacios en blanco. Si interiorizas el bucle, cambiar de lenguaje se convierte en un ejercicio de sintaxis, no en un replanteamiento.
Lista de comprobación para solucionar problemas de resultados de extracción desordenados
Cuando extraes texto de HTML con Python en entornos reales, el resultado rara vez es perfecto a la primera. Esta tabla relaciona los síntomas que ves realmente con las soluciones que funcionan de verdad.
|
Síntoma en el resultado |
Causa probable |
Solución |
|---|---|---|
|
Código fuente de JavaScript o CSS en el texto |
|
|
|
Palabras pegadas entre sí ( |
Falta |
|
|
Espacios extraños o |
NBSP y discrepancia de codificación |
|
|
La página se ve vacía, sin texto en el cuerpo |
SPA renderizada con JavaScript |
Utiliza Playwright, un punto final prerenderizado o una API de scraping |
|
La navegación, el pie de página o los anuncios aparecen en la salida |
No se ha eliminado el chrome del sitio |
|
|
Página completa como texto, sin aislamiento de artículos |
Extracción de |
|
|
Mojibake ( |
|
|
|
Tres líneas en blanco entre párrafos |
Plantillas CMS, no normalizadas |
`re.sub(r' {3,}', ' ', text)` |
|
|
Códec incorrecto o flujo truncado |
|
Trabaja de arriba abajo: elimina primero los scripts y los estilos, luego el chrome y, por último, la codificación. La gran mayoría de los errores del tipo «mi extracción no funciona» se encuentran en una de las cuatro primeras filas.
Conclusiones clave
- La forma fiable de extraer texto de HTML en Python es un bucle de cuatro pasos: analizar con un analizador real, limpiar el ruido evidente y el chrome del sitio, extraer el texto de lo que queda y normalizar los espacios en blanco y el Unicode.
- Empieza con BeautifulSoup para casi todo. Cambia a
lxml.htmlademáshtml-textcuando necesites velocidad o un manejo predeterminado más limpio de los espacios en blanco. Usa Parsel para campos estructurados, no para la limpieza de texto sin formato. - Nunca ejecutes expresiones regulares sobre HTML completo. Analiza primero y luego usa expresiones regulares para pulir la cadena de texto sin formato resultante (NBSP, comillas tipográficas, espacios en blanco colapsados).
- Aísla el artículo principal con
<main>,<article>, o[role="main"]antes de extraerlo. Recurre a heurísticas de estilo «readability» solo cuando el marcado no tenga puntos de referencia semánticos. requestsno puede ejecutar JavaScript. Para páginas renderizadas por el cliente, cambia el fetcher a un navegador sin interfaz gráfica o una API de renderizado; el código de análisis sigue siendo el mismo.- Guarda los metadatos como JSONL y los cuerpos de cada página como
.txt. La combinación te ofrece un índice transmisible más texto listo para el canal de procesamiento sin tener que comprometerlo en una base de datos demasiado pronto.
Recursos relacionados de WebScrapingAPI
Preguntas frecuentes
¿Cuál es la diferencia entre BeautifulSoup, lxml, html-text y Parsel para la extracción de texto?
BeautifulSoup es tolerante y fácil de usar para principiantes; lxml.html es rápido y estricto, con soporte completo para XPath; html-text se basa en lxml para generar texto limpio y legible con espacios en blanco sensatos; Parsel se centra en los selectores para extraer campos estructurados como precios o autores. Diferentes formas de abordar el mismo problema: elige BeautifulSoup a menos que alguno de los otros tenga una característica que necesites específicamente.
¿Cómo extraigo solo el texto principal del artículo y omito la navegación, los anuncios y los pies de página?
Selecciona primero el subárbol principal: prueba soup.select_one("main"), y luego "article", luego "[role='main']", y recurre a soup.body. Dentro de ese subárbol, elimina los anuncios, los bloques de entradas relacionadas, los widgets para compartir y cualquier elemento oculto mediante el selector CSS. Cuando el marcado no tiene ganchos semánticos, bibliotecas como readability-lxml o trafilatura puntuar los bloques según la densidad del texto y devolver el mejor candidato.
¿Por qué el texto extraído contiene código JavaScript o CSS, y cómo puedo evitarlo?
Significa que has llamado a get_text() antes de eliminar <script> y <style> . El analizador trata su contenido como nodos de texto normales. Recorre esas etiquetas y llama a .decompose() en cada una de ellas antes de la extracción. Añade <noscript> y <template> a la misma lista mientras lo haces; ambas pueden filtrar marcado o texto de reserva en tu salida.
¿Cómo extraigo texto de una página renderizada con JavaScript en la que requests devuelve un cuerpo HTML vacío?
O bien recupera la API subyacente que utiliza la página (consulta la pestaña Red de DevTools), o bien renderiza la página con un navegador sin interfaz gráfica como Playwright, Selenium o Pyppeteer. Una vez que tengas la cadena HTML renderizada, el resto de tu proceso de extracción es idéntico. Una API de renderizado alojada funciona de la misma manera si no quieres ejecutar navegadores tú mismo.
¿Debería usar expresiones regulares para extraer texto de HTML en Python?
No como analizador. Las expresiones regulares no pueden manejar de forma fiable etiquetas anidadas, elementos sin cerrar, comentarios con corchetes angulares o CDATA. Utiliza un analizador HTML real para aplanar primero el documento y, a continuación, aplica expresiones regulares a la cadena simple resultante para tareas sencillas como colapsar espacios en blanco, normalizar caracteres de comillas o sustituir espacios no separables.
Conclusión y próximos pasos
La razón por la que extraer texto de HTML en Python parece más difícil de lo que debería es que la mayoría de los tutoriales se detienen en soup.get_text(). El flujo de trabajo real tiene cuatro pasos: analizar, limpiar, extraer y normalizar, y un quinto paso (guardar) una vez que lo integras en un proceso. Interioriza ese ciclo y la elección de la biblioteca pasa a ser una nota al pie: BeautifulSoup para la mayoría de las tareas, lxml.html además html-text cuando necesites velocidad y valores predeterminados más limpios, Parsel cuando quieras campos estructurados, un navegador sin interfaz gráfica cuando JavaScript sea un obstáculo.
A partir de aquí, los siguientes pasos lógicos son el rastreo a gran escala (paginación, limitación cortés del tráfico, deduplicación), familiarizarse con los selectores y XPath, y decidir cuándo recurrir a analizadores sintácticos sensibles a la estructura como Parsel o a heurísticas de legibilidad. Cada uno es un laberinto en sí mismo, pero todos se asientan sobre el mismo bucle de extracción.
Si la capa de obtención de datos es lo que te está ralentizando (bloqueos, CAPTCHAs, renderizado de JS), vale la pena probar WebScrapingAPI como un recuperador listo para usar: envía una URL, obtén el HTML renderizado y deja que tu código de extracción en Python haga el resto. Empieza de forma sencilla con BeautifulSoup, analiza el rendimiento cuando deje de escalar y solo entonces recurre a las herramientas más pesadas.




