fix(scraper-kit): бедный re-scrape больше не стирает признаки продавца #3067

Merged
lekss361 merged 3 commits from fix/yandex-seller-fields-erosion into main 2026-08-23 21:52:38 +00:00
Owner

Закрывает пункт 1 из #3063баг активен прямо сейчас, данные теряются с каждым проходом.

Что происходило

Апсерт listings присваивал шесть полей напрямую (= EXCLUDED.X): phones, is_homeowner, is_pro_seller, bargain_allowed, sale_type, metro_stations.

Комментарий рядом — «Cian-specific: обновляем при каждом re-scrape» — был верен, пока в listings писал один cian, который отдаёт эти поля на каждом проходе. Сейчас в ту же таблицу пишут yandex / avito / domklik, чей SERP их не отдаёт вовсе. NULL молча затирал значение, добытое detail-обогащением: ни ошибки, ни лога, ни изменения статуса прогона — колонка просто откатывалась к NULL на обычном переобходе.

Прод 23-24.08, yandex, из 16 460 активных листингов:

Поле NULL
is_homeowner 98.6 % (16 232)
is_pro_seller 76.6 %

Соседние поля в том же операторе уже защищены COALESCE ровно от этого, с явными объяснениями #2007 / #2594 / #2777address, city, kitchen_area_m2, ceiling_height_m, mortgage_available, is_apartments, is_rosreestr_checked. Эти шесть в защиту просто не попали. Это недосмотр, а не решение.

Правка в ДВУХ местах, не в одном

