fix(tradein/scraper): не помечать городом развёртки объявления соседних городов #2626

Merged
lekss361 merged 1 commit from fix/tradein-city-stamp-geo-guard into main 2026-08-02 11:46:03 +00:00
Owner

Проблема

oblast city-sweep (avito/cian/yandex) проставлял ОДИН city на весь batch save_listings(..., city=resolve_city_name(city_slug)) — независимо от того, где объект физически. Верхняя Пышма (~15км от ЕКБ, yandex radius_m=25000) получала 97% лотов, физически лежащих в Екатеринбурге (замер на проде). listings.city теперь читается app/tasks/asking_to_sold_ratio.py как ценовой предиктор — неверная метка напрямую двигала выкупные цены.

Фикс

save_listings(..., city_anchor=(lat, lon), city_radius_km=...) — гео-guard (haversine, без ST_DWithin round-trip): для лота С координатами дальше city_radius_km от anchor'а города-цели city НЕ проставляется (пишется NULL). Место правки: packages/scraper-kit/src/scraper_kit/base.py (сама проверка) + .../orchestration/pipeline.py (get_city_anchor_point, get_city_stamp_radius_km, wiring в run_avito_city_sweep/run_yandex_city_sweep/run_cian_city_sweep).

Три решения (обоснование)

  1. Вместо неверного города — NULL (не ближайший город из справочника, не drop лота). Честное «не знаем», не ломает существующую COALESCE-семантику ON CONFLICT, не выдумывает данные.
  2. Радиус — per-city, не константа. Дефолт 15км (Первоуральск ~41км / Каменск-Уральский ~93км / Н.Тагил ~125км / Серов ~307км от ЕКБ — guard никогда ложно не режет их лоты, но ловит редкие выбросы). Верхняя Пышма — 8км: сам город лишь ~15.3км от центра ЕКБ (агломерации почти смыкаются), дефолтный 15км-порог никогда бы не сработал.
  3. Лоты без координат (Серов — 142/150 у avito) — city проставляется как обычно. Нечем сверить против anchor'а, а без city колонка теряет главную ценность именно для адресов без города в тексте (#2594, ради чего колонку и завели). Guard применяется ТОЛЬКО когда lot.lat/lot.lon заданы.

Guard активен только для oblast-городов (city_slug задан) — ЕКБ-развёртка (city_slug=None) не имеет более крупного соседа в регионе и не проверяется (тест test_save_listings_geo_guard_inactive_ekaterinburg_sweep_unaffected + orchestration-level *_no_geo_guard_anchor_for_ekaterinburg).

Замер масштаба (read-only, прод, tradein-postgres)

Правило применено к существующим строкам listings (per-city anchor + мой радиус — 8км для В.Пышмы, 15км для остальных):

city source total active with_coords flip→NULL (total) flip→NULL (active)
Верхняя Пышма avito 174 153 26 21 0
Верхняя Пышма cian 22 22 22 0 0
Верхняя Пышма yandex 119 119 119 106 106
Каменск-Уральский avito 408 164 222 209 0
Каменск-Уральский cian 5 5 5 0 0
Каменск-Уральский yandex 129 129 129 0 0
Нижний Тагил avito 574 183 506 387 0
Нижний Тагил cian 8 8 8 0 0
Нижний Тагил yandex 87 87 87 0 0
Первоуральск avito 245 150 98 1 0
Первоуральск cian 2 2 2 0 0
Первоуральск yandex 63 63 63 9 9
Серов avito 172 150 30 25 3
Серов yandex 10 10 10 0 0

