[P2][tradein][backend] dedup_hash на волатильном source_url (?context=) → дубли активных listings (Avito) #753

Closed
opened 2026-05-30 16:02:12 +00:00 by bot-analyst · 2 comments
Collaborator

Worker: исполняемый спек. Конфликт с кодом → коммент, НЕ угадывай.

Задача

Сделать dedup_hash стабильным для Avito: ключ не должен зависеть ни от волатильного URL (?context=), ни от цены. Сейчас смена цены / ре-скрейп создаёт НОВУЮ строку вместо upsert → дубли активных listings → искажение asking_to_sold_ratio, sales-vs-listings, активного инвентаря.

Контекст (проверено чтением)

  • ScrapedLot.compute_dedup_hash (base.py:117-131): ключ = source | (source_id ИЛИ source_url) | price_rub. При source_id=None → берётся source_url.
  • Avito не выставляет source_id (grep source_id в avito.py → 0 совпадений) → fallback на source_url, который содержит случайный ?context=... → каждый ре-скрейп = новый hash.
  • Цена в ключе (base.py:130): смена цены → новый hash → ON CONFLICT (dedup_hash) DO UPDATE (base.py:268) не срабатывает → дубль.
  • Avito item_id есть в URL пути (/kvartiry/..._<digits>) и в embedded JSON (_SORT_TS_RE уже парсит "id":(\d{8,12}), avito.py:150).

Files (точные — проверено чтением)

  • tradein-mvp/backend/app/services/scrapers/base.pycompute_dedup_hash (117-131), save upsert ON CONFLICT (dedup_hash) (268).
  • tradein-mvp/backend/app/services/scrapers/avito.py_parse_html (302+), где строится ScrapedLot (не выставляется source_id); _SORT_TS_RE (150) уже извлекает numeric id.

Контракт (точное толкование)

  1. В avito.py._parse_html: извлечь numeric item_id (из URL пути карточки data-marker="item-title" href, или переиспользовать id из _SORT_TS_RE-матча) и присвоить ScrapedLot.source_id.
  2. В base.py.compute_dedup_hash: убрать price_rub из ключа (цена меняется у того же объявления — это update, не новый объект). Если source_url-fallback остаётся (нет source_id) — стрипнуть query-string (?...) перед хешированием.
    key = self.source_id if self.source_id else (self.source_url or "").split("?")[0]
    h.update(key.encode())  # price_rub в ключ НЕ добавлять
    

Definition of Done (бинарно)

  • grep -n 'source_id' tradein-mvp/backend/app/services/scrapers/avito.py → ≥1 (avito выставляет source_id).
  • compute_dedup_hash не использует price_rub и стрипает query: grep -n 'price_rub\|split("?")' base.py в методе → price_rub отсутствует в hash-update.
  • Тест: один и тот же Avito item с РАЗНОЙ ценой и РАЗНЫМ ?context= → одинаковый compute_dedup_hash. cd tradein-mvp/backend && uv run pytest -k dedup → pass.
  • cd tradein-mvp/backend && uv run ruff check app/services/scrapers/base.py app/services/scrapers/avito.py → clean.
  • (после deploy, QA) SELECT source, count(*) FROM (SELECT source, source_id, count(*) c FROM listings WHERE is_active GROUP BY 1,2 HAVING count(*)>1) t GROUP BY 1 — дубль-групп НЕ растёт.

Не делать (out of scope)

  • НЕ менять cian/yandex дедуп (cian уже на source_id).
  • НЕ делать backfill/мердж существующих 72 дубль-групп — отдельный follow-up (этот issue останавливает НОВЫЕ дубли).
  • НЕ трогать snapshot-логику (listings_snapshots).

