feat(macro): land construction price index (СМР) via open Rosstat xlsx (#946)
All checks were successful
CI / changes (push) Successful in 6s
CI / changes (pull_request) Successful in 7s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Successful in 6m27s
Deploy / build-frontend (push) Has been skipped
CI / frontend-tests (push) Has been skipped
CI / backend-tests (push) Successful in 6m29s
Deploy / changes (push) Successful in 6s
Deploy / build-backend (push) Successful in 1m43s
Deploy / build-worker (push) Successful in 3m3s
Deploy / deploy (push) Successful in 1m15s

Closes the last #946 gap (СМР). Fetches «Индексы цен производителей на
строительную продукцию по РФ» from the OPEN Rosstat xlsx
(rosstat.gov.ru/storage/mediabank/Invest_ind_stroitel_MM-YYYY.xlsx),
parses the monthly «к предыдущему месяцу» section → macro_indicator
(indicator_type='construction_price_index', region='rf', monthly, %).

The reCAPTCHA-gated fedstat dataGrid.do path is DELIBERATELY NOT used —
the open Rosstat source needs no captcha. EMISS id=31108 documented as a
prod-only SDMX alternative (not wired).

URL is date-derived with bounded look-back (filename changes monthly;
_fetch_construction_latest walks current month back to 6, first 200 wins;
year-boundary handled, exhaustion raises, 404 breaks fast — no hang).
Wired into rosstat_macro_sync with per-source try/except (can't break
demography/income) + SAVEPOINT-per-row. None-not-0 on blank months.
33 tests (incl look-back + year-boundary + graceful), ruff clean.

Note: no construction-cost channel in macro_coefficient yet (demand-side
only) — series is ingested/available for future cost-side use + display.

Refs #946
This commit is contained in:
Light1YT 2026-06-09 11:28:05 +05:00
parent 4adfef2ad0
commit f9e045028c
5 changed files with 601 additions and 19 deletions

View file

@ -7,7 +7,7 @@
Celery-таска ``rosstat_macro_sync`` через SAVEPOINT-per-row.
ИСТОЧНИК ДАННЫХ open-data + ЕМИСС-SDMX (разведано live 2026-06-07)
ИСТОЧНИК ДАННЫХ 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``
@ -30,9 +30,11 @@ Celery-таска ``rosstat_macro_sync`` через SAVEPOINT-per-row.
id кода (НЕ значение ОКАТО), берётся отдельным filter-tree AJAX; плюс на странице
индикатора reCAPTCHA. Это глубокий RE отложено (закрывается тем же EmissIndicatorSpec
+ параметризацией data.do-выборки, когда добудем filter_id-маппинг).
construction_price_index (индекс цен на СМР) id не подтверждён: кандидаты
(31448 «Средние потреб. цены», 37426 «Еженедельные цены») много-мерные и БЕЗ
Свердл в дефолт-экспорте (та же проблема, что у ИПЦ). НЕ landed.
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):
@ -50,9 +52,16 @@ Celery-таска ``rosstat_macro_sync`` через SAVEPOINT-per-row.
муниципальный срез; берём субъект-агрегат ОКТМО ``6500000000`` + г. Екатеринбург
``657010000011000``). Это § 7.11 «демография (численность)».
Что НЕ приземляется из open-data (но среднедушевые доходы теперь идут через ЕМИСС-прод):
ИПЦ, индекс цен на СМР EMISS-only и в open-data их нет (0 совпадений по реестру);
из ЕМИСС пока не landed (много-мерные, см. EMISS_INDICATORS-блок выше).
Индекс цен на СМР приземляется из ОТКРЫТОГО 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
@ -70,8 +79,11 @@ 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__)
@ -83,6 +95,19 @@ ROSSTAT_OPENDATA_BASE = "https://rosstat.gov.ru/opendata"
# 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 = (
@ -637,8 +662,9 @@ INCOME_PER_CAPITA_SPEC = EmissIndicatorSpec(
comment="ЕМИСС id=57039 'Среднедушевые денежные доходы населения' (#946 §7.11)",
)
# Активные ЕМИСС-индикаторы. ИПЦ (id=31074) / индекс цен на СМР пока НЕ здесь —
# многомерные, дефолт-экспорт data.do без Свердл, нужна dataGrid-выборка (см. шапку).
# Активные ЕМИСС-индикаторы. ИПЦ (id=31074) пока НЕ здесь — многомерный, дефолт-экспорт
# data.do без Свердл, нужна dataGrid-выборка (см. шапку). Индекс цен на СМР берётся НЕ из
# ЕМИСС, а из открытого xlsx Росстата (CONSTRUCTION_PRICE_SPEC ниже).
EMISS_INDICATORS: tuple[EmissIndicatorSpec, ...] = (INCOME_PER_CAPITA_SPEC,)
@ -846,6 +872,239 @@ def fetch_all_emiss(
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 ────────────────────────────────────────────────────────────
@ -867,6 +1126,19 @@ def _main() -> None:
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()

View file

@ -1,15 +1,20 @@
"""Celery task: синхронизация макро-показателей Росстата в ``macro_indicator``
(#946, EPIC2 «macro-ingest», GG-форсайт / Site Finder v2).
Тянет региональные ряды Росстата для Свердловской обл. из ДВУХ источников и апсертит
Тянет региональные/федеральные ряды Росстата из ТРЁХ источников и апсертит
каждую обсервацию в ``macro_indicator``:
open-data (``rosstat_emiss.fetch_all_datasets``) демография ``population_total``
(ОКТМО 65 / ЕКБ), ``source='rosstat'``, frequency='yearly';
ЕМИСС / fedstat (``rosstat_emiss.fetch_all_emiss``) ``income_per_capita``
(среднедушевые доходы, ОКАТО 65), ``source='emiss'``, frequency='quarterly'.
(среднедушевые доходы, ОКАТО 65), ``source='emiss'``, frequency='quarterly';
открытый xlsx Росстата (``rosstat_emiss.fetch_construction_index``)
``construction_price_index`` (индекс цен на СМР, region='rf'), ``source='rosstat'``,
frequency='monthly'. ОТКРЫТЫЙ источник (без dataGrid.do / reCAPTCHA), fetch с dev.
Каждый источник в своём try/except (per-source guard): сбой одного НЕ роняет другой и
не валит таску частичная поставка допустима (ТЗ §7.11). ЕМИСС-fetch доступен только
с прода (fedstat за WAF с dev); ИПЦ/индекс цен на СМР пока не landed (см. rosstat_emiss).
с прода (fedstat за WAF с dev); индекс цен на СМР теперь landed через открытый
rosstat.gov.ru-xlsx (reCAPTCHA-gated dataGrid.do НАМЕРЕННО НЕ используется); ИПЦ пока
не landed (см. rosstat_emiss).
Детерминированно, без LLM. ON CONFLICT делает повторные прогоны идемпотентными
(re-run обновляет value + updated_at). Расписание ежемесячно (эти ряды
@ -35,6 +40,7 @@ from app.services.scrapers.rosstat_emiss import (
MacroRow,
fetch_all_datasets,
fetch_all_emiss,
fetch_construction_index,
)
from app.workers.celery_app import celery_app
@ -88,6 +94,30 @@ UPSERT_EMISS_SQL = text(
"""
)
# Индекс цен на СМР — открытый xlsx Росстата: source='rosstat' (rosstat.gov.ru-файл),
# НО frequency='monthly' (в отличие от open-data демографии — там фикс 'yearly'),
# поэтому frequency параметризована. MacroRow не несёт frequency-поля, подставляем
# литералом в bind. Тот же PK и ON CONFLICT-контракт. CAST(:x AS type) — psycopg v3.
UPSERT_ROSSTAT_MONTHLY_SQL = text(
"""
INSERT INTO macro_indicator (
indicator_type, region, obs_date, value,
source, frequency, unit, comment
) VALUES (
CAST(:itype AS text), CAST(:region AS text), CAST(:d AS date),
CAST(:v AS numeric),
'rosstat', 'monthly', CAST(:unit AS text), CAST(:comment AS text)
)
ON CONFLICT (indicator_type, region, obs_date) DO UPDATE SET
value = EXCLUDED.value,
source = EXCLUDED.source,
frequency = EXCLUDED.frequency,
unit = EXCLUDED.unit,
comment = EXCLUDED.comment,
updated_at = now()
"""
)
def _upsert_rows(db: Session, rows: list[MacroRow]) -> int:
"""Апсертит MacroRow в macro_indicator. SAVEPOINT per-row, чтобы один битый
@ -121,6 +151,39 @@ def _upsert_rows(db: Session, rows: list[MacroRow]) -> int:
return upserted
def _upsert_monthly_rows(db: Session, rows: list[MacroRow]) -> int:
"""Апсертит месячные MacroRow (индекс цен на СМР) — source='rosstat',
frequency='monthly'. SAVEPOINT per-row, чтобы один битый ряд не откатывал всю
транзакцию. Возвращает число успешных upsert'ов."""
upserted = 0
for r in rows:
try:
with db.begin_nested():
db.execute(
UPSERT_ROSSTAT_MONTHLY_SQL,
{
"itype": r.indicator_type,
"region": r.region,
"d": r.obs_date,
"v": r.value,
"unit": r.unit,
"comment": r.comment,
},
)
upserted += 1
except Exception as e:
logger.warning(
"upsert rosstat-monthly %s/%s@%s=%s failed: %s",
r.indicator_type,
r.region,
r.obs_date,
r.value,
e,
)
db.commit()
return upserted
def _upsert_emiss_rows(db: Session, rows: list[EmissRow]) -> int:
"""Апсертит EmissRow в macro_indicator (source='emiss', frequency per-row).
SAVEPOINT per-row, чтобы один битый ряд не откатывал всю транзакцию. Возвращает
@ -161,25 +224,27 @@ def _upsert_emiss_rows(db: Session, rows: list[EmissRow]) -> int:
max_retries=2,
)
def rosstat_macro_sync(self: Any) -> dict[str, Any]:
"""Загрузить региональные ряды Росстата (open-data + ЕМИСС) и апсертить в macro_indicator.
"""Загрузить ряды Росстата (open-data + ЕМИСС + xlsx-СМР) и апсертить в macro_indicator.
Источники выполняются НЕЗАВИСИМО (per-source try/except): сбой одного источника
логируется, апсертит здоровый источник, и в конце ошибка ПРОБРАСЫВАЕТСЯ (чтобы фейл
был виден в Celery / GlitchTip, а не молча проглочен). Re-run идемпотентен (ON CONFLICT),
поэтому Celery-retry безопасно повторит оба источника.
поэтому Celery-retry безопасно повторит все источники.
Returns:
Счётчики ``{"fetched": N, "upserted": M, "rosstat": ..., "emiss": ...}``.
Счётчики ``{"fetched": N, "upserted": M, "rosstat": ..., "emiss": ...,
"construction": ...}``.
Raises:
Первую пойманную ошибку любого источника ПОСЛЕ попытки обоих (surfaces в
Первую пойманную ошибку любого источника ПОСЛЕ попытки всех трёх (surfaces в
Celery/GlitchTip). Частичный сбой отдельного датасета/индикатора внутри источника
НЕ роняет источник (fetch_all_* graceful per-item).
НЕ роняет источник (fetch_all_* / fetch_construction_index graceful per-item).
"""
db = SessionLocal()
first_error: Exception | None = None
rosstat_fetched = rosstat_upserted = 0
emiss_fetched = emiss_upserted = 0
constr_fetched = constr_upserted = 0
try:
# ── Источник 1: Росстат open-data (демография) ──────────────────────────
try:
@ -209,14 +274,30 @@ def rosstat_macro_sync(self: Any) -> dict[str, Any]:
first_error = first_error or e
logger.exception("rosstat_macro_sync: emiss source failed: %s", e)
# ── Источник 3: открытый xlsx Росстата (индекс цен на СМР, region='rf') ──
# Открытый источник (без dataGrid.do/captcha); месячный → _upsert_monthly_rows.
try:
constr_rows = fetch_construction_index()
constr_fetched = len(constr_rows)
constr_upserted = _upsert_monthly_rows(db, constr_rows)
logger.info(
"rosstat_macro_sync: construction upserted %d/%d rows",
constr_upserted,
constr_fetched,
)
except Exception as e:
first_error = first_error or e
logger.exception("rosstat_macro_sync: construction source failed: %s", e)
if first_error is not None:
raise first_error
return {
"fetched": rosstat_fetched + emiss_fetched,
"upserted": rosstat_upserted + emiss_upserted,
"fetched": rosstat_fetched + emiss_fetched + constr_fetched,
"upserted": rosstat_upserted + emiss_upserted + constr_upserted,
"rosstat": {"fetched": rosstat_fetched, "upserted": rosstat_upserted},
"emiss": {"fetched": emiss_fetched, "upserted": emiss_upserted},
"construction": {"fetched": constr_fetched, "upserted": constr_upserted},
}
finally:
db.close()

