"""CBR (Банк России) macro indicators scraper — key rate + inflation (ИПЦ YoY). Fills two ``macro_indicator`` series (both region='rf', source='cbr'), детерминированно, без LLM: • ``key_rate`` — ключевая ставка ЦБ, ДНЕВНОЙ ряд, через SOAP DailyInfoWebServ. • ``inflation_yoy`` — инфляция «% г/г» (year-over-year, на базе ИПЦ Росстата), МЕСЯЧНЫЙ ряд, через CBR UniDbQuery DownloadExcel (#946). #946 / ИПЦ-вход: путь fedstat ИПЦ заблокирован reCAPTCHA (dataGrid, см. ``rosstat_emiss``). ЦБ публикует инфляцию ОТКРЫТО (НЕ за WAF, работает с dev напрямую) на ``https://www.cbr.ru/hd_base/infl/`` — таблица «Дата | Ключевая ставка | Инфляция, % г/г | Цель по инфляции». Чистый машиночитаемый источник за этой страницей — её Excel-экспорт UniDbQuery (HTML-таблица рендерит только последние ~6 месяцев и НЕ реагирует на GET-параметр диапазона, поэтому для бэкфилла берём Excel). Endpoint / method (KEY RATE — verified live 2026-06-02): POST https://www.cbr.ru/DailyInfoWebServ/DailyInfo.asmx SOAP 1.2 action ``KeyRateXML(fromDate, ToDate)``. ⚠ Параметры называются ``fromDate`` и ``ToDate`` (вторая — с ЗАГЛАВНОЙ T, так в WSDL). Оба типа ``xsd:dateTime`` — нужен суффикс времени ``T00:00:00``; голый ``YYYY-MM-DD`` сервис молча игнорит и возвращает пустой ``KeyRateXMLResponse``. (Поэтому плоский GET-form ``/KeyRate?fromDate=..&toDate=..`` тоже не работает — у DailyInfoWebServ нет HTTP-GET binding для этого метода, только SOAP.) Response shape (``KeyRateXML`` — без diffgram-обёртки, в отличие от ``KeyRate``):
2023-08-15T00:00:00+03:00
12.00
...
Строки идут в обратном хронологическом порядке; ``parse_key_rate_xml`` сортировку не навязывает — отдаёт в порядке появления. Endpoint / method (INFLATION — verified live 2026-06-07/08): GET https://www.cbr.ru/Queries/UniDbQuery/DownloadExcel/132934 ?FromDate=MM/DD/YYYY&ToDate=MM/DD/YYYY&posted=False Возвращает .xlsx (openpyxl-читаемый): колонки «Дата (MM.YYYY) | Ключевая ставка, % годовых | Инфляция, % г/г | Цель …». Берём ТОЛЬКО колонку «Инфляция, % г/г» (year-over-year %). Полная история 01.2013→текущий месяц; FromDate/ToDate точно ограничивают диапазон. Запятая — десятичный разделитель в HTML, но openpyxl отдаёт нативный float. Ранние строки (до 09.2013) несут пустую ставку, но инфляция есть всегда. NB: id=132934 — стабильный id ряда «Инфляция и ключевая ставка» (взят из ссылки DownloadExcel на самой странице /hd_base/infl/). Если ЦБ его сменит — fetch вернёт не-200 / пустой парс → task залогирует и пробросит (видно в GlitchTip). Парсинг вынесен в чистые ``parse_key_rate_xml`` / ``parse_inflation_xlsx`` (offline-тестируемые на фикстурах); HTTP-обвязки ``fetch_key_rate`` / ``fetch_inflation`` тонкие (httpx, timeout, UA, retry). Standalone smoke (для ручной проверки против живого cbr.ru): python -m app.services.scrapers.cbr_macro Mirror conventions: httpx.Client + явный User-Agent (как objective.py / ekburg_permits.py), logger вместо print, типизированные сигнатуры. """ from __future__ import annotations import logging from datetime import date, datetime, timedelta from decimal import Decimal, InvalidOperation from io import BytesIO from typing import Any from xml.etree import ElementTree as ET import httpx from openpyxl import load_workbook logger = logging.getLogger(__name__) # ── constants ──────────────────────────────────────────────────────────────── CBR_DAILYINFO_URL = "https://www.cbr.ru/DailyInfoWebServ/DailyInfo.asmx" # SOAP method that returns a clean ... structure (no ADO diffgram). CBR_KEYRATE_METHOD = "KeyRateXML" # Инфляция (ИПЦ YoY): Excel-экспорт UniDbQuery страницы /hd_base/infl/. id=132934 — # стабильный id ряда «Инфляция и ключевая ставка» (см. ссылку DownloadExcel на странице). CBR_INFLATION_EXCEL_URL = "https://www.cbr.ru/Queries/UniDbQuery/DownloadExcel/132934" # Заголовок колонки инфляции в Excel-экспорте (подстрочный матч — устойчив к мелким # правкам формулировки/единиц после «Инфляция»). НЕ путать с «Цель по инфляции». CBR_INFLATION_COLUMN_MARKER = "инфляц" CBR_INFLATION_TARGET_MARKER = "цель" USER_AGENT = "GenDesign/1.0 (+https://gendsgn.ru) macro indicators scraper" DEFAULT_TIMEOUT_S = 30.0 DEFAULT_RETRIES = 3 # Полный бэкфилл истории ключевой ставки. ON CONFLICT в таске делает повторные # прогоны идемпотентными, так что диапазон можно держать широким. DEFAULT_FROM_DATE = date(2019, 1, 1) # Инфляция ЦБ публикуется с 01.2013 (ИПЦ YoY). Широкий старт безопасен — ON CONFLICT # в таске делает прогон идемпотентным. DEFAULT_INFLATION_FROM_DATE = date(2013, 1, 1) class CBRScraperError(RuntimeError): """Сетевая / протокольная ошибка обращения к DailyInfoWebServ.""" # ── SOAP envelope ────────────────────────────────────────────────────────────── def _soap_envelope(from_date: date, to_date: date) -> str: """Собрать SOAP 1.2 envelope для ``KeyRateXML(fromDate, ToDate)``. Даты сериализуются как ``YYYY-MM-DDT00:00:00`` — сервис принимает только ``xsd:dateTime`` (см. module docstring). ``ToDate`` — с заглавной T. """ return ( '' '' "" f'<{CBR_KEYRATE_METHOD} xmlns="http://web.cbr.ru/">' f"{from_date.isoformat()}T00:00:00" f"{to_date.isoformat()}T00:00:00" f"" "" "" ) # ── pure parse (unit-testable offline) ────────────────────────────────────────── def _parse_dt(raw: str) -> date | None: """Распарсить ``
`` (``2023-08-15T00:00:00+03:00``) в ``date``. Берём только календарную дату; tz-суффикс игнорируем (ключевая ставка — дневной показатель, время всегда полночь МСК). Поддерживаем и формы без суффикса / с 'Z'. """ raw = (raw or "").strip() if not raw: return None # Дата всегда в первых 10 символах ISO-8601 (YYYY-MM-DD...). Это устойчиво к # любому tz-суффиксу (+03:00 / Z / отсутствию) без зависимости от Python <3.11 # fromisoformat ограничений. head = raw[:10] try: return datetime.strptime(head, "%Y-%m-%d").date() except ValueError: logger.warning("CBR KeyRate: не удалось распарсить DT=%r", raw) return None def _parse_rate(raw: str) -> float | None: """Распарсить ```` в float. Принимаем и запятую как десятичный разделитель (на всякий случай — фактически CBR отдаёт точку).""" raw = (raw or "").strip().replace(",", ".") if not raw: return None try: # Через Decimal — чтобы не словить артефакты float-парсинга строки. return float(Decimal(raw)) except (InvalidOperation, ValueError): logger.warning("CBR KeyRate: не удалось распарсить Rate=%r", raw) return None def _localname(tag: str) -> str: """ElementTree-тег без namespace-префикса: ``{ns}KR`` → ``KR``.""" return tag.rsplit("}", 1)[-1] if "}" in tag else tag def parse_key_rate_xml(xml: str) -> list[tuple[date, float]]: """Pure-парсер SOAP-ответа CBR KeyRate → отсортированный список (date, rate). Находит все элементы ```` где угодно в дереве (устойчиво и к чистому ``KeyRateXML``, и к diffgram-варианту ``KeyRate``, и к разным namespace'ам). Из каждого берёт дочерние ``
`` и ````. Возвращает список ``(date, rate)``, отсортированный по возрастанию даты. Пустой / битый / без KR XML → ``[]`` (не бросает) — единственное исключение логируется как warning. Дубли по дате схлопываются (последний выигрывает). """ if not xml or not xml.strip(): return [] try: root = ET.fromstring(xml) except ET.ParseError as e: logger.warning("CBR KeyRate: XML parse error: %s", e) return [] by_date: dict[date, float] = {} for kr in root.iter(): if _localname(kr.tag) != "KR": continue dt_val: date | None = None rate_val: float | None = None for child in kr: name = _localname(child.tag) if name == "DT": dt_val = _parse_dt(child.text or "") elif name == "Rate": rate_val = _parse_rate(child.text or "") if dt_val is not None and rate_val is not None: by_date[dt_val] = rate_val return sorted(by_date.items()) # ── HTTP fetch (thin) ─────────────────────────────────────────────────────────── def fetch_key_rate( from_date: date | None = None, to_date: date | None = None, *, timeout_s: float = DEFAULT_TIMEOUT_S, retries: int = DEFAULT_RETRIES, ) -> list[tuple[date, Decimal]]: """Загрузить историю ключевой ставки ЦБ за период [from_date, to_date]. Args: from_date: начало периода. По умолчанию ``DEFAULT_FROM_DATE`` (2019-01-01) — полный бэкфилл истории. to_date: конец периода. По умолчанию сегодня (UTC-date достаточно; ставка дневная). timeout_s: httpx timeout на запрос. retries: число повторов на сетевых / 5xx ошибках (экспоненциальный backoff). Returns: Отсортированный по дате список ``(date, Decimal)``. Decimal — чтобы отдать в numeric-колонку без потери точности. Raises: CBRScraperError: при сетевой ошибке / не-200 / SOAP fault после всех повторов. Caller (Celery task) логирует и пробрасывает — surfaces в GlitchTip. """ from_date = from_date or DEFAULT_FROM_DATE to_date = to_date or datetime.now().date() body = _soap_envelope(from_date, to_date) headers = { "Content-Type": "application/soap+xml; charset=utf-8", "User-Agent": USER_AGENT, } last_exc: Exception | None = None with httpx.Client(timeout=timeout_s, headers=headers) as client: for attempt in range(retries + 1): try: resp = client.post(CBR_DAILYINFO_URL, content=body.encode("utf-8")) except httpx.HTTPError as e: last_exc = e logger.warning( "CBR KeyRate fetch network error (attempt %d/%d): %s", attempt + 1, retries + 1, e, ) else: if resp.status_code == 200: rows = parse_key_rate_xml(resp.text) logger.info( "CBR KeyRate fetched %d rows for [%s..%s]", len(rows), from_date.isoformat(), to_date.isoformat(), ) # Decimal(str(float)) — нормализуем через строку, чтобы # numeric получил аккуратное значение (12.0 → '12.0'). return [(d, Decimal(str(v))) for d, v in rows] # 5xx — повторяем; 4xx — нет смысла. last_exc = CBRScraperError( f"CBR DailyInfoWebServ HTTP {resp.status_code}: {resp.text[:300]}" ) if resp.status_code < 500: break logger.warning( "CBR KeyRate HTTP %d (attempt %d/%d)", resp.status_code, attempt + 1, retries + 1, ) # backoff перед следующей попыткой (не на последней итерации) if attempt < retries: import time time.sleep(min(2**attempt, 10)) raise CBRScraperError(f"CBR KeyRate fetch failed after {retries + 1} attempts: {last_exc}") # ── inflation (ИПЦ YoY, region='rf', monthly) — pure parse ────────────────────── def _parse_month_cell(raw: Any) -> date | None: """Распарсить ячейку «Дата» Excel-экспорта инфляции в 1-е число месяца. Формат CBR — строка ``MM.YYYY`` (напр. '04.2026'). На всякий случай поддерживаем и datetime/date (если ЦБ когда-нибудь отдаст типизированную ячейку — берём её год/месяц, нормализуя к 1-му числу). Незнакомое → None (строка пропускается). """ if isinstance(raw, datetime): return date(raw.year, raw.month, 1) if isinstance(raw, date): return date(raw.year, raw.month, 1) s = str(raw or "").strip() if not s: return None # Основной формат MM.YYYY. Терпим и MM/YYYY (на случай локали). s = s.replace("/", ".") parts = s.split(".") if len(parts) != 2: logger.warning("CBR inflation: не удалось распарсить дату=%r", raw) return None try: month = int(parts[0]) year = int(parts[1]) except ValueError: logger.warning("CBR inflation: не удалось распарсить дату=%r", raw) return None if not (1 <= month <= 12 and year >= 1900): logger.warning("CBR inflation: дата вне диапазона=%r", raw) return None return date(year, month, 1) def _parse_inflation_value(raw: Any) -> float | None: """Распарсить ячейку «Инфляция, % г/г» в float. None/пусто → None (строка скип). openpyxl отдаёт нативный int/float; на случай строковой ячейки принимаем и запятую как десятичный разделитель. """ if raw is None: return None if isinstance(raw, (int, float)): return float(raw) s = str(raw).strip().replace(",", ".") if not s: return None try: return float(Decimal(s)) except (InvalidOperation, ValueError): logger.warning("CBR inflation: не удалось распарсить значение=%r", raw) return None def _find_header_and_inflation_col(rows: list[tuple[Any, ...]]) -> tuple[int, int] | None: """Найти строку-заголовок и индекс колонки «Инфляция, % г/г» в Excel-экспорте. Сканируем первые строки: заголовок — та, где есть ячейка, СОДЕРЖАЩАЯ 'инфляц' и НЕ содержащая 'цель' (чтобы не схватить «Цель по инфляции»). Возвращает ``(header_row_idx, inflation_col_idx)`` или None, если колонка не найдена (формат изменился) — тогда парсер вернёт [] и task залогирует пустой результат. """ for r_idx, row in enumerate(rows[:10]): # заголовок всегда в самом начале for c_idx, cell in enumerate(row): text = str(cell or "").strip().lower() if CBR_INFLATION_COLUMN_MARKER in text and CBR_INFLATION_TARGET_MARKER not in text: return r_idx, c_idx return None def parse_inflation_xlsx(content: bytes) -> list[tuple[date, float]]: """Pure-парсер Excel-экспорта инфляции CBR → отсортированный список (date, %). Берёт колонку «Инфляция, % г/г» (YoY %), дату «MM.YYYY» → 1-е число месяца. Строки без даты ИЛИ без значения инфляции пропускаются (ранние месяцы несут пустую ставку, но инфляция там есть; обратной ситуации в данных нет, но guard симметричен). Дубли по дате схлопываются (последний выигрывает). Graceful: пустой / битый xlsx / отсутствие колонки «Инфляция» → [] (НЕ бросает) — единственное исключение логируется как warning. Сортировка по возрастанию даты. Args: content: байты .xlsx (как вернул DownloadExcel). Returns: Отсортированный по дате список ``(date, float)`` (значение инфляции в %). """ if not content: return [] try: wb = load_workbook(BytesIO(content), read_only=True, data_only=True) except Exception as e: # openpyxl бросает разные типы на битом файле logger.warning("CBR inflation: не удалось открыть xlsx: %s", e) return [] try: sheet = wb.active if sheet is None: return [] rows = list(sheet.iter_rows(values_only=True)) finally: wb.close() if not rows: return [] found = _find_header_and_inflation_col(rows) if found is None: logger.warning("CBR inflation: колонка «Инфляция, %% г/г» не найдена в xlsx") return [] header_idx, infl_col = found by_date: dict[date, float] = {} for row in rows[header_idx + 1 :]: if not row: continue d = _parse_month_cell(row[0] if len(row) > 0 else None) if d is None: continue value = _parse_inflation_value(row[infl_col] if len(row) > infl_col else None) if value is None: continue by_date[d] = value return sorted(by_date.items()) # ── inflation (ИПЦ YoY) — HTTP fetch (thin) ───────────────────────────────────── def fetch_inflation( from_date: date | None = None, to_date: date | None = None, *, timeout_s: float = DEFAULT_TIMEOUT_S, retries: int = DEFAULT_RETRIES, ) -> list[tuple[date, Decimal]]: """Загрузить месячный ряд инфляции «% г/г» (ИПЦ YoY) ЦБ за период. Источник — CBR UniDbQuery DownloadExcel (см. module docstring): открытый, НЕ за WAF, работает с dev напрямую (в отличие от fedstat ИПЦ за reCAPTCHA). Тянем .xlsx и парсим колонку «Инфляция, % г/г» через ``parse_inflation_xlsx``. Args: from_date: начало периода. По умолчанию ``DEFAULT_INFLATION_FROM_DATE`` (2013-01-01) — полный бэкфилл. to_date: конец периода. По умолчанию сегодня. timeout_s: httpx timeout на запрос. retries: число повторов на сетевых / 5xx ошибках (экспоненциальный backoff). Returns: Отсортированный по дате список ``(date, Decimal)`` (значение в %). Decimal — чтобы отдать в numeric-колонку без потери точности. Raises: CBRScraperError: при сетевой ошибке / не-200 после всех повторов. Caller (Celery task) логирует и пробрасывает (surfaces в GlitchTip). Пустой (но валидный) Excel → [] без исключения. """ from_date = from_date or DEFAULT_INFLATION_FROM_DATE to_date = to_date or datetime.now().date() # CBR UniDbQuery ждёт даты в формате MM/DD/YYYY (US), URL-кодирование делает httpx. params = { "FromDate": from_date.strftime("%m/%d/%Y"), "ToDate": to_date.strftime("%m/%d/%Y"), "posted": "False", } headers = {"User-Agent": USER_AGENT} last_exc: Exception | None = None with httpx.Client(timeout=timeout_s, headers=headers, follow_redirects=True) as client: for attempt in range(retries + 1): try: resp = client.get(CBR_INFLATION_EXCEL_URL, params=params) except httpx.HTTPError as e: last_exc = e logger.warning( "CBR inflation fetch network error (attempt %d/%d): %s", attempt + 1, retries + 1, e, ) else: if resp.status_code == 200: rows = parse_inflation_xlsx(resp.content) logger.info( "CBR inflation fetched %d rows for [%s..%s]", len(rows), from_date.isoformat(), to_date.isoformat(), ) return [(d, Decimal(str(v))) for d, v in rows] last_exc = CBRScraperError( f"CBR inflation DownloadExcel HTTP {resp.status_code}: {resp.text[:300]}" ) if resp.status_code < 500: break logger.warning( "CBR inflation HTTP %d (attempt %d/%d)", resp.status_code, attempt + 1, retries + 1, ) if attempt < retries: import time time.sleep(min(2**attempt, 10)) raise CBRScraperError(f"CBR inflation fetch failed after {retries + 1} attempts: {last_exc}") # ── national mortgage weighted rate (region='rf') ─────────────────────────────── # TODO(#945): национальная средневзвешенная ставка по ипотеке (region='rf'). # Намеренно НЕ реализовано в этом PR. У ЦБ ряд есть, но стабильного # машиночитаемого endpoint без авторизации / Excel-парсинга не нашлось # (формы 0409316 публикуются как .xlsx с плавающей структурой листов — # рискованно для детерминированного scraper). key_rate — must-have, закрыт. # Ипотека deferrable: можно добавить отдельным fetch_*-методом по образцу # ekburg_permits.parse_xlsx, когда определимся со стабильным источником. # ── standalone smoke ──────────────────────────────────────────────────────────── def _main() -> None: """``python -m app.services.scrapers.cbr_macro`` — печатает несколько свежих строк ключевой ставки для ручной верификации против живого cbr.ru.""" logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s") # Узкий диапазон (последние ~120 дней) — быстрый smoke без полного бэкфилла. today = datetime.now().date() rows = fetch_key_rate(from_date=today - timedelta(days=120), to_date=today) logger.info("KeyRate rows fetched: %d", len(rows)) # Печатаем последние 5 (самые свежие) — sorted asc, поэтому хвост. for d, v in rows[-5:]: logger.info(" key_rate %s -> %s%%", d.isoformat(), v) if not rows: logger.warning("No key_rate rows returned — проверь endpoint/период.") # Инфляция (ИПЦ YoY) — последние ~12 месяцев. infl = fetch_inflation(from_date=date(today.year - 1, today.month, 1), to_date=today) logger.info("Inflation rows fetched: %d", len(infl)) for d, v in infl[-6:]: logger.info(" inflation_yoy %s -> %s%%", d.isoformat(), v) if not infl: logger.warning("No inflation rows returned — проверь endpoint/период.") if __name__ == "__main__": _main()