lxml es la biblioteca de parseo de HTML y XML más rápida del ecosistema Python. Está construida sobre las bibliotecas C libxml2 y libxslt, por lo que supera con creces a los parsers escritos en Python puro y, además, ofrece XPath completo, más potente que los selectores CSS. Si ha topado con el límite de rendimiento de BeautifulSoup en volúmenes grandes o necesita selecciones complejas, lxml es la elección correcta.
Este artículo profundiza en un tema de la guía general «Web scraping con Python», donde lxml aparece mencionado junto a otros parsers. Aquí lo analizamos en detalle.
Contenido
- Instalación y los dos módulos de lxml
- Parseo de HTML
- XPath: la base de la potencia de lxml
- Selectores CSS con cssselect
- Extracción de datos: texto, atributos y enlaces
- Acentos y codificaciones en lxml
- Documentos grandes: iterparse
- lxml vs BeautifulSoup
- Pros y contras
1. Instalación y los dos módulos de lxml
pip install lxmllxml tiene dos módulos clave:
lxml.html: para parsear HTML (perdona el marcado «sucio», igual que un navegador).lxml.etree: para XML estricto (véase el artículo dedicado «Parseo de XML en Python»).
Para el scraping web, en el 95% de los casos necesitará lxml.html.
2. Parseo de HTML
import requests
from lxml import html
resp = requests.get("https://example.com", timeout=10)
tree = html.fromstring(resp.content) # pasamos bytes: lxml detecta la codificación por sí solofromstring devuelve el elemento raíz del árbol. A partir de ahí se le aplican XPath o selectores CSS. También se puede parsear desde un archivo:
tree = html.parse("page.html").getroot()Importante: pase resp.content (bytes) y no resp.text (cadena); así lxml leerá correctamente la declaración de codificación del propio HTML. Esto elimina la mayoría de los problemas con acentos y caracteres especiales.
3. XPath: la base de la potencia de lxml
XPath es un lenguaje para direccionar nodos dentro del árbol del documento, y es aquí donde lxml despliega todo su potencial.
# texto de todos los encabezados h2 con la clase title
titles = tree.xpath('//h2[@class="title"]/text()')
# atributo href de todos los enlaces
links = tree.xpath('//a/@href')
# texto dentro de un bloque concreto
price = tree.xpath('//div[@class="price"]/text()')[0]Construcciones útiles de XPath
# por coincidencia parcial de la clase (cuando class="title big featured")
tree.xpath('//h2[contains(@class, "title")]/text()')
# por el texto del elemento
tree.xpath('//a[text()="Ver más"]/@href')
# n-ésimo elemento (¡la numeración empieza en 1!)
tree.xpath('(//div[@class="item"])[3]')
# ruta relativa desde un nodo encontrado
for card in tree.xpath('//div[@class="card"]'):
name = card.xpath('.//h3/text()') # el punto significa «desde el nodo actual»
link = card.xpath('.//a/@href')El punto inicial (.//) en un XPath relativo es crítico: sin él, la búsqueda partirá de la raíz del documento y no de la tarjeta actual. Es el error más frecuente entre quienes empiezan.
Ejes de XPath
XPath sabe moverse por el árbol en cualquier dirección, algo que CSS no permite:
# el padre del elemento
tree.xpath('//span[@class="price"]/parent::div')
# el siguiente elemento del mismo nivel
tree.xpath('//h2/following-sibling::p[1]/text()')
# el ancestro con una clase determinada
tree.xpath('//a[@id="buy"]/ancestor::div[@class="product"]')
4. Selectores CSS con cssselect
Si XPath le resulta farragoso, lxml admite selectores CSS (hace falta el paquete cssselect):
pip install cssselect# el método .cssselect() devuelve una lista de elementos
cards = tree.cssselect("div.product-card")
title = tree.cssselect("h1.title")[0].text_content()
links = [a.get("href") for a in tree.cssselect("a.product-link")]CSS resulta más familiar para quien viene del frontend. Por debajo, cssselect traduce el CSS a XPath, así que la velocidad se mantiene. Para las selecciones complejas (por texto, por ancestros) seguirá haciendo falta XPath.
5. Extracción de datos: texto, atributos y enlaces
el = tree.cssselect(".product")[0]
el.text_content() # todo el texto interior, etiquetas anidadas incluidas
el.get("href") # valor de un atributo
el.attrib # diccionario con todos los atributos
el.tag # nombre de la etiqueta
el.text # solo el texto directo del nodo (sin los anidados)La diferencia entre .text y .text_content() importa:
# <p>Hola, <b>mundo</b>!</p>
p.text # "Hola, " (solo hasta la etiqueta anidada)
p.text_content() # "Hola, mundo!" (todo el texto, de forma recursiva)Para convertir los enlaces relativos en absolutos:
tree.make_links_absolute("https://example.com")
links = tree.xpath('//a/@href') # ahora todos los enlaces son absolutosmake_links_absolute ahorra el pegado manual de URL, algo muy práctico al recorrer un sitio.
6. Acentos y codificaciones en lxml
lxml maneja sin problemas los acentos, las eñes y cualquier carácter no ASCII siempre que reciba bytes y no una cadena ya decodificada:
# CORRECTO: bytes; lxml leerá la codificación del <meta charset>
tree = html.fromstring(resp.content)
# ARRIESGADO: cadena ya decodificada por requests (quizá de forma errónea)
tree = html.fromstring(resp.text)Si necesita fijar la codificación a mano (por ejemplo, cuando el servidor miente en las cabeceras):
from lxml import html
parser = html.HTMLParser(encoding="windows-1252")
tree = html.fromstring(resp.content, parser=parser)La teoría completa del problema de las codificaciones está en la guía general, sección «Codificaciones».
7. Documentos grandes: iterparse
Cuando el documento es enorme (un feed XML de cientos de megabytes), cargarlo entero en memoria es un despilfarro. iterparse lo lee en flujo, procesando los elementos a medida que aparecen y liberando memoria:
from lxml import etree
for event, element in etree.iterparse("huge.xml", tag="item"):
title = element.findtext("title")
process(title)
element.clear() # liberamos memoria
while element.getprevious() is not None:
del element.getparent()[0] # eliminamos los nodos ya procesadosEsta técnica (lectura + clear()) permite parsear archivos que no caben enteros en la RAM. Hay más detalles sobre el parseo de XML en flujo en «Parseo de XML en Python».
8. lxml vs BeautifulSoup
| Criterio | lxml | BeautifulSoup |
|---|---|---|
| Velocidad | muy alta (C) | menor (sin el motor lxml) |
| API | más estricta, exige saber XPath | amigable, se lee como texto |
| XPath | completo | no (solo búsqueda CSS) |
| HTML «sucio» | bien | muy bien, perdona casi todo |
| Memoria | más eficiente, con iterparse | consumo mayor |
| Barrera de entrada | más alta | más baja |
En la práctica se combinan a menudo: BeautifulSoup puede usar lxml como motor (BeautifulSoup(html, "lxml")), con lo que obtiene la comodidad de la API de bs4 y la velocidad de lxml. Si necesita XPath o el parseo en flujo de archivos de gigabytes, use lxml directamente. La comparación de los distintos parsers está en la guía general, y la extracción de tablas, en un artículo aparte.
9. Pros y contras de lxml
Pros:
- Uno de los parsers más rápidos del ecosistema Python (núcleo en C).
- XPath completo con ejes (parent, sibling, ancestor), algo inalcanzable para CSS.
- Soporte de CSS mediante cssselect: lo mejor de ambos mundos.
iterparsepara el procesamiento en flujo de documentos gigantescos.- Consumo de memoria contenido.
Contras:
- Curva de aprendizaje más pronunciada, sobre todo por XPath.
- API más estricta y menos «indulgente» que la de BeautifulSoup.
- Mensajes de error a veces crípticos.
- La instalación exige en ocasiones bibliotecas del sistema (en algunos sistemas operativos).
Conclusión: lxml es la opción cuando priman el rendimiento y las selecciones complejas. Para tareas puntuales y máxima legibilidad, BeautifulSoup es más sencillo; para volúmenes gigantescos con selecciones CSS también merece un vistazo selectolax. Precisamente por su velocidad, lxml suele emparejarse con el scraping asíncrono: cuando se descargan cientos de páginas a la vez, un parser lento se convierte en el cuello de botella, y el motor en C de lxml lo elimina.