Scraping por lenguaje 8 min de lectura

Parseo de XML en Python: ElementTree, lxml y procesamiento en flujo

Trabajar con XML en Python: ElementTree para tareas simples, lxml para XPath y parseo en flujo de archivos de varios gigabytes con iterparse.

EW
Equipo Web-Scraping.es
Recopilación de datos para las necesidades del negocio
Publicado: 27 enero 2025

XML sigue estando en todas partes: feeds RSS y Atom, sitemaps, respuestas de SOAP y de muchas REST-API, exportaciones de sistemas ERP, formatos bancarios y de la administración pública. Python ofrece varias formas de parsear XML: desde el ElementTree integrado hasta el veloz lxml y el SAX en flujo para archivos gigantescos. Veamos cuándo conviene cada una.

Este es un artículo especializado que continúa la guía general «Web scraping con Python». Si el XML le llega mezclado con páginas HTML, empiece por ella; aquí nos centramos en el XML.

Índice

  1. En qué se diferencia XML de HTML
  2. ElementTree, el módulo integrado
  3. lxml.etree, rápido y con XPath
  4. Espacios de nombres (namespaces)
  5. Práctica: feed RSS y sitemap
  6. Codificaciones y acentos en XML
  7. Archivos grandes: parseo en flujo
  8. Crear y escribir XML
  9. Seguridad al parsear XML
  10. Ventajas e inconvenientes de cada enfoque

1. En qué se diferencia XML de HTML

Aunque ambos son lenguajes de marcado, la diferencia es fundamental:

  • XML es estricto: cada etiqueta debe cerrarse, las mayúsculas importan y la estructura tiene que ser válida. HTML perdona los errores.
  • En XML no hay etiquetas predefinidas: las define usted (o el formato): <book>, <price>, <author>.
  • XML usa a menudo espacios de nombres para separar vocabularios.

Por su rigidez, XML se parsea de forma más predecible que HTML, pero cualquier error en el marcado hace fallar el análisis. Para XML no válido, ayuda el modo «tolerante» de lxml.

Conviene tener presente una tendencia general: en las API modernas, JSON, más ligero, ha desplazado en gran medida a XML; si la fuente ofrece un endpoint JSON, suele ser más fácil de parsear (las técnicas están en «Parseo de JSON en Python»). Pero allí donde XML sigue siendo el estándar (RSS, sitemap, SOAP, formatos de la administración pública, exportaciones de ERP), no queda más remedio que parsearlo.


2. ElementTree, el módulo integrado

xml.etree.ElementTree viene en la biblioteca estándar, sin instalar dependencias. Es suficiente para la mayoría de las tareas.

python
import xml.etree.ElementTree as ET

xml_data = """
<catalog>
    <book id="1">
        <title>Python para principiantes</title>
        <price>590</price>
    </book>
    <book id="2">
        <title>Extracción de datos</title>
        <price>720</price>
    </book>
</catalog>
"""

root = ET.fromstring(xml_data)          # desde una cadena
# root = ET.parse("catalog.xml").getroot()   # desde un archivo

for book in root.findall("book"):
    title = book.find("title").text
    price = book.find("price").text
    book_id = book.get("id")            # atributo
    print(book_id, title, price)

Métodos clave:

  • find("tag") — el primer elemento hijo con esa etiqueta;
  • findall("tag") — todos esos elementos;
  • .text — el texto dentro del elemento;
  • .get("attr") — el valor de un atributo;
  • .attrib — el diccionario con todos los atributos.

ElementTree admite un XPath limitado:

python
root.findall(".//book")                 # todos los book a cualquier profundidad
root.findall("book[@id='1']")           # por atributo
root.findall(".//price")

3. lxml.etree, rápido y con XPath completo

Cuando se necesita velocidad o un XPath completo, se recurre a lxml.etree. Su API casi coincide con la de ElementTree, así que la transición es fluida.

python
from lxml import etree

root = etree.fromstring(xml_data.encode("utf-8"))

# XPath completo
titles = root.xpath("//book/title/text()")
expensive = root.xpath("//book[price > 600]/title/text()")
first_id = root.xpath("//book[1]/@id")

lxml ofrece lo que ElementTree no tiene: XPath 1.0 completo con funciones (contains, starts-with, comparaciones), ejes (parent, sibling) y transformaciones XSLT. Sobre sus capacidades con HTML, vea el artículo sobre lxml.

Consejo: en lxml, codifique la cadena a bytes (.encode("utf-8")) antes de fromstring; de lo contrario, si el XML incluye una declaración <?xml encoding=...?>, puede producirse un error.


4. Espacios de nombres (namespaces)

Es la piedra en el zapato de los principiantes. Si el XML tiene xmlns, un simple find("title") no encuentra nada: las etiquetas están «escondidas» en el espacio de nombres.

xml
<feed xmlns="http://www.w3.org/2005/Atom">
    <entry><title>Noticia</title></entry>
</feed>

Hay que indicar el espacio de nombres explícitamente:

python
import xml.etree.ElementTree as ET

root = ET.fromstring(xml_data)
ns = {"atom": "http://www.w3.org/2005/Atom"}

# mediante un diccionario de prefijos
titles = root.findall(".//atom:title", ns)
for t in titles:
    print(t.text)

En lxml es más cómodo: se puede usar local-name() ignorando el espacio de nombres:

python
from lxml import etree
root = etree.fromstring(xml_bytes)
# tomamos title sin importar el namespace
titles = root.xpath("//*[local-name()='title']/text()")

local-name() es el salvavidas cuando hay muchos espacios de nombres o no se conocen de antemano.


5. Práctica: feed RSS y sitemap

