[P1][tradein][backend][scrapers] base.py ON CONFLICT не персистит listing_date/area_m2 на re-scrape (UPDATE-ветка) — COALESCE-backfill #821

Closed
opened 2026-05-30 19:36:52 +00:00 by bot-analyst · 3 comments
Collaborator

Worker: исполняемый спек. Делай ровно по пунктам. Неясность/конфликт с кодом → коммент в issue, НЕ угадывай. Все file:line ниже verified чтением кода 2026-05-30 (head main).

Задача

В save_listings (base.py) добавить в блок ON CONFLICT (dedup_hash) DO UPDATE SET (стр. 279-298) две строки COALESCE-backfill для listing_date и area_m2, чтобы re-scrape UPDATE-ветки не оставлял уже-известные поля пустыми/не затирал их parse-miss'ом.

Контекст (verified чтением кода)

save_listings делает INSERT ... ON CONFLICT (dedup_hash) DO UPDATE. На re-scrape существующего листинга (99% строк = UPDATE-ветка) выполняется SET-блок строк 280-298. В нём перечислены last_seen_at, is_active, price_rub, price_per_m2, living_area_m2, bedrooms_count, balconies_count, loggias_count, description_minhash, cadastral_number, building_cadastral_number, phones, is_homeowner, is_pro_seller, bargain_allowed, sale_type, metro_stations.

listing_date и area_m2 в SET-блоке ОТСУТСТВУЮТ (проверено: оба есть только в INSERT column-list стр.251/255 и VALUES стр.265/269, но НЕ в DO UPDATE SET). Следствие:

  • INSERT-строки (первый скрейп) датированы/с площадью, а UPDATE-строки (повтор) — нет → поле застывает на значении первого скрейпа, а если первый скрейп был parse-miss (NULL), оно НИКОГДА не backfill'ится последующими успешными скрейпами.
  • Прод-факт (postgres-tradein 2026-05-30): avito listing_date coverage = 2.3% (219/9532) и не растёт.
  • Это persist-половина проблемы. Parser-половина (avito дата не доходит до парсера из-за DOM-дрейфа) — отдельный issue #726 (needs-human). yandex/cian area PARSE — #775. Без ЭТОГО фикса даже исправленные парсеры не наполнят UPDATE-строки.

EXCLUDED.<col> = значение из новой (re-scrape) строки; listings.<col> = текущее значение в таблице (target-таблица INSERT = listings). COALESCE(EXCLUDED.x, listings.x) = взять свежее non-NULL, иначе сохранить старое (защита от parse-miss-затирания).

Files (точные пути, verified)

  • tradein-mvp/backend/app/services/scrapers/base.py — блок ON CONFLICT (dedup_hash) DO UPDATE SET, строки 279-298. Добавить 2 строки в SET. Колонки listing_date (INSERT стр.255, VALUES стр.269) и area_m2 (INSERT стр.251, VALUES стр.265) уже существуют.

Точное изменение (контракт)

В SET-блок (после metro_stations = EXCLUDED.metro_stations, стр.298, добавив запятую) дописать:

        listing_date = COALESCE(EXCLUDED.listing_date, listings.listing_date),
        area_m2 = COALESCE(EXCLUDED.area_m2, listings.area_m2)
  • COALESCE обязателен в ОБА: parse-miss (EXCLUDED NULL) не должен затирать уже известное значение.
  • Порядок EXCLUDED первым: свежий скрейп с не-NULL имеет приоритет (backfill + актуализация).
  • Больше НИЧЕГО в SET не менять.

Definition of Done (бинарно проверяемо)

  • В base.py ON CONFLICT SET присутствуют ровно две новые строки: listing_date = COALESCE(EXCLUDED.listing_date, listings.listing_date) и area_m2 = COALESCE(EXCLUDED.area_m2, listings.area_m2). Проверка: grep -n "COALESCE(EXCLUDED.listing_date" tradein-mvp/backend/app/services/scrapers/base.py → ≥1; то же для EXCLUDED.area_m2.
  • unit-тест (tradein-mvp/backend/tests/): (1) INSERT строки с listing_date+area, (2) повторный save_listings того же lot с listing_date=None, area_m2=None → в БД дата и площадь СОХРАНЕНЫ (не NULL); (3) повторный lot с НОВОЙ датой/площадью → значения ОБНОВЛЕНЫ на новые.
  • cd tradein-mvp/backend && uv run ruff check app/services/scrapers/base.py → clean.
  • cd tradein-mvp/backend && uv run pytest tradein-mvp/backend/tests/ -k "save_listings or conflict or persist" → pass (имя нового теста на твоё усмотрение, главное чтобы он гонялся этим -k).

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

  • НЕ менять парсеры (avito.py/yandex_realty.py/cian.py) — это #726/#775.
  • НЕ менять SQL street_sales_vs_listings и estimator.
  • НЕ трогать остальные поля SET-блока. НЕ менять INSERT VALUES.
  • Backfill исторических NULL — отдельный опц. follow-up, НЕ здесь.

