fix(tradein/imv): домовая оценка перестаёт врать про ремонт и тип дома (#2674) #2675
No reviewers
Labels
No labels
Fable 5 ревью
GG-форсайт
admin
analytics
auth
automation
bug
business
chore
ci
compliance
data
data-moat
docs
duplicate
dx
enhancement
feedback/max
generative
needs-discussion
needs-human
observability
pause-bots
performance
priority/p0
priority/p1
priority/p2
priority/p3
scope/backend
scope/db
scope/devops
scope/frontend
scope/qa
scrapers
security
site-finder
stage/1
stage/2
status/blocked
status/done
status/needs-analysis
status/needs-fix
status/qa
status/ready
status/review
status/wip
tech-debt
tradein
ux
week ревью 1
wontfix
ИРД
вторичка
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: lekss361/gendesign#2675
Loading…
Add table
Reference in a new issue
No description provided.
Delete branch "fix/2674-house-imv-params"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Главное: почему бэкфилл не сохранил ни одной оценки за 34 дня
Дело не в параметрах. Даже с идеальными параметрами эффекта не было бы: запросы не доходили до Авито.
31 прогон подряд (
scrape_runs.source='house_imv_backfill', 05.07–05.08):saved=0,checked=50,errors30–50. Разбивка причин поhouses.imv_error_reason:503 browser unavailable (proxy may be down)отtradein-browser:3000/fetch-json500от сайдкара:Page.evaluate: Execution context was destroyed, most likely because of a navigation403Авито (geocode-A/geocode-B/imv-evaluate)All connection attempts failed,Server disconnected)То есть 93.5% отказов (1240 из 1327) произошли ДО единственного запроса к Авито — сайдкар отказывался поднимать браузер. Это гейт #2616 (
browser/server.py:731): на проде без прокси мы намеренно не ходим напрямую с IP сервера. Прокси лежал ~месяц (ср. ротация #2611 и токен ASOCKS).Последний прогон 05.08 показывает, что прокси вернулся —
503исчезли, но появилось два новых блокера, иsavedвсё равно 0 (50 домов = 15no_params+ 23 сайдкар-500 + 12403):a) Сайдкар,
Page.evaluate: Execution context was destroyed._fetch_json_once(browser/server.py:998-1038) делаетpage.goto(origin, wait_until="domcontentloaded"), ждёт фиксированныйFETCH_JSON_SETTLE_MSи затемpage.evaluate(...). Страница Авито за это время уходит в клиентскую навигацию → контекст исполнения умирает → исключение. Ретрая нет:_is_browser_crash(server.py:1062) матчит толькоTargetClosedError/browser has been closed/target closed/connection closed/browser disconnected— «execution context was destroyed» в список не входит, поэтому_do_fetch_jsonне делает relaunch+retry и отдаёт 500. В логах хорошо видно: один и тот же URL падает дважды подряд (in-page retry внутри JS не помогает, страницы уже нет), а соседние запросы того же прогона возвращают 200.b)
403Авито на всех трёх шагах — анти-бот/бан IP, внешнее ограничение.Что нужно (не входит в этот PR — другой файл, общий сайдкар всех провайдеров): добавить «execution context was destroyed» / «most likely because of a navigation» в распознавание восстановимого сбоя, чтобы
_do_fetch_jsonделал повтор на свежей странице (или дожидатьсяload/networkidleвместо фиксированного settle). Это один общий на avito/cian/domclick путь — правка туда заслуживает своего PR и своего ревью.403кодом не чинится: это прокси/ротация IP (#2611).Иными словами: этот PR устраняет враньё в данных, но сам по себе не заставит бэкфилл сохранять оценки — сначала нужен сайдкар + живой прокси.
Три дефекта: что было и что стало
1. Тип ремонта захардкожен (деньги)
Было: литерал
"renovation_type": "cosmetic"вpick_lot_params— 2685 из 2685 запросов ушли как «косметический ремонт» (подтверждено:SELECT renovation_type, count(*) FROM house_imv_evaluations→cosmetic 2685).Реальная мода
repair_stateпо объявлениям тех же домов: standard 4564 / good 4118 / needs_repair 2279 / excellent 1631 — косметика лишь 36%.Стало:
mode() WITHIN GROUP (ORDER BY repair_state)добавлен в тот же агрегат, который уже считает медианы комнат/площади/этажа (лишнего запроса нет), значение проходит через существующийestimator._IMV_REPAIR_MAP(needs_repair→required/standard→cosmetic/good→euro/excellent→designer). Второго словаря не заведено; импорт ленивый, потому чтоestimator→scraper_adapters→house_imv_backfill— цикл (тот же приём, что уже применён в файле дляRealScraperConfig).Неизвестный ремонт (498 домов из 2685 — ни одного объявления с
repair_state) остаётсяcosmetic, в отличие от неизвестного типа дома. Обоснование по данным:cosmetic(=standard) — это одновременно мода и медианная категория популяции (standard 7984 / good 7116 / needs_repair 4738 / excellent 2562; кумулятивно needs_repair 21.2%, +standard 56.8%), то есть наилучшая одиночная догадка, а не просто «не край шкалы». У типа дома такой догадки нет:panel— почти край.Асимметрия названа в комментарии, чтобы следующий читатель не принял её за недосмотр: поштучный путь эстиматора при неизвестном ремонте IMV вообще не зовёт, а домовой дефолтит — иначе теряем ещё ~32% домов очереди поверх тех, что уже отсекает неизвестный тип дома.
2. Неизвестный тип дома молча становился «панелью» (деньги)
Было:
_HOUSE_TYPE_DEFAULT = "panel"возвращался и когда типа нет вовсе, и когда он есть, но не совпал со словарём после.lower().Панель — почти самый дешёвый класс. Замер по нашим же 2685 оценкам (все при одном и том же
cosmetic, медианаrecommended_price/area):Занижение: 363 дома без типа где-либо + 75 домов с camelCase-типом (
monolithBrick56,other18,stalin3,aerocreteBlock/gasSilicateBlock/wireframeпо 1). Для 56 домовmonolithBrickэто −11.7% (128.8k вместо 145.9k)..lower()тут не лечит: ключ канона пишется через подчёркивание (monolith_brick), а приходитmonolithbrick.Стало: сырое значение прогоняется через общий
scraper_kit.house_type_normalizer.normalize_house_type(#2007) — он уже знает camelCase-вокабуляр Циана (monolithBrick/gasSilicateBlock/aerocreteBlock/foamConcreteBlock/stalin) и SCREAMING-вокабуляр Яндекса. Дефолтpanelудалён. Тип не распознан →house_type=None→ дом помечаетсяno_paramsс причинойunknown house_type(тот же путь, что уже есть для «нет комнат/площади») и запрос не тратится.otherиwireframeнамеренно НЕ маппятся: честного соответствия в вокабуляре Авито у них нет, а любое присвоение — то же враньё, только в другую сторону.Цена этой честности на НЕснятых домах — больше, чем на снятых
Эффект на 2685 уже снятых посчитан ниже, но главное число — про очередь. Из домов, до которых бэкфилл ещё не дошёл, в «нет параметров» уедут:
pendingtransient_errorПричём camelCase тут ни при чём: среди этих домов нераспознаваемых токенов ноль, у всех тип отсутствует вообще нигде — ни в объявлениях, ни в
houses.Практически это значит, что у этих домов в интерфейсе не будет маркера оценки Авито — рекомендованная цена рисуется напрямую. Это не дефект, а ровно та честность, которую PR и продаёт, и она совпадает с поштучным путём эстиматора (тот при неизвестном типе IMV тоже не зовёт). Смягчающее: у фронта есть progressive enrichment — недостающий тип он умеет спросить у пользователя.
Решение о заморозке 369 строк (см. ниже) принимать в связке с этим числом, а не отдельно.
3. Прогон не умел падать (наблюдаемость)
Было:
_job_house_imv_backfillвсегда звалmark_done— 31 прогон сsaved=0и десятками ошибок помечен успешным.Стало:
saved == 0 and errors > 0→mark_failedс текстомsaved=0 при errors=N (checked=M). Честная пустота (ноль сохранённых без ошибок — всё ушло вskipped) по-прежнемуdone; частичный успех (saved>0при ошибках) тожеdone.Плюс (второй коммит, по ревью): счётчики доезжают до выделенных колонок.
_column_counts(scrape_runs.py) берётtotal_seenиз ключейtotal_seen|lots_fetchedиnew_countизnew_count|lots_inserted— ни одного из них в дикте не было, поэтому все 39 прогонов этого source лежат в БД сtotal_seen=0(проверено). Аmark_doneпо этой же колонке шлёт алерт «3 подрядdoneс нулевым результатом» (#2625) — то есть даже идеальный прогон с 50 сохранёнными считался бы нулевым и через три дня выстрелил бы ложной тревогой про капчу. Добавленыtotal_seen=checked,new_count=saved. Трейд-офф назван в комментарии: на исчерпанной очередиchecked=0три дня подряд тоже даст алерт — но пустая очередь при ежедневном расписании это и правда сигнал.Плюс: лог на дрейф вокабуляра ремонта.
_IMV_REPAIR_MAP.get(x) or "cosmetic"молча схлопывал любое незнакомое непустое значение. Сегодня в проде ровно четыре канонических, живого эффекта нет, но дрейф реален — добавленlogger.debug, паритет сhouse_type_normalizer, который такой лог уже пишет.План пересъёмки старых 2685 оценок (подготовлен, НА ПРОДЕ НЕ ВЫПОЛНЕН)
У
house_imv_evaluationsуникальность поhouse_id, поэтому запись перезапишется только при новом прогоне. Посчитал, сколько домов реально изменят параметры (пересчёт новых значений из тех же источников):Бюджет запросов. Один дом = 4 HTTP к Авито (warm-up
/evaluation/realty+coords/by_address+geo/position+realty-imv/get-data). 1343 × 4 ≈ 5372 запроса.При текущем расписании (
batch_size=50, раз в сутки,request_delay_sec=5) это 27 суток только на пересъёмку — и это поверх уже стоящей очереди 5672pending+ 1414transient_error(~142 суток).Поэтому «разом» не предлагаю. Рекомендация — растянуть по времени, порциями, и только после того, как починен сайдкар (иначе пересъёмка просто сожжёт очередь в 500-е):
Миграцией это НЕ оформлено намеренно:
tradein-mvp/backend/data/sql/NN_*.sqlприменяется на прод автоматически на деплое, а решение о пересъёмке и её темпе — за владельцем.Отдельно на решение: у 369 домов тип дома теперь неизвестен, честно переснять их нельзя. Их старые строки
panel/cosmeticостаются в таблице и продолжают читаться эстиматором. Варианты — удалить эти строки (потерять цену) либо пометить их провенансом «параметры угаданы». Не делал ни того, ни другого: это продуктовое решение, не техническое.Что НЕ входит
tradein-browser(Page.evaluate: Execution context was destroyed) — другой файл, общий для avito/cian/domclick, свой blast radius и своё ревью.403Авито — внешнее ограничение, кодом не чинится (ср. #2611).listings.has_balcony13.8%,listings.balcony_loggia9.4%, и колонки противоречат друг другу (поhas_balcony«есть» у 62%, а поbalcony_loggiaсамый частый случай —loggia5650 противbalcony2794). Мода по одному-двум объявлениям на таком покрытии — шум.monolith_brick → monolithicне трогал: по эхуitemParams.rawParamsиз ответов Авито видно, чтоmonolithic— валидный токен (param 498 = 5247, отличный от brick 5244 / panel 5245 / block 5246). Расхождение сestimator._IMV_HOUSE_TYPE_MAP(тамmonolith_brick → monolith_brick) осталось как есть — зондmonolithпротивmonolithicидёт отдельным follow-up.housesпо образцу 141, min-support и фильтр по активности для моды, сон 5 с на скипнутых домах, провенанс/удаление 369 строк.Отдельно про денежный риск, который создаёт этот фикс (довод за осторожную пересъёмку): подбор домового якоря идёт по комнатам и площади и не смотрит на тип ремонта, а подмешивание однонаправленное вверх. Раньше все 2685 строк были
cosmetic— база была кривой, но однородной. После пересъёмки дом, где объявления скошены в «евро», получит более дорогой якорь, и он применится к клиентской квартире с любым ремонтом, включая «требует ремонта» — только вверх. Поэтому пересъёмку начинать с полусотни домов и замера сдвига, а не с массового сброса.Test plan
pytest tests/test_house_imv_params_honesty.py tests/test_backfill_wave2.py tests/test_house_imv_backfill_browser_flag.py tests/test_sweep_imv_phase.py tests/test_scraper_kit_scheduler_parity.py -q→ 91 passed (+1 новый тест наtotal_seen/new_countво втором коммите)test_search_api.py::test_search_cache_hit(401), падает и на чистомorigin/main, к этому PR отношения не имеетtest_counters_feed_total_seen_and_new_countкраснеет на дикте без ключей (_column_counts→(None, None))saved=0+errors>0 → не done. Честно:standard → cosmeticНЕ краснеет — старый хардкод случайно совпадал с правильным ответом для этой одной ветки; красноту дефекта ловят остальные три параметра иtest_renovation_type_not_hardcoded_cosmetic. Анти-оверрич-тесты (честная пустота → done, частичный успех → done, неизвестный ремонт → cosmetic) зелёные и до, и после — это гейты, а не регрессии.ruff check/ruff format --checkпо изменённым файлам чисто; pre-commit прошёлRefs #2674
Три дефекта в house_imv_backfill, найденные системным поиском (эпик #2674/#2673). 1. Тип ремонта был захардкожен литералом 'cosmetic' — все 2685 запросов ушли как «косметический ремонт», хотя мода repair_state по объявлениям тех же домов другая: standard 4564 / good 4118 / needs_repair 2279 / excellent 1631 (косметика лишь 36%). Теперь renovation_type берётся из mode(repair_state) в том же агрегате, что уже считает медианы комнат/площади/этажа, и проходит через существующий estimator._IMV_REPAIR_MAP (ленивый импорт — estimator тянет scraper_adapters, а тот импортирует этот модуль). Второго словаря не заводим. Неизвестный ремонт (498 домов из 2685) остаётся 'cosmetic': это середина порядковой шкалы required < cosmetic < euro < designer, а не край, системного сдвига в одну сторону не даёт. 2. Неизвестный тип дома молча становился 'panel' — и когда типа нет вовсе, и когда он есть, но не совпал со словарём. Панель почти самый дешёвый класс (медиана по нашим же 2685 оценкам: block 122.6k < panel 128.8k < brick 131.1k < monolithic 145.9k руб/м2), то есть дефолт систематически занижал. На проде так уехали 363 дома совсем без типа и 75 домов с camelCase-типом из Циана (56 из них monolithBrick — минус 11.7% против monolithic). Теперь сырое значение прогоняется через общий scraper_kit.house_type_normalizer.normalize_house_type (знает monolithBrick / gasSilicateBlock / aerocreteBlock / stalin и SCREAMING-вокабуляр Яндекса), дефолт 'panel' убран: тип не распознан → house_type=None → дом помечается no_params ('unknown house_type') и запрос к площадке не тратится. 'other' и 'wireframe' намеренно НЕ маппятся — честного соответствия у них нет. 3. Прогон не умел падать: 31 прогон подряд с saved=0 и ~35 ошибками из 50 помечен 'done'. Тот же класс, что #2670/#2657 — успех определялся как «не поймали известное исключение». Теперь saved=0 при errors>0 → mark_failed. Ноль сохранённых БЕЗ ошибок (всё отфильтровано в skipped) остаётся done. Балкон/лоджия оставлены константами намеренно: покрытие listings.has_balcony 13.8%, listings.balcony_loggia 9.4%, и колонки противоречат друг другу (по has_balcony «есть» у 62%, а по balcony_loggia самый частый случай — loggia 5650 против balcony 2794). Мода по одному-двум объявлениям на таком покрытии — шум, а не данные. Причина, по которой бэкфилл не сохранил НИ ОДНОЙ оценки за 34 дня, — вне этого модуля и здесь не чинится (детали и числа в описании PR): 1240 домов легли на отказе браузерного сайдкара «нет прокси» (гейт #2616, 05.07-02.08), а после возврата прокси 05.08 — 23 на Page.evaluate «Execution context was destroyed» в tradein-browser и 12 на 403 Авито. Refs #2674