gendesign/backend/app/services/scrapers/rosstat_emiss.py
bot-backend 5170107405
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
fix(sql): EMISS PK includes period_type so yearly+Q1 coexist (#1606 follow-up)
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.
2026-06-17 21:27:34 +03:00

1172 lines
59 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 + открытый 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()