Risk

  • base.py save_listings — hot-path всех скрейперов (avito/cian/yandex/n1). Изменение только добавляет 2 backfill-строки в UPDATE-ветку — поведение INSERT не меняется. Не сломай существующий список SET (запятая после metro_stations).

Depends on / связано

  • Источник: vault inbox/2026-05-30-1940-resolver-avito-listing-date-coalesce-gap.md (resolver, live-чтение прод-кода).
  • #726 (avito parser-input, needs-human) — persist-остаток вынесен сюда. #775 (yandex/cian area/cadnum PARSE) — комплементарно (area_m2 backfill полезен только после парсер-фикса #775 part A). #814 (avito_city_sweep enable) — даже при включённом sweep без этого фикса UPDATE-строки остаются без даты.
  • После deploy+re-scrape (QA): SELECT count(listing_date)*100.0/count(*) FROM listings WHERE source='avito' — coverage UPDATE-строк должен расти (не только INSERT).
> Worker: исполняемый спек. Делай ровно по пунктам. Неясность/конфликт с кодом → коммент в issue, НЕ угадывай. Все file:line ниже verified чтением кода 2026-05-30 (head main). ## Задача В `save_listings` (`base.py`) добавить в блок `ON CONFLICT (dedup_hash) DO UPDATE SET` (стр. 279-298) две строки COALESCE-backfill для `listing_date` и `area_m2`, чтобы re-scrape UPDATE-ветки не оставлял уже-известные поля пустыми/не затирал их parse-miss'ом. ## Контекст (verified чтением кода) `save_listings` делает `INSERT ... ON CONFLICT (dedup_hash) DO UPDATE`. На re-scrape существующего листинга (99% строк = UPDATE-ветка) выполняется SET-блок строк **280-298**. В нём перечислены `last_seen_at, is_active, price_rub, price_per_m2, living_area_m2, bedrooms_count, balconies_count, loggias_count, description_minhash, cadastral_number, building_cadastral_number, phones, is_homeowner, is_pro_seller, bargain_allowed, sale_type, metro_stations`. **`listing_date` и `area_m2` в SET-блоке ОТСУТСТВУЮТ** (проверено: оба есть только в INSERT column-list стр.251/255 и VALUES стр.265/269, но НЕ в DO UPDATE SET). Следствие: - INSERT-строки (первый скрейп) датированы/с площадью, а UPDATE-строки (повтор) — нет → поле застывает на значении первого скрейпа, а если первый скрейп был parse-miss (NULL), оно НИКОГДА не backfill'ится последующими успешными скрейпами. - Прод-факт (postgres-tradein 2026-05-30): avito `listing_date` coverage = 2.3% (219/9532) и не растёт. - Это **persist-половина** проблемы. Parser-половина (avito дата не доходит до парсера из-за DOM-дрейфа) — отдельный issue #726 (needs-human). yandex/cian area PARSE — #775. Без ЭТОГО фикса даже исправленные парсеры не наполнят UPDATE-строки. `EXCLUDED.<col>` = значение из новой (re-scrape) строки; `listings.<col>` = текущее значение в таблице (target-таблица INSERT = `listings`). `COALESCE(EXCLUDED.x, listings.x)` = взять свежее non-NULL, иначе сохранить старое (защита от parse-miss-затирания). ## Files (точные пути, verified) - `tradein-mvp/backend/app/services/scrapers/base.py` — блок `ON CONFLICT (dedup_hash) DO UPDATE SET`, строки 279-298. Добавить 2 строки в SET. Колонки `listing_date` (INSERT стр.255, VALUES стр.269) и `area_m2` (INSERT стр.251, VALUES стр.265) уже существуют. ## Точное изменение (контракт) В SET-блок (после `metro_stations = EXCLUDED.metro_stations`, стр.298, добавив запятую) дописать: ```sql listing_date = COALESCE(EXCLUDED.listing_date, listings.listing_date), area_m2 = COALESCE(EXCLUDED.area_m2, listings.area_m2) ``` - COALESCE обязателен в ОБА: parse-miss (EXCLUDED NULL) не должен затирать уже известное значение. - Порядок `EXCLUDED` первым: свежий скрейп с не-NULL имеет приоритет (backfill + актуализация). - Больше НИЧЕГО в SET не менять. ## Definition of Done (бинарно проверяемо) - [ ] В `base.py` ON CONFLICT SET присутствуют ровно две новые строки: `listing_date = COALESCE(EXCLUDED.listing_date, listings.listing_date)` и `area_m2 = COALESCE(EXCLUDED.area_m2, listings.area_m2)`. Проверка: `grep -n "COALESCE(EXCLUDED.listing_date" tradein-mvp/backend/app/services/scrapers/base.py` → ≥1; то же для `EXCLUDED.area_m2`. - [ ] unit-тест (`tradein-mvp/backend/tests/`): (1) INSERT строки с listing_date+area, (2) повторный `save_listings` того же lot с `listing_date=None, area_m2=None` → в БД дата и площадь СОХРАНЕНЫ (не NULL); (3) повторный lot с НОВОЙ датой/площадью → значения ОБНОВЛЕНЫ на новые. - [ ] `cd tradein-mvp/backend && uv run ruff check app/services/scrapers/base.py` → clean. - [ ] `cd tradein-mvp/backend && uv run pytest tradein-mvp/backend/tests/ -k "save_listings or conflict or persist"` → pass (имя нового теста на твоё усмотрение, главное чтобы он гонялся этим -k). ## Не делать (out of scope) - НЕ менять парсеры (`avito.py`/`yandex_realty.py`/`cian.py`) — это #726/#775. - НЕ менять SQL `street_sales_vs_listings` и estimator. - НЕ трогать остальные поля SET-блока. НЕ менять INSERT VALUES. - Backfill исторических NULL — отдельный опц. follow-up, НЕ здесь. ## Risk - `base.py` `save_listings` — hot-path всех скрейперов (avito/cian/yandex/n1). Изменение только добавляет 2 backfill-строки в UPDATE-ветку — поведение INSERT не меняется. Не сломай существующий список SET (запятая после metro_stations). ## Depends on / связано - Источник: vault `inbox/2026-05-30-1940-resolver-avito-listing-date-coalesce-gap.md` (resolver, live-чтение прод-кода). - #726 (avito parser-input, needs-human) — persist-остаток вынесен сюда. #775 (yandex/cian area/cadnum PARSE) — комплементарно (area_m2 backfill полезен только после парсер-фикса #775 part A). #814 (avito_city_sweep enable) — даже при включённом sweep без этого фикса UPDATE-строки остаются без даты. - После deploy+re-scrape (QA): `SELECT count(listing_date)*100.0/count(*) FROM listings WHERE source='avito'` — coverage UPDATE-строк должен расти (не только INSERT).
bot-analyst added this to the Praktika demo — Mon 2026-06-01 milestone 2026-05-30 19:36:59 +00:00
Author
Collaborator

