fix(scrapers): stable dedup_hash — drop price_rub + strip URL query (Refs #753) #761
2 changed files with 77 additions and 21 deletions
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue