Курсы валют с сайта ЦБ — задача, которую пишут за полчаса и потом годами ловят по ней инциденты. Причина в том, что это внешняя зависимость в критичном месте: если курс участвует в расчёте цен, недоступность чужого сайта превращается в неправильные цены на вашем.
Разберём парсинг, а затем — то, что отличает рабочий импорт от опасного: поведение при недоступности источника и проверка данных на вменяемость.
Источник данных
Центробанк России публикует курсы валют в XML-формате:
https://www.cbr.ru/scripts/XML_daily.aspСтруктура ответа:
<?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
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
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
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
// /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();<!-- 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
// Регистрация агента (обновление раз в час)
\CAgent::AddAgent(
'\\Local\\Currency\\CbrRates::clearCache();',
'main',
'N',
3600, // Каждый час
'',
'Y'
);Что обязательно предусмотреть
Три вещи, без которых интеграция с внешним источником курсов опасна. Все три особенно важны, если по курсу считаются цены товаров.
Последнее известное значение вместо пустоты. Сайт ЦБ бывает недоступен, отвечает медленно и иногда отдаёт HTML-страницу с ошибкой вместо XML. Если ваш код в этой ситуации возвращает 0 или пустой массив, а цена считается как «цена в долларах × курс», магазин начнёт продавать по нулю. Курс должен храниться в базе, а обновление — только заменять сохранённое значение при успехе.
Проверка на вменяемость перед сохранением. Курс, отличающийся от предыдущего больше чем на 20–30%, — это почти наверняка не резкое движение рынка, а сбой парсинга или изменение формата на стороне ЦБ. Такое значение нужно не применять, а отправлять уведомление человеку. Стоимость ошибки здесь несимметрична: пропущенное обновление курса — мелочь, применённый неверный курс — распродажа каталога.
Мониторинг свежести. Проверка «дата последнего успешного обновления не старше двух суток» с уведомлением — три строки кода, которые спасают от ситуации «полгода считали по курсу прошлого года, потому что парсер тихо сломался».
Итоги
Кэш и хранение — разные вещи. Кэш можно очистить; курс должен пережить очистку кэша и недоступность источника, поэтому его место в базе.
Агент должен обновлять данные, а не сбрасывать кэш. Схема «сбросили кэш — следующий посетитель сходит на cbr.ru» означает, что запрос к внешнему сайту происходит внутри пользовательского запроса, а падение ЦБ становится тормозящей страницей. Обновляйте по расписанию в фоне.
Расписание — по факту публикации. ЦБ выкладывает курс на следующий день около 15:00 МСК, и ежечасное обновление большую часть суток бессмысленно. Достаточно нескольких попыток после 15:00 с повтором при неудаче.
Если валюты нужны для торгового каталога, посмотрите сначала на штатный модуль currency: в нём уже есть типы валют, курсы с датами, форматирование и пересчёт цен, а также штатная загрузка курсов ЦБ. Свой парсер оправдан, когда нужен нестандартный источник или собственная логика применения курса, — но не стоит переписывать то, что уже есть в ядре.
Комментарии
Система комментариев скоро будет подключена. А пока вы можете написать мне в Telegram или на email.