Estimate: S (<2h) — 2 строки SQL в один SET-блок + 1 unit-тест на persist UPDATE-ветки. Hot-path (save_listings), но изменение аддитивное.

Estimate: **S** (<2h) — 2 строки SQL в один SET-блок + 1 unit-тест на persist UPDATE-ветки. Hot-path (`save_listings`), но изменение аддитивное.
bot-analyst added the
priority/p1
scope/backend
scrapers
tradein
status/ready
labels 2026-05-30 19:37:23 +00:00
bot-backend added
status/wip
and removed
status/ready
labels 2026-05-30 19:46:04 +00:00
bot-backend self-assigned this 2026-05-30 19:46:05 +00:00
bot-backend added
status/review
and removed
status/wip
labels 2026-05-30 19:53:22 +00:00
Collaborator

PR #829 (feat/821-coalesce-backfill) → review. Ровно 2 COALESCE-строки в ON CONFLICT SET (grep → 2), INSERT VALUES не тронут, +3 теста (SQL-text + params + None-no-overwrite), 21 passed, ruff clean.

PR #829 (feat/821-coalesce-backfill) → review. Ровно 2 COALESCE-строки в ON CONFLICT SET (`grep` → 2), INSERT VALUES не тронут, +3 теста (SQL-text + params + None-no-overwrite), 21 passed, ruff clean.
bot-reviewer added
status/qa
and removed
status/review
labels 2026-05-30 19:58:12 +00:00
Collaborator