Итого: 758 строк (всего) / 118 активных строк получили бы NULL вместо текущего города по этому правилу — оценка масштаба будущей backfill-очистки (отдельная задача, НЕ в этом PR). Активная утечка сосредоточена в yandex (В.Пышма 106, Первоуральск 9) + 3 avito-выброса в Серове — сходится по порядку величины с прод-замером из задачи (В.Пышма yandex 115/119≈97% по методике «расстояние до центра ЕКБ»; моя метрика «расстояние до anchor'а города-цели» даёт 106/119 — оба метода сходятся на одном выводе). Первоуральск/yandex (9/63), Каменск-Уральский/yandex (0/129), Нижний Тагил/yandex (0/87), Серов/avito active (3/150) — точное совпадение с числами из задачи.

Большая часть КУ/НТ avito-строк (209/387) уже is_active=false — исторический мусор, не влияет на текущий asking_to_sold_ratio.

Falsification

git stash implementation-файлов (base.py+pipeline.py), тесты остаются применёнными → 9 новых тестов падают на pre-fix коде (TypeError: unexpected keyword argument 'city_anchor' / ImportError: get_city_anchor_point / KeyError: 'city_anchor'), 36 существующих проходят без изменений (backward-compat подтверждён). git stash pop → все 45 проходят.

Тесты

  • tests/test_listings_city_from_sweep.py — save_listings-уровень: ЕКБ-координаты в В.Пышма-развёртке → city=NULL; координаты anchor'а В.Пышмы → city сохранён; лот без координат → city сохранён; ЕКБ-развёртка без anchor/radius → не сломана.
  • tests/test_scraper_kit_pipeline_parity.py + parity2.py — orchestration-уровень: run_avito_city_sweep/run_yandex_city_sweep/run_cian_city_sweep передают правильные city_anchor/city_radius_km для oblast-города и None/None для ЕКБ.

Полный pytest -q --deselect "tests/test_search_api.py::test_search_cache_hit" (tradein-mvp/backend): 3091 passed, 9 skipped, 1 deselected (тот самый pre-existing 401 RBAC-фейл, не мой).

Границы (не тронуто)

  • app/services/geocoder.py, proxy_pool.py, proxy_rotation.py, оценщик — не тронуты.
  • Накопленные данные — не бэкфиллены (отдельная задача).
  • Радиусы развёрток в расписаниях (radius_m) — не менялись.
  • Миграций/новых колонок нет.

Refs #2594

## Проблема oblast city-sweep (avito/cian/yandex) проставлял ОДИН `city` на весь batch `save_listings(..., city=resolve_city_name(city_slug))` — независимо от того, где объект физически. Верхняя Пышма (~15км от ЕКБ, yandex `radius_m=25000`) получала 97% лотов, физически лежащих в Екатеринбурге (замер на проде). `listings.city` теперь читается `app/tasks/asking_to_sold_ratio.py` как ценовой предиктор — неверная метка напрямую двигала выкупные цены. ## Фикс `save_listings(..., city_anchor=(lat, lon), city_radius_km=...)` — гео-guard (haversine, без ST_DWithin round-trip): для лота С координатами дальше `city_radius_km` от anchor'а города-цели `city` НЕ проставляется (пишется NULL). Место правки: `packages/scraper-kit/src/scraper_kit/base.py` (сама проверка) + `.../orchestration/pipeline.py` (`get_city_anchor_point`, `get_city_stamp_radius_km`, wiring в `run_avito_city_sweep`/`run_yandex_city_sweep`/`run_cian_city_sweep`). ## Три решения (обоснование) 1. **Вместо неверного города — `NULL`** (не ближайший город из справочника, не drop лота). Честное «не знаем», не ломает существующую COALESCE-семантику ON CONFLICT, не выдумывает данные. 2. **Радиус — per-city, не константа.** Дефолт 15км (Первоуральск ~41км / Каменск-Уральский ~93км / Н.Тагил ~125км / Серов ~307км от ЕКБ — guard никогда ложно не режет их лоты, но ловит редкие выбросы). Верхняя Пышма — **8км**: сам город лишь ~15.3км от центра ЕКБ (агломерации почти смыкаются), дефолтный 15км-порог никогда бы не сработал. 3. **Лоты без координат (Серов — 142/150 у avito) — city проставляется как обычно.** Нечем сверить против anchor'а, а без city колонка теряет главную ценность именно для адресов без города в тексте (#2594, ради чего колонку и завели). Guard применяется ТОЛЬКО когда lot.lat/lot.lon заданы. Guard активен только для oblast-городов (`city_slug` задан) — ЕКБ-развёртка (`city_slug=None`) не имеет более крупного соседа в регионе и не проверяется (тест `test_save_listings_geo_guard_inactive_ekaterinburg_sweep_unaffected` + orchestration-level `*_no_geo_guard_anchor_for_ekaterinburg`). ## Замер масштаба (read-only, прод, `tradein-postgres`) Правило применено к существующим строкам `listings` (per-city anchor + мой радиус — 8км для В.Пышмы, 15км для остальных): | city | source | total | active | with_coords | flip→NULL (total) | flip→NULL (active) | |---|---|---|---|---|---|---| | Верхняя Пышма | avito | 174 | 153 | 26 | 21 | 0 | | Верхняя Пышма | cian | 22 | 22 | 22 | 0 | 0 | | Верхняя Пышма | yandex | 119 | 119 | 119 | 106 | **106** | | Каменск-Уральский | avito | 408 | 164 | 222 | 209 | 0 | | Каменск-Уральский | cian | 5 | 5 | 5 | 0 | 0 | | Каменск-Уральский | yandex | 129 | 129 | 129 | 0 | 0 | | Нижний Тагил | avito | 574 | 183 | 506 | 387 | 0 | | Нижний Тагил | cian | 8 | 8 | 8 | 0 | 0 | | Нижний Тагил | yandex | 87 | 87 | 87 | 0 | 0 | | Первоуральск | avito | 245 | 150 | 98 | 1 | 0 | | Первоуральск | cian | 2 | 2 | 2 | 0 | 0 | | Первоуральск | yandex | 63 | 63 | 63 | 9 | **9** | | Серов | avito | 172 | 150 | 30 | 25 | **3** | | Серов | yandex | 10 | 10 | 10 | 0 | 0 | **Итого:** 758 строк (всего) / **118 активных** строк получили бы NULL вместо текущего города по этому правилу — оценка масштаба будущей backfill-очистки (отдельная задача, НЕ в этом PR). Активная утечка сосредоточена в yandex (В.Пышма 106, Первоуральск 9) + 3 avito-выброса в Серове — сходится по порядку величины с прод-замером из задачи (В.Пышма yandex 115/119≈97% по методике «расстояние до центра ЕКБ»; моя метрика «расстояние до anchor'а города-цели» даёт 106/119 — оба метода сходятся на одном выводе). Первоуральск/yandex (9/63), Каменск-Уральский/yandex (0/129), Нижний Тагил/yandex (0/87), Серов/avito active (3/150) — точное совпадение с числами из задачи. Большая часть КУ/НТ avito-строк (209/387) уже `is_active=false` — исторический мусор, не влияет на текущий `asking_to_sold_ratio`. ## Falsification `git stash` implementation-файлов (`base.py`+`pipeline.py`), тесты остаются применёнными → 9 новых тестов падают на pre-fix коде (`TypeError: unexpected keyword argument 'city_anchor'` / `ImportError: get_city_anchor_point` / `KeyError: 'city_anchor'`), 36 существующих проходят без изменений (backward-compat подтверждён). `git stash pop` → все 45 проходят. ## Тесты - `tests/test_listings_city_from_sweep.py` — save_listings-уровень: ЕКБ-координаты в В.Пышма-развёртке → city=NULL; координаты anchor'а В.Пышмы → city сохранён; лот без координат → city сохранён; ЕКБ-развёртка без anchor/radius → не сломана. - `tests/test_scraper_kit_pipeline_parity.py` + `parity2.py` — orchestration-уровень: `run_avito_city_sweep`/`run_yandex_city_sweep`/`run_cian_city_sweep` передают правильные `city_anchor`/`city_radius_km` для oblast-города и `None`/`None` для ЕКБ. Полный `pytest -q --deselect "tests/test_search_api.py::test_search_cache_hit"` (tradein-mvp/backend): **3091 passed, 9 skipped, 1 deselected** (тот самый pre-existing 401 RBAC-фейл, не мой). ## Границы (не тронуто) - `app/services/geocoder.py`, `proxy_pool.py`, `proxy_rotation.py`, оценщик — не тронуты. - Накопленные данные — не бэкфиллены (отдельная задача). - Радиусы развёрток в расписаниях (`radius_m`) — не менялись. - Миграций/новых колонок нет. Refs #2594
lekss361 added 1 commit 2026-08-02 11:31:27 +00:00
fix(tradein/scraper): не помечать городом развёртки объявления соседних городов
All checks were successful
CI / changes (pull_request) Successful in 9s
CI Trade-In / changes (pull_request) Successful in 9s
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 2m36s
5f4b88e6a9
oblast city-sweep (avito/cian/yandex) ставил ОДИН city на весь batch
save_listings без проверки координат — Верхняя Пышма (~15км от ЕКБ,
yandex radius_m=25000) получала 97% лотов, физически лежащих в ЕКБ
(замер на проде). listings.city теперь читается asking_to_sold_ratio.py
как ценовой предиктор — неверная метка искажала выкупные цены.

save_listings(..., city_anchor=(lat, lon), city_radius_km=...) режет
city per-lot, если у лота ЕСТЬ координаты и они дальше radius_km от
anchor'а города-цели (haversine, без ST_DWithin round-trip). Лоты БЕЗ
координат (авито — большинство, Серов 142/150) оставлены как есть:
нечем сверить, а без city колонка теряет смысл именно для адресов без
города в тексте, ради которых её и завели (#2594).

Радиус per-city (pipeline.get_city_stamp_radius_km): дефолт 15км
(Первоуральск/Каменск-Уральский/Н.Тагил/Серов — 41-307км от ЕКБ,
guard никогда ложно не режет их лоты); Верхняя Пышма — 8км (сам город
всего ~15.3км от ЕКБ, дефолтный порог не сработал бы).

Guard активен только для oblast-городов (city_slug задан) — ЕКБ-развёртка
(city_slug=None) не имеет более крупного соседа в регионе и не проверяется.
lekss361 merged commit 7c9319d3b9 into main 2026-08-02 11:46:03 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
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#2626
No description provided.