Битрикс: получение курсов валют из XML ЦБ РФ

Парсинг XML-ленты Центробанка для получения актуальных курсов валют. Класс CDataXML, конвертация, кэширование.

Дмитрий Мещеряков
Дмитрий Мещеряков
📅 15 февраля 2020 г.📖 7 мин чтения

Курсы валют с сайта ЦБ — задача, которую пишут за полчаса и потом годами ловят по ней инциденты. Причина в том, что это внешняя зависимость в критичном месте: если курс участвует в расчёте цен, недоступность чужого сайта превращается в неправильные цены на вашем.

Разберём парсинг, а затем — то, что отличает рабочий импорт от опасного: поведение при недоступности источника и проверка данных на вменяемость.

Источник данных

Центробанк России публикует курсы валют в XML-формате:

text
https://www.cbr.ru/scripts/XML_daily.asp

Структура ответа:

xml
<?xml version="1.0" encoding="windows-1251"?>
<ValCurs Date="15.06.2026" name="Foreign Currency Market">
    <Valute ID="R01010">
        <NumCode>036</NumCode>
        <CharCode>AUD</CharCode>
        <Nominal>1</Nominal>
        <Name>Австралийский доллар</Name>
        <Value>58,4521</Value>
    </Valute>
    <Valute ID="R01235">
        <NumCode>840</NumCode>
        <CharCode>USD</CharCode>
        <Nominal>1</Nominal>
        <Name>Доллар США</Name>
        <Value>89,7532</Value>
    </Valute>
    <!-- ... -->
</ValCurs>

Базовый парсинг

php
<?php
use Bitrix\Main\Web\HttpClient;

// Таймауты обязательны: без них зависший сокет держит ваш процесс
// столько, сколько отведено max_execution_time
$httpClient = new HttpClient([
    'socketTimeout' => 5,
    'streamTimeout' => 10,
]);

$response = $httpClient->get('https://www.cbr.ru/scripts/XML_daily.asp');

if ($response === false || $httpClient->getStatus() !== 200) {
    // НЕ die(): в агенте или в шаблоне это убьёт страницу целиком.
    // Логируем и работаем на последних известных значениях
    AddMessage2Log('CBR unavailable: status ' . $httpClient->getStatus(), 'currency');

    return;
}

// Парсим XML
$xml = new \CDataXML();
$xml->LoadString($response);

$rates = [];

if ($node = $xml->SelectNodes('/ValCurs')) {
    foreach ($node->children() as $valute) {
        $currency = [];
        
        foreach ($valute->children() as $field) {
            $value = iconv('windows-1251', 'UTF-8', $field->textContent());
            $currency[$field->name()] = $value;
        }
        
        $rates[$currency['CharCode']] = [
            'name' => $currency['Name'],
            'code' => $currency['CharCode'],
            'nominal' => (int) $currency['Nominal'],
            'value' => (float) str_replace(',', '.', $currency['Value']),
        ];
    }
}

print_r($rates);
💡 Совет

Заголовок Content-Type в GET-запросе бессмыслен — он описывает тело запроса, которого у GET нет. Если хочется явно сообщить о желаемом формате, это Accept, но ЦБ его всё равно игнорирует.

⚠️ Важно

Про кодировку и двойную конвертацию. XML от ЦБ объявляет encoding="windows-1251", и многие парсеры честно приводят содержимое к UTF-8 сами, ориентируясь на это объявление. Если после этого применить iconv('windows-1251', 'UTF-8', ...) ещё раз, вы получите испорченные русские названия валют — те самые «крокозябры».

Проверяется это за одну секунду: выведите $currency['Name'] до конвертации. Если там читаемый «Доллар США» — второй проход не нужен. Если нечитаемое — нужен. Не полагайтесь на то, что «в статье было написано»: поведение зависит от парсера и версии PHP.

Кстати, для отображения курса кодировка вообще не критична: CharCode и Value — ASCII. Ломается только человекочитаемое название.

Класс для работы с курсами

php
<?php

namespace Local\Currency;

