gendesign/backend/tests/services/scrapers/test_domrf_kn_normalize.py
bot-backend f6fb79522a
Some checks failed
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Failing after 1m57s
CI / backend-tests (pull_request) Successful in 14m44s
feat(scrapers): wire per-flat DOM.РФ catalog scraper + propagate catalog_url_hash (#2442)
Task 1: _norm_flat теперь читает catalog_url_hash из уже доступного kn-API
elemId вместо жёсткого None (domrf_kn.py:479) — разблокирует источник
хэшей для 983k квартир при следующем kn-sweep. elemId or None корректно
нормализует и отсутствие, и пустую строку.

Task 2: новый scrape_kn_catalog_flats Celery task оборачивает уже
смерженный scrape_catalog_batch (#2441) — batch-выборка по последнему
snapshot WHERE catalog_url_hash IS NOT NULL + 30d freshness/force.
Зарегистрирован в celery_app, ручной триггер POST /kn-catalog-flats.

Beat-запись оставлена закомментированной — по образцу уже отключённого
object-level catalog scraper: DOM.РФ WAF hard-ban на VPS IP с 2026-05-24
(см. #2443), cooldown не подтверждён. Task ships полностью dormant:
beat не назначен в рантайме, admin-endpoint требует авторизации, browser-
stack импортируется лениво внутри функции (не на старте воркера), живой
fetch недостижим без непустого batch. code-reviewer отдельно проверил
WAF-safety независимо — подтверждено.

Batch наполнится непустыми строками только после того, как Task 1
задеплоится И пройдёт свежий kn-sweep — ожидаемо 0 строк первое время.

Refs #2442, #2441, #2443
2026-07-05 21:39:39 +05:00

88 lines
4.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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.

"""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
class TestCatalogUrlHash:
"""catalog_url_hash ОБЯЗАН пробрасываться из elemId (#2442 Task 1).
Ранее хардкодился в None → flat-catalog scraper выбирал 0 строк
(WHERE catalog_url_hash IS NOT NULL). Теперь берётся из того же elemId,
что и fallback flat_id, и живёт независимо от того, пришёл ли flatId.
"""
def test_hash_populated_from_elem_id(self) -> None:
row = {"elemId": "abc-hash-123"}
out = _norm_flat(row, region_cd=66)
assert out["catalog_url_hash"] == "abc-hash-123"
def test_hash_populated_even_when_flat_id_present(self) -> None:
# flatId присутствует → fallback-ветка flat_id НЕ выполняется, но hash
# всё равно должен прочитаться (регрессия, которую чинит Task 1).
row = {"flatId": 999, "elemId": "hash-with-flatid"}
out = _norm_flat(row, region_cd=66)
assert out["id"] == 999
assert out["catalog_url_hash"] == "hash-with-flatid"
def test_hash_none_when_elem_id_absent(self) -> None:
out = _norm_flat({"flatId": 1}, region_cd=66)
assert out["catalog_url_hash"] is None
def test_hash_empty_string_normalized_to_none(self) -> None:
# Пустую строку трактуем как отсутствие (не пишем "" в БД).
out = _norm_flat({"flatId": 1, "elemId": ""}, region_cd=66)
assert out["catalog_url_hash"] is None