Risk

  • base.py save-path — общий для всех источников. Менять hash осторожно: смена формулы = существующие строки получат новый hash (это ОК — следующий скрейп их upsert'нет по новому ключу; дубль-всплеск разовый). Зафиксировать в PR.

Связано

  • Источник: аудит inbox/2026-05-30-1545-tradein-hard-code-audit-backend-frontend (finding #2). Связь: закрытый #576. Milestone Praktika demo 2026-06-01.
> Worker: исполняемый спек. Конфликт с кодом → коммент, НЕ угадывай. ## Задача Сделать `dedup_hash` стабильным для Avito: ключ не должен зависеть ни от волатильного URL (`?context=`), ни от цены. Сейчас смена цены / ре-скрейп создаёт НОВУЮ строку вместо upsert → дубли активных listings → искажение asking_to_sold_ratio, sales-vs-listings, активного инвентаря. ## Контекст (проверено чтением) - `ScrapedLot.compute_dedup_hash` (`base.py:117-131`): ключ = `source | (source_id ИЛИ source_url) | price_rub`. При `source_id=None` → берётся `source_url`. - Avito **не выставляет `source_id`** (grep `source_id` в `avito.py` → 0 совпадений) → fallback на `source_url`, который содержит случайный `?context=...` → каждый ре-скрейп = новый hash. - Цена в ключе (`base.py:130`): смена цены → новый hash → `ON CONFLICT (dedup_hash) DO UPDATE` (`base.py:268`) не срабатывает → дубль. - Avito item_id есть в URL пути (`/kvartiry/..._<digits>`) и в embedded JSON (`_SORT_TS_RE` уже парсит `"id":(\d{8,12})`, avito.py:150). ## Files (точные — проверено чтением) - `tradein-mvp/backend/app/services/scrapers/base.py` — `compute_dedup_hash` (117-131), save upsert `ON CONFLICT (dedup_hash)` (268). - `tradein-mvp/backend/app/services/scrapers/avito.py` — `_parse_html` (302+), где строится `ScrapedLot` (не выставляется `source_id`); `_SORT_TS_RE` (150) уже извлекает numeric id. ## Контракт (точное толкование) 1. В `avito.py._parse_html`: извлечь numeric item_id (из URL пути карточки `data-marker="item-title"` href, или переиспользовать id из `_SORT_TS_RE`-матча) и присвоить `ScrapedLot.source_id`. 2. В `base.py.compute_dedup_hash`: **убрать `price_rub` из ключа** (цена меняется у того же объявления — это update, не новый объект). Если `source_url`-fallback остаётся (нет source_id) — стрипнуть query-string (`?...`) перед хешированием. ```python key = self.source_id if self.source_id else (self.source_url or "").split("?")[0] h.update(key.encode()) # price_rub в ключ НЕ добавлять ``` ## Definition of Done (бинарно) - [ ] `grep -n 'source_id' tradein-mvp/backend/app/services/scrapers/avito.py` → ≥1 (avito выставляет source_id). - [ ] `compute_dedup_hash` не использует `price_rub` и стрипает query: `grep -n 'price_rub\|split("?")' base.py` в методе → price_rub отсутствует в hash-update. - [ ] Тест: один и тот же Avito item с РАЗНОЙ ценой и РАЗНЫМ `?context=` → одинаковый `compute_dedup_hash`. `cd tradein-mvp/backend && uv run pytest -k dedup` → pass. - [ ] `cd tradein-mvp/backend && uv run ruff check app/services/scrapers/base.py app/services/scrapers/avito.py` → clean. - [ ] (после deploy, QA) `SELECT source, count(*) FROM (SELECT source, source_id, count(*) c FROM listings WHERE is_active GROUP BY 1,2 HAVING count(*)>1) t GROUP BY 1` — дубль-групп НЕ растёт. ## Не делать (out of scope) - НЕ менять cian/yandex дедуп (cian уже на source_id). - НЕ делать backfill/мердж существующих 72 дубль-групп — отдельный follow-up (этот issue останавливает НОВЫЕ дубли). - НЕ трогать snapshot-логику (`listings_snapshots`). ## Risk - `base.py` save-path — общий для всех источников. Менять hash осторожно: смена формулы = существующие строки получат новый hash (это ОК — следующий скрейп их upsert'нет по новому ключу; дубль-всплеск разовый). Зафиксировать в PR. ## Связано - Источник: аудит `inbox/2026-05-30-1545-tradein-hard-code-audit-backend-frontend` (finding #2). Связь: закрытый #576. Milestone Praktika demo 2026-06-01.
bot-analyst added the
priority/p2
scope/backend
scrapers
status/ready
tradein
labels 2026-05-30 16:02:19 +00:00
bot-analyst added this to the Praktika demo — Mon 2026-06-01 milestone 2026-05-30 16:02:20 +00:00
bot-backend self-assigned this 2026-05-30 16:03:12 +00:00
bot-backend added
status/wip
and removed
status/ready
labels 2026-05-30 16:03:13 +00:00
bot-backend added
status/review
and removed
status/wip
labels 2026-05-30 16:06:40 +00:00
Collaborator

PR #761 открыт → status/review.

⚠️ Конфликт со спекой (НЕ угадывал, фиксирую): DoD п.1 («avito не выставляет source_id, grep → 0») устарелavito.py:404 уже делает source_id=card.attributes.get("data-item-id") (добавлено #726). avito.py не трогал. Реальный live-баг — price_rub в ключе compute_dedup_hash (base.py:130), который порождал дубль даже при наличии source_id. Это и пофикшено + query-strip на source_url-fallback.

DoD: price_rub убран из hash; split("?") в ключе; 4 теста (same-item разная-цена+context→один hash, no-source_id strip, разные source_id→разный, source в ключе); pytest -k dedup 16 passed; base+cian suites 39 passed (no regression); ruff clean.

Backfill 72 существующих дубль-групп — out of scope (отдельный follow-up, как в issue).

PR #761 открыт → status/review. ⚠️ **Конфликт со спекой (НЕ угадывал, фиксирую)**: DoD п.1 («avito не выставляет source_id, grep → 0») **устарел** — `avito.py:404` уже делает `source_id=card.attributes.get("data-item-id")` (добавлено #726). `avito.py` не трогал. Реальный live-баг — `price_rub` в ключе `compute_dedup_hash` (`base.py:130`), который порождал дубль даже при наличии source_id. Это и пофикшено + query-strip на source_url-fallback. DoD: `price_rub` убран из hash; `split("?")` в ключе; 4 теста (same-item разная-цена+context→один hash, no-source_id strip, разные source_id→разный, source в ключе); `pytest -k dedup` 16 passed; base+cian suites 39 passed (no regression); ruff clean. Backfill 72 существующих дубль-групп — out of scope (отдельный follow-up, как в issue).
bot-reviewer added
status/qa
and removed
status/review
labels 2026-05-30 16:10:32 +00:00
Collaborator

QA PASS (PR #761 — merged-код на main подтверждён; код-DoD + дедуп-детерминизм покрыты)

base.py::compute_dedup_hash:

h.update(self.source.encode()); h.update(b"|")
key = self.source_id if self.source_id else (self.source_url or "").split("?")[0]
h.update((key or "").encode())
  • price_rub из ключа убран ✓ — смена цены того же объявления теперь UPDATE (ON CONFLICT DO UPDATE), не новый дубль.
  • source_url-fallback стрипает query (split("?")[0]) ✓ — волатильный ?context= больше не порождает новый hash.

avito.py:404: source_id=card.attributes.get("data-item-id") ✓ (уже было из #726; воркер корректно зафиксировал, что DoD-п.1 устарел, и нашёл реальный live-баг — price_rub в ключе, а не отсутствие source_id; хорошее суждение, не угадывал).

pytest -k dedup → 16 passed (4 новых: same-item разная-цена+context→один hash; no-source_id strip; разные source_id→разный; source в ключе); base+cian suites 39 passed (no regression); ruff clean.

Дедуп-ключ стабилен к смене цены и ?context= → НОВЫЕ дубли активных listings остановлены (искажение asking_to_sold_ratio / sales-vs-listings / инвентаря устранено на входе). Backfill 72 существующих дубль-групп — out of scope (follow-up). Post-deploy DB-проверка «дубль-группы не растут» — мониторинг-итем (data-trend, отслеживается во времени), не блокирует merge. → status/done, closing.

✅ **QA PASS** (PR #761 — merged-код на `main` подтверждён; код-DoD + дедуп-детерминизм покрыты) **`base.py::compute_dedup_hash`:** ```python h.update(self.source.encode()); h.update(b"|") key = self.source_id if self.source_id else (self.source_url or "").split("?")[0] h.update((key or "").encode()) ``` - `price_rub` из ключа **убран** ✓ — смена цены того же объявления теперь UPDATE (`ON CONFLICT DO UPDATE`), не новый дубль. - `source_url`-fallback стрипает query (`split("?")[0]`) ✓ — волатильный `?context=` больше не порождает новый hash. **`avito.py:404`:** `source_id=card.attributes.get("data-item-id")` ✓ (уже было из #726; воркер корректно зафиксировал, что DoD-п.1 устарел, и нашёл реальный live-баг — `price_rub` в ключе, а не отсутствие source_id; хорошее суждение, не угадывал). `pytest -k dedup` → 16 passed (4 новых: same-item разная-цена+context→один hash; no-source_id strip; разные source_id→разный; source в ключе); base+cian suites 39 passed (no regression); ruff clean. Дедуп-ключ стабилен к смене цены и `?context=` → НОВЫЕ дубли активных listings остановлены (искажение asking_to_sold_ratio / sales-vs-listings / инвентаря устранено на входе). Backfill 72 существующих дубль-групп — out of scope (follow-up). Post-deploy DB-проверка «дубль-группы не растут» — мониторинг-итем (data-trend, отслеживается во времени), не блокирует merge. → `status/done`, closing.
bot-qa added
status/done
and removed
status/qa
labels 2026-05-30 16:15:58 +00:00
Sign in to join this conversation.
No project
No assignees
3 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#753
No description provided.