use Bitrix\Main\Web\HttpClient;
use Bitrix\Main\Data\Cache;

class CbrRates
{
    private const API_URL = 'https://www.cbr.ru/scripts/XML_daily.asp';
    private const CACHE_TTL = 3600; // 1 час
    private const CACHE_DIR = '/cbr_rates/';

    private array $rates = [];
    private ?string $date = null;

    public function __construct()
    {
        $this->loadRates();
    }

    /**
     * Получение курса валюты
     */
    public function getRate(string $code): ?array
    {
        $code = strtoupper($code);
        return $this->rates[$code] ?? null;
    }

    /**
     * Получение значения курса
     */
    public function getValue(string $code): ?float
    {
        $rate = $this->getRate($code);
        return $rate ? $rate['value'] / $rate['nominal'] : null;
    }

    /**
     * Конвертация в рубли
     */
    public function toRub(float $amount, string $code): ?float
    {
        $rate = $this->getValue($code);
        return $rate ? $amount * $rate : null;
    }

    /**
     * Конвертация из рублей
     */
    public function fromRub(float $amount, string $code): ?float
    {
        $rate = $this->getValue($code);
        return $rate ? $amount / $rate : null;
    }

    /**
     * Конвертация между валютами
     */
    public function convert(float $amount, string $from, string $to): ?float
    {
        if ($from === 'RUB') {
            return $this->fromRub($amount, $to);
        }
        
        if ($to === 'RUB') {
            return $this->toRub($amount, $from);
        }

        // Конвертация через рубли
        $inRub = $this->toRub($amount, $from);
        return $inRub !== null ? $this->fromRub($inRub, $to) : null;
    }

    /**
     * Все курсы
     */
    public function getAllRates(): array
    {
        return $this->rates;
    }

    /**
     * Дата курсов
     */
    public function getDate(): ?string
    {
        return $this->date;
    }

    /**
     * Основные валюты
     */
    public function getMainRates(): array
    {
        $main = ['USD', 'EUR', 'CNY', 'GBP', 'JPY'];
        
        return array_filter(
            $this->rates,
            fn($code) => in_array($code, $main),
            ARRAY_FILTER_USE_KEY
        );
    }

    /**
     * Загрузка курсов с кэшированием
     */
    private function loadRates(): void
    {
        $cache = Cache::createInstance();
        
        if ($cache->initCache(self::CACHE_TTL, 'cbr_rates', self::CACHE_DIR)) {
            $cached = $cache->getVars();
            $this->rates = $cached['rates'];
            $this->date = $cached['date'];
            return;
        }

        $cache->startDataCache();

        try {
            $this->fetchRates();
            
            $cache->endDataCache([
                'rates' => $this->rates,
                'date' => $this->date,
            ]);
        } catch (\Throwable $e) {
            $cache->abortDataCache();
            throw $e;
        }
    }

    /**
     * Получение курсов с ЦБ
     */
    private function fetchRates(): void
    {
        $httpClient = new HttpClient([
            'socketTimeout' => 5,
            'streamTimeout' => 10,
        ]);
        
        $httpClient->setHeader('Content-Type', 'application/xml; charset=UTF-8');
        $response = $httpClient->get(self::API_URL);

        if (!$response) {
            throw new \RuntimeException('Не удалось получить курсы валют');
        }

        $xml = new \CDataXML();
        if (!$xml->LoadString($response)) {
            throw new \RuntimeException('Ошибка парсинга XML');
        }

        $root = $xml->SelectNodes('/ValCurs');
        if (!$root) {
            throw new \RuntimeException('Неверная структура XML');
        }

        // Дата курсов
        $this->date = $root->getAttribute('Date');

        // Парсим валюты
        foreach ($root->children() as $valute) {
            $currency = [];
            
            foreach ($valute->children() as $field) {
                $value = iconv('windows-1251', 'UTF-8', $field->textContent());
                $currency[$field->name()] = $value;
            }

            $code = $currency['CharCode'];
            $this->rates[$code] = [
                'code' => $code,
                'name' => $currency['Name'],
                'nominal' => (int) $currency['Nominal'],
                'value' => (float) str_replace(',', '.', $currency['Value']),
            ];
        }
    }