Binary file not shown.

View file

@ -11,19 +11,32 @@ from __future__ import annotations
from datetime import date
from decimal import Decimal
from pathlib import Path
import pytest
from app.services.scrapers.rosstat_emiss import (
CONSTRUCTION_PRICE_SPEC,
OKTMO_EKATERINBURG,
OKTMO_SVERDLOVSK_OBLAST,
POPULATION_SPEC,
DatasetSpec,
RegionTarget,
_parse_value,
_parse_year_cell,
parse_construction_index_xlsx,
parse_dataset_rows,
parse_meta_data_url,
parse_meta_modified_year,
)
# Фикстура: обрезанный РЕАЛЬНЫЙ xlsx Росстата «Индексы цен производителей на строительную
# продукцию по РФ» (лист '1', секции «К предыдущему месяцу» 2022-2025 + «К декабрю
# предыдущего года»; снято live 2026-06-08 с Invest_ind_stroitel_*.xlsx). Декабрь-2025
# намеренно ПУСТ (будущий/неопубликованный месяц → проверка None-not-0 пропуска).
_FIXTURES = Path(__file__).resolve().parent.parent / "fixtures"
CONSTRUCTION_XLSX = (_FIXTURES / "rosstat_construction_stroitel.xlsx").read_bytes()
# Реальный shape meta.csv Росстата (open-data 4.0): property,value; строка данных —
# property 'data-<ts>-structure-<ts>.csv' → абсолютный URL CSV-данных.
FIXTURE_META = (
@ -171,3 +184,133 @@ def test_oktmo_constants_used_in_spec() -> None:
oktmos = {t.oktmo for t in POPULATION_SPEC.targets}
assert OKTMO_SVERDLOVSK_OBLAST in oktmos
assert OKTMO_EKATERINBURG in oktmos
# ── индекс цен на СМР (открытый xlsx Росстата) ────────────────────────────────────
def test_parse_year_cell_plain_and_footnote() -> None:
"""Год из ячейки: чистый int, строка-год и сноска-маркер ('2022 1)' → 2022)."""
assert _parse_year_cell(2024) == 2024
assert _parse_year_cell("2022 1)") == 2022
assert _parse_year_cell("2025") == 2025
# не-год / пусто / вне диапазона → None
assert _parse_year_cell(None) is None
assert _parse_year_cell("Январь") is None
assert _parse_year_cell(1800) is None
def test_construction_spec_contract() -> None:
"""Спека СМР: тип, region='rf', monthly, % — открытый xlsx, без dataGrid.do/captcha."""
assert CONSTRUCTION_PRICE_SPEC.indicator_type == "construction_price_index"
assert CONSTRUCTION_PRICE_SPEC.region == "rf"
assert CONSTRUCTION_PRICE_SPEC.frequency == "monthly"
assert CONSTRUCTION_PRICE_SPEC.unit == "%"
assert "dataGrid" not in CONSTRUCTION_PRICE_SPEC.url_template
assert CONSTRUCTION_PRICE_SPEC.url_template.startswith("https://rosstat.gov.ru/")
assert "{mm:02d}" in CONSTRUCTION_PRICE_SPEC.url_template # резолв по дате, НЕ хардкод месяца
def test_construction_url_resolves_with_lookback(monkeypatch: pytest.MonkeyPatch) -> None:
"""Имя файла меняется ежемесячно → резолвер идёт назад до первого 200 (не хардкод)."""
from app.services.scrapers import rosstat_emiss as mod
tried: list[str] = []
def fake_get(client: object, url: str, *, retries: int = 0) -> bytes:
tried.append(url)
if len(tried) < 3: # первые два месяца → 404
raise mod.RosstatScraperError(f"HTTP 404 {url}")
return CONSTRUCTION_XLSX
monkeypatch.setattr(mod, "_http_get", fake_get)
content = mod._fetch_construction_latest(
client=object(), # type: ignore[arg-type] # не используется (get замокан)
url_template=mod.ROSSTAT_CONSTRUCTION_XLSX_URL_TEMPLATE,
today=date(2026, 6, 15),
)
assert content == CONSTRUCTION_XLSX
assert tried == [
"https://rosstat.gov.ru/storage/mediabank/Invest_ind_stroitel_06-2026.xlsx",
"https://rosstat.gov.ru/storage/mediabank/Invest_ind_stroitel_05-2026.xlsx",
"https://rosstat.gov.ru/storage/mediabank/Invest_ind_stroitel_04-2026.xlsx",
]
def test_construction_url_lookback_year_boundary_and_exhaustion(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Все кандидаты 404 → RosstatScraperError; look-back корректно переходит через год."""
from app.services.scrapers import rosstat_emiss as mod
tried: list[str] = []
def fake_get(client: object, url: str, *, retries: int = 0) -> bytes:
tried.append(url)
raise mod.RosstatScraperError(f"HTTP 404 {url}")
monkeypatch.setattr(mod, "_http_get", fake_get)
with pytest.raises(mod.RosstatScraperError):
mod._fetch_construction_latest(
client=object(), # type: ignore[arg-type]
url_template=mod.ROSSTAT_CONSTRUCTION_XLSX_URL_TEMPLATE,
today=date(2026, 2, 15),
max_lookback=3,
)
assert tried == [ # переход через год: 02-2026 → 01-2026 → 12-2025 → 11-2025
"https://rosstat.gov.ru/storage/mediabank/Invest_ind_stroitel_02-2026.xlsx",
"https://rosstat.gov.ru/storage/mediabank/Invest_ind_stroitel_01-2026.xlsx",
"https://rosstat.gov.ru/storage/mediabank/Invest_ind_stroitel_12-2025.xlsx",
"https://rosstat.gov.ru/storage/mediabank/Invest_ind_stroitel_11-2025.xlsx",
]
def test_parse_construction_monthly_series() -> None:
"""xlsx → месячный ряд СМР: region='rf', obs_date к 1-му числу, значение в %."""
rows = parse_construction_index_xlsx(CONSTRUCTION_XLSX, CONSTRUCTION_PRICE_SPEC)
assert rows, "ожидались строки из фикстуры"
assert all(r.region == "rf" for r in rows)
assert all(r.indicator_type == "construction_price_index" for r in rows)
assert all(r.unit == "%" for r in rows)
assert all(r.obs_date.day == 1 for r in rows)
by_date = {r.obs_date: r.value for r in rows}
# реальные значения «к предыдущему месяцу» из фикстуры (2022-2025)
assert by_date[date(2022, 1, 1)] == Decimal("100.6")
assert by_date[date(2025, 1, 1)] == Decimal("100.8")
# годы покрывают 2022..2025
assert {d.year for d in by_date} == {2022, 2023, 2024, 2025}
def test_parse_construction_skips_blank_future_month() -> None:
"""Пустая ячейка будущего/неопубликованного месяца → пропуск (None-not-0)."""
rows = parse_construction_index_xlsx(CONSTRUCTION_XLSX, CONSTRUCTION_PRICE_SPEC)
dates = {r.obs_date for r in rows}
# Декабрь-2025 в фикстуре намеренно пуст → его НЕ должно быть (а не 0)
assert date(2025, 12, 1) not in dates
assert date(2025, 11, 1) in dates # соседний заполненный — есть
def test_parse_construction_ignores_annual_section() -> None:
"""Секция «К декабрю предыдущего года» (годовой базис) НЕ попадает в месячный ряд.
Годовые декабрьские значения большие (>105), месячные около 100. Убеждаемся, что
ни одно декабрьское наблюдение не унаследовало годовое значение.
"""
rows = parse_construction_index_xlsx(CONSTRUCTION_XLSX, CONSTRUCTION_PRICE_SPEC)
dec_vals = [r.value for r in rows if r.obs_date.month == 12]
assert dec_vals, "ожидались декабрьские месячные наблюдения"
assert all(v < Decimal("105") for v in dec_vals)
def test_parse_construction_empty_and_garbage_graceful() -> None:
"""Пустые байты / не-xlsx → [] (+warning), не бросает."""
assert parse_construction_index_xlsx(b"", CONSTRUCTION_PRICE_SPEC) == []
assert parse_construction_index_xlsx(b"not a zip", CONSTRUCTION_PRICE_SPEC) == []
def test_parse_construction_missing_sheet_graceful() -> None:
"""Если запрошенного листа нет в книге → [] (+warning)."""
from dataclasses import replace
spec_bad = replace(CONSTRUCTION_PRICE_SPEC, sheet="НЕТ_ТАКОГО")
assert parse_construction_index_xlsx(CONSTRUCTION_XLSX, spec_bad) == []

View file

@ -69,15 +69,18 @@ def test_task_upserts_each_row_with_correct_params() -> None:
patch.object(task_mod, "SessionLocal", return_value=db),
patch.object(task_mod, "fetch_all_datasets", return_value=fake_rows) as mock_fetch,
patch.object(task_mod, "fetch_all_emiss", return_value=[]) as mock_emiss,
patch.object(task_mod, "fetch_construction_index", return_value=[]) as mock_constr,
):
result = task_mod.rosstat_macro_sync.run()
mock_fetch.assert_called_once()
mock_emiss.assert_called_once()
mock_constr.assert_called_once()
assert result["fetched"] == 2
assert result["upserted"] == 2
assert result["rosstat"] == {"fetched": 2, "upserted": 2}
assert result["emiss"] == {"fetched": 0, "upserted": 0}
assert result["construction"] == {"fetched": 0, "upserted": 0}
assert captured == [
{
"itype": "population_total",
@ -113,6 +116,7 @@ def test_task_reraises_fetch_failure() -> None:
patch.object(task_mod, "SessionLocal", return_value=db),
patch.object(task_mod, "fetch_all_datasets", side_effect=RuntimeError("rosstat down")),
patch.object(task_mod, "fetch_all_emiss", return_value=[]),
patch.object(task_mod, "fetch_construction_index", return_value=[]),
):
try:
task_mod.rosstat_macro_sync.run()
@ -137,6 +141,7 @@ def test_task_emiss_failure_does_not_block_opendata() -> None:
patch.object(task_mod, "SessionLocal", return_value=db),
patch.object(task_mod, "fetch_all_datasets", return_value=rosstat_rows),
patch.object(task_mod, "fetch_all_emiss", side_effect=RuntimeError("emiss down")),
patch.object(task_mod, "fetch_construction_index", return_value=[]),
):
try:
task_mod.rosstat_macro_sync.run()
@ -166,6 +171,7 @@ def test_task_upserts_emiss_rows_with_correct_params() -> None:
patch.object(task_mod, "SessionLocal", return_value=db),
patch.object(task_mod, "fetch_all_datasets", return_value=[]),
patch.object(task_mod, "fetch_all_emiss", return_value=emiss_rows),
patch.object(task_mod, "fetch_construction_index", return_value=[]),
):
result = task_mod.rosstat_macro_sync.run()
@ -198,6 +204,86 @@ def test_upsert_emiss_sql_contract() -> None:
assert "::" not in sql
def test_upsert_rosstat_monthly_sql_contract() -> None:
"""UPSERT_ROSSTAT_MONTHLY_SQL: source='rosstat', frequency='monthly', CAST not ::."""
from app.workers.tasks.rosstat_macro_sync import UPSERT_ROSSTAT_MONTHLY_SQL
sql = str(UPSERT_ROSSTAT_MONTHLY_SQL)
upper = sql.upper()
assert "INTO MACRO_INDICATOR" in upper
assert "'rosstat'" in sql
assert "'monthly'" in sql
assert "ON CONFLICT (INDICATOR_TYPE, REGION, OBS_DATE) DO UPDATE" in upper
assert "CAST(:ITYPE AS TEXT)" in upper
assert "CAST(:D AS DATE)" in upper
assert "CAST(:V AS NUMERIC)" in upper
assert "::" not in sql
def test_task_upserts_construction_rows_with_correct_params() -> None:
"""Каждая construction MacroRow → execute UPSERT_ROSSTAT_MONTHLY_SQL c bind-параметрами."""
from app.services.scrapers.rosstat_emiss import MacroRow
from app.workers.tasks import rosstat_macro_sync as task_mod
db, captured = _make_mock_db()
constr_rows = [
MacroRow(
"construction_price_index", "rf", date(2025, 1, 1),
Decimal("100.8"), "%", "smr",
),
]
with (
patch.object(task_mod, "SessionLocal", return_value=db),
patch.object(task_mod, "fetch_all_datasets", return_value=[]),
patch.object(task_mod, "fetch_all_emiss", return_value=[]),
patch.object(task_mod, "fetch_construction_index", return_value=constr_rows),
):
result = task_mod.rosstat_macro_sync.run()
assert result["construction"] == {"fetched": 1, "upserted": 1}
assert result["fetched"] == 1
assert captured == [
{
"itype": "construction_price_index",
"region": "rf",
"d": date(2025, 1, 1),
"v": Decimal("100.8"),
"unit": "%",
"comment": "smr",
},
]
def test_task_construction_failure_does_not_block_others() -> None:
"""Per-source guard: сбой СМР-источника НЕ мешает open-data/ЕМИСС, ошибка пробрасывается."""
from app.services.scrapers.rosstat_emiss import MacroRow
from app.workers.tasks import rosstat_macro_sync as task_mod
db, captured = _make_mock_db()
rosstat_rows = [
MacroRow("population_total", "sverdl", date(2014, 1, 1), Decimal("4320677"), "чел", "c"),
]
with (
patch.object(task_mod, "SessionLocal", return_value=db),
patch.object(task_mod, "fetch_all_datasets", return_value=rosstat_rows),
patch.object(task_mod, "fetch_all_emiss", return_value=[]),
patch.object(
task_mod, "fetch_construction_index", side_effect=RuntimeError("rosstat xlsx down")
),
):
try:
task_mod.rosstat_macro_sync.run()
raise AssertionError("expected RuntimeError to propagate")
except RuntimeError as e:
assert "rosstat xlsx down" in str(e)
# open-data всё равно апсертнулся ДО проброса construction-ошибки
assert any(p.get("itype") == "population_total" for p in captured)
db.close.assert_called_once()
def test_task_registered_in_beat_monthly() -> None:
"""Задача зарегистрирована в beat как ежемесячная (1-е число)."""
from app.workers.beat_schedule import build_beat_schedule