diff --git a/backend/app/services/scrapers/rosstat_emiss.py b/backend/app/services/scrapers/rosstat_emiss.py index 7be04b96..239d70e7 100644 --- a/backend/app/services/scrapers/rosstat_emiss.py +++ b/backend/app/services/scrapers/rosstat_emiss.py @@ -7,15 +7,32 @@ Celery-таска ``rosstat_macro_sync`` через SAVEPOINT-per-row. ──────────────────────────────────────────────────────────────────────────────── -ИСТОЧНИК ДАННЫХ — почему open-data, а НЕ ЕМИСС-SDMX (разведано live 2026-06-07) +ИСТОЧНИК ДАННЫХ — open-data + ЕМИСС-SDMX (разведано live 2026-06-07) ──────────────────────────────────────────────────────────────────────────────── -ЕМИСС / fedstat.ru (SDMX + ``/indicator/data.do``) — за WAF: ЛЮБОЙ путь/метод/набор -заголовков отдаёт статичный 80-байтный ``

Forbidden

`` БЕЗ ``Server``-заголовка -(блок на фронтящем WAF/CDN до приложения — IP-reputation, не JS-challenge, который мог -бы решить браузер). ``sdmx.gks.ru`` — DNS мёртв, ``showdata.gks.ru`` — 502. То есть -EMISS-only ряды (ИПЦ, среднедушевые доходы, индекс цен на СМР) автоматически НЕдоступны. -Это независимо зафиксировано прежней разведкой (комментарий миграции 126 §7.11, vault -data-sources). +ЕМИСС / fedstat.ru — за WAF с DEV-машины (hard-403), но ДОСТУПЕН С ПРОДА (verified +2026-06-07: ``POST https://www.fedstat.ru/indicator/data.do`` body ``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=_``, + перечисляющим выбранные коды ПО ВСЕМ размерностям, где ``internalId`` — внутренний + id кода (НЕ значение ОКАТО), берётся отдельным filter-tree AJAX; плюс на странице + индикатора reCAPTCHA. Это глубокий RE — отложено (закрывается тем же EmissIndicatorSpec + + параметризацией data.do-выборки, когда добудем filter_id-маппинг). + • construction_price_index (индекс цен на СМР) — id не подтверждён: кандидаты + (31448 «Средние потреб. цены», 37426 «Еженедельные цены») — много-мерные и БЕЗ + Свердл в дефолт-экспорте (та же проблема, что у ИПЦ). НЕ landed. ``rosstat.gov.ru/opendata`` — БЕЗ WAF, машиночитаем (российский стандарт открытых данных 4.0): @@ -33,13 +50,11 @@ data-sources). муниципальный срез; берём субъект-агрегат ОКТМО ``6500000000`` + г. Екатеринбург ``657010000011000``). Это § 7.11 «демография (численность)». -Что НЕ приземляется (документировано, частичная поставка допустима ТЗ): - • среднедушевые доходы, ИПЦ, индекс цен на СМР — EMISS-only → WAF (см. выше). В - open-data их нет (проверено по реестру: 0 совпадений). Закрывается, когда появится - рабочий доступ к ЕМИСС (прокси / ручная выгрузка) — тогда добавится новый - ``DatasetSpec`` с тем же upsert-контрактом. +Что НЕ приземляется из open-data (но среднедушевые доходы теперь идут через ЕМИСС-прод): + • ИПЦ, индекс цен на СМР — EMISS-only и в open-data их нет (0 совпадений по реестру); + из ЕМИСС пока не landed (много-мерные, см. EMISS_INDICATORS-блок выше). -Standalone smoke (ручная проверка против живого rosstat.gov.ru): +Standalone smoke (ручная проверка; open-data — c dev, ЕМИСС-fetch — только с прода): python -m app.services.scrapers.rosstat_emiss Mirror conventions: ``httpx.Client`` + явный User-Agent (как cbr_macro / ekburg_permits), @@ -51,6 +66,7 @@ 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 @@ -63,6 +79,10 @@ logger = logging.getLogger(__name__) ROSSTAT_OPENDATA_BASE = "https://rosstat.gov.ru/opendata" +# ЕМИСС / fedstat.ru — SDMX-экспорт индикатора (POST id=&format=sdmx). За WAF с +# dev (403), доступен с прода (см. шапку модуля). verify off — тот же российский гос-УЦ. +FEDSTAT_DATA_URL = "https://www.fedstat.ru/indicator/data.do" + # Браузероподобный UA: rosstat.gov.ru отдаёт open-data без WAF, но «голый» httpx-UA # иногда ловит 403 на edge-кэше — даём обычный desktop-Chrome UA (как nspd_client). USER_AGENT = ( @@ -397,6 +417,56 @@ def _http_get( 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, *, @@ -475,6 +545,307 @@ def fetch_all_datasets( return out +# ══════════════════════════════════════════════════════════════════════════════════ +# ЕМИСС / fedstat.ru — SDMX-ML v1.0 GenericData (EMISS-only ряды, fetch только с прода) +# ══════════════════════════════════════════════════════════════════════════════════ +# Источник: POST https://www.fedstat.ru/indicator/data.do body: id=&format=sdmx +# → SDMX-ML v1.0 GenericData (см. tests/fixtures/emiss_income_57039.xml). Структура: +# +# …коды регионов… +# (fedstat-расширение) +# +# +# +# +# (единица измерения) +# (под-период года) +# +# 2024 +# +# … +# Один = один (регион, PERIOD) ряд; год наблюдения — в