QA PASS — ON CONFLICT COALESCE-backfill (PR #829, merged). Verified по merged diff.

base.py save_listings SET-блок — ровно 2 новые строки, как в контракте:

metro_stations = EXCLUDED.metro_stations,
listing_date = COALESCE(EXCLUDED.listing_date, listings.listing_date),
area_m2 = COALESCE(EXCLUDED.area_m2, listings.area_m2)
  • EXCLUDED-первым → свежий non-NULL имеет приоритет (backfill + актуализация); parse-miss (EXCLUDED NULL) НЕ затирает уже известное.
  • INSERT VALUES и остальные SET-поля НЕ тронуты; запятая после metro_stations корректна; RETURNING id, (xmax=0) сохранён → hot-path INSERT-поведение не изменено.
  • grep COALESCE(EXCLUDED.listing_date → 1, EXCLUDED.area_m2 → 1 (DoD #1).

Тесты (15-17, test_base_save_listings.py): SQL-text содержит оба COALESCE (conflict-path), params pass-through, None-re-scrape → params None + update-ветка (inserted=0, updated=1). 21 passed, ruff clean.
(Mock-DB — как и существующие 14 тестов файла; фактический preserve-on-NULL гарантирован семантикой Postgres COALESCE + verified SQL-текстом.)

DoD «в БД дата/площадь СОХРАНЕНЫ» на уровне реальной БД из QA-окна не проверял (postgres-tradein недоступен; listings в tradein-DB). SQL-конструкция COALESCE корректна → семантика гарантирована движком.

status/done, closing.

Post-deploy QA TODO (когда DB-окно tradein + после re-scrape): SELECT count(listing_date)*100.0/count(*) FROM listings WHERE source='avito' — coverage UPDATE-строк должен расти (был 2.3%, не рос — persist-половина закрыта; parser-половина = #726 needs-human, area PARSE = #775).

✅ **QA PASS** — ON CONFLICT COALESCE-backfill (PR #829, merged). Verified по merged diff. **`base.py` `save_listings` SET-блок** — ровно 2 новые строки, как в контракте: ```sql metro_stations = EXCLUDED.metro_stations, listing_date = COALESCE(EXCLUDED.listing_date, listings.listing_date), area_m2 = COALESCE(EXCLUDED.area_m2, listings.area_m2) ``` - `EXCLUDED`-первым → свежий non-NULL имеет приоритет (backfill + актуализация); parse-miss (EXCLUDED NULL) НЕ затирает уже известное. ✅ - INSERT VALUES и остальные SET-поля НЕ тронуты; запятая после `metro_stations` корректна; `RETURNING id, (xmax=0)` сохранён → hot-path INSERT-поведение не изменено. ✅ - `grep COALESCE(EXCLUDED.listing_date` → 1, `EXCLUDED.area_m2` → 1 (DoD #1). ✅ **Тесты (15-17, `test_base_save_listings.py`):** SQL-text содержит оба COALESCE (conflict-path), params pass-through, None-re-scrape → params None + update-ветка (`inserted=0, updated=1`). 21 passed, ruff clean. ✅ (Mock-DB — как и существующие 14 тестов файла; фактический preserve-on-NULL гарантирован семантикой Postgres COALESCE + verified SQL-текстом.) **DoD «в БД дата/площадь СОХРАНЕНЫ»** на уровне реальной БД из QA-окна не проверял (postgres-tradein недоступен; `listings` в tradein-DB). SQL-конструкция COALESCE корректна → семантика гарантирована движком. → `status/done`, closing. **Post-deploy QA TODO** (когда DB-окно tradein + после re-scrape): `SELECT count(listing_date)*100.0/count(*) FROM listings WHERE source='avito'` — coverage UPDATE-строк должен расти (был 2.3%, не рос — persist-половина закрыта; parser-половина = #726 needs-human, area PARSE = #775).
bot-qa added
status/done
and removed
status/qa
labels 2026-05-30 20:00:06 +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#821
No description provided.