Яндекс: is_homeowner / is_pro_seller стираются при каждом «бедном» re-scrape (живая потеря данных) #3063

Closed
opened 2026-08-23 21:12:07 +00:00 by lekss361 · 5 comments
Owner

Найдено аудитом скрапперов 24.08.2026. Баг активен прямо сейчас — данные теряются с каждым проходом, ошибок в логах нет.

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

В tradein-mvp/packages/scraper-kit/src/scraper_kit/base.py:597-601 upsert для семи полей пишет напрямую:

is_homeowner   = EXCLUDED.is_homeowner
is_pro_seller  = EXCLUDED.is_pro_seller
agency_name    = EXCLUDED.agency_name
metro_stations = EXCLUDED.metro_stations
phones         = EXCLUDED.phones
bargain_allowed = EXCLUDED.bargain_allowed
sale_type      = EXCLUDED.sale_type

Соседние поля в том же самом операторе сознательно защищены COALESCE(EXCLUDED.X, listings.X) — именно чтобы бедный SERP-проход не затирал уже известное значение, добытое detail-обогащением:

Поле Строка Защита
address base.py:614 COALESCE
city base.py:617 COALESCE
kitchen_area_m2 base.py:621-623 COALESCE
ceiling_height_m base.py:624-626 COALESCE
mortgage_available base.py:630-632 COALESCE
is_apartments base.py:633-635 COALESCE
is_rosreestr_checked base.py:636-638 COALESCE

