Python: от данных до своей сводки Урок 19 из 56
Разбор XML: дерево вместо строки, `None` вместо тега и адрес перед именем
Девятнадцатый урок курса по Python. Тот же ответ Нацбанка, но читает его разборщик: 39 валют, курс по имени тега, а не по кускам строки. Плюс два подвоха: `find` для отсутствующего тега возвращает `None`, а тег с пространством имён по короткому имени не находится вовсе.
Зачем это нужно
В прошлом уроке курс доллара мы достали так: нашли <title>USD</title>, отрезали кусок, потом ещё кусок. Это работало ровно до первого изменения на стороне банка.
Разметка — не строка. У неё есть структура: теги вложены друг в друга, у каждого есть имя, у некоторых — атрибуты. Разборщик читает именно структуру, и тогда «курс доллара» — это адрес в дереве, а не номер символа.
Стандартная библиотека умеет это сама: модуль xml.etree.ElementTree, ставить ничего не нужно.
Сразу целиком
Файл talda.py. Запуск: python talda.py из окружения.
Обязательное — первые два блока: дерево и выбор нужных валют по имени тега. Третий и четвёртый показывают два подвоха, из-за которых разбор ломается у всех по очереди.
"""Урок 19: разбор XML — числа из чужой разметки.
В прошлом уроке курс доллара достали строковыми методами: нашли тег, отрезали
по кускам. Так делать нельзя: изменится один пробел — и разбор поедет. Сегодня
тот же ответ читает разборщик, который знает, что такое тег.
"""
import urllib.request
import xml.etree.ElementTree as ET
URL = "https://nationalbank.kz/rss/get_rates.cfm?fdate=15.01.2026"
WANTED = ("USD", "EUR", "RUB", "CNY")
request = urllib.request.Request(URL, headers={"User-Agent": "shanraq-course/1.0"})
with urllib.request.urlopen(request, timeout=30) as answer:
charset = answer.headers.get_content_charset() or "utf-8"
text = answer.read().decode(charset)
print("== дерево вместо строки")
root = ET.fromstring(text)
print("корень:", root.tag, "| детей:", len(root))
print("дата:", root.findtext("date"))
print("всего валют:", len(root.findall("item")))
print()
print("== четыре валюты по имени тега")
for item in root.findall("item"):
code = item.findtext("title")
if code not in WANTED:
continue
rate = float(item.findtext("description"))
quant = int(item.findtext("quant"))
print(f"{code}: {rate:8.2f} за {quant} | {item.findtext('fullname')}")
print()
print("== чего в разметке нет")
first = root.find("item")
print("find по чужому тегу:", first.find("net-takogo-tega"))
print("findtext с умолчанием:", first.findtext("net-takogo-tega", "нет такого тега"))
try:
print(first.find("net-takogo-tega").text)
except AttributeError as error:
print("а так падает:", error)
print()
print("== пространство имён: тег с адресом впереди")
atom = ET.fromstring('<feed xmlns="http://www.w3.org/2005/Atom">'
'<entry><title>Курс</title></entry></feed>')
print("тег корня:", atom.tag)
print("поиск по короткому имени:", atom.find("entry"))
NS = {"a": "http://www.w3.org/2005/Atom"}
print("поиск с пространством:", atom.find("a:entry/a:title", NS).text)
Выводит:
== дерево вместо строки
корень: rates | детей: 45
дата: 15.01.2026
всего валют: 39
== четыре валюты по имени тега
USD: 510.43 за 1 | ДОЛЛАР США
EUR: 594.86 за 1 | ЕВРО
CNY: 73.21 за 1 | КИТАЙСКИЙ ЮАНЬ
RUB: 6.49 за 1 | РОССИЙСКИЙ РУБЛЬ
== чего в разметке нет
find по чужому тегу: None
findtext с умолчанием: нет такого тега
а так падает: 'NoneType' object has no attribute 'text'
== пространство имён: тег с адресом впереди
тег корня: {http://www.w3.org/2005/Atom}feed
поиск по короткому имени: None
поиск с пространством: Курс
Разбор
Дерево, а не строка
root = ET.fromstring(text)
fromstring берёт текст разметки и возвращает корневой элемент. Дальше работают не с текстом, а с деревом:
root.tag— имя тега;len(root)— сколько прямых детей;root.find("item")— первый ребёнок с таким именем;root.findall("item")— все такие дети;root.findtext("date")— текст внутри первого такого ребёнка.
Обратите внимание на разницу в выводе: детей у корня 45, а item — 39. Остальные шесть — это generator, title, link и прочая шапка. findall берёт только то, что просили; len считает всех.
Образ. Оглавление книги. Чтобы найти третью главу, вы не считаете буквы от начала — вы смотрите в оглавление и открываете страницу.
Значения всегда строки
rate = float(item.findtext("description"))
quant = int(item.findtext("quant"))
В XML нет чисел — там только текст. findtext вернёт "510.43", и пока это строка, её нельзя ни сложить, ни сравнить по величине.
Это тот же разговор, что был про JSON и про даты: превращать в число надо при разборе. И там же, при разборе, видно, что делать с n/a, пустой строкой и запятой вместо точки.
quant тут не украшение: банк даёт курс не всегда за одну единицу. Узбекский сум — за сто, иранский риал — за тысячу. Сравнение без деления на quant ошибётся в сто раз, и ошибётся молча.
find возвращает None, а не ошибку
find по чужому тегу: None
findtext с умолчанием: нет такого тега
а так падает: 'NoneType' object has no attribute 'text'
Если тега нет, find отдаёт None — и это ровно тот пропуск, о котором был седьмой урок. Пока вы не спросили .text, ничего не происходит; спросили — и программа падает в месте, которое выглядит невинно.
Отсюда правило: берут findtext с умолчанием, а не find(...).text. Одна функция вместо двух действий, и решение о пропуске принято сразу, а не отложено до падения.
Пространство имён: тег с адресом впереди
тег корня: {http://www.w3.org/2005/Atom}feed
поиск по короткому имени: None
поиск с пространством: Курс
Вот подвох, на котором спотыкаются все, кто впервые разбирает RSS, Atom или банковский обмен. Если в разметке есть xmlns="...", имя каждого тега на самом деле длиннее, чем выглядит: перед ним стоит адрес пространства имён в фигурных скобках.
find("entry") ищет тег с именем entry — и не находит, потому что тег называется {http://www.w3.org/2005/Atom}entry. Ошибки при этом нет: снова None, снова тишина.
Лечится словарём префиксов:
NS = {"a": "http://www.w3.org/2005/Atom"}
atom.find("a:entry/a:title", NS)
Ответ Нацбанка пространств имён не имеет — поэтому в уроке он и разбирается коротко. Но в первом же чужом фиде вы это встретите.
Чужой XML и безопасность
ElementTree разбирает то, что ему дали, и на специально составленном файле его можно заставить съесть много памяти. Для данных из-под контроля — банк, статистика — это не проблема. Для файлов, которые присылают пользователи, берут defusedxml: тот же интерфейс, отключённые опасные возможности.
Сказать об этом честнее, чем промолчать: разбор чужой разметки — это работа с чужим вводом.
Карта урока
Скажите своими словами
Не подглядывая, ответьте вслух или на бумаге. Ответы — в конце урока.
- Чем разбор дерева лучше поиска по строке, если результат одинаковый?
- Что вернёт
findдля тега, которого нет, и чем это опасно? - Почему
find("entry")не находит тег в разметке сxmlns?
Разминка
Три коротких шага перед заданием: предсказать, дописать, починить. Ответы — в конце урока, но сначала ответьте сами.
1. Предскажите. Что напечатает эта программа?
import xml.etree.ElementTree as ET
root = ET.fromstring("<rates><item><title>USD</title></item><item><title>EUR</title></item></rates>")
print(len(root.findall("item")), root.find("item").findtext("title"))
2. Заполните пропуск. Вместо ... превратите значение в число.
import xml.etree.ElementTree as ET
root = ET.fromstring("<item><description>510.43</description></item>")
value = root.findtext("description")
print(type(value).__name__, ... * 2)
3. Почините. Тега change в этом ответе нет, и программа падает. Сделайте так, чтобы она сказала об этом словами.
import xml.etree.ElementTree as ET
root = ET.fromstring("<item><title>USD</title></item>")
print(root.find("change").text)
Задание
Обязательное. Возьмите ответ Нацбанка за тот же закреплённый день и разберите его разборщиком:
URL = "https://nationalbank.kz/rss/get_rates.cfm?fdate=15.01.2026"
WANTED = ("USD", "RUB", "UZS")
Напечатайте дату из ответа и число валют в нём. Затем по каждой из трёх валют — курс как он дан, за сколько единиц он дан и сколько стоит одна единица (четыре знака после точки). Последней строкой попросите тег, которого в ответе нет, — с умолчанием вместо падения.
Ожидаемый вывод:
дата: 15.01.2026 | валют в ответе: 39
USD: 510.43 за 1 → 510.4300 тенге за единицу
RUB: 6.49 за 1 → 6.4900 тенге за единицу
UZS: 4.24 за 100 → 0.0424 тенге за единицу
автора у ответа нет: тега author нет
Готово, критерии: вывод совпадает построчно; ни одного split по разметке — только find, findall, findtext; курс за единицу посчитан делением на quant; отсутствующий тег обработан умолчанием, а не проверкой на None после find.
На своих данных. Возьмите любой RSS-фид, который читаете, — новости, блог, подкаст. Разберите его тем же способом и напечатайте заголовки первых пяти записей. Если у фида окажется xmlns, вы узнаете об этом по пустому результату — и теперь знаете, что делать.
По желанию.
- Напечатайте
item.attribдля любого элемента и посмотрите, что там. - Найдите в ответе валюту с
quant= 1000 и посчитайте её курс за единицу. - Разберите тот же ответ строковыми методами из прошлого урока и сравните длину кода.
Куда это встанет в проекте
Сводка получает второй настоящий источник — курс валют, — и получает его надёжно: разбор больше не зависит от того, переставит ли банк пробел. Дальше числа из двух источников можно свести в одну таблицу: инфляция за год и курс за день.
Долги. Мы читаем весь ответ в память — для одного дня это правильно, для истории за десять лет уже нет. И quant мы учли, а вот выходные и праздники, когда курс не меняется, сводка пока не различает.
Ответы
Показать ответы
На вопросы
- Потому что результат одинаковый только сегодня. Строковый разбор держится на порядке символов: изменится пробел, переставят теги, добавят поле — и он молча начнёт брать не то. Разборщик держится на структуре, а она у разметки — обещание.
- Вернёт
None. Опасно тем, что ошибка появится не там, где тега нет, а там, где уNoneспросили.text. Поэтому берутfindtextс умолчанием. - Потому что при
xmlnsполное имя тега включает адрес пространства:{http://www.w3.org/2005/Atom}entry. Короткое имя ему не равно, иfindчестно отвечаетNone.
К разминке
2 USD.findallнашёл оба элемента,find— первый, аfindtextвзял текст внутри егоtitle.
2 USD
float(value).findtextвсегда отдаёт строку, а строку на два умножать нельзя так, как хочется:"510.43" * 2дало бы удвоенную строку, а не удвоенное число.
import xml.etree.ElementTree as ET
root = ET.fromstring("<item><description>510.43</description></item>")
value = root.findtext("description")
print(type(value).__name__, float(value) * 2)
str 1020.86
findвернулNone, а уNoneнет.text. Умолчание отвечает на вопрос сразу:
import xml.etree.ElementTree as ET
root = ET.fromstring("<item><title>USD</title></item>")
print(root.findtext("change", "изменения нет"))
изменения нет
Источники
Если вы нашли ошибку или опечатку в тексте статьи, то сообщите нам об этом
Комментарии (0)
Войдите, чтобы оставить комментарий →
Пока нет комментариев. Будьте первым.