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
- En qué se diferencia XML de HTML
- ElementTree, el módulo integrado
- lxml.etree, rápido y con XPath
- Espacios de nombres (namespaces)
- Práctica: feed RSS y sitemap
- Codificaciones y acentos en XML
- Archivos grandes: parseo en flujo
- Crear y escribir XML
- Seguridad al parsear XML
- 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.
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:
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.
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 defromstring; 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.
<feed xmlns="http://www.w3.org/2005/Atom">
<entry><title>Noticia</title></entry>
</feed>Hay que indicar el espacio de nombres explícitamente:
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:
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
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:
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 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:
# 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:
from lxml import etree
root = etree.fromstring(resp.content) # bytes, no resp.textSi la codificación del prólogo es incorrecta (ocurre en exportaciones de sistemas antiguos), transcodifique a mano:
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
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.
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:
pip install defusedxmlfrom 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.