gendesign/backend/app/services/scrapers/rosstat_emiss.py
Light1YT 2da8a285b3
All checks were successful
CI / changes (push) Successful in 6s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 6m18s
CI / backend-tests (pull_request) Successful in 6m33s
feat(macro): Росстат open-data macro scraper -> macro_indicator (#946)
EPIC2 macro-ingest: региональный (Свердловская обл. ОКТМО 65 / ЕКБ) скрейпер
Росстата в унифицированную macro_indicator (м.123). Зеркалит форму cbr_macro:
pure-парсеры + тонкий httpx, Celery-таска с SAVEPOINT-per-row upsert
(CAST not ::, ON CONFLICT по PK indicator_type/region/obs_date).

ЕМИСС/fedstat.ru (SDMX) за WAF (hard-403 на всех путях из dev-IP) -> ИПЦ/доходы/
СМР-цены пока недоступны (документировано в коде). rosstat.gov.ru/opendata
(стандарт 4.0) WAF-free -> приземляет демографию population_total
(sverdl 4.32M / ekb 1.41M, §7.11).

- services/scrapers/rosstat_emiss.py — fetch+parse open-data (registry->meta->data CSV)
- workers/tasks/rosstat_macro_sync.py — Celery upsert (source=rosstat)
- beat: rosstat-macro-sync-monthly; include в celery_app
- site_finder/macro.py: population_total -> region-aware default sverdl (additive)
- tests: 18 offline-тестов парсера + контракта таски

Без миграции (вписано в существующую схему), без новых зависимостей.

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

501 lines
24 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 (SDMX + ``/indicator/data.do``) — за WAF: ЛЮБОЙ путь/метод/набор
заголовков отдаёт статичный 80-байтный ``<h1>Forbidden</h1>`` БЕЗ ``Server``-заголовка
(блок на фронтящем WAF/CDN до приложения — IP-reputation, не JS-challenge, который мог
бы решить браузер). ``sdmx.gks.ru`` — DNS мёртв, ``showdata.gks.ru`` — 502. То есть
EMISS-only ряды (ИПЦ, среднедушевые доходы, индекс цен на СМР) автоматически НЕдоступны.
Это независимо зафиксировано прежней разведкой (комментарий миграции 126 §7.11, vault
data-sources).
``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 «демография (численность)».
Что НЕ приземляется (документировано, частичная поставка допустима ТЗ):
• среднедушевые доходы, ИПЦ, индекс цен на СМР — 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/<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 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()