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 если источник их не отдаёт.
|
Поля optional если источник их не отдаёт.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
source: str # 'avito' / 'cian' / 'domklik' / ...
|
source: str # 'avito' / 'cian' / 'domklik' / ...
|
||||||
source_url: str # ссылка на оригинал — кликается из UI
|
source_url: str # ссылка на оригинал — кликается из UI
|
||||||
source_id: str | None = None # внутренний id источника (для update)
|
source_id: str | None = None # внутренний id источника (для update)
|
||||||
|
|
||||||
# Локация — должна быть хотя бы address ИЛИ (lat, lon)
|
# Локация — должна быть хотя бы address ИЛИ (lat, lon)
|
||||||
address: str | None = None
|
address: str | None = None
|
||||||
|
|
@ -73,7 +73,7 @@ class ScrapedLot(BaseModel):
|
||||||
lon: float | None = None
|
lon: float | None = None
|
||||||
|
|
||||||
# Параметры квартиры
|
# Параметры квартиры
|
||||||
rooms: int | None = None # 0 = студия
|
rooms: int | None = None # 0 = студия
|
||||||
area_m2: float | None = None
|
area_m2: float | None = None
|
||||||
floor: int | None = None
|
floor: int | None = None
|
||||||
total_floors: int | None = None
|
total_floors: int | None = None
|
||||||
|
|
@ -84,10 +84,10 @@ class ScrapedLot(BaseModel):
|
||||||
kadastr_num: str | None = None
|
kadastr_num: str | None = None
|
||||||
|
|
||||||
# Avito house linking (Stage 2a — search → houses)
|
# Avito house linking (Stage 2a — search → houses)
|
||||||
house_source: str | None = None # 'avito' / 'cian'
|
house_source: str | None = None # 'avito' / 'cian'
|
||||||
house_ext_id: str | None = None # Avito's '3171365'
|
house_ext_id: str | None = None # Avito's '3171365'
|
||||||
house_url: str | None = None # full URL of /catalog/houses/...
|
house_url: str | None = None # full URL of /catalog/houses/...
|
||||||
listing_segment: str | None = None # 'vtorichka' / 'novostroyki'
|
listing_segment: str | None = None # 'vtorichka' / 'novostroyki'
|
||||||
|
|
||||||
# Цена (обязательно)
|
# Цена (обязательно)
|
||||||
price_rub: int = Field(gt=0)
|
price_rub: int = Field(gt=0)
|
||||||
|
|
@ -115,19 +115,24 @@ class ScrapedLot(BaseModel):
|
||||||
metro_stations: list[dict] = Field(default_factory=list)
|
metro_stations: list[dict] = Field(default_factory=list)
|
||||||
|
|
||||||
def compute_dedup_hash(self) -> str:
|
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=...` токены — поэтому ключ берём
|
stable_key = source_id (Cian offer_id 330200428, Avito data-item-id) если
|
||||||
source_id (offer_id 330200428), а не source_url. Если source_id нет —
|
есть; иначе source_url БЕЗ query-string. Cian/Avito URL несут волатильный
|
||||||
откатываемся на source_url (Yandex, Avito с offerId).
|
`?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 = hashlib.sha256()
|
||||||
h.update(self.source.encode())
|
h.update(self.source.encode())
|
||||||
h.update(b"|")
|
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((key or "").encode())
|
||||||
h.update(b"|")
|
|
||||||
h.update(str(self.price_rub).encode())
|
|
||||||
return h.hexdigest()
|
return h.hexdigest()
|
||||||
|
|
||||||
def compute_price_per_m2(self) -> int | None:
|
def compute_price_per_m2(self) -> int | None:
|
||||||
|
|
@ -190,9 +195,7 @@ class BaseScraper(ABC):
|
||||||
await asyncio.sleep(self.request_delay_sec * jitter)
|
await asyncio.sleep(self.request_delay_sec * jitter)
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def fetch_around(
|
async def fetch_around(self, lat: float, lon: float, radius_m: int = 1000) -> list[ScrapedLot]:
|
||||||
self, lat: float, lon: float, radius_m: int = 1000
|
|
||||||
) -> list[ScrapedLot]:
|
|
||||||
"""Главный метод — собрать объявления вокруг (lat, lon) в радиусе radius_m.
|
"""Главный метод — собрать объявления вокруг (lat, lon) в радиусе radius_m.
|
||||||
|
|
||||||
Returns:
|
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).
|
# even without house linkage (e.g. for later backfill).
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"save_listings:house_match_failed source=%s ext_id=%s: %s",
|
"save_listings:house_match_failed source=%s ext_id=%s: %s",
|
||||||
lot.source, ext_id, e,
|
lot.source,
|
||||||
|
ext_id,
|
||||||
|
e,
|
||||||
)
|
)
|
||||||
house_id = None
|
house_id = None
|
||||||
|
|
||||||
|
|
@ -465,12 +470,12 @@ def _link_listing_to_house(db: Session, listing_id: int, lot: ScrapedLot) -> Non
|
||||||
listing_id=listing_id,
|
listing_id=listing_id,
|
||||||
ext_source=lot.source,
|
ext_source=lot.source,
|
||||||
ext_id=str(ext_id),
|
ext_id=str(ext_id),
|
||||||
method='source_link',
|
method="source_link",
|
||||||
confidence=1.0,
|
confidence=1.0,
|
||||||
price_rub=lot.price_rub,
|
price_rub=lot.price_rub,
|
||||||
area_m2=lot.area_m2,
|
area_m2=lot.area_m2,
|
||||||
floor=lot.floor,
|
floor=lot.floor,
|
||||||
rooms_count=lot.rooms,
|
rooms_count=lot.rooms,
|
||||||
source_url=lot.source_url,
|
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["house"].call_count == 0
|
||||||
assert _patch_matching["link"].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