From 79a79004cfc90bca61505d76464ad74824c5f033 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 30 May 2026 16:10:27 +0000 Subject: [PATCH] =?UTF-8?q?fix(scrapers):=20stable=20dedup=5Fhash=20?= =?UTF-8?q?=E2=80=94=20drop=20price=5Frub=20+=20strip=20URL=20query=20(Ref?= =?UTF-8?q?s=20#753)=20(#761)=20Co-authored-by:=20bot-backend=20=20Co-committed-by:=20bot-backend=20?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/app/services/scrapers/base.py | 47 +++++++++-------- .../backend/tests/test_base_save_listings.py | 51 +++++++++++++++++++ 2 files changed, 77 insertions(+), 21 deletions(-) diff --git a/tradein-mvp/backend/app/services/scrapers/base.py b/tradein-mvp/backend/app/services/scrapers/base.py index 914c50a1..2c478ba6 100644 --- a/tradein-mvp/backend/app/services/scrapers/base.py +++ b/tradein-mvp/backend/app/services/scrapers/base.py @@ -63,9 +63,9 @@ class ScrapedLot(BaseModel): Поля optional если источник их не отдаёт. """ - source: str # 'avito' / 'cian' / 'domklik' / ... - source_url: str # ссылка на оригинал — кликается из UI - source_id: str | None = None # внутренний id источника (для update) + source: str # 'avito' / 'cian' / 'domklik' / ... + source_url: str # ссылка на оригинал — кликается из UI + source_id: str | None = None # внутренний id источника (для update) # Локация — должна быть хотя бы address ИЛИ (lat, lon) address: str | None = None @@ -73,7 +73,7 @@ class ScrapedLot(BaseModel): lon: float | None = None # Параметры квартиры - rooms: int | None = None # 0 = студия + rooms: int | None = None # 0 = студия area_m2: float | None = None floor: int | None = None total_floors: int | None = None @@ -84,10 +84,10 @@ class ScrapedLot(BaseModel): kadastr_num: str | None = None # Avito house linking (Stage 2a — search → houses) - house_source: str | None = None # 'avito' / 'cian' - house_ext_id: str | None = None # Avito's '3171365' - house_url: str | None = None # full URL of /catalog/houses/... - listing_segment: str | None = None # 'vtorichka' / 'novostroyki' + house_source: str | None = None # 'avito' / 'cian' + house_ext_id: str | None = None # Avito's '3171365' + house_url: str | None = None # full URL of /catalog/houses/... + listing_segment: str | None = None # 'vtorichka' / 'novostroyki' # Цена (обязательно) price_rub: int = Field(gt=0) @@ -115,19 +115,24 @@ class ScrapedLot(BaseModel): metro_stations: list[dict] = Field(default_factory=list) def compute_dedup_hash(self) -> str: - """SHA256(source + (source_id или source_url) + price_rub) — стабильный uniqueness key. + """SHA256(source + stable_key) — стабильный uniqueness key. - Cian URL содержит random `?context=...` токены — поэтому ключ берём - source_id (offer_id 330200428), а не source_url. Если source_id нет — - откатываемся на source_url (Yandex, Avito с offerId). + stable_key = source_id (Cian offer_id 330200428, Avito data-item-id) если + есть; иначе source_url БЕЗ query-string. Cian/Avito URL несут волатильный + `?context=...` токен — без strip каждый ре-скрейп давал бы новый hash. + + #753: price_rub в ключ НЕ входит. Смена цены того же объявления — это UPDATE + существующей строки (`ON CONFLICT (dedup_hash) DO UPDATE`), а не новый объект. + Раньше цена была частью ключа → каждое изменение цены порождало дубль + активного listing → искажение asking_to_sold_ratio / sales-vs-listings / + активного инвентаря. Смена формулы разово даёт новый hash существующим + строкам — следующий скрейп upsert'нет их по новому ключу (всплеск разовый). """ h = hashlib.sha256() h.update(self.source.encode()) h.update(b"|") - key = self.source_id if self.source_id else self.source_url + key = self.source_id if self.source_id else (self.source_url or "").split("?")[0] h.update((key or "").encode()) - h.update(b"|") - h.update(str(self.price_rub).encode()) return h.hexdigest() def compute_price_per_m2(self) -> int | None: @@ -190,9 +195,7 @@ class BaseScraper(ABC): await asyncio.sleep(self.request_delay_sec * jitter) @abstractmethod - async def fetch_around( - self, lat: float, lon: float, radius_m: int = 1000 - ) -> list[ScrapedLot]: + async def fetch_around(self, lat: float, lon: float, radius_m: int = 1000) -> list[ScrapedLot]: """Главный метод — собрать объявления вокруг (lat, lon) в радиусе radius_m. Returns: @@ -456,7 +459,9 @@ def _link_listing_to_house(db: Session, listing_id: int, lot: ScrapedLot) -> Non # even without house linkage (e.g. for later backfill). logger.warning( "save_listings:house_match_failed source=%s ext_id=%s: %s", - lot.source, ext_id, e, + lot.source, + ext_id, + e, ) house_id = None @@ -465,12 +470,12 @@ def _link_listing_to_house(db: Session, listing_id: int, lot: ScrapedLot) -> Non listing_id=listing_id, ext_source=lot.source, ext_id=str(ext_id), - method='source_link', + method="source_link", confidence=1.0, price_rub=lot.price_rub, area_m2=lot.area_m2, floor=lot.floor, rooms_count=lot.rooms, source_url=lot.source_url, - source_data={'house_id': house_id} if house_id else None, + source_data={"house_id": house_id} if house_id else None, ) diff --git a/tradein-mvp/backend/tests/test_base_save_listings.py b/tradein-mvp/backend/tests/test_base_save_listings.py index 0d165336..03878f58 100644 --- a/tradein-mvp/backend/tests/test_base_save_listings.py +++ b/tradein-mvp/backend/tests/test_base_save_listings.py @@ -473,3 +473,54 @@ def test_save_listings_empty_lots_skips_matching(_patch_matching): assert _patch_matching["house"].call_count == 0 assert _patch_matching["link"].call_count == 0 + + +# ── #753: dedup_hash стабильность (Avito ?context= + смена цены) ───────────── + + +def test_dedup_hash_stable_across_price_and_context_query(): + """#753: тот же Avito item (один source_id) с РАЗНОЙ ценой и РАЗНЫМ ?context= + в URL → ОДИНАКОВЫЙ dedup_hash. Иначе смена цены / ре-скрейп = дубль.""" + lot_a = _base_lot( + source_url="https://www.avito.ru/ekaterinburg/kvartiry/1-k_987654?context=AAAA", + source_id="987654", + price_rub=4_500_000, + ) + lot_b = _base_lot( + source_url="https://www.avito.ru/ekaterinburg/kvartiry/1-k_987654?context=ZZZZ", + source_id="987654", + price_rub=4_300_000, # цена снижена + ) + assert lot_a.compute_dedup_hash() == lot_b.compute_dedup_hash() + + +def test_dedup_hash_strips_query_when_no_source_id(): + """#753: при отсутствии source_id ключ = source_url без query-string — + волатильный ?context= не должен влиять на hash.""" + lot_a = _base_lot( + source_url="https://www.avito.ru/ekaterinburg/kvartiry/1-k_55?context=AAAA", + source_id=None, + price_rub=5_000_000, + ) + lot_b = _base_lot( + source_url="https://www.avito.ru/ekaterinburg/kvartiry/1-k_55?context=BBBB", + source_id=None, + price_rub=5_900_000, + ) + assert lot_a.compute_dedup_hash() == lot_b.compute_dedup_hash() + + +def test_dedup_hash_distinguishes_different_listings(): + """#753 guard: разные объявления (разные source_id) → РАЗНЫЙ hash + (фикс не должен схлопывать несвязанные listings).""" + lot_a = _base_lot(source_id="111", source_url="https://www.avito.ru/a_111") + lot_b = _base_lot(source_id="222", source_url="https://www.avito.ru/a_222") + assert lot_a.compute_dedup_hash() != lot_b.compute_dedup_hash() + + +def test_dedup_hash_distinguishes_source_even_same_id(): + """#753 guard: одинаковый source_id у разных источников → РАЗНЫЙ hash + (source — часть ключа).""" + lot_avito = _base_lot(source="avito", source_id="500") + lot_cian = _base_lot(source="cian", source_id="500") + assert lot_avito.compute_dedup_hash() != lot_cian.compute_dedup_hash()