gendesign/backend/app/services/scrapers/cbr_macro.py
Light1YT c78c6ec2d0
All checks were successful
Deploy / build-frontend (push) Has been skipped
Deploy / deploy (push) Successful in 1m10s
Deploy / changes (push) Successful in 6s
Deploy / build-backend (push) Successful in 1m24s
Deploy / build-worker (push) Successful in 2m53s
feat(site-finder): live CBR key-rate scraper → macro_indicator (#945 PR B) (#964)
2026-06-02 19:05:57 +00:00

282 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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``):
<KeyRateXMLResult>
<KeyRate>
<KR><DT>2023-08-15T00:00:00+03:00</DT><Rate>12.00</Rate></KR>
...
</KeyRate>
</KeyRateXMLResult>
Строки идут в обратном хронологическом порядке; ``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 <KeyRate><KR>... 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 (
'<?xml version="1.0" encoding="utf-8"?>'
'<soap12:Envelope xmlns:soap12="http://www.w3.org/2003/05/soap-envelope">'
"<soap12:Body>"
f'<{CBR_KEYRATE_METHOD} xmlns="http://web.cbr.ru/">'
f"<fromDate>{from_date.isoformat()}T00:00:00</fromDate>"
f"<ToDate>{to_date.isoformat()}T00:00:00</ToDate>"
f"</{CBR_KEYRATE_METHOD}>"
"</soap12:Body>"
"</soap12:Envelope>"
)
# ── pure parse (unit-testable offline) ──────────────────────────────────────────
def _parse_dt(raw: str) -> date | None:
"""Распарсить ``<DT>`` (``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:
"""Распарсить ``<Rate>`` в 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).
Находит все элементы ``<KR>`` где угодно в дереве (устойчиво и к чистому
``KeyRateXML``, и к diffgram-варианту ``KeyRate``, и к разным namespace'ам).
Из каждого берёт дочерние ``<DT>`` и ``<Rate>``.
Возвращает список ``(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()