"""CBR (Банк России) macro indicators scraper — key rate history. Fills the ``key_rate`` series of the ``macro_indicator`` table (region='rf') from the live CBR SOAP service ``DailyInfoWebServ``. Deterministic, no LLM. Endpoint / method (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`` сортировку не навязывает — отдаёт в порядке появления. Парсинг вынесен в чистую ``parse_key_rate_xml`` (offline-тестируемую на фикстуре); HTTP-обвязка ``fetch_key_rate`` тонкая (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 xml.etree import ElementTree as ET import httpx 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" 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) 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}") # ── 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(" %s -> %s%%", d.isoformat(), v) if not rows: logger.warning("No rows returned — проверь endpoint/период.") if __name__ == "__main__": _main()