Комментарии рядом (#2007, #2594, #2777) прямо объясняют, зачем защита нужна. Семь полей выше в неё просто не попали — недосмотр, а не решение.

Итог: любой SERP-проход, где author не заполнен, молча стирает ранее известный признак. Ни ошибки, ни лога, ни изменения статуса прогона — колонка просто откатывается к NULL.

Масштаб на проде (замер 23-24.08, tradein-postgres)

Из 16 460 активных yandex-листингов:

  • is_homeowner NULL — 98.6 % (16 232)
  • is_pro_seller NULL — 76.6 %

Отдельный, усугубляющий факт: 4 469 активных листингов уже имеют заполненный agency_name (пришёл из detail-бэкфилла, где COALESCE-защита есть — yandex/detail.py:614), но is_pro_seller у них при этом NULL. То есть данных достаточно, чтобы вывести признак, но он не выводится.

Что делать

  1. Перевести семь полей в base.py:597-601 на COALESCE(EXCLUDED.X, listings.X) — по образцу соседних строк.
  2. Вывести is_pro_seller из наличия agency_name (тот же паттерн уже есть у cian и avito).
  3. Рассмотреть извлечение is_homeowner в yandex/detail.py — отсутствие агентского блока на детальной странице само по себе сигнал «частник». Сейчас detail не извлекает это поле вовсе, только agency_name / seller_name / agency_founded_year.

Пункты 1-2 — S/S. Пункт 3 — отдельно, можно позже.

Почему это важно за пределами гигиены

Признак «частник или агентство» напрямую идёт в оценщик trade-in. Сейчас он у 98.6 % листингов отсутствует не потому, что площадка его не отдаёт, а потому что мы его сами затираем.

Оценка: выигрыш S-M, усилие S.

Найдено аудитом скрапперов 24.08.2026. **Баг активен прямо сейчас** — данные теряются с каждым проходом, ошибок в логах нет. ## Что происходит В `tradein-mvp/packages/scraper-kit/src/scraper_kit/base.py:597-601` upsert для семи полей пишет напрямую: ``` is_homeowner = EXCLUDED.is_homeowner is_pro_seller = EXCLUDED.is_pro_seller agency_name = EXCLUDED.agency_name metro_stations = EXCLUDED.metro_stations phones = EXCLUDED.phones bargain_allowed = EXCLUDED.bargain_allowed sale_type = EXCLUDED.sale_type ``` Соседние поля в том же самом операторе **сознательно защищены** `COALESCE(EXCLUDED.X, listings.X)` — именно чтобы бедный SERP-проход не затирал уже известное значение, добытое detail-обогащением: | Поле | Строка | Защита | |---|---|---| | `address` | `base.py:614` | COALESCE | | `city` | `base.py:617` | COALESCE | | `kitchen_area_m2` | `base.py:621-623` | COALESCE | | `ceiling_height_m` | `base.py:624-626` | COALESCE | | `mortgage_available` | `base.py:630-632` | COALESCE | | `is_apartments` | `base.py:633-635` | COALESCE | | `is_rosreestr_checked` | `base.py:636-638` | COALESCE | Комментарии рядом (#2007, #2594, #2777) прямо объясняют, зачем защита нужна. Семь полей выше в неё просто не попали — недосмотр, а не решение. Итог: любой SERP-проход, где `author` не заполнен, **молча стирает** ранее известный признак. Ни ошибки, ни лога, ни изменения статуса прогона — колонка просто откатывается к NULL. ## Масштаб на проде (замер 23-24.08, `tradein-postgres`) Из **16 460** активных yandex-листингов: - `is_homeowner` NULL — **98.6 %** (16 232) - `is_pro_seller` NULL — **76.6 %** Отдельный, усугубляющий факт: **4 469** активных листингов уже имеют заполненный `agency_name` (пришёл из detail-бэкфилла, где COALESCE-защита есть — `yandex/detail.py:614`), но `is_pro_seller` у них при этом NULL. То есть данных достаточно, чтобы вывести признак, но он не выводится. ## Что делать 1. Перевести семь полей в `base.py:597-601` на `COALESCE(EXCLUDED.X, listings.X)` — по образцу соседних строк. 2. Вывести `is_pro_seller` из наличия `agency_name` (тот же паттерн уже есть у cian и avito). 3. Рассмотреть извлечение `is_homeowner` в `yandex/detail.py` — отсутствие агентского блока на детальной странице само по себе сигнал «частник». Сейчас detail не извлекает это поле вовсе, только `agency_name` / `seller_name` / `agency_founded_year`. Пункты 1-2 — S/S. Пункт 3 — отдельно, можно позже. ## Почему это важно за пределами гигиены Признак «частник или агентство» напрямую идёт в оценщик trade-in. Сейчас он у 98.6 % листингов отсутствует не потому, что площадка его не отдаёт, а потому что мы его сами затираем. Оценка: выигрыш S-M, усилие S.
Author
Owner

Пункт 1 сделан — PR #3067. Пункты 2 и 3 остаются, и по обоим есть уточнения к исходному тексту.

Поправка к описанию: agency_name уже защищён

В issue я записал agency_name в список незащищённых полей — это неверно. В коде он уже идёт через COALESCE, причём в обоих местах:

  • base.py:661agency_name = COALESCE(EXCLUDED.agency_name, listings.agency_name)
  • base.py:747 — та же COALESCE в правой части гейта IS DISTINCT FROM

Так что эрозии подвержены шесть полей, а не семь: phones, is_homeowner, is_pro_seller, bargain_allowed, sale_type, metro_stations. Все шесть починены в #3067.

Заодно нашлось: правку нужно было делать в двух местах

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

Пункт 2 — почему НЕ вошёл

Вывод is_pro_seller из agency_name выглядит безобидно, но у него есть риск, которого нет у пункта 1. Правка #3067 может только перестать разрушать данные — обратного эффекта у неё нет по построению. А вывод признака — это запись нового значения: если хоть один провайдер кладёт в agency_name имя частного продавца (а не агентства), вывод молча пометит 4 469 листингов как агентские, и это поедет прямо в оценщик trade-in.

Проверяется это не рассуждением, а чтением того, что именно каждый провайдер кладёт в agency_name — у yandex это yandex/detail.py:614, у остальных свои места. Работа на один заход, но её надо сделать, а не предположить.

Пункт 3 — уточнение

is_homeowner в yandex/detail.py действительно не извлекается вовсе (там только agency_name / seller_name / agency_founded_year). Но выводить «нет агентского блока ⇒ частник» стоит только вместе с пунктом 2 и той же проверкой — это ровно то же допущение, вид сбоку.

Пункт 1 сделан — PR #3067. Пункты 2 и 3 остаются, и по обоим есть уточнения к исходному тексту. ## Поправка к описанию: `agency_name` уже защищён В issue я записал `agency_name` в список незащищённых полей — это **неверно**. В коде он уже идёт через COALESCE, причём в обоих местах: - `base.py:661` — `agency_name = COALESCE(EXCLUDED.agency_name, listings.agency_name)` - `base.py:747` — та же COALESCE в правой части гейта `IS DISTINCT FROM` Так что эрозии подвержены **шесть** полей, а не семь: `phones`, `is_homeowner`, `is_pro_seller`, `bargain_allowed`, `sale_type`, `metro_stations`. Все шесть починены в #3067. ## Заодно нашлось: правку нужно было делать в двух местах Гейт `IS DISTINCT FROM` (#2992) сравнивает текущие значения с **итоговыми**, post-COALESCE. Если бы я поменял только `SET`, гейт остался бы на сыром `EXCLUDED`, увидел «NULL против значения» и посчитал строку изменившейся там, где она не меняется — вернулись бы лишние UPDATE и TOAST-чанки, ради устранения которых #2992 и делался. В #3067 тест сторожит этот паритет структурно (сравнение кортежей поэлементно), поэтому он поймает и будущее расхождение по любой другой колонке. ## Пункт 2 — почему НЕ вошёл Вывод `is_pro_seller` из `agency_name` выглядит безобидно, но у него есть риск, которого нет у пункта 1. Правка #3067 может только **перестать разрушать** данные — обратного эффекта у неё нет по построению. А вывод признака — это запись нового значения: если хоть один провайдер кладёт в `agency_name` имя частного продавца (а не агентства), вывод молча пометит **4 469** листингов как агентские, и это поедет прямо в оценщик trade-in. Проверяется это не рассуждением, а чтением того, что именно каждый провайдер кладёт в `agency_name` — у yandex это `yandex/detail.py:614`, у остальных свои места. Работа на один заход, но её надо сделать, а не предположить. ## Пункт 3 — уточнение `is_homeowner` в `yandex/detail.py` действительно не извлекается вовсе (там только `agency_name` / `seller_name` / `agency_founded_year`). Но выводить «нет агентского блока ⇒ частник» стоит только вместе с пунктом 2 и той же проверкой — это ровно то же допущение, вид сбоку.
Collaborator

Пункт 2 сделан и принят на проде (26.08, PR #3100, деплой 2116c377)

  • save_detail_enrichment (yandex/detail.py): is_pro_seller = COALESCE(:is_pro_seller, ...), параметр TRUE при известном agency_name, иначе NULL — вывод только в одну сторону, «частник» из отсутствия блока не выводится (это п.3).
  • Миграция 271_listings_pro_seller_from_agency.sql: бэкфилл по всем источникам (правило «есть имя агентства ⇒ профи» источник-независимо).

Приёмка по данным (Poincare, сразу после деплоя):

дыр (agency_name есть, is_pro_seller≠true): 0     ← было 4 469 только по яндексу
is_pro_seller = true всего:                 32 654

Дальше признак поддерживается detail-обогащением на записи. Остаётся п.3 (извлечение is_homeowner из отсутствия агентского блока на детальной) — отдельное решение, как и было в постановке.

## Пункт 2 сделан и принят на проде (26.08, PR #3100, деплой 2116c377) - `save_detail_enrichment` (yandex/detail.py): `is_pro_seller = COALESCE(:is_pro_seller, ...)`, параметр TRUE при известном `agency_name`, иначе NULL — вывод только в одну сторону, «частник» из отсутствия блока не выводится (это п.3). - Миграция `271_listings_pro_seller_from_agency.sql`: бэкфилл по всем источникам (правило «есть имя агентства ⇒ профи» источник-независимо). **Приёмка по данным** (Poincare, сразу после деплоя): ``` дыр (agency_name есть, is_pro_seller≠true): 0 ← было 4 469 только по яндексу is_pro_seller = true всего: 32 654 ``` Дальше признак поддерживается detail-обогащением на записи. Остаётся **п.3** (извлечение `is_homeowner` из отсутствия агентского блока на детальной) — отдельное решение, как и было в постановке.
Collaborator

П.3 «рассмотреть» — рассмотрел замером: инференс валиден с точностью ~92%, решение о включении за тобой

Валидация на собственных данных, где есть обе стороны истины (yandex, detail-обогащённые, n=9 527; истина — SERP author.category, предиктор — agency_name после detail):

SERP-истина всего без агентского блока на detail
частник (is_homeowner) 199 199 (100%)
профи (is_pro_seller) 8 672 17 (0,2%)
истина неизвестна 656 656 (100%)

P(частник | нет блока) = 199/216 = 92,1% на размеченных. Сигнал очень сильный (базовая доля частников в обогащённых — 2%, в подгруппе «без блока» — 92%). Пул выигрыша — все 656 неразмеченных: fill-only is_homeowner=true при отсутствии блока дал бы 4,3× покрытие признака (199 → ~855) ценой ~8% ожидаемой ошибки (~52 строки из 656).

Почему не сделал молча: признак идёт в оценщик, а 8% — это осознанная цена, не нулевая. Контрпримеры (17 профи без блока) — либо отказ парса агентской секции, либо частные риелторы без юрлица; ужесточение правила (требовать вдобавок успешный парс продавца/непустую detail-страницу) цену снизит, но её надо мерить после.

Если добро — реализация зеркальна п.2 (PR #3100): fill-only параметр в save_detail_enrichment + COALESCE, тест с этой таблицей в док-стринге. Если 8% дорого — честно закрыть п.3 как «рассмотрено, отклонено по цене ошибки».

## П.3 «рассмотреть» — рассмотрел замером: инференс валиден с точностью ~92%, решение о включении за тобой Валидация на собственных данных, где есть обе стороны истины (yandex, detail-обогащённые, n=9 527; истина — SERP `author.category`, предиктор — `agency_name` после detail): | SERP-истина | всего | без агентского блока на detail | |---|---:|---:| | частник (`is_homeowner`) | 199 | **199 (100%)** | | профи (`is_pro_seller`) | 8 672 | 17 (0,2%) | | истина неизвестна | 656 | **656 (100%)** | **P(частник | нет блока) = 199/216 = 92,1%** на размеченных. Сигнал очень сильный (базовая доля частников в обогащённых — 2%, в подгруппе «без блока» — 92%). Пул выигрыша — все 656 неразмеченных: fill-only `is_homeowner=true` при отсутствии блока дал бы 4,3× покрытие признака (199 → ~855) ценой ~8% ожидаемой ошибки (~52 строки из 656). **Почему не сделал молча**: признак идёт в оценщик, а 8% — это осознанная цена, не нулевая. Контрпримеры (17 профи без блока) — либо отказ парса агентской секции, либо частные риелторы без юрлица; ужесточение правила (требовать вдобавок успешный парс продавца/непустую detail-страницу) цену снизит, но её надо мерить после. Если добро — реализация зеркальна п.2 (PR #3100): fill-only параметр в `save_detail_enrichment` + COALESCE, тест с этой таблицей в док-стринге. Если 8% дорого — честно закрыть п.3 как «рассмотрено, отклонено по цене ошибки».
Collaborator

Контрольная точка п.2 сутки спустя: is_pro_seller=true 32 654 → 32 774, из них 18 — через detail-обогащение после деплоя (26.08 15:35). Деривация на записи работает, признак поддерживается сам — приёмка п.2 полная. П.3 ждёт твоего решения по цене ошибки 8% (замер выше).

Контрольная точка п.2 сутки спустя: `is_pro_seller=true` 32 654 → **32 774**, из них **18** — через detail-обогащение после деплоя (26.08 15:35). Деривация на записи работает, признак поддерживается сам — приёмка п.2 полная. П.3 ждёт твоего решения по цене ошибки 8% (замер выше).
Author
Owner

Ревизия открытых задач 2026-08-30. Проверено в коде на forgejo/main — сделано, закрываю.

Эрозия признаков закрыта COALESCE в scraper_kit/base.py, is_pro_seller выводится из агентского блока. Два теста: test_3063_seller_fields_not_eroded.py и test_3063_pro_seller_from_agency.py.

Ревизия открытых задач 2026-08-30. Проверено в коде на forgejo/main — сделано, закрываю. Эрозия признаков закрыта COALESCE в scraper_kit/base.py, is_pro_seller выводится из агентского блока. Два теста: test_3063_seller_fields_not_eroded.py и test_3063_pro_seller_from_agency.py.
Sign in to join this conversation.
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#3063
No description provided.