Кроме SET изменена правая часть гейта IS DISTINCT FROM (#2992). Он решает, переписывать ли строку, сравнивая текущие значения с итоговыми (post-COALESCE). Оставить его на сыром EXCLUDED значило бы, что гейт видит «NULL против значения» и считает строку изменившейся там, где она не меняется — вернулись бы ровно те лишние UPDATE и TOAST-чанки, ради которых #2992 и делался.

Проверено выравнивание кортежей гейта: 39 = 39 элементов, порядок колонок совпал.

Тонкость с пустыми значениями

phones и metro_stations — jsonb. Если бы пустой список сериализовался как [], COALESCE ничего бы не спас (не-NULL перезаписал бы значение пустым массивом). Проверено: в сборке параметров стоит _to_json(lot.X) if lot.X else None, то есть пусто → SQL NULL → COALESCE удерживает. Фикс для них реально работает.

Test plan

  • Фальсификация: все 7 тестов падают на коде до правки (6 параметризованных по полям + гард паритета)
  • Регрессия: 770 passed, 10 skipped (-k "upsert or listing or scrape or writer or snapshot or timestamps")
  • Гард паритета сравнивает оба кортежа гейта поэлементно, поэтому поймает будущее расхождение по любой колонке, не только по этим шести
  • Два поведенческих теста на живом Postgres локально пропускаются (нет тестовой БД), в CI Trade-In postgres-сервис есть — там они отработают: «бедный проход не стирает» и обратный «настоящая смена признака всё ещё перезаписывает»

Второй из них существенен: без него фикс мог бы «защитить» данные ценой того, что поля стали бы write-once. Настоящая смена (частник → агентство) приходит не NULL'ом и обязана записаться.

Чего здесь НЕТ

Пункт 2 из #3063 — выводить is_pro_seller из agency_name — сознательно не вошёл. У него есть риск, которого нет у этой правки: если хоть один провайдер кладёт в agency_name имя частного продавца, вывод молча пометит 4 469 листингов как агентские, и это поедет в оценщик. Проверять это нужно чтением каждого провайдера отдельно. Здешняя же правка может только перестать разрушать данные — обратного эффекта у неё нет.

Подробности комментарием в #3063.

Refs #3063

Закрывает пункт 1 из #3063 — **баг активен прямо сейчас**, данные теряются с каждым проходом. ## Что происходило Апсерт `listings` присваивал шесть полей напрямую (`= EXCLUDED.X`): `phones`, `is_homeowner`, `is_pro_seller`, `bargain_allowed`, `sale_type`, `metro_stations`. Комментарий рядом — *«Cian-specific: обновляем при каждом re-scrape»* — был верен, пока в `listings` писал один cian, который отдаёт эти поля на каждом проходе. Сейчас в ту же таблицу пишут yandex / avito / domklik, чей SERP их не отдаёт **вовсе**. NULL молча затирал значение, добытое detail-обогащением: ни ошибки, ни лога, ни изменения статуса прогона — колонка просто откатывалась к NULL на обычном переобходе. Прод 23-24.08, yandex, из 16 460 активных листингов: | Поле | NULL | |---|---| | `is_homeowner` | **98.6 %** (16 232) | | `is_pro_seller` | **76.6 %** | Соседние поля в **том же операторе** уже защищены `COALESCE` ровно от этого, с явными объяснениями #2007 / #2594 / #2777 — `address`, `city`, `kitchen_area_m2`, `ceiling_height_m`, `mortgage_available`, `is_apartments`, `is_rosreestr_checked`. Эти шесть в защиту просто не попали. Это недосмотр, а не решение. ## Правка в ДВУХ местах, не в одном Кроме `SET` изменена правая часть гейта `IS DISTINCT FROM` (#2992). Он решает, переписывать ли строку, сравнивая текущие значения с **итоговыми** (post-COALESCE). Оставить его на сыром `EXCLUDED` значило бы, что гейт видит «NULL против значения» и считает строку изменившейся там, где она не меняется — вернулись бы ровно те лишние UPDATE и TOAST-чанки, ради которых #2992 и делался. Проверено выравнивание кортежей гейта: **39 = 39** элементов, порядок колонок совпал. ## Тонкость с пустыми значениями `phones` и `metro_stations` — jsonb. Если бы пустой список сериализовался как `[]`, COALESCE ничего бы не спас (не-NULL перезаписал бы значение пустым массивом). Проверено: в сборке параметров стоит `_to_json(lot.X) if lot.X else None`, то есть пусто → SQL NULL → COALESCE удерживает. Фикс для них реально работает. ## Test plan - [x] **Фальсификация:** все 7 тестов падают на коде до правки (6 параметризованных по полям + гард паритета) - [x] **Регрессия:** `770 passed, 10 skipped` (`-k "upsert or listing or scrape or writer or snapshot or timestamps"`) - [x] Гард паритета сравнивает оба кортежа гейта **поэлементно**, поэтому поймает будущее расхождение по любой колонке, не только по этим шести - [ ] Два поведенческих теста на живом Postgres локально пропускаются (нет тестовой БД), **в CI Trade-In postgres-сервис есть** — там они отработают: «бедный проход не стирает» и обратный «настоящая смена признака всё ещё перезаписывает» Второй из них существенен: без него фикс мог бы «защитить» данные ценой того, что поля стали бы write-once. Настоящая смена (частник → агентство) приходит не NULL'ом и обязана записаться. ## Чего здесь НЕТ **Пункт 2 из #3063** — выводить `is_pro_seller` из `agency_name` — сознательно не вошёл. У него есть риск, которого нет у этой правки: если хоть один провайдер кладёт в `agency_name` имя частного продавца, вывод молча пометит 4 469 листингов как агентские, и это поедет в оценщик. Проверять это нужно чтением каждого провайдера отдельно. Здешняя же правка может только перестать разрушать данные — обратного эффекта у неё нет. Подробности комментарием в #3063. Refs #3063
lekss361 added 1 commit 2026-08-23 21:32:51 +00:00
fix(scraper-kit): бедный re-scrape больше не стирает признаки продавца
Some checks failed
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Failing after 1m0s
d3d75801ca
Апсерт listings присваивал шесть полей напрямую (= EXCLUDED.X): phones,
is_homeowner, is_pro_seller, bargain_allowed, sale_type, metro_stations.

Комментарий рядом («Cian-specific: обновляем при каждом re-scrape») был
верен, пока в listings писал один cian, который отдаёт эти поля на каждом
проходе. Сейчас в ту же таблицу пишут yandex/avito/domklik, чей SERP их не
отдаёт вовсе — и NULL молча затирал значение, добытое detail-обогащением.
Ни ошибки, ни лога, ни изменения статуса прогона: колонка просто
откатывалась к NULL на обычном переобходе.

Прод 23-24.08, yandex: is_homeowner NULL у 98.6% (16 232 из 16 460)
активных листингов, is_pro_seller — у 76.6%.

Соседние поля в том же операторе уже защищены COALESCE ровно от этого, с
явными объяснениями #2007 / #2594 / #2777 — address, city, kitchen_area_m2,
ceiling_height_m, mortgage_available, is_apartments, is_rosreestr_checked.
Эти шесть в защиту просто не попали.

Правка в ДВУХ местах, не в одном. Кроме SET изменена правая часть гейта
`IS DISTINCT FROM` (#2992): он решает, переписывать ли строку, сравнивая
текущие значения с ИТОГОВЫМИ (post-COALESCE). Оставить его на сыром
EXCLUDED значило бы, что гейт видит «NULL против значения» и считает
строку изменившейся там, где она не меняется — вернулись бы ровно те
лишние UPDATE и TOAST-чанки, ради которых #2992 делался.

Пустые phones/metro_stations сериализуются как None (`_to_json(...) if
lot.X else None`), то есть приходят SQL NULL — COALESCE их удерживает, а
не подменяет пустым массивом.

Тест сторожит и паритет тоже: сравнивает оба кортежа гейта поэлементно,
поэтому поймает будущее расхождение по ЛЮБОЙ колонке, не только по этим
шести.

Проверено:
  - фальсификация: все 7 тестов падают на коде до правки
  - 770 passed, 10 skipped (-k "upsert or listing or scrape or writer or
    snapshot or timestamps")
  - выравнивание кортежей гейта: 39 = 39 элементов, порядок колонок совпал

Из #3063 сделан только пункт 1. Пункт 2 (выводить is_pro_seller из
agency_name) НЕ вошёл — см. комментарий в issue: у него есть риск ложного
срабатывания, которого нет у этой правки.

Refs #3063
bot-backend added 1 commit 2026-08-23 21:38:47 +00:00
fix(tests): zip(strict=True) в гарде паритета — ruff B905
Some checks failed
CI / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 8s
CI / backend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Failing after 4m38s
0cce97bd75
CI поймал то, что локально не проверилось: ruff в этом окружении вызывался
не тем интерпретатором и молча не запускался. strict=True здесь не просто
успокаивает линтер — равенство длин кортежей уже проверено assert'ом выше,
так что строгий zip выражает ровно то же намерение явно.

Refs #3063
bot-backend added 1 commit 2026-08-23 21:46:08 +00:00
fix(tests): phones в фикстуре — list[dict], а не список строк
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m36s
85fc18dcb3
CI (там живой postgres, и эти два теста реально исполняются) поймал
ValidationError: ScrapedLot.phones — jsonb вида
[{countryCode, number, type}, ...] (019_listings_alter_cian.sql:41), а я
передавал ["+79000000000"].

Заодно проверено конструирование лота напрямую, без БД — это ловит любые
pydantic-ошибки, не дожидаясь прогона CI. Всплыла деталь, подтверждающая
разбор в самом фиксе: у бедного лота phones не None, а ПУСТОЙ СПИСОК.
Сборка параметров (`_to_json(lot.phones) if lot.phones else None`)
схлопывает его в None, то есть в SQL приходит NULL и COALESCE удерживает
прежнее значение. Если бы пустой список доезжал как '[]', фикс для phones
и metro_stations молча не работал бы.

Refs #3063
lekss361 merged commit e73cde7ad3 into main 2026-08-23 21:52:38 +00:00
lekss361 deleted branch fix/yandex-seller-fields-erosion 2026-08-23 21:52:39 +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#3067
No description provided.