"""Росстат / ЕМИСС 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 (SDMX + ``/indicator/data.do``) — за WAF: ЛЮБОЙ путь/метод/набор заголовков отдаёт статичный 80-байтный ``
/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 «демография (численность)».
Что НЕ приземляется (документировано, частичная поставка допустима ТЗ):
• среднедушевые доходы, ИПЦ, индекс цен на СМР — EMISS-only → WAF (см. выше). В
open-data их нет (проверено по реестру: 0 совпадений). Закрывается, когда появится
рабочий доступ к ЕМИСС (прокси / ручная выгрузка) — тогда добавится новый
``DatasetSpec`` с тем же upsert-контрактом.
Standalone smoke (ручная проверка против живого rosstat.gov.ru):
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
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"
# Браузероподобный 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 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
# ── standalone smoke ────────────────────────────────────────────────────────────
def _main() -> None:
"""``python -m app.services.scrapers.rosstat_emiss`` — печать нескольких строк
для ручной верификации против живого rosstat.gov.ru."""
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
rows = fetch_all_datasets()
logger.info("Rosstat rows fetched: %d", len(rows))
for r in rows:
logger.info(
" %s / %s @ %s = %s %s",
r.indicator_type,
r.region,
r.obs_date.isoformat(),
r.value,
r.unit,
)
if not rows:
logger.warning("No rows — проверь доступность rosstat.gov.ru/opendata.")
if __name__ == "__main__":
_main()