Shanraq.org Shanraq.org
Разбор XML: дерево вместо строки, `None` вместо тега и адрес перед именем
IT

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: тот же интерфейс, отключённые опасные возможности.

Сказать об этом честнее, чем промолчать: разбор чужой разметки — это работа с чужим вводом.

Карта урока

Карта урока: дерево, findtext и пространство имён

Скажите своими словами

Не подглядывая, ответьте вслух или на бумаге. Ответы — в конце урока.

  1. Чем разбор дерева лучше поиска по строке, если результат одинаковый?
  2. Что вернёт find для тега, которого нет, и чем это опасно?
  3. Почему 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 мы учли, а вот выходные и праздники, когда курс не меняется, сводка пока не различает.

Ответы

Показать ответы

На вопросы

  1. Потому что результат одинаковый только сегодня. Строковый разбор держится на порядке символов: изменится пробел, переставят теги, добавят поле — и он молча начнёт брать не то. Разборщик держится на структуре, а она у разметки — обещание.
  2. Вернёт None. Опасно тем, что ошибка появится не там, где тега нет, а там, где у None спросили .text. Поэтому берут findtext с умолчанием.
  3. Потому что при xmlns полное имя тега включает адрес пространства: {http://www.w3.org/2005/Atom}entry. Короткое имя ему не равно, и find честно отвечает None.

К разминке

  1. 2 USD. findall нашёл оба элемента, find — первый, а findtext взял текст внутри его title.
2 USD
  1. 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
  1. find вернул None, а у None нет .text. Умолчание отвечает на вопрос сразу:
import xml.etree.ElementTree as ET

root = ET.fromstring("<item><title>USD</title></item>")
print(root.findtext("change", "изменения нет"))
изменения нет

Источники

Если вы нашли ошибку или опечатку в тексте статьи, то сообщите нам об этом

Проверить задание

Сначала решите и запустите в VS Code — редактор покажет ошибку на месте. Готовое решение вставьте сюда. Проверяет модель: она укажет на ошибку, но не даст готовый ответ.

Чтобы проверить, нужно войти. Войти

Комментарии (0)

Пока нет комментариев. Будьте первым.