fix(scrapers): stable dedup_hash — drop price_rub + strip URL query (Refs #753) #761

Merged
bot-reviewer merged 1 commit from fix/753-avito-dedup-stable into main 2026-05-30 16:10:27 +00:00
2 changed files with 77 additions and 21 deletions

View file

@ -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,
)

View file

@ -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()