diff --git a/backend/app/services/scrapers/domrf_kn.py b/backend/app/services/scrapers/domrf_kn.py index 9f6f2353..5cdc0094 100644 --- a/backend/app/services/scrapers/domrf_kn.py +++ b/backend/app/services/scrapers/domrf_kn.py @@ -17,6 +17,7 @@ auth shipped in their frontend bundle). from __future__ import annotations import asyncio +import hashlib import json import logging from datetime import date, datetime @@ -395,10 +396,17 @@ def _norm_flat(row: dict[str, Any], region_cd: int | None) -> dict[str, Any]: """ flat_id = _g(row, "flatId", "id") if flat_id is None: - # Fallback: hash of elemId (stable, fits BIGINT). Without an id we cannot UPSERT. + # Fallback: ДЕТЕРМИНИРОВАННЫЙ sha256-digest от elemId (#1208). Раньше + # использовался hash(elem), который в CPython РАНДОМИЗИРОВАН per-process + # (PYTHONHASHSEED нигде в репо не зафиксирован) → при resume упавшего + # sweep (новый процесс воркера) та же квартира получала ДРУГОЙ id → + # ON CONFLICT (id, snapshot_date) не срабатывал → дубли строк одной + # квартиры в одном snapshot, инфляция агрегаций. sha256 стабилен между + # процессами/перезапусками. Берём 8 байт (BIGINT'у достаточно). elem = _g(row, "elemId") if elem: - flat_id = abs(hash(elem)) % (2**63 - 1) + digest = hashlib.sha256(str(elem).encode("utf-8")).digest()[:8] + flat_id = int.from_bytes(digest, "big") % (2**63 - 1) price_rub = _g(row, "price") price_per_m2 = _g(row, "pricePerSquareMeter") diff --git a/backend/tests/services/scrapers/test_domrf_kn_normalize.py b/backend/tests/services/scrapers/test_domrf_kn_normalize.py new file mode 100644 index 00000000..a8d98e62 --- /dev/null +++ b/backend/tests/services/scrapers/test_domrf_kn_normalize.py @@ -0,0 +1,57 @@ +"""Unit-тесты для нормализаторов scrapers/domrf_kn (#1208). + +Главный сценарий: детерминированный fallback flat_id через sha256 — НЕ +рандомизированный hash(). Раньше при resume упавшего sweep (новый процесс +воркера) та же квартира получала другой id → ON CONFLICT не срабатывал → +дубли в одном snapshot. +""" + +from __future__ import annotations + +import hashlib + +from app.services.scrapers.domrf_kn import _norm_flat + + +def _expected_sha_fallback(elem: str) -> int: + """Зеркало формулы из _norm_flat — для прямого сравнения в тестах.""" + digest = hashlib.sha256(str(elem).encode("utf-8")).digest()[:8] + return int.from_bytes(digest, "big") % (2**63 - 1) + + +class TestFlatIdFallbackDeterministic: + """flat_id fallback ОБЯЗАН быть детерминированным между процессами/перезапусками.""" + + def test_fallback_matches_sha256_formula(self) -> None: + row = {"elemId": "abc123"} + out = _norm_flat(row, region_cd=66) + assert out["id"] == _expected_sha_fallback("abc123") + + def test_fallback_stable_across_calls(self) -> None: + # Тот же elemId → тот же id. (Раньше hash() мог дать другой id в новом процессе.) + row = {"elemId": "stable-elem-id"} + a = _norm_flat(row, region_cd=66)["id"] + b = _norm_flat(row, region_cd=66)["id"] + assert a == b + assert a == _expected_sha_fallback("stable-elem-id") + + def test_different_elems_give_different_ids(self) -> None: + a = _norm_flat({"elemId": "elem-A"}, region_cd=66)["id"] + b = _norm_flat({"elemId": "elem-B"}, region_cd=66)["id"] + assert a != b + + def test_flat_id_present_wins_over_fallback(self) -> None: + # Если flatId есть — используем его, fallback не активируется. + row = {"flatId": 12345, "elemId": "should-be-ignored"} + out = _norm_flat(row, region_cd=66) + assert out["id"] == 12345 + + def test_no_flat_id_no_elem_id_returns_none(self) -> None: + out = _norm_flat({}, region_cd=66) + assert out["id"] is None + + def test_fallback_fits_bigint(self) -> None: + # 8-байтовый digest усечён в pos-int, всегда < 2**63. + out = _norm_flat({"elemId": "x" * 1000}, region_cd=66) + assert out["id"] is not None + assert 0 <= out["id"] < 2**63