Feed RSS

python
import requests
import xml.etree.ElementTree as ET

resp = requests.get("https://example.com/rss", timeout=10)
root = ET.fromstring(resp.content)       # .content son bytes

for item in root.findall(".//item"):
    title = item.findtext("title")
    link = item.findtext("link")
    date = item.findtext("pubDate")
    print(title, link, date)

findtext es más cómodo que find().text: no falla con AttributeError si el elemento no existe, sino que devuelve None o el valor por defecto indicado.

Sitemap (mapa del sitio)

El sitemap casi siempre lleva espacio de nombres: una trampa habitual al recopilar todas las URL de un sitio:

python
import requests
from lxml import etree

resp = requests.get("https://example.com/sitemap.xml", timeout=10)
root = etree.fromstring(resp.content)

# sorteamos el namespace con local-name()
urls = root.xpath("//*[local-name()='loc']/text()")
print(f"Encontradas {len(urls)} URL")

El sitemap es un excelente punto de partida para un crawler: la lista de todas las páginas del sitio sin seguir enlaces. Sobre cómo almacenar estas URL en una cola, vea la guía general, sección «Colas». Cuando hay muchos de estos feeds o archivos sitemap (en los sitios grandes el sitemap se divide en decenas de partes), conviene descargarlos en paralelo: vea «Scraping asíncrono en Python».


6. Codificaciones y acentos en XML

En XML, la codificación se declara en el prólogo:

xml
<?xml version="1.0" encoding="ISO-8859-1"?>

La regla de oro: pásele al parser bytes, no una cadena, para que lea esa declaración por sí mismo:

python
# CORRECTO: bytes
root = ET.fromstring(resp.content)

# ERROR: si la cadena ya se decodificó mal, aparecen caracteres corruptos
root = ET.fromstring(resp.text)

Un error típico de lxml es ValueError: Unicode strings with encoding declaration are not supported. Ocurre cuando pasa una cadena ya decodificada a un documento con declaración de codificación. La solución es pasar bytes:

python
from lxml import etree
root = etree.fromstring(resp.content)        # bytes, no resp.text

Si la codificación del prólogo es incorrecta (ocurre en exportaciones de sistemas antiguos), transcodifique a mano:

python
text = resp.content.decode("iso-8859-1", errors="replace")
# quitamos el prólogo problemático y transcodificamos
text = text.replace('encoding="iso-8859-1"', 'encoding="utf-8"')
root = etree.fromstring(text.encode("utf-8"))

La teoría general de las codificaciones está en la guía general, sección «Codificaciones».


7. Archivos grandes: parseo en flujo

Las exportaciones de cientos de megabytes (feeds de productos, volcados) no se pueden cargar enteras en memoria. Dos enfoques en flujo.

iterparse en lxml, el recomendado

python
from lxml import etree

for event, elem in etree.iterparse("huge_feed.xml", tag="offer"):
    title = elem.findtext("name")
    price = elem.findtext("price")
    process(title, price)

    elem.clear()                          # liberamos la memoria de lo ya procesado
    while elem.getprevious() is not None:
        del elem.getparent()[0]

La combinación de clear() y la eliminación de los nodos anteriores mantiene el uso de memoria casi constante, sea cual sea el tamaño del archivo. Esto permite parsear archivos de decenas de gigabytes.

SAX, análisis por eventos

El xml.sax integrado invoca sus callbacks en cada etiqueta de apertura/cierre. Consume muy poca memoria, pero el código es más verboso y menos cómodo que iterparse. En la práctica, el iterparse de lxml cubre casi todas las necesidades de procesamiento en flujo.


8. Crear y escribir XML

El parseo suele ir de la mano de la generación: por ejemplo, para guardar el resultado en XML.

python
from lxml import etree

root = etree.Element("catalog")
book = etree.SubElement(root, "book", id="1")
etree.SubElement(book, "title").text = "Web scraping con Python"
etree.SubElement(book, "price").text = "590"

xml_bytes = etree.tostring(
    root,
    pretty_print=True,
    xml_declaration=True,
    encoding="utf-8",
)
with open("output.xml", "wb") as f:       # 'wb': ¡bytes!
    f.write(xml_bytes)

encoding="utf-8" en tostring garantiza que los acentos y la ñ se guarden correctamente, y la escritura en modo "wb" evita que los bytes se vuelvan a recodificar.


9. Seguridad al parsear XML

El XML de fuentes no confiables puede contener ataques: por ejemplo, «billion laughs» (expansión exponencial de entidades que agota la memoria) o entidades externas (XXE) que leen archivos de su servidor.

La defensa es el paquete defusedxml:

bash
pip install defusedxml
python
from defusedxml.ElementTree import fromstring   # reemplazo seguro
root = fromstring(untrusted_xml)

Si parsea XML de fuentes externas no controladas, use defusedxml en lugar de los parsers estándar. Elimina las principales clases de ataques por XML.


10. Ventajas e inconvenientes de cada enfoque

Herramienta Ventajas Inconvenientes
ElementTree integrado, sin dependencias, API simple XPath limitado, más lento que lxml
lxml.etree rápido, XPath completo, iterparse, XSLT dependencia externa, más estricto con los errores
xml.sax memoria mínima en archivos enormes verboso, incómodo de desarrollar
defusedxml protección frente a ataques XML solo para parsear, no para escribir

Cómo elegir:

  • XML pequeño, sin dependencias extra → ElementTree.
  • Necesita velocidad, XPath complejo o namespaces → lxml.etree.
  • El archivo no cabe en memoria → iterparse (lxml).
  • XML de fuente no confiable → defusedxml.