Scraping per linguaggio 8 min di lettura

Parsing di XML in Python: ElementTree, lxml ed elaborazione in streaming

Lavorare con XML in Python: ElementTree per i compiti semplici, lxml per XPath e parsing in streaming di file da diversi gigabyte con iterparse.

TW
Team Web-Scraping.it
Raccolta dati per le esigenze del business
Pubblicato il 27 gennaio 2025

XML è ancora dappertutto: feed RSS e Atom, sitemap dei siti, risposte delle API SOAP e di molte REST, esportazioni dai gestionali ERP, formati di interscambio bancari e della pubblica amministrazione. Python offre diversi modi per analizzare XML: dall’ElementTree integrato al veloce lxml, fino al SAX in streaming per i file giganteschi. Vediamo quando scegliere cosa.

Questo è un approfondimento tematico della guida generale «Web scraping con Python». Se l’XML ti arriva mescolato a pagine HTML, parti da lì; qui il focus è proprio sull’XML.

Indice

  1. In cosa XML si differenzia da HTML
  2. ElementTree — il modulo integrato
  3. lxml.etree — veloce e con XPath
  4. Namespace (spazi dei nomi)
  5. Pratica: feed RSS e sitemap
  6. Codifiche e caratteri accentati in XML
  7. File grandi: parsing in streaming
  8. Creare e scrivere XML
  9. Sicurezza nel parsing di XML
  10. Pro e contro dei vari approcci

1. In cosa XML si differenzia da HTML

Sebbene entrambi siano linguaggi di markup, la differenza è sostanziale:

  • XML è rigoroso: ogni tag va chiuso, le maiuscole contano e la struttura deve essere valida. HTML perdona gli errori.
  • In XML non ci sono tag predefiniti: li definisci tu (o il formato): <book>, <price>, <author>.
  • XML usa spesso i namespace per separare i vocabolari.

Grazie alla sua rigidità, XML si analizza in modo più prevedibile dell’HTML, ma qualsiasi errore nel markup fa fallire il parsing. Con l’XML non valido aiuta la modalità «tollerante» di lxml.

Vale la pena tenere presente anche la tendenza generale: nelle API moderne XML è stato in gran parte soppiantato dal più leggero JSON, e se la fonte espone un endpoint JSON, di solito è più semplice analizzare quello — le tecniche sono raccolte in «Parsing di JSON in Python». Ma dove XML resta lo standard (RSS, sitemap, SOAP, formati della pubblica amministrazione, esportazioni dai gestionali), del suo parsing non si può fare a meno.


2. ElementTree — il modulo integrato

xml.etree.ElementTree fa parte della libreria standard — niente dipendenze da installare. Per la maggior parte dei compiti è sufficiente.

python
import xml.etree.ElementTree as ET

xml_data = """
<catalog>
    <book id="1">
        <title>Python per principianti</title>
        <price>590</price>
    </book>
    <book id="2">
        <title>Estrazione di dati</title>
        <price>720</price>
    </book>
</catalog>
"""

root = ET.fromstring(xml_data)          # da una stringa
# root = ET.parse("catalog.xml").getroot()   # da un file

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

Metodi chiave:

  • find("tag"): il primo elemento figlio con quel tag;
  • findall("tag"): tutti gli elementi di quel tipo;
  • .text: il testo dentro l’elemento;
  • .get("attr"): il valore di un attributo;
  • .attrib: il dizionario di tutti gli attributi.

ElementTree supporta un XPath limitato:

python
root.findall(".//book")                 # tutti i book a qualsiasi profondità
root.findall("book[@id='1']")           # per attributo
root.findall(".//price")

3. lxml.etree — veloce e con XPath completo

Quando servono velocità o un XPath completo, si passa a lxml.etree. L’API coincide quasi del tutto con quella di ElementTree — la transizione è indolore.

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 offre ciò che ElementTree non ha: XPath 1.0 completo con funzioni (contains, starts-with, confronti), assi (parent, sibling) e trasformazioni XSLT. Delle sue capacità con l’HTML parliamo nell’articolo su lxml.

Consiglio: con lxml codifica la stringa in byte (.encode("utf-8")) prima di fromstring; altrimenti, se l’XML contiene una dichiarazione <?xml encoding=...?>, può verificarsi un errore.


4. Namespace (spazi dei nomi)

È lo scoglio dei principianti. Se nell’XML c’è xmlns, un semplice find("title") non trova nulla — i tag sono «nascosti» nel namespace.

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

Bisogna indicare il namespace esplicitamente:

python
import xml.etree.ElementTree as ET

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

