XML до сих пор повсюду: RSS- и Atom-ленты, sitemap-карты сайтов, ответы SOAP- и многих REST-API, выгрузки из 1С, банковские и государственные форматы обмена. Python предлагает несколько способов разобрать XML — от встроенного ElementTree до быстрого lxml и потокового SAX для гигантских файлов. Разберём, когда что выбирать.
Это узкотематическое продолжение обзорного материала «Парсинг на Python». Если XML приходит вперемешку с HTML-страницами, начните с него; здесь — фокус именно на XML.
Оглавление
- Чем XML отличается от HTML
- ElementTree — встроенный модуль
- lxml.etree — быстро и с XPath
- Пространства имён (namespaces)
- Практика: RSS-лента и sitemap
- Кириллица и кодировки в XML
- Большие файлы: потоковый разбор
- Создание и запись XML
- Безопасность парсинга XML
- Плюсы и минусы подходов
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 есть в стандартной библиотеке — без установки зависимостей. Этого достаточно для большинства задач.
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:
root.findall(".//book") # все book на любой глубине
root.findall("book[@id='1']") # по атрибуту
root.findall(".//price")
3. lxml.etree — быстро и с полным XPath
Когда нужна скорость или полноценный XPath, берут lxml.etree. API почти совпадает с ElementTree — переход бесшовный.
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") ничего не найдёт — теги «спрятаны» в пространстве имён.
<feed xmlns="http://www.w3.org/2005/Atom">
<entry><title>Новость</title></entry>
</feed>
Нужно указывать пространство имён явно:
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(), игнорируя пространство имён:
from lxml import etree
root = etree.fromstring(xml_bytes)
# берём title независимо от namespace
titles = root.xpath("//*[local-name()='title']/text()")
local-name() — спасательный круг, когда пространств имён много или они заранее неизвестны.
5. Практика: RSS-лента и sitemap
RSS-лента
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 сайта:
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 version="1.0" encoding="windows-1251"?>
Главное правило — передавать парсеру байты, а не строку, чтобы он прочитал это объявление сам:
# ПРАВИЛЬНО: байты
root = ET.fromstring(resp.content)
# ОШИБКА: если строка уже декодирована неверно — кракозябры
root = ET.fromstring(resp.text)
Типичная ошибка lxml — ValueError: Unicode strings with encoding declaration are not supported. Возникает, когда вы передаёте уже декодированную строку в документ с объявлением кодировки. Решение — передавать байты:
from lxml import etree
root = etree.fromstring(resp.content) # байты, не resp.text
Если кодировка в прологе указана неверно (бывает в выгрузках из старых систем), перекодируйте вручную:
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 — рекомендуемый
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.
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:
pip install defusedxml
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.