Парсинг XML на Python: ElementTree, lxml и потоковая обработка

Работа с XML на Python: ElementTree для простых задач, lxml для XPath и потоковый разбор многогигабайтных файлов через iterparse.

КP
Команда Parsing.agency
Сбор данных под задачи бизнеса
Опубликовано: 27 января 2025

XML до сих пор повсюду: RSS- и Atom-ленты, sitemap-карты сайтов, ответы SOAP- и многих REST-API, выгрузки из 1С, банковские и государственные форматы обмена. Python предлагает несколько способов разобрать XML — от встроенного ElementTree до быстрого lxml и потокового SAX для гигантских файлов. Разберём, когда что выбирать.

Это узкотематическое продолжение обзорного материала «Парсинг на Python». Если XML приходит вперемешку с HTML-страницами, начните с него; здесь — фокус именно на XML.

Оглавление

  1. Чем XML отличается от HTML
  2. ElementTree — встроенный модуль
  3. lxml.etree — быстро и с XPath
  4. Пространства имён (namespaces)
  5. Практика: RSS-лента и sitemap
  6. Кириллица и кодировки в XML
  7. Большие файлы: потоковый разбор
  8. Создание и запись XML
  9. Безопасность парсинга XML
  10. Плюсы и минусы подходов

1. Чем XML отличается от HTML

Хотя оба — языки разметки, разница принципиальна:

  • XML строгий: каждый тег должен быть закрыт, регистр важен, структура обязана быть валидной. HTML прощает ошибки.
  • В XML нет предопределённых тегов — вы (или формат) задаёте их сами: <book>, <price>, <author>.
  • XML часто использует пространства имён для разделения словарей.

Из-за строгости XML парсится предсказуемее HTML, но любая ошибка в разметке роняет разбор. Для невалидного XML спасает «прощающий» режим lxml.

Стоит держать в голове и общий тренд: в современных API XML во многом вытеснен более лёгким JSON, и если у источника есть JSON-эндпоинт, разбирать его обычно проще — приёмы собраны в «Парсинг JSON на Python». Но там, где XML остаётся стандартом (RSS, sitemap, SOAP, госформаты, выгрузки из 1С), без его разбора не обойтись.


2. ElementTree — встроенный модуль

xml.etree.ElementTree есть в стандартной библиотеке — без установки зависимостей. Этого достаточно для большинства задач.

python
import xml.etree.ElementTree as ET

xml_data = """
<catalog>
    <book id="1">
        <title>Python для начинающих</title>
        <price>590</price>
    </book>
    <book id="2">
        <title>Парсинг данных</title>
        <price>720</price>
    </book>
</catalog>
"""

root = ET.fromstring(xml_data)          # из строки
# root = ET.parse("catalog.xml").getroot()   # из файла

for book in root.findall("book"):
    title = book.find("title").text
    price = book.find("price").text
    book_id = book.get("id")            # атрибут
    print(book_id, title, price)

Ключевые методы:

  • find("tag") — первый дочерний элемент с тегом;
  • findall("tag") — все такие элементы;
  • .text — текст внутри элемента;
  • .get("attr") — значение атрибута;
  • .attrib — словарь всех атрибутов.

ElementTree поддерживает ограниченный XPath:

python
root.findall(".//book")                 # все book на любой глубине
root.findall("book[@id='1']")           # по атрибуту
root.findall(".//price")

3. lxml.etree — быстро и с полным XPath

Когда нужна скорость или полноценный XPath, берут lxml.etree. API почти совпадает с ElementTree — переход бесшовный.

python
from lxml import etree

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

# полноценный XPath
titles = root.xpath("//book/title/text()")
expensive = root.xpath("//book[price > 600]/title/text()")
first_id = root.xpath("//book[1]/@id")

lxml даёт то, чего нет в ElementTree: полный XPath 1.0 с функциями (contains, starts-with, сравнения), оси (parent, sibling) и XSLT-преобразования. О его HTML-возможностях — в статье про lxml.

Совет: для lxml кодируйте строку в байты (.encode("utf-8")) перед fromstring, иначе при наличии в XML объявления <?xml encoding=...?> возможна ошибка.


4. Пространства имён (namespaces)

Это камень преткновения новичков. Если в XML есть xmlns, простой find("title") ничего не найдёт — теги «спрятаны» в пространстве имён.

xml
<feed xmlns="http://www.w3.org/2005/Atom">
    <entry><title>Новость</title></entry>
</feed>

Нужно указывать пространство имён явно:

python
import xml.etree.ElementTree as ET

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

# через словарь префиксов
titles = root.findall(".//atom:title", ns)
for t in titles:
    print(t.text)

В lxml удобнее — можно использовать local-name(), игнорируя пространство имён:

