Some checks failed
CI / backend-tests (push) Has been cancelled
CI / frontend-tests (push) Has been cancelled
CI / openapi-codegen-check (push) Has been cancelled
CI / changes (push) Has been cancelled
CI / changes (pull_request) Successful in 6s
CI / backend-tests (pull_request) Has been cancelled
CI / frontend-tests (pull_request) Has been cancelled
CI / openapi-codegen-check (pull_request) Has been cancelled
Adds period_type TEXT NOT NULL DEFAULT 'unknown' to macro_indicator and
widens the PRIMARY KEY to (indicator_type, region, obs_date, period_type).
Before: yearly aggregate ('год'→obs_date YYYY-01-01) and Q1 ('I квартал'→
same date) shared the same PK slot → ON CONFLICT DO UPDATE made them
overwrite each other despite the in-memory dedup fix in #1687.
After: each EmissRow carries period_type from _emiss_period_granularity
('year'/'quarter'/'month'); DB ON CONFLICT targets include it, so
yearly+Q1 rows genuinely coexist.
Non-EMISS sources (CBR, rosstat open-data, domrf) use period_type='unknown'
(literal in SQL) — they are disambiguated by obs_date already, so the
wider PK is backward-compatible. All ON CONFLICT clauses in
cbr_macro_sync.py and rosstat_macro_sync.py updated accordingly.
Migration: 163_emiss_pk_period_type.sql (idempotent BEGIN/COMMIT).
Tests: 49 passed (emiss + rosstat suite), ruff clean, py_compile OK.
1172 lines
59 KiB
Python
1172 lines
59 KiB
Python
"""Росстат / ЕМИСС 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=<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 (индекс цен на СМР) — из ЕМИСС НЕ берётся (кандидат
|
||
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 ``…/<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 «демография (численность)».
|
||
|
||
Индекс цен на СМР приземляется из ОТКРЫТОГО 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=<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/<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-выборка (см. шапку). Индекс цен на СМР берётся НЕ из
|
||
# ЕМИСС, а из открытого xlsx Росстата (CONSTRUCTION_PRICE_SPEC ниже).
|
||
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 и period_type — контракт upsert'а у
|
||
них иной.
|
||
|
||
period_type: гранулярность под-периода ('year' | 'quarter' | 'month' | 'unknown').
|
||
Берётся из _emiss_period_granularity(PERIOD). Необходима как часть PK
|
||
macro_indicator (migration 163), чтобы годовой агрегат ('год' → 'year') и
|
||
Q1 ('I квартал' → 'quarter') за один год не перезаписывали друг друга при
|
||
ON CONFLICT DO UPDATE (оба дают obs_date=YYYY-01-01) (#1606).
|
||
"""
|
||
|
||
indicator_type: str
|
||
region: str
|
||
obs_date: date
|
||
value: Decimal
|
||
unit: str
|
||
frequency: str
|
||
comment: str
|
||
period_type: str = "unknown"
|
||
|
||
|
||
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 _emiss_period_granularity(period: str) -> str:
|
||
"""PERIOD-строку fedstat → гранулярность ('year' | 'quarter' | 'month').
|
||
|
||
Используется как третья компонента ключа дедупликации в parse_emiss_sdmx, чтобы
|
||
годовой ряд ('год') и квартальный Q1 ('I квартал') не перезаписывали друг друга при
|
||
одинаковых (region, obs_date=YYYY-01-01) (#1606).
|
||
Возвращает 'unknown' для нераспознанных под-периодов (на практике такие ряды
|
||
пропускаются ещё до этого вызова через _emiss_period_to_month → None).
|
||
"""
|
||
p = (period or "").strip().lower()
|
||
if p in _YEAR_PERIODS:
|
||
return "year"
|
||
if p in _QUARTER_START_MONTH:
|
||
return "quarter"
|
||
if p in _MONTH_NAME_TO_NUM:
|
||
return "month"
|
||
return "unknown"
|
||
|
||
|
||
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, granularity) схлопываются (последний выигрывает).
|
||
Ключ трёхкомпонентный — гранулярность ('year'/'quarter'/'month') предотвращает
|
||
перезапись годового ряда ('год' → obs_date YYYY-01-01) Q1-рядом ('I квартал' →
|
||
тот же YYYY-01-01) при совместном их присутствии в одном SDMX-ответе (#1606).
|
||
|
||
Никогда не бросает на данных: битые ячейки/периоды/годы → наблюдение пропускается.
|
||
"""
|
||
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}
|
||
# Ключ: (region, obs_date, granularity) — три компоненты предотвращают коллизию
|
||
# годового ряда ('год' → YYYY-01-01, granularity='year') и Q1 ('I квартал' →
|
||
# тот же YYYY-01-01, granularity='quarter') при их совместном присутствии в SDMX (#1606).
|
||
by_key: dict[tuple[str, date, str], 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
|
||
period_raw = attrs.get("PERIOD", "")
|
||
month = _emiss_period_to_month(period_raw)
|
||
if month is None:
|
||
logger.warning(
|
||
"EMISS %s: неизвестный PERIOD %r (ОКАТО %s) — пропуск",
|
||
spec.indicator_id,
|
||
period_raw,
|
||
okato,
|
||
)
|
||
continue
|
||
granularity = _emiss_period_granularity(period_raw)
|
||
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, granularity)] = EmissRow(
|
||
indicator_type=spec.indicator_type,
|
||
region=target.region,
|
||
obs_date=obs_date,
|
||
value=value,
|
||
unit=spec.unit,
|
||
frequency=spec.frequency,
|
||
comment=spec.comment,
|
||
period_type=granularity,
|
||
)
|
||
|
||
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
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════════════════════
|
||
# Индекс цен на строительную продукцию (СМР) — ОТКРЫТЫЙ xlsx Росстата (#946, EPIC2)
|
||
# ══════════════════════════════════════════════════════════════════════════════════
|
||
# Источник (разведано live 2026-06-08, fetch работает С DEV): страница
|
||
# https://rosstat.gov.ru/statistics/price → раздел «строительная продукция» →
|
||
# xlsx ROSSTAT_CONSTRUCTION_XLSX_URL_TEMPLATE «Индексы цен производителей на строительную
|
||
# продукцию по РФ в 1995-2025 гг.». reCAPTCHA-gated dataGrid.do НЕ используется —
|
||
# это статический файл прямой ссылкой (см. HARD SAFETY CONSTRAINT в шапке #946).
|
||
# Форма листа «1» (широкий pivot, верифицировано на реальном файле):
|
||
# r3 — заголовок; r5 — «на конец периода, в %»;
|
||
# r6 — годы по колонкам (B..) c возможным сноска-маркером ('2022 1)' и т.п.);
|
||
# r7 — секция «К предыдущему месяцу» (то, что берём: ИСТИННЫЙ месячный ряд);
|
||
# r8..r19 — месяцы Январь..Декабрь, ячейка = индекс % за (месяц, год-колонка);
|
||
# r20 — секция «К декабрю предыдущего года» (НЕ берём — иной базис, годовой).
|
||
# Один (месяц, год) → одно наблюдение; obs_date = date(year, month, 1) — как и прочие
|
||
# месячные ряды нормализуем к 1-му числу. Регион = 'rf' (файл — общефедеральный).
|
||
# Будущие/неопубликованные месяцы текущего года приходят пустой ячейкой → None → скип
|
||
# (None-not-0: пропуск, НИКОГДА не подставляем 0). Парсер pure → dev-тестируем на фикстуре.
|
||
|
||
# Заголовки секций листа: какую берём (месячная), какую игнорируем (годовая к декабрю).
|
||
_CONSTR_SECTION_MONTHLY = "к предыдущему месяцу"
|
||
_CONSTR_SECTION_TO_DEC = "к декабрю предыдущего года"
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class XlsxIndicatorSpec:
|
||
"""Описание приземляемого ряда из открытого xlsx Росстата (широкий pivot год×месяц).
|
||
|
||
Атрибуты:
|
||
url_template: шаблон ссылки на xlsx с плейсхолдерами {mm:02d}/{yyyy}
|
||
(ROSSTAT_CONSTRUCTION_XLSX_URL_TEMPLATE) — резолвится по дате в fetch.
|
||
sheet: имя листа с данными (для construction — '1').
|
||
indicator_type: тип ряда в macro_indicator ('construction_price_index').
|
||
region: метка region (общефедеральный файл → 'rf').
|
||
unit: единица (для индекса цен — '%'; справочно, в PK не участвует).
|
||
frequency: 'monthly' — берём секцию «К предыдущему месяцу».
|
||
comment: пояснение для колонки comment macro_indicator.
|
||
"""
|
||
|
||
url_template: str
|
||
sheet: str
|
||
indicator_type: str
|
||
region: str
|
||
unit: str
|
||
frequency: str
|
||
comment: str = ""
|
||
|
||
|
||
CONSTRUCTION_PRICE_SPEC = XlsxIndicatorSpec(
|
||
url_template=ROSSTAT_CONSTRUCTION_XLSX_URL_TEMPLATE,
|
||
sheet="1",
|
||
indicator_type="construction_price_index",
|
||
region="rf",
|
||
unit="%",
|
||
frequency="monthly",
|
||
comment=(
|
||
"Росстат 'Индексы цен производителей на строительную продукцию по РФ', "
|
||
"к предыдущему месяцу (#946 §7.11; СМР, open-data, без dataGrid.do/captcha)"
|
||
),
|
||
)
|
||
|
||
|
||
def _parse_year_cell(raw: Any) -> int | None:
|
||
"""Год из ячейки-заголовка → int. Допускает сноска-маркер ('2022 1)' → 2022) и
|
||
числовой год (openpyxl отдаёт int). Не-год / пусто → None (колонка пропускается)."""
|
||
if raw is None:
|
||
return None
|
||
if isinstance(raw, int):
|
||
return raw if 1900 <= raw <= 2100 else None
|
||
digits = str(raw).strip()[:4]
|
||
if digits.isdigit():
|
||
year = int(digits)
|
||
return year if 1900 <= year <= 2100 else None
|
||
return None
|
||
|
||
|
||
def parse_construction_index_xlsx(content: bytes, spec: XlsxIndicatorSpec) -> list[MacroRow]:
|
||
"""Pure-парсер открытого xlsx Росстата (индекс цен на СМР) → список MacroRow.
|
||
|
||
Алгоритм (форма листа — см. блок выше):
|
||
1. Открывает лист spec.sheet. Находит строку годов-заголовков (первая, где ≥2
|
||
ячеек распарсились как год через _parse_year_cell) → карта колонка→год.
|
||
2. Идёт по строкам ПОСЛЕ заголовка только внутри секции «К предыдущему месяцу»
|
||
(по достижении секции «К декабрю предыдущего года» / иной — останавливается).
|
||
Строка месяца (ячейка A ∈ _MONTH_NAME_TO_NUM) → для каждой год-колонки берёт
|
||
значение через _parse_value; пусто/нечисло → пропуск (None-not-0).
|
||
3. obs_date = date(year, month, 1). Дубли по (region, obs_date) схлопываются.
|
||
|
||
Graceful: пустой/битый xlsx, нет листа, нет строки годов → [] (+warning), не бросает.
|
||
"""
|
||
if not content:
|
||
return []
|
||
try:
|
||
wb = load_workbook(BytesIO(content), read_only=True, data_only=True)
|
||
except Exception as e: # openpyxl бросает разные типы на битом файле
|
||
logger.warning("Rosstat construction: не удалось открыть xlsx: %s", e)
|
||
return []
|
||
try:
|
||
if spec.sheet not in wb.sheetnames:
|
||
logger.warning("Rosstat construction: нет листа %r в xlsx", spec.sheet)
|
||
return []
|
||
rows = list(wb[spec.sheet].iter_rows(values_only=True))
|
||
finally:
|
||
wb.close()
|
||
if not rows:
|
||
return []
|
||
|
||
# 1. Строка годов: первая, где ≥2 ячеек (после A) распарсились как год.
|
||
year_by_col: dict[int, int] = {}
|
||
header_idx: int | None = None
|
||
for r_idx, row in enumerate(rows):
|
||
cols = {c: y for c in range(1, len(row)) if (y := _parse_year_cell(row[c]))}
|
||
if len(cols) >= 2:
|
||
header_idx, year_by_col = r_idx, cols
|
||
break
|
||
if header_idx is None:
|
||
logger.warning("Rosstat construction: не найдена строка годов-заголовков")
|
||
return []
|
||
|
||
# 2. Месяцы внутри секции «К предыдущему месяцу» (до начала годовой секции).
|
||
by_key: dict[tuple[str, date], MacroRow] = {}
|
||
in_monthly = False
|
||
for row in rows[header_idx + 1 :]:
|
||
if not row:
|
||
continue
|
||
label = str(row[0] or "").strip().lower()
|
||
if label == _CONSTR_SECTION_MONTHLY:
|
||
in_monthly = True
|
||
continue
|
||
if label == _CONSTR_SECTION_TO_DEC or (label and "к декабрю" in label):
|
||
break # дальше идёт годовой базис — нам не нужен
|
||
month = _MONTH_NAME_TO_NUM.get(label)
|
||
if month is None or not in_monthly:
|
||
continue
|
||
for col, year in year_by_col.items():
|
||
if col >= len(row):
|
||
continue
|
||
value = _parse_value(str(row[col]) if row[col] is not None else "")
|
||
if value is None:
|
||
continue # пустой/будущий месяц → пропуск (None-not-0)
|
||
obs_date = date(year, month, 1)
|
||
by_key[(spec.region, obs_date)] = MacroRow(
|
||
indicator_type=spec.indicator_type,
|
||
region=spec.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]))]
|
||
|
||
|
||
def _fetch_construction_latest(
|
||
client: httpx.Client,
|
||
*,
|
||
url_template: str,
|
||
today: date,
|
||
retries: int = DEFAULT_RETRIES,
|
||
max_lookback: int = _CONSTRUCTION_MAX_LOOKBACK,
|
||
) -> bytes:
|
||
"""Тело свежайшего xlsx индекса цен на СМР: резолв от today с look-back по месяцам.
|
||
|
||
Имя файла Росстата несёт месяц-вёрстки (Invest_ind_stroitel_MM-YYYY.xlsx) и меняется
|
||
ежемесячно с лагом публикации. Пробуем текущий месяц и назад до max_lookback мес —
|
||
возвращаем тело первого ответившего 200 (404 у _http_get ломается сразу, без ретрая,
|
||
поэтому отсутствующие месяцы дёшевы). dataGrid.do/captcha НЕ задействованы.
|
||
|
||
Raises:
|
||
RosstatScraperError: если ни один кандидат (today..-max_lookback мес) не отдал 200.
|
||
"""
|
||
year, month = today.year, today.month
|
||
last_exc: Exception | None = None
|
||
for _ in range(max_lookback + 1):
|
||
url = url_template.format(mm=month, yyyy=year)
|
||
try:
|
||
return _http_get(client, url, retries=retries)
|
||
except RosstatScraperError as exc:
|
||
last_exc = exc
|
||
logger.info("Rosstat СМР xlsx нет за %02d-%d, пробуем предыдущий месяц", month, year)
|
||
month -= 1
|
||
if month == 0:
|
||
month, year = 12, year - 1
|
||
raise RosstatScraperError(
|
||
f"Rosstat СМР xlsx: не найден за {max_lookback + 1} мес от {today.isoformat()}: {last_exc}"
|
||
)
|
||
|
||
|
||
def fetch_construction_index(
|
||
spec: XlsxIndicatorSpec = CONSTRUCTION_PRICE_SPEC,
|
||
*,
|
||
today: date | None = None,
|
||
timeout_s: float = DEFAULT_TIMEOUT_S,
|
||
retries: int = DEFAULT_RETRIES,
|
||
client: httpx.Client | None = None,
|
||
) -> list[MacroRow]:
|
||
"""Загрузить открытый xlsx Росстата (индекс цен на СМР) → список MacroRow.
|
||
|
||
URL резолвится по дате с look-back (имя файла меняется ежемесячно — см.
|
||
_fetch_construction_latest), затем pure-парсинг. Работает С DEV (verify off,
|
||
российский гос-УЦ — как и прочие rosstat.gov.ru-эндпоинты). dataGrid.do/captcha
|
||
НЕ задействованы. today=None → date.today() (инъекция для тестов).
|
||
|
||
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:
|
||
content = _fetch_construction_latest(
|
||
client, url_template=spec.url_template, today=today or date.today(), retries=retries
|
||
)
|
||
rows = parse_construction_index_xlsx(content, spec)
|
||
logger.info(
|
||
"Rosstat construction (%s): распарсено %d месячных наблюдений (region=%s)",
|
||
spec.indicator_type,
|
||
len(rows),
|
||
spec.region,
|
||
)
|
||
return rows
|
||
finally:
|
||
if own_client:
|
||
client.close()
|
||
|
||
|
||
# ── 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.")
|
||
|
||
# Индекс цен на СМР — открытый xlsx, тоже доступен с dev (печатаем хвост ряда).
|
||
constr = fetch_construction_index()
|
||
logger.info("Rosstat construction rows fetched: %d", len(constr))
|
||
for r in constr[-6:]:
|
||
logger.info(
|
||
" %s / %s @ %s = %s %s",
|
||
r.indicator_type,
|
||
r.region,
|
||
r.obs_date.isoformat(),
|
||
r.value,
|
||
r.unit,
|
||
)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
_main()
|