"""Росстат / ЕМИСС macro-indicator scraper → ``macro_indicator`` (#946, EPIC2). Заполняет региональные (Свердловская обл., ОКТМО 65 / ЕКБ) макро-ряды Росстата в унифицированной таблице ``macro_indicator`` (миграция ``data/sql/123_macro_indicator.sql``). Детерминированно, без LLM. Зеркалит форму ``cbr_macro.py``: pure-парсеры (offline- тестируемые на фикстурах) + тонкая httpx-обвязка (timeout, UA, retry); upsert делает Celery-таска ``rosstat_macro_sync`` через SAVEPOINT-per-row. ──────────────────────────────────────────────────────────────────────────────── ИСТОЧНИК ДАННЫХ — open-data + ЕМИСС-SDMX + открытый xlsx (разведано live 2026-06-07/08) ──────────────────────────────────────────────────────────────────────────────── ЕМИСС / fedstat.ru — за WAF с DEV-машины (hard-403), но ДОСТУПЕН С ПРОДА (verified 2026-06-07: ``POST https://www.fedstat.ru/indicator/data.do`` body ``id=&format=sdmx`` → 200 + SDMX-ML v1.0 GenericData). Поэтому ЕМИСС-fetch выполняется ТОЛЬКО на проде (Celery); парсер SDMX — pure и dev-тестируем на фикстурах ``tests/fixtures/emiss_*.xml``. ЧТО ПРИЗЕМЛЯЕТСЯ из ЕМИСС (см. EMISS_INDICATORS ниже): • income_per_capita — «Среднедушевые денежные доходы населения», fedstat id=57039, размерность ``s_OKATO`` (Свердл = ОКАТО 65000000000), квартальный (рубль/мес). ВАЖНО: дефолтный SDMX-экспорт этого индикатора СОДЕРЖИТ все субъекты, в т.ч. 65. ЧТО НЕ ПРИЗЕМЛЯЕТСЯ из ЕМИСС (документировано, частичная поставка допустима ТЗ §7.11): • cpi (ИПЦ) — fedstat id=31074 «Индексы потребительских цен на товары и услуги» (% , монотип, есть месячные и квартальные периоды). НЕ landed: индикатор много- мерный (поля 57831=ОКАТО, 58273=виды товаров/услуг, 57937=виды показателя, 33560=период, 3=год), и его ДЕФОЛТНЫЙ data.do-экспорт отдаёт лишь узкий срез (ОКАТО 030/14/15 — ЦФО/Белгород/Брянск), БЕЗ Свердл-65. Чтобы выбрать регион, fedstat требует POST ``dataGrid.do`` c ``selectedFilterIds=_``, перечисляющим выбранные коды ПО ВСЕМ размерностям, где ``internalId`` — внутренний id кода (НЕ значение ОКАТО), берётся отдельным filter-tree AJAX; плюс на странице индикатора reCAPTCHA. Это глубокий RE — отложено (закрывается тем же EmissIndicatorSpec + параметризацией data.do-выборки, когда добудем filter_id-маппинг). • construction_price_index (индекс цен на СМР) — из ЕМИСС НЕ берётся (кандидат EMISS id=31108 «Индексы цен производителей на строительную продукцию» доступен тем же открытым SDMX-путём POST data.do, но fedstat за WAF с dev — не верифицируем здесь). ПРИЗЕМЛЯЕТСЯ из ОТКРЫТОГО xlsx Росстата (см. блок «Индекс цен на СМР» ниже, region='rf'), reCAPTCHA-gated dataGrid.do НАМЕРЕННО НЕ используется. ``rosstat.gov.ru/opendata`` — БЕЗ WAF, машиночитаем (российский стандарт открытых данных 4.0): • реестр ``/opendata/list.csv`` — CSV (property,title,value,format), value = URL ``…//meta.csv``; • метаданные ``/opendata//meta.csv`` — CP1251 CSV (property,value); строка ``data--structure-.csv`` → URL фактического файла данных; • данные ``/opendata//data--…csv`` — табличный CSV; регионы ключуются ОКТМО (Свердловская обл. = префикс ``65``; ЕКБ-горокруг = ``6570100000``). Что РЕАЛЬНО приземляется отсюда для региона 65 (см. ниже DATASETS): • ``population_total`` — Численность постоянного населения (датасет ``…-population``, муниципальный срез; берём субъект-агрегат ОКТМО ``6500000000`` + г. Екатеринбург ``657010000011000``). Это § 7.11 «демография (численность)». Индекс цен на СМР приземляется из ОТКРЫТОГО xlsx Росстата (НЕ из opendata-реестра — там 0 совпадений; и НЕ из ЕМИСС/dataGrid.do): • construction_price_index — «Индексы цен производителей на строительную продукцию по РФ» (region='rf', месячный, к предыдущему месяцу, %). Прямая ссылка на xlsx со страницы rosstat.gov.ru «Цены»; reCAPTCHA-gated dataGrid.do НАМЕРЕННО НЕ используется (см. блок «Индекс цен на СМР» ниже + HARD SAFETY CONSTRAINT #946). Что НЕ приземляется из open-data (но среднедушевые доходы идут через ЕМИСС-прод): • ИПЦ — EMISS-only и в open-data его нет (0 совпадений по реестру); из ЕМИСС пока не landed (много-мерный, см. EMISS_INDICATORS-блок выше). Standalone smoke (ручная проверка; open-data — c dev, ЕМИСС-fetch — только с прода): python -m app.services.scrapers.rosstat_emiss Mirror conventions: ``httpx.Client`` + явный User-Agent (как cbr_macro / ekburg_permits), ``logger`` вместо print, типизированные сигнатуры, без хардкода секретов. """ from __future__ import annotations import csv import io import logging import xml.etree.ElementTree as ET from dataclasses import dataclass from datetime import date from decimal import Decimal, InvalidOperation from io import BytesIO from typing import Any import httpx from openpyxl import load_workbook logger = logging.getLogger(__name__) # ── constants ──────────────────────────────────────────────────────────────── ROSSTAT_OPENDATA_BASE = "https://rosstat.gov.ru/opendata" # ЕМИСС / fedstat.ru — SDMX-экспорт индикатора (POST id=&format=sdmx). За WAF с # dev (403), доступен с прода (см. шапку модуля). verify off — тот же российский гос-УЦ. FEDSTAT_DATA_URL = "https://www.fedstat.ru/indicator/data.do" # Индекс цен на строительную продукцию (СМР) — ОТКРЫТЫЙ xlsx Росстата (БЕЗ captcha, # БЕЗ dataGrid.do): страница «Цены» → «Индексы цен и средние цены строительной # продукции» → «Индексы цен производителей на строительную продукцию по РФ». Имя файла # несёт месяц-вёрстки (Invest_ind_stroitel_MM-YYYY.xlsx) и обновляется ежемесячно → # URL НЕ хардкодим (иначе протух бы каждый месяц и ingest тихо перестал бы тянуться). # Резолвим от текущей даты с look-back (Росстат публикует с лагом 1-3 мес): пробуем # текущий месяц и назад до _CONSTRUCTION_MAX_LOOKBACK, берём первый 200 — см. # _fetch_construction_latest. Доступен с dev (verify off, российский гос-УЦ). live 2026-06-08. ROSSTAT_CONSTRUCTION_XLSX_URL_TEMPLATE = ( "https://rosstat.gov.ru/storage/mediabank/Invest_ind_stroitel_{mm:02d}-{yyyy}.xlsx" ) _CONSTRUCTION_MAX_LOOKBACK = 6 # мес назад от текущего (запас на лаг публикации Росстата) # Браузероподобный UA: rosstat.gov.ru отдаёт open-data без WAF, но «голый» httpx-UA # иногда ловит 403 на edge-кэше — даём обычный desktop-Chrome UA (как nspd_client). USER_AGENT = ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36" ) DEFAULT_TIMEOUT_S = 30.0 DEFAULT_RETRIES = 3 # Кодировки CSV Росстата в порядке проб: data-файлы бывают и utf-8(-sig), и CP1251; # meta.csv почти всегда CP1251. Декодируем «первой подошедшей» (см. _decode_csv). _CSV_ENCODINGS: tuple[str, ...] = ("utf-8-sig", "cp1251", "utf-8") # rosstat.gov.ru отдаёт TLS-цепочку российского гос-УЦ (Russian Trusted / Минцифры), # корневой CA которой НЕ в trust-store наших контейнеров — verify=True падает с # CERTIFICATE_VERIFY_FAILED (подтверждено live 2026-06-07). Отключаем верификацию: # данные ПУБЛИЧНЫЕ open-data, секреты не передаются → MITM-риск незначим. Прецедент: # ekb_geoportal_client._VERIFY_TLS / nspd_lite._SSL_CTX для тех же росс-гос-эндпоинтов. _VERIFY_TLS = False # ОКТМО-коды интересующих территорий (Свердловская обл. / ЕКБ). ТЗ §7.11 «region 65». # • субъект «Свердловская область» — ОКТМО-агрегат '6500000000' (region='sverdl'); # • г. Екатеринбург (горокруг) — ОКТМО '657010000011000' (region='ekb'). # Ключуем по ТОЧНОМУ ОКТМО (а не по имени) — имена в датасетах варьируются # («Муниципальные образования Свердловской области» и т.п.), ОКТМО стабилен. OKTMO_SVERDLOVSK_OBLAST = "6500000000" OKTMO_EKATERINBURG = "657010000011000" class RosstatScraperError(RuntimeError): """Сетевая / протокольная ошибка обращения к rosstat.gov.ru/opendata.""" # ── dataset specs (что и откуда тянем) ────────────────────────────────────────── @dataclass(frozen=True) class RegionTarget: """Целевая территория ряда: ОКТМО-ключ → (region-метка macro_indicator).""" oktmo: str region: str # метка region в macro_indicator: 'sverdl' | 'ekb' @dataclass(frozen=True) class DatasetSpec: """Описание одного приземляемого open-data датасета Росстата. Атрибуты: code: код датасета в реестре (часть URL ``/opendata//…``). indicator_type: тип ряда в macro_indicator (напр. 'population_total'). unit: единица измерения (справочно, в PK не участвует). value_col_header: заголовок колонки со значением в data-CSV (для population — 'total'; матчим без учёта регистра/пробелов). oktmo_col_header: заголовок колонки с ОКТМО-кодом (для population — 'kode'). year_col_header: заголовок колонки с годом наблюдения, если он есть в данных (для population — 'year'); None → год берётся из meta ``modified``. targets: список территорий (ОКТМО → region-метка), которые извлекаем. comment: пояснение для colонки comment macro_indicator. """ code: str indicator_type: str unit: str value_col_header: str oktmo_col_header: str year_col_header: str | None targets: tuple[RegionTarget, ...] comment: str = "" # Демография (§7.11 «численность населения»). Датасет муниципального уровня; берём # субъект-агрегат Свердловской обл. + г. Екатеринбург. year — в самих данных ('year'). POPULATION_SPEC = DatasetSpec( code="7708234640-population", indicator_type="population_total", unit="чел", value_col_header="total", oktmo_col_header="kode", year_col_header="year", targets=( RegionTarget(oktmo=OKTMO_SVERDLOVSK_OBLAST, region="sverdl"), RegionTarget(oktmo=OKTMO_EKATERINBURG, region="ekb"), ), comment="Росстат open-data 'Численность постоянного населения' (#946 §7.11)", ) # Активные спеки скрейпера. Расширяется добавлением DatasetSpec (напр. когда появится # доступ к EMISS-рядам ИПЦ/доходов — у них будет свой Spec с тем же upsert-контрактом). DATASETS: tuple[DatasetSpec, ...] = (POPULATION_SPEC,) # ── parsed-row contract ───────────────────────────────────────────────────────── @dataclass(frozen=True) class MacroRow: """Одна готовая к upsert строка macro_indicator (источник 'rosstat').""" indicator_type: str region: str obs_date: date value: Decimal unit: str comment: str # ── pure helpers (unit-testable offline) ──────────────────────────────────────── def _decode_csv(raw: bytes) -> str: """Декодировать байты CSV Росстата «первой подошедшей» кодировкой (_CSV_ENCODINGS). Росстат смешивает utf-8(-sig) и CP1251 между файлами. Возвращает str; если ни одна кодировка не подошла строго — падаем на utf-8 с errors='replace' (лучше частично-битый текст, чем исключение на одном файле). """ for enc in _CSV_ENCODINGS: try: return raw.decode(enc) except UnicodeDecodeError: continue logger.warning("Rosstat CSV: ни одна кодировка не подошла строго — utf-8/replace") return raw.decode("utf-8", errors="replace") def _sniff_rows(text: str) -> list[list[str]]: """Разобрать CSV-текст в список строк, авто-детект разделителя (',' либо ';'). Берём тот разделитель, что даёт > 1 колонки в первой непустой строке. Росстат использует и запятую (population), и точку-с-запятой (часть рядов) как delimiter. """ for delim in (",", ";"): rows = list(csv.reader(io.StringIO(text), delimiter=delim)) first = next((r for r in rows if any(c.strip() for c in r)), []) if len(first) > 1: return rows # фоллбэк — как запятая (даже если одна колонка) return list(csv.reader(io.StringIO(text), delimiter=",")) def _norm(s: str) -> str: """Нормализовать заголовок колонки для матча: lower + strip пробелов/BOM.""" return (s or "").strip().lstrip("").lower() def parse_meta_data_url(meta_text: str) -> str | None: """Извлечь URL файла данных из meta.csv (open-data standard 4.0). meta.csv — две колонки (property,value). Строка данных имеет property вида ``data--structure-.csv`` и value = абсолютный URL CSV-данных. Если таких строк несколько — возвращаем ПОСЛЕДНЮЮ (самый свежий снапшот). None — если строки данных нет. """ rows = _sniff_rows(meta_text) data_url: str | None = None for row in rows: if len(row) < 2: continue prop = row[0].strip() if prop.startswith("data-") and row[1].strip().lower().startswith("http"): data_url = row[1].strip() return data_url def parse_meta_modified_year(meta_text: str) -> int | None: """Извлечь год из meta.csv ``modified`` (формат 'YYYYMMDD') — фоллбэк для obs_date. Используется, когда в самих данных нет колонки года (year_col_header is None). None — если поле отсутствует / не парсится. """ rows = _sniff_rows(meta_text) for row in rows: if len(row) >= 2 and row[0].strip() == "modified": digits = row[1].strip()[:4] if digits.isdigit(): return int(digits) return None def _parse_value(raw: str) -> Decimal | None: """Распарсить числовое значение Росстата → Decimal. Принимает запятую как десятичный разделитель (Росстат: '1,5'), вырезает пробелы-разделители тысяч (включая узкий неразрывный  / ). Пустая строка / '-' / нечисло → None (строка пропускается caller'ом). """ raw = (raw or "").strip() if not raw or raw in {"-", "—", "…", "..."}: return None cleaned = raw.replace(" ", "").replace(" ", "").replace(" ", "").replace(",", ".") try: return Decimal(cleaned) except (InvalidOperation, ValueError): logger.warning("Rosstat: не удалось распарсить значение %r", raw) return None def _obs_date_for_year(year: int) -> date: """Годовой ряд → obs_date нормализуем к 1 января года (как monthly-ряды к 1-му числу).""" return date(year, 1, 1) def parse_dataset_rows( data_text: str, spec: DatasetSpec, *, fallback_year: int | None, ) -> list[MacroRow]: """Pure-парсер data-CSV датасета → список MacroRow для целевых ОКТМО. Алгоритм: 1. Находит индекс колонок ОКТМО / значения / года по их заголовкам (spec.*_col_header, матч через _norm). Нет нужной колонки → [] (+warning). 2. Для каждой строки: если ОКТМО ∈ spec.targets — парсит значение и год (из year-колонки либо fallback_year), эмитит MacroRow с region-меткой цели. 3. Дубли по (indicator_type, region, obs_date) схлопываются (последний выигрывает) — защита от повторов строк в исходнике; PK macro_indicator всё равно один. Никогда не бросает на данных: битые/пустые ячейки → строка пропускается. """ rows = _sniff_rows(data_text) if not rows: return [] # Заголовок — первая строка, где встречается искомый заголовок ОКТМО-колонки. want_oktmo = _norm(spec.oktmo_col_header) want_value = _norm(spec.value_col_header) want_year = _norm(spec.year_col_header) if spec.year_col_header else None header_idx: int | None = None for i, row in enumerate(rows[:5]): # заголовок Росстата в первых нескольких строках norm = [_norm(c) for c in row] if want_oktmo in norm and want_value in norm: header_idx = i break if header_idx is None: logger.warning( "Rosstat %s: не найдены колонки %r/%r в заголовке", spec.code, spec.oktmo_col_header, spec.value_col_header, ) return [] header = [_norm(c) for c in rows[header_idx]] col_oktmo = header.index(want_oktmo) col_value = header.index(want_value) col_year = header.index(want_year) if want_year and want_year in header else None target_by_oktmo = {t.oktmo: t for t in spec.targets} by_key: dict[tuple[str, date], MacroRow] = {} for row in rows[header_idx + 1 :]: if len(row) <= max(col_oktmo, col_value): continue oktmo = (row[col_oktmo] or "").strip() target = target_by_oktmo.get(oktmo) if target is None: continue value = _parse_value(row[col_value]) if value is None: continue year: int | None = None if col_year is not None and len(row) > col_year: yr = (row[col_year] or "").strip()[:4] if yr.isdigit(): year = int(yr) if year is None: year = fallback_year if year is None: logger.warning("Rosstat %s: нет года для ОКТМО %s — пропуск", spec.code, oktmo) continue obs_date = _obs_date_for_year(year) by_key[(target.region, obs_date)] = MacroRow( indicator_type=spec.indicator_type, region=target.region, obs_date=obs_date, value=value, unit=spec.unit, comment=spec.comment, ) return [by_key[k] for k in sorted(by_key, key=lambda k: (k[0], k[1]))] # ── HTTP fetch (thin) ─────────────────────────────────────────────────────────── def _http_get(client: httpx.Client, url: str, *, retries: int = DEFAULT_RETRIES) -> bytes: """GET с ретраями (сеть / 5xx) → байты тела. Module-level для monkeypatch в тестах. Raises: RosstatScraperError: на не-200 / сетевой ошибке после всех ретраев. """ import time last_exc: Exception | None = None for attempt in range(retries + 1): try: resp = client.get(url) except httpx.HTTPError as e: last_exc = e logger.warning( "Rosstat GET network error (attempt %d/%d) %s: %s", attempt + 1, retries + 1, url, e, ) else: if resp.status_code == 200: return resp.content last_exc = RosstatScraperError( f"Rosstat GET HTTP {resp.status_code} {url}: {resp.text[:200]}" ) if resp.status_code < 500: break # 4xx — ретрай бессмыслен logger.warning( "Rosstat GET HTTP %d (attempt %d/%d) %s", resp.status_code, attempt + 1, retries + 1, url, ) if attempt < retries: time.sleep(min(2**attempt, 10)) raise RosstatScraperError(f"Rosstat GET failed after {retries + 1} attempts {url}: {last_exc}") def _http_post( client: httpx.Client, url: str, *, data: dict[str, str], retries: int = DEFAULT_RETRIES, ) -> bytes: """POST form-data с ретраями (сеть / 5xx) → байты тела. Для ЕМИСС data.do. Module-level для monkeypatch в тестах. Раздельно от _http_get: fedstat принимает данные ТОЛЬКО POST'ом (form-urlencoded id+format), GET отдаёт SPA-оболочку. Raises: RosstatScraperError: на не-200 / сетевой ошибке после всех ретраев. """ import time last_exc: Exception | None = None for attempt in range(retries + 1): try: resp = client.post(url, data=data) except httpx.HTTPError as e: last_exc = e logger.warning( "EMISS POST network error (attempt %d/%d) %s: %s", attempt + 1, retries + 1, url, e, ) else: if resp.status_code == 200: return resp.content last_exc = RosstatScraperError( f"EMISS POST HTTP {resp.status_code} {url}: {resp.text[:200]}" ) if resp.status_code < 500: break # 4xx (в т.ч. WAF-403 с dev) — ретрай бессмыслен logger.warning( "EMISS POST HTTP %d (attempt %d/%d) %s", resp.status_code, attempt + 1, retries + 1, url, ) if attempt < retries: time.sleep(min(2**attempt, 10)) raise RosstatScraperError(f"EMISS POST failed after {retries + 1} attempts {url}: {last_exc}") def fetch_dataset( spec: DatasetSpec, *, timeout_s: float = DEFAULT_TIMEOUT_S, retries: int = DEFAULT_RETRIES, client: httpx.Client | None = None, ) -> list[MacroRow]: """Загрузить и распарсить один датасет Росстата → список MacroRow. Два запроса: meta.csv (→ URL данных + fallback-год) и сам data-CSV. Парсинг — в pure-функциях выше (offline-тестируемых). Возвращает строки для целевых ОКТМО. Args: spec: описание датасета (код, колонки, целевые ОКТМО). timeout_s: httpx timeout на запрос. retries: число ретраев на сетевых / 5xx ошибках. client: переиспользуемый httpx.Client (для батча). None → создаём свой. Raises: RosstatScraperError: на сетевой/HTTP-ошибке либо если meta не содержит URL данных. """ own_client = client is None if client is None: client = httpx.Client( timeout=timeout_s, headers={"User-Agent": USER_AGENT, "Accept-Language": "ru-RU,ru;q=0.9"}, follow_redirects=True, verify=_VERIFY_TLS, ) try: meta_url = f"{ROSSTAT_OPENDATA_BASE}/{spec.code}/meta.csv" meta_text = _decode_csv(_http_get(client, meta_url, retries=retries)) data_url = parse_meta_data_url(meta_text) if not data_url: raise RosstatScraperError(f"Rosstat {spec.code}: meta.csv без URL данных") fallback_year = parse_meta_modified_year(meta_text) data_text = _decode_csv(_http_get(client, data_url, retries=retries)) rows = parse_dataset_rows(data_text, spec, fallback_year=fallback_year) logger.info( "Rosstat %s (%s): распарсено %d строк для %d целевых ОКТМО", spec.code, spec.indicator_type, len(rows), len(spec.targets), ) return rows finally: if own_client: client.close() def fetch_all_datasets( specs: tuple[DatasetSpec, ...] = DATASETS, *, timeout_s: float = DEFAULT_TIMEOUT_S, retries: int = DEFAULT_RETRIES, ) -> list[MacroRow]: """Загрузить все активные датасеты Росстата → плоский список MacroRow. Один общий httpx.Client на батч. Сбой одного датасета логируется и НЕ валит остальные (graceful — частичная поставка допустима ТЗ). """ out: list[MacroRow] = [] with httpx.Client( timeout=timeout_s, headers={"User-Agent": USER_AGENT, "Accept-Language": "ru-RU,ru;q=0.9"}, follow_redirects=True, verify=_VERIFY_TLS, ) as client: for spec in specs: try: out.extend(fetch_dataset(spec, retries=retries, client=client)) except RosstatScraperError as e: logger.warning("Rosstat: датасет %s пропущен: %s", spec.code, e) return out # ══════════════════════════════════════════════════════════════════════════════════ # ЕМИСС / fedstat.ru — SDMX-ML v1.0 GenericData (EMISS-only ряды, fetch только с прода) # ══════════════════════════════════════════════════════════════════════════════════ # Источник: POST https://www.fedstat.ru/indicator/data.do body: id=&format=sdmx # → SDMX-ML v1.0 GenericData (см. tests/fixtures/emiss_income_57039.xml). Структура: # # …коды регионов… # (fedstat-расширение) # # # # # (единица измерения) # (под-период года) # # 2024 # # … # Один = один (регион, PERIOD) ряд; год наблюдения — в