    /**
     * Сброс кэша
     */
    public static function clearCache(): void
    {
        $cache = Cache::createInstance();
        $cache->cleanDir(self::CACHE_DIR);
    }
}

Использование

php
<?php
use Local\Currency\CbrRates;

$rates = new CbrRates();

// Курс доллара
$usd = $rates->getValue('USD');
echo "Курс USD: {$usd} руб.\n";

// Конвертация 100 долларов в рубли
$rubAmount = $rates->toRub(100, 'USD');
echo "100 USD = {$rubAmount} RUB\n";

// Конвертация евро в доллары
$eurToUsd = $rates->convert(100, 'EUR', 'USD');
echo "100 EUR = {$eurToUsd} USD\n";

// Все основные курсы
foreach ($rates->getMainRates() as $rate) {
    printf(
        "%s (%s): %.4f руб.\n",
        $rate['name'],
        $rate['code'],
        $rate['value'] / $rate['nominal']
    );
}

echo "\nДата курсов: " . $rates->getDate();

Виджет курсов валют

php
<?php
// /local/components/local/currency.widget/component.php

use Local\Currency\CbrRates;

$rates = new CbrRates();

$arResult = [
    'DATE' => $rates->getDate(),
    'RATES' => [],
];

$showCurrencies = $arParams['CURRENCIES'] ?? ['USD', 'EUR', 'CNY'];

foreach ($showCurrencies as $code) {
    $rate = $rates->getRate($code);
    if ($rate) {
        $arResult['RATES'][] = [
            'CODE' => $rate['code'],
            'NAME' => $rate['name'],
            'VALUE' => $rate['value'] / $rate['nominal'],
        ];
    }
}

$this->includeComponentTemplate();
php
<!-- template.php -->
<div class="currency-widget">
    <div class="currency-widget__date">
        Курсы ЦБ на <?= $arResult['DATE'] ?>
    </div>
    <div class="currency-widget__list">
        <?php foreach ($arResult['RATES'] as $rate): ?>
            <div class="currency-widget__item">
                <span class="currency-widget__code"><?= $rate['CODE'] ?></span>
                <span class="currency-widget__value"><?= number_format($rate['VALUE'], 2, ',', ' ') ?></span>
                <span class="currency-widget__sign">₽</span>
            </div>
        <?php endforeach; ?>
    </div>
</div>

Агент для обновления

php
<?php
// Регистрация агента (обновление раз в час)
\CAgent::AddAgent(
    '\\Local\\Currency\\CbrRates::clearCache();',
    'main',
    'N',
    3600, // Каждый час
    '',
    'Y'
);

Что обязательно предусмотреть

Три вещи, без которых интеграция с внешним источником курсов опасна. Все три особенно важны, если по курсу считаются цены товаров.

Последнее известное значение вместо пустоты. Сайт ЦБ бывает недоступен, отвечает медленно и иногда отдаёт HTML-страницу с ошибкой вместо XML. Если ваш код в этой ситуации возвращает 0 или пустой массив, а цена считается как «цена в долларах × курс», магазин начнёт продавать по нулю. Курс должен храниться в базе, а обновление — только заменять сохранённое значение при успехе.

Проверка на вменяемость перед сохранением. Курс, отличающийся от предыдущего больше чем на 20–30%, — это почти наверняка не резкое движение рынка, а сбой парсинга или изменение формата на стороне ЦБ. Такое значение нужно не применять, а отправлять уведомление человеку. Стоимость ошибки здесь несимметрична: пропущенное обновление курса — мелочь, применённый неверный курс — распродажа каталога.

Мониторинг свежести. Проверка «дата последнего успешного обновления не старше двух суток» с уведомлением — три строки кода, которые спасают от ситуации «полгода считали по курсу прошлого года, потому что парсер тихо сломался».

Итоги

Кэш и хранение — разные вещи. Кэш можно очистить; курс должен пережить очистку кэша и недоступность источника, поэтому его место в базе.

