From 9dbfb03a3d32bce6ddd1765835582daee704764f Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 17 Jun 2026 20:39:52 +0300 Subject: [PATCH] fix(scrapers): disambiguate EMISS yearly vs Q1 in dedup key (#1606) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _YEAR_PERIODS ('год') → month=1 collided with Q1 ('I квартал') → month=1 for the same region/year: both produced obs_date=YYYY-01-01, so the second series in the XML silently overwrote the first in the in-memory by_key dict. Fix: add _emiss_period_granularity() and extend the dedup key from (region, obs_date) to (region, obs_date, granularity). 'year' and 'quarter' are now distinct slots, so both rows survive the parser and reach the upsert. Test: test_yearly_and_q1_both_survive_dedup verifies len==2 with both values present; test_period_granularity covers all classification branches. --- .../app/services/scrapers/rosstat_emiss.py | 53 ++++++++++------ backend/tests/scrapers/test_emiss_sdmx.py | 61 +++++++++++++++++-- 2 files changed, 92 insertions(+), 22 deletions(-) diff --git a/backend/app/services/scrapers/rosstat_emiss.py b/backend/app/services/scrapers/rosstat_emiss.py index 181f4a8b..14b633bf 100644 --- a/backend/app/services/scrapers/rosstat_emiss.py +++ b/backend/app/services/scrapers/rosstat_emiss.py @@ -298,9 +298,7 @@ def _parse_value(raw: str) -> Decimal | None: raw = (raw or "").strip() if not raw or raw in {"-", "—", "…", "..."}: return None - cleaned = ( - raw.replace(" ", "").replace(" ", "").replace(" ", "").replace(",", ".") - ) + cleaned = raw.replace(" ", "").replace(" ", "").replace(" ", "").replace(",", ".") try: return Decimal(cleaned) except (InvalidOperation, ValueError): @@ -399,9 +397,7 @@ def parse_dataset_rows( # ── HTTP fetch (thin) ─────────────────────────────────────────────────────────── -def _http_get( - client: httpx.Client, url: str, *, retries: int = DEFAULT_RETRIES -) -> bytes: +def _http_get(client: httpx.Client, url: str, *, retries: int = DEFAULT_RETRIES) -> bytes: """GET с ретраями (сеть / 5xx) → байты тела. Module-level для monkeypatch в тестах. Raises: @@ -622,9 +618,7 @@ _MONTH_NAME_TO_NUM: dict[str, int] = { "декабрь": 12, } # Годовой под-период (некоторые индикаторы): «значение показателя за год», «год». -_YEAR_PERIODS: frozenset[str] = frozenset( - {"год", "значение показателя за год", "за год"} -) +_YEAR_PERIODS: frozenset[str] = frozenset({"год", "значение показателя за год", "за год"}) @dataclass(frozen=True) @@ -704,6 +698,25 @@ def _emiss_period_to_month(period: str) -> int | None: 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 словари из . @@ -736,7 +749,10 @@ def parse_emiss_sdmx(raw: bytes | str, spec: EmissIndicatorSpec) -> list[EmissRo не среди spec.targets — ряд пропускается. PERIOD (Attributes) → стартовый месяц под-периода; год — из ' "" '' '' "" - '2024' + "2024" '' "" ) @@ -179,7 +230,7 @@ def test_comma_decimal_value_parsed() -> None: '' '' "" - '2024' + "2024" '' "" ) @@ -199,13 +250,13 @@ def test_dedup_last_wins() -> None: '' '' "" - '2024' + "2024" '' "" '' '' "" - '2024' + "2024" '' "" )