"""Росстат / ЕМИСС 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 (разведано live 2026-06-07) ──────────────────────────────────────────────────────────────────────────────── ЕМИСС / 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 (индекс цен на СМР) — id не подтверждён: кандидаты (31448 «Средние потреб. цены», 37426 «Еженедельные цены») — много-мерные и БЕЗ Свердл в дефолт-экспорте (та же проблема, что у ИПЦ). НЕ landed. ``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 «демография (численность)». Что НЕ приземляется из 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 import httpx 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" # Браузероподобный 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) ряд; год наблюдения — в