python
from lxml import etree
root = etree.fromstring(xml_bytes)
# берём title независимо от namespace
titles = root.xpath("//*[local-name()='title']/text()")

local-name() — спасательный круг, когда пространств имён много или они заранее неизвестны.


5. Практика: RSS-лента и sitemap

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 — байты

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

findtext удобнее find().text — он не упадёт с AttributeError, если элемента нет, а вернёт None или указанное значение по умолчанию.

Sitemap (карта сайта)

Sitemap почти всегда с пространством имён — частая ловушка при сборе всех URL сайта:

python
import requests
from lxml import etree

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

# обходим namespace через local-name()
urls = root.xpath("//*[local-name()='loc']/text()")
print(f"Найдено {len(urls)} URL")

Sitemap — отличная точка старта для краулера: список всех страниц сайта без обхода ссылок. О хранении этих URL в очереди — в хабе, раздел «Очереди». Когда таких лент или sitemap-файлов много (у крупных сайтов sitemap разбит на десятки частей), их разумно загружать параллельно — см. «Асинхронный парсинг на Python».


6. Кириллица и кодировки в XML

В XML кодировка объявляется в прологе:

xml
<?xml version="1.0" encoding="windows-1251"?>

Главное правило — передавать парсеру байты, а не строку, чтобы он прочитал это объявление сам:

python
# ПРАВИЛЬНО: байты
root = ET.fromstring(resp.content)

# ОШИБКА: если строка уже декодирована неверно — кракозябры
root = ET.fromstring(resp.text)

Типичная ошибка lxml — ValueError: Unicode strings with encoding declaration are not supported. Возникает, когда вы передаёте уже декодированную строку в документ с объявлением кодировки. Решение — передавать байты:

python
from lxml import etree
root = etree.fromstring(resp.content)        # байты, не resp.text

Если кодировка в прологе указана неверно (бывает в выгрузках из старых систем), перекодируйте вручную:

python
text = resp.content.decode("windows-1251", errors="replace")
# уберём проблемный пролог и перекодируем
text = text.replace('encoding="windows-1251"', 'encoding="utf-8"')
root = etree.fromstring(text.encode("utf-8"))

Общая теория кодировок — в хабе, раздел «Кириллица».


7. Большие файлы: потоковый разбор

Выгрузки на сотни мегабайт (товарные фиды, дампы) нельзя грузить в память целиком. Два потоковых подхода.

iterparse в lxml — рекомендуемый

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()                          # освобождаем память от обработанного
    while elem.getprevious() is not None:
        del elem.getparent()[0]

Связка clear() + удаление предыдущих узлов держит потребление памяти почти постоянным независимо от размера файла. Это позволяет разбирать файлы в десятки гигабайт.

SAX — событийный разбор

Встроенный xml.sax вызывает ваши колбэки на каждый открывающий/закрывающий тег. Памяти ест минимум, но код многословнее и менее удобен, чем iterparse. На практике iterparse из lxml покрывает почти все потребности в потоковой обработке.


8. Создание и запись XML

Парсинг часто соседствует с генерацией — например, чтобы сохранить результат в XML.

python
from lxml import etree

root = etree.Element("catalog")
book = etree.SubElement(root, "book", id="1")
etree.SubElement(book, "title").text = "Парсинг на 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' — байты!
    f.write(xml_bytes)

encoding="utf-8" в tostring гарантирует корректное сохранение кириллицы, а запись в режиме "wb" — что байты не будут повторно перекодированы.


9. Безопасность парсинга XML

XML из недоверенных источников может содержать атаки — например, «billion laughs» (экспоненциальное раскрытие сущностей, исчерпывающее память) или внешние сущности (XXE), читающие файлы с вашего сервера.

Защита — пакет defusedxml:

bash
pip install defusedxml
python
from defusedxml.ElementTree import fromstring   # безопасная замена
root = fromstring(untrusted_xml)

Если разбираете XML из внешних, неконтролируемых источников — используйте defusedxml вместо стандартных парсеров. Это снимает основные классы XML-атак.


10. Плюсы и минусы подходов

Инструмент Плюсы Минусы
ElementTree встроен, без зависимостей, простой API ограниченный XPath, медленнее lxml
lxml.etree быстрый, полный XPath, iterparse, XSLT внешняя зависимость, строже к ошибкам
xml.sax минимум памяти на огромных файлах многословный, неудобный для разработки
defusedxml защита от XML-атак только для разбора, не для записи

Как выбрать:

  • Небольшой XML, без лишних зависимостей → ElementTree.
  • Нужны скорость, сложный XPath или namespaces → lxml.etree.
  • Файл не влезает в память → iterparse (lxml).
  • XML из недоверенного источника → defusedxml.