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
Collaborator

Что

ScrapedLot.compute_dedup_hash включал price_rub и волатильный source_url (?context=...) в ключ → смена цены / ре-скрейп того же объявления давали новый hash → INSERT дубля вместо ON CONFLICT (dedup_hash) DO UPDATE → дубли активных listings → искажение asking_to_sold_ratio, sales-vs-listings, активного инвентаря.

Как (base.py.compute_dedup_hash)

key = self.source_id if self.source_id else (self.source_url or "").split("?")[0]
# price_rub в ключ НЕ добавляется
  • price_rub убран из ключа — смена цены того же объявления это UPDATE строки, не новый объект;
  • source_url-fallback стрипает query-string (?...) — для источников без source_id (и Avito-карточек без data-item-id).

⚠️ Конфликт со спекой (зафиксировано, не угадано)

DoD п.1 требовал «avito выставляет source_id (grep → 0 совпадений)». Премиса устарела: avito.py:404 уже делает source_id=card.attributes.get("data-item-id") (добавлено #726). avito.py я не менял — DoD п.1 удовлетворён существующим кодом. Реальный live-баг — только price_rub в ключе base.py (срабатывал даже когда source_id есть). Его и чиню.

Разовый эффект (как оговорено в issue)

Смена формулы → существующие строки получат новый hash → следующий скрейп upsert'нет их по новому ключу. Всплеск дублей разовый. Backfill/merge 72 существующих дубль-групп — out of scope (отдельный follow-up).

Тесты (tests/test_base_save_listings.py, offline)

  • test_dedup_hash_stable_across_price_and_context_query — тот же source_id, разная цена + разный ?context=один hash.
  • test_dedup_hash_strips_query_when_no_source_id — без source_id query-strip работает.
  • test_dedup_hash_distinguishes_different_listings — разные source_id → разный hash (не схлопывает несвязанное).
  • test_dedup_hash_distinguishes_source_even_same_idsource остаётся частью ключа.

pytest -k dedup → 16 passed. test_base_save_listings.py + test_cian_serp_scraper.py → 39 passed (нет регрессии). ruff check clean.

Refs #753

## Что `ScrapedLot.compute_dedup_hash` включал `price_rub` **и** волатильный `source_url` (`?context=...`) в ключ → смена цены / ре-скрейп того же объявления давали новый hash → `INSERT` дубля вместо `ON CONFLICT (dedup_hash) DO UPDATE` → дубли активных listings → искажение `asking_to_sold_ratio`, sales-vs-listings, активного инвентаря. ## Как (`base.py.compute_dedup_hash`) ```python key = self.source_id if self.source_id else (self.source_url or "").split("?")[0] # price_rub в ключ НЕ добавляется ``` - `price_rub` убран из ключа — смена цены того же объявления это **UPDATE** строки, не новый объект; - `source_url`-fallback стрипает query-string (`?...`) — для источников без `source_id` (и Avito-карточек без `data-item-id`). ## ⚠️ Конфликт со спекой (зафиксировано, не угадано) DoD п.1 требовал «avito выставляет `source_id` (grep → 0 совпадений)». **Премиса устарела**: `avito.py:404` **уже** делает `source_id=card.attributes.get("data-item-id")` (добавлено #726). `avito.py` я **не менял** — DoD п.1 удовлетворён существующим кодом. Реальный live-баг — только `price_rub` в ключе `base.py` (срабатывал даже когда `source_id` есть). Его и чиню. ## Разовый эффект (как оговорено в issue) Смена формулы → существующие строки получат новый hash → следующий скрейп upsert'нет их по новому ключу. Всплеск дублей **разовый**. Backfill/merge 72 существующих дубль-групп — out of scope (отдельный follow-up). ## Тесты (`tests/test_base_save_listings.py`, offline) - `test_dedup_hash_stable_across_price_and_context_query` — тот же `source_id`, разная цена + разный `?context=` → **один** hash. - `test_dedup_hash_strips_query_when_no_source_id` — без `source_id` query-strip работает. - `test_dedup_hash_distinguishes_different_listings` — разные `source_id` → разный hash (не схлопывает несвязанное). - `test_dedup_hash_distinguishes_source_even_same_id` — `source` остаётся частью ключа. `pytest -k dedup` → 16 passed. `test_base_save_listings.py` + `test_cian_serp_scraper.py` → 39 passed (нет регрессии). `ruff check` clean. Refs #753
bot-backend added 1 commit 2026-05-30 16:06:34 +00:00
compute_dedup_hash включал price_rub и волатильный source_url (?context=) в
ключ → смена цены / ре-скрейп того же объявления давали новый hash → INSERT
дубля вместо ON CONFLICT DO UPDATE → дубли активных listings → искажение
asking_to_sold_ratio / sales-vs-listings / активного инвентаря.

Фикс (base.py.compute_dedup_hash):
- price_rub убран из ключа (смена цены = UPDATE строки, не новый объект);
- source_url-fallback стрипает query-string (?...) перед хешированием.

Avito source_id (DoD п.1): УЖЕ выставляется avito.py:404
(source_id=data-item-id, добавлено #726) — премиса issue про '0 совпадений
source_id' устарела. avito.py НЕ менялся, конфликт зафиксирован в PR/комменте.

Разовый эффект: существующие строки получат новый hash → следующий скрейп
upsert'нет их по новому ключу (всплеск дублей разовый, как и оговорено в issue).

Tests (test_base_save_listings.py): same item разная цена+context→один hash;
no-source_id query-strip; разные source_id→разный hash; source в ключе.
bot-reviewer approved these changes 2026-05-30 16:10:14 +00:00
bot-reviewer left a comment
Collaborator

APPROVE

Stable dedup_hash (#753). Проверил тщательно — dedup_hash это ON CONFLICT upsert-ключ listings.

Correctness

  • Убран price_rub из ключа: смена цены того же объявления теперь хешируется одинаково → ON CONFLICT (dedup_hash) DO UPDATE обновляет строку, а не вставляет дубль. Это и есть корневой баг (цена в ключе ломала upsert). Верно.
  • Strip query на URL-fallback: (source_url or "").split("?")[0] убирает волатильный ?context= для источников без source_id. Верно.
  • Ключ теперь source | (source_id or stripped_url): Avito (source_id из #726), Cian (offer_id), Yandex (stripped URL) — стабильны; source в ключе (тест на same-id-different-source). Нет риска коллизии (в source нет |).
  • DoD п.1 conflict обработан верно: issue предполагал что Avito не ставит source_id и просил добавить; worker нашёл что #726 уже добавил source_id=data-item-id (avito.py:404) — я ревьюил #726, подтверждаю — поэтому avito.py не тронут, починен только реальный баг в base.py. Ровно поведение «конфликт → коммент, не угадывай», задокументировано в PR. Хорошая cross-PR осведомлённость.
  • Пустой source_id falsy → fallback на URL (безопасно).

Tests — 4 новых вкл. anti-over-collapse guards (разный source_id → разный hash; same id different source → разный hash). 16 dedup passed, 39 total без регрессии, ruff clean.

Разовый churn — смена формулы ре-хеширует существующие строки → разовый всплеск дублей на следующем скрейпе; backfill 72 групп — out-of-scope follow-up. Задокументировано и принято в issue.

Scope/security — base.py + тест (+ ruff-format churn); avito.py корректно не тронут; нет миграции (computed hash), нет secrets, нет self-extending триггера.

Verdict: APPROVE (Praktika #753). Post-deploy мониторинг дубль-групп → qa.

<!-- gendesign-review-bot: sha=f25fa57 verdict=approve --> ✅ APPROVE Stable `dedup_hash` (#753). Проверил тщательно — `dedup_hash` это `ON CONFLICT` upsert-ключ listings. **Correctness** - **Убран `price_rub` из ключа**: смена цены того же объявления теперь хешируется одинаково → `ON CONFLICT (dedup_hash) DO UPDATE` обновляет строку, а не вставляет дубль. Это и есть корневой баг (цена в ключе ломала upsert). Верно. - **Strip query на URL-fallback**: `(source_url or "").split("?")[0]` убирает волатильный `?context=` для источников без `source_id`. Верно. - Ключ теперь `source | (source_id or stripped_url)`: Avito (source_id из #726), Cian (offer_id), Yandex (stripped URL) — стабильны; `source` в ключе (тест на same-id-different-source). Нет риска коллизии (в source нет `|`). - **DoD п.1 conflict обработан верно**: issue предполагал что Avito не ставит source_id и просил добавить; worker нашёл что #726 уже добавил `source_id=data-item-id` (avito.py:404) — я ревьюил #726, подтверждаю — поэтому avito.py не тронут, починен только реальный баг в base.py. Ровно поведение «конфликт → коммент, не угадывай», задокументировано в PR. Хорошая cross-PR осведомлённость. - Пустой `source_id` falsy → fallback на URL (безопасно). **Tests** — 4 новых вкл. anti-over-collapse guards (разный source_id → разный hash; same id different source → разный hash). 16 dedup passed, 39 total без регрессии, ruff clean. **Разовый churn** — смена формулы ре-хеширует существующие строки → разовый всплеск дублей на следующем скрейпе; backfill 72 групп — out-of-scope follow-up. Задокументировано и принято в issue. **Scope/security** — base.py + тест (+ ruff-format churn); avito.py корректно не тронут; нет миграции (computed hash), нет secrets, нет self-extending триггера. **Verdict: ✅ APPROVE** (Praktika #753). Post-deploy мониторинг дубль-групп → qa.
bot-reviewer merged commit 79a79004cf into main 2026-05-30 16:10:27 +00:00
bot-reviewer deleted branch fix/753-avito-dedup-stable 2026-05-30 16:10:27 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#761
No description provided.