Агент должен обновлять данные, а не сбрасывать кэш. Схема «сбросили кэш — следующий посетитель сходит на cbr.ru» означает, что запрос к внешнему сайту происходит внутри пользовательского запроса, а падение ЦБ становится тормозящей страницей. Обновляйте по расписанию в фоне.

Расписание — по факту публикации. ЦБ выкладывает курс на следующий день около 15:00 МСК, и ежечасное обновление большую часть суток бессмысленно. Достаточно нескольких попыток после 15:00 с повтором при неудаче.

💡 Совет

Если валюты нужны для торгового каталога, посмотрите сначала на штатный модуль currency: в нём уже есть типы валют, курсы с датами, форматирование и пересчёт цен, а также штатная загрузка курсов ЦБ. Свой парсер оправдан, когда нужен нестандартный источник или собственная логика применения курса, — но не стоит переписывать то, что уже есть в ядре.

Частые вопросы

Откуда брать курсы валют ЦБ РФ?

Центробанк публикует курсы в XML по адресу cbr.ru/scripts/XML_daily.asp. В ответе каждая валюта описана элементом Valute с полями NumCode, CharCode, Nominal, Name и Value, а дата курсов лежит в атрибуте Date корневого элемента ValCurs. Обратите внимание на Nominal: для ряда валют курс указан за 10 или 100 единиц, поэтому значение нужно делить на номинал.

Почему после парсинга XML ЦБ ломаются русские названия валют?

Из-за двойной конвертации кодировки. XML объявляет encoding windows-1251, и многие парсеры честно приводят содержимое к UTF-8 сами, ориентируясь на это объявление; если после этого применить iconv ещё раз, получатся крокозябры. Проверяется за секунду: выведите название до конвертации — если читается «Доллар США», второй проход не нужен. Поведение зависит от парсера и версии PHP, поэтому проверять надо у себя. Кстати, для отображения курса кодировка не критична: CharCode и Value — ASCII, ломается только человекочитаемое название.

Что делать, если сайт ЦБ недоступен?

Работать на последнем известном значении. Сайт ЦБ бывает недоступен, отвечает медленно и иногда отдаёт HTML-страницу с ошибкой вместо XML. Если код в этой ситуации возвращает 0 или пустой массив, а цена считается как цена в долларах, умноженная на курс, магазин начнёт продавать по нулю. Курс должен храниться в базе, а обновление — только заменять сохранённое значение при успехе. И никакого die() в обработчике: в агенте или в шаблоне это убьёт страницу целиком, нужно логировать и выходить. Таймауты сокета и потока тоже обязательны, иначе зависший сокет держит процесс всё отведённое max_execution_time.

Зачем проверять полученный курс перед сохранением?

Потому что стоимость ошибки несимметрична: пропущенное обновление курса — мелочь, применённый неверный курс — распродажа каталога. Курс, отличающийся от предыдущего больше чем на 20–30%, почти наверняка означает сбой парсинга или изменение формата на стороне ЦБ, а не резкое движение рынка. Такое значение нужно не применять, а отправлять уведомление человеку.

Как часто обновлять курсы валют?

По факту публикации, а не ежечасно. ЦБ выкладывает курс на следующий день около 15:00 по Москве, поэтому обновление раз в час большую часть суток бессмысленно — достаточно нескольких попыток после 15:00 с повтором при неудаче. Плюс обязателен мониторинг свежести: проверка, что дата последнего успешного обновления не старше двух суток, — это три строки кода, которые спасают от ситуации «полгода считали по курсу прошлого года, потому что парсер тихо сломался».

Писать свой парсер курсов или использовать модуль currency?

Если валюты нужны для торгового каталога, сначала посмотрите на штатный модуль currency: в нём уже есть типы валют, курсы с датами, форматирование, пересчёт цен и штатная загрузка курсов ЦБ. Свой парсер оправдан, когда нужен нестандартный источник или собственная логика применения курса, — но переписывать то, что уже есть в ядре, не стоит.