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

Битрикс: получение курсов валют из 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: в нём уже есть типы валют, курсы с датами, форматирование и пересчёт цен, а также штатная загрузка курсов ЦБ. Свой парсер оправдан, когда нужен нестандартный источник или собственная логика применения курса, — но не стоит переписывать то, что уже есть в ядре.

🚀

Хотите такое же решение?

Настрою окружение под ваш проект, учту специфику инфраструктуры и обучу команду.

Обсудить проект →
Бесплатная консультация · Ответ в течение дня

Комментарии

Система комментариев скоро будет подключена. А пока вы можете написать мне в Telegram или на email.