gendesign/backend/app/services/scrapers/rosstat_emiss.py
Light1YT 1f66dfd400
All checks were successful
CI / changes (push) Successful in 6s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 6s
Deploy / changes (push) Successful in 6s
CI / frontend-tests (pull_request) Has been skipped
Deploy / build-backend (push) Successful in 1m31s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 2m33s
Deploy / deploy (push) Successful in 1m12s
CI / backend-tests (push) Successful in 6m23s
CI / backend-tests (pull_request) Successful in 6m31s
feat(macro): ЕМИСС среднедушевые доходы (id=57039) -> macro_indicator (#946 part2)
Расширяет Росстат-скрейпер ЕМИСС/fedstat-рядом «Среднедушевые денежные доходы
населения» (fedstat id=57039, OKATO 65=Свердл, квартальный, руб). Добавляет pure
SDMX-ML v1.0 GenericData-парсер (stdlib ElementTree, dev-тестируем на фикстурах) +
EMISS-ветку Celery-таски rosstat_macro_sync (open-data + ЕМИСС, per-source guard,
source='emiss', frequency параметризован, CAST not ::, SAVEPOINT per-row).
income_per_capita проброшен в site_finder/macro (region='sverdl').

ЕМИСС за WAF с dev -> fetch исполняется на проде (verified: httpx POST из
gendesign-worker-1 -> 200 + SDMX, парсер извлёк 9 Свердл-строк). ИПЦ (id=31074) /
индекс цен СМР НЕ landed: многомерные, дефолт-экспорт data.do без Свердл, нужна
dataGrid-выборка (selectedFilterIds + filter-tree AJAX + reCAPTCHA) -- задокументировано.

Refs #946.
2026-06-07 18:44:38 +05:00

872 lines
42 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

"""Росстат / ЕМИСС 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=<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=<fieldId>_<internalId>``,
перечисляющим выбранные коды ПО ВСЕМ размерностям, где ``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 ``…/<code>/meta.csv``;
• метаданные ``/opendata/<code>/meta.csv`` — CP1251 CSV (property,value); строка
``data-<ts>-structure-<ts>.csv`` →
URL фактического файла данных;
• данные ``/opendata/<code>/data-<ts>-…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=<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/<code>/…``).
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-<ts>-structure-<ts>.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=<id>&format=sdmx
# → SDMX-ML v1.0 GenericData (см. tests/fixtures/emiss_income_57039.xml). Структура:
# <GenericData xmlns=...message xmlns:generic=...generic xmlns:structure=...>
# <CodeLists><structure:CodeList id="s_OKATO">…коды регионов…</structure:CodeList>
# <Description><Indicator id="57039" name="…"/></Description> (fedstat-расширение)
# <DataSet>
# <generic:Series>
# <generic:SeriesKey><generic:Value concept="s_OKATO" value="65000000000"/></…>
# <generic:Attributes>
# <generic:Value concept="EI" value="рубль"/> (единица измерения)
# <generic:Value concept="PERIOD" value="I квартал"/> (под-период года)
# </generic:Attributes>
# <generic:Obs><generic:Time>2024</generic:Time>
# <generic:ObsValue value="54006"/></generic:Obs>
# </generic:Series> …
# Один <Series> = один (регион, PERIOD) ряд; год наблюдения — в <Obs><Time> (напр. 2024),
# фактический момент = PERIOD + Time-год (I квартал 2024 → 2024-01-01). Регион берём из
# SeriesKey concept="s_OKATO" (ОКАТО-код), фильтруем по целевым кодам (Свердл = 65…).
# Парсер pure → dev-тестируем на фикстурах; сам fetch (httpx POST) исполняется на проде.
_SDMX_NS = {
"msg": "http://www.SDMX.org/resources/SDMXML/schemas/v1_0/message",
"g": "http://www.SDMX.org/resources/SDMXML/schemas/v1_0/generic",
}
# PERIOD-значения fedstat → стартовый месяц периода (obs_date нормализуем к 1-му числу
# стартового месяца под-периода, как monthly-ряды к 1-му числу). Покрывает кварталы и
# месяцы (ИПЦ публикуется и помесячно). Незнакомый PERIOD → None (ряд пропускается).
_QUARTER_START_MONTH: dict[str, int] = {
"i квартал": 1,
"ii квартал": 4,
"iii квартал": 7,
"iv квартал": 10,
}
_MONTH_NAME_TO_NUM: dict[str, int] = {
"январь": 1,
"февраль": 2,
"март": 3,
"апрель": 4,
"май": 5,
"июнь": 6,
"июль": 7,
"август": 8,
"сентябрь": 9,
"октябрь": 10,
"ноябрь": 11,
"декабрь": 12,
}
# Годовой под-период (некоторые индикаторы): «значение показателя за год», «год».
_YEAR_PERIODS: frozenset[str] = frozenset(
{"год", "значение показателя за год", "за год"}
)
@dataclass(frozen=True)
class EmissIndicatorSpec:
"""Описание одного приземляемого ЕМИСС-индикатора (fedstat data.do, SDMX).
Атрибуты:
indicator_id: числовой fedstat ``id`` (param ``id=`` в data.do).
indicator_type: тип ряда в macro_indicator (напр. 'income_per_capita').
unit: единица измерения (справочно; в PK не участвует).
frequency: 'monthly' | 'quarterly' | 'yearly' — для колонки frequency.
targets: целевые территории (ОКАТО-код → region-метка macro_indicator).
comment: пояснение для колонки comment macro_indicator.
"""
indicator_id: str
indicator_type: str
unit: str
frequency: str
targets: tuple[RegionTarget, ...]
comment: str = ""
# Свердловская обл. = ОКАТО '65000000000' (region='sverdl'). ЕКБ-горокруг в ОКАТО-
# размерности ЕМИСС-индикаторов отсутствует (только субъектовый срез), поэтому здесь
# целимся ТОЛЬКО в субъект-65 (в отличие от open-data population, где есть и ЕКБ).
OKATO_SVERDLOVSK_OBLAST = "65000000000"
INCOME_PER_CAPITA_SPEC = EmissIndicatorSpec(
indicator_id="57039",
indicator_type="income_per_capita",
unit="руб",
frequency="quarterly",
targets=(RegionTarget(oktmo=OKATO_SVERDLOVSK_OBLAST, region="sverdl"),),
comment="ЕМИСС id=57039 'Среднедушевые денежные доходы населения' (#946 §7.11)",
)
# Активные ЕМИСС-индикаторы. ИПЦ (id=31074) / индекс цен на СМР пока НЕ здесь —
# многомерные, дефолт-экспорт data.do без Свердл, нужна dataGrid-выборка (см. шапку).
EMISS_INDICATORS: tuple[EmissIndicatorSpec, ...] = (INCOME_PER_CAPITA_SPEC,)
@dataclass(frozen=True)
class EmissRow:
"""Одна готовая к upsert строка macro_indicator из ЕМИСС (source='emiss').
Отдельно от ``MacroRow`` (open-data, source='rosstat', yearly): ЕМИСС-ряды несут
свою frequency (quarterly/monthly) и source — контракт upsert'а у них иной.
"""
indicator_type: str
region: str
obs_date: date
value: Decimal
unit: str
frequency: str
comment: str
def _emiss_period_to_month(period: str) -> int | None:
"""PERIOD-строку fedstat → стартовый месяц под-периода (1..12) или None.
Квартал ('I квартал''IV квартал') → первый месяц квартала; месяц ('январь'…) →
его номер; годовой под-период ('год') → 1 (январь). Незнакомое → None (caller
пропустит ряд + warning) — защита от неожиданных под-периодов (недели и т.п.).
"""
p = (period or "").strip().lower()
if not p:
return None
if p in _QUARTER_START_MONTH:
return _QUARTER_START_MONTH[p]
if p in _MONTH_NAME_TO_NUM:
return _MONTH_NAME_TO_NUM[p]
if p in _YEAR_PERIODS:
return 1
return None
def _series_concepts(series: ET.Element) -> tuple[dict[str, str], dict[str, str]]:
"""Извлечь (SeriesKey, Attributes) concept→value словари из <generic:Series>.
SeriesKey несёт размерности (s_OKATO и пр.); Attributes — EI (единица) и PERIOD.
Отсутствующий блок → пустой словарь (не падаем).
"""
def _vals(parent: ET.Element | None) -> dict[str, str]:
out: dict[str, str] = {}
if parent is None:
return out
for v in parent.findall("g:Value", _SDMX_NS):
concept = v.get("concept")
value = v.get("value")
if concept is not None and value is not None:
out[concept] = value
return out
key = _vals(series.find("g:SeriesKey", _SDMX_NS))
attrs = _vals(series.find("g:Attributes", _SDMX_NS))
return key, attrs
def parse_emiss_sdmx(raw: bytes | str, spec: EmissIndicatorSpec) -> list[EmissRow]:
"""Pure-парсер SDMX-ML v1.0 GenericData fedstat → список EmissRow целевых регионов.
Алгоритм:
1. Парсит XML (ET). Битый/непарсящийся XML → [] (+warning), не бросает.
2. Для каждого <generic:Series>: регион = SeriesKey concept='s_OKATO'. Если ОКАТО
не среди spec.targets — ряд пропускается. PERIOD (Attributes) → стартовый месяц
под-периода; год — из <Obs><Time>; obs_date = date(year, month, 1). Значение —
из <Obs><ObsValue value=...> через _parse_value (запятая-десятичный, разделители).
3. Дубли по (region, obs_date) схлопываются (последний выигрывает) — PK всё равно один.
Никогда не бросает на данных: битые ячейки/периоды/годы → наблюдение пропускается.
"""
if isinstance(raw, bytes):
try:
text_xml = raw.decode("utf-8")
except UnicodeDecodeError:
text_xml = raw.decode("utf-8", errors="replace")
else:
text_xml = raw
try:
root = ET.fromstring(text_xml)
except ET.ParseError as e:
logger.warning("EMISS %s: SDMX XML не распарсился: %s", spec.indicator_id, e)
return []
dataset = root.find("msg:DataSet", _SDMX_NS)
if dataset is None:
logger.warning("EMISS %s: нет <DataSet> в SDMX", spec.indicator_id)
return []
target_by_okato = {t.oktmo: t for t in spec.targets}
by_key: dict[tuple[str, date], EmissRow] = {}
for series in dataset.findall("g:Series", _SDMX_NS):
key, attrs = _series_concepts(series)
okato = key.get("s_OKATO", "").strip()
target = target_by_okato.get(okato)
if target is None:
continue
month = _emiss_period_to_month(attrs.get("PERIOD", ""))
if month is None:
logger.warning(
"EMISS %s: неизвестный PERIOD %r (ОКАТО %s) — пропуск",
spec.indicator_id,
attrs.get("PERIOD"),
okato,
)
continue
for obs in series.findall("g:Obs", _SDMX_NS):
time_el = obs.find("g:Time", _SDMX_NS)
val_el = obs.find("g:ObsValue", _SDMX_NS)
if time_el is None or time_el.text is None or val_el is None:
continue
yr = time_el.text.strip()[:4]
if not yr.isdigit():
continue
value = _parse_value(val_el.get("value", ""))
if value is None:
continue
obs_date = date(int(yr), month, 1)
by_key[(target.region, obs_date)] = EmissRow(
indicator_type=spec.indicator_type,
region=target.region,
obs_date=obs_date,
value=value,
unit=spec.unit,
frequency=spec.frequency,
comment=spec.comment,
)
return [by_key[k] for k in sorted(by_key, key=lambda k: (k[0], k[1]))]
def fetch_emiss_indicator(
spec: EmissIndicatorSpec,
*,
timeout_s: float = DEFAULT_TIMEOUT_S,
retries: int = DEFAULT_RETRIES,
client: httpx.Client | None = None,
) -> list[EmissRow]:
"""Загрузить и распарсить один ЕМИСС-индикатор с fedstat → список EmissRow.
ВНИМАНИЕ: fedstat.ru за WAF с dev-машины (403) — этот fetch рассчитан на исполнение
с ПРОДА (Celery). POST ``data.do`` body ``id=<id>&format=sdmx``; verify off (российский
гос-УЦ, как и open-data выше). Парсинг — в pure ``parse_emiss_sdmx`` (offline-тестируем).
Raises:
RosstatScraperError: на сетевой/HTTP-ошибке после ретраев.
"""
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:
raw = _http_post(
client,
f"{FEDSTAT_DATA_URL}",
data={"id": spec.indicator_id, "format": "sdmx"},
retries=retries,
)
rows = parse_emiss_sdmx(raw, spec)
logger.info(
"EMISS %s (%s): распарсено %d строк для %d целевых ОКАТО",
spec.indicator_id,
spec.indicator_type,
len(rows),
len(spec.targets),
)
return rows
finally:
if own_client:
client.close()
def fetch_all_emiss(
specs: tuple[EmissIndicatorSpec, ...] = EMISS_INDICATORS,
*,
timeout_s: float = DEFAULT_TIMEOUT_S,
retries: int = DEFAULT_RETRIES,
) -> list[EmissRow]:
"""Загрузить все активные ЕМИСС-индикаторы → плоский список EmissRow.
Один общий httpx.Client на батч. Сбой одного индикатора логируется и НЕ валит
остальные (graceful — частичная поставка допустима ТЗ). Исполняется на проде.
"""
out: list[EmissRow] = []
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_emiss_indicator(spec, retries=retries, client=client))
except RosstatScraperError as e:
logger.warning("EMISS: индикатор %s пропущен: %s", spec.indicator_id, 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()