# tramite un dizionario di prefissi
titles = root.findall(".//atom:title", ns)
for t in titles:
    print(t.text)

In lxml è più comodo — si può usare local-name() ignorando il namespace:

python
from lxml import etree
root = etree.fromstring(xml_bytes)
# prendiamo title indipendentemente dal namespace
titles = root.xpath("//*[local-name()='title']/text()")

local-name() è la scialuppa di salvataggio quando i namespace sono tanti o non si conoscono in anticipo.


5. Pratica: feed RSS e 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 sono byte

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

findtext è più comodo di find().text: non va in errore con AttributeError se l’elemento manca, ma restituisce None o il valore predefinito indicato.

Sitemap (mappa del sito)

Il sitemap ha quasi sempre un namespace — una trappola frequente quando si raccolgono tutti gli URL di un sito:

python
import requests
from lxml import etree

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

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

Il sitemap è un ottimo punto di partenza per un crawler: l’elenco di tutte le pagine del sito senza seguire i link. Su come conservare questi URL in una coda, vedi la guida generale, sezione «Code». Quando i feed o i file sitemap sono tanti (nei siti grandi il sitemap è diviso in decine di parti), conviene scaricarli in parallelo — vedi «Scraping asincrono in Python».


6. Codifiche e caratteri accentati in XML

In XML la codifica si dichiara nel prologo:

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

La regola d’oro: passa al parser byte, non una stringa, così quella dichiarazione la legge da sé:

python
# CORRETTO: byte
root = ET.fromstring(resp.content)

# ERRORE: se la stringa è già stata decodificata male, compaiono caratteri corrotti
root = ET.fromstring(resp.text)

Un errore tipico di lxml è ValueError: Unicode strings with encoding declaration are not supported. Si verifica quando passi una stringa già decodificata a un documento con dichiarazione di codifica. La soluzione è passare byte:

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

Se la codifica indicata nel prologo è sbagliata (capita nelle esportazioni dei sistemi datati), ricodifica a mano:

python
text = resp.content.decode("iso-8859-1", errors="replace")
# togliamo il prologo problematico e ricodifichiamo
text = text.replace('encoding="iso-8859-1"', 'encoding="utf-8"')
root = etree.fromstring(text.encode("utf-8"))

La teoria generale delle codifiche è nella guida generale, sezione «Codifiche».


7. File grandi: parsing in streaming

Le esportazioni da centinaia di megabyte (feed di prodotti, dump) non si possono caricare in memoria per intero. Due approcci in streaming.

iterparse in lxml — il consigliato

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()                          # liberiamo la memoria da quanto già elaborato
    while elem.getprevious() is not None:
        del elem.getparent()[0]

La combinazione di clear() e dell’eliminazione dei nodi precedenti mantiene il consumo di memoria quasi costante, qualunque sia la dimensione del file. Questo permette di analizzare file da decine di gigabyte.

SAX — parsing a eventi

Il modulo integrato xml.sax invoca le tue callback a ogni tag di apertura/chiusura. Consuma pochissima memoria, ma il codice è più verboso e meno comodo di iterparse. In pratica, l’iterparse di lxml copre quasi tutte le esigenze di elaborazione in streaming.


8. Creare e scrivere XML

Il parsing va spesso a braccetto con la generazione — ad esempio per salvare il risultato in 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': byte!
    f.write(xml_bytes)

encoding="utf-8" in tostring garantisce che le lettere accentate vengano salvate correttamente, e la scrittura in modalità "wb" evita che i byte vengano ricodificati una seconda volta.


9. Sicurezza nel parsing di XML

L’XML proveniente da fonti non affidabili può contenere attacchi — ad esempio il «billion laughs» (espansione esponenziale delle entità che esaurisce la memoria) o le entità esterne (XXE), che leggono file dal tuo server.

La difesa è il pacchetto defusedxml:

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

Se analizzi XML da fonti esterne non controllate, usa defusedxml al posto dei parser standard. Elimina le principali classi di attacchi via XML.


10. Pro e contro dei vari approcci

Strumento Pro Contro
ElementTree integrato, senza dipendenze, API semplice XPath limitato, più lento di lxml
lxml.etree veloce, XPath completo, iterparse, XSLT dipendenza esterna, più severo con gli errori
xml.sax memoria minima sui file enormi verboso, scomodo da sviluppare
defusedxml protezione dagli attacchi XML solo per il parsing, non per la scrittura

Come scegliere:

  • XML piccolo, senza dipendenze extra → ElementTree.
  • Ti servono velocità, XPath complesso o i namespace → lxml.etree.
  • Il file non entra in memoria → iterparse (lxml).
  • XML da una fonte non affidabile → defusedxml.