fix(tradein): бэкфилл-чистка неверных oblast-меток city для yandex/cian (#2628)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
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 2m44s

Write-time гео-guard (#2626) не самозалечивает накопленное: COALESCE в
upsert не даёт новому NULL перетереть старую неверную метку. Чистка строго
ограничена yandex/cian (провайдерские координаты правдивы) — Авито не
трогаем: его SERP-координаты — ЕКБ-центроид геокодера при верном тексте
адреса, наивный гео-критерий снёс бы ~640 корректных меток. Двойной
критерий (haversine 1:1 с guard'ом + «адрес не называет город метки»)
покрывает оба известных false-positive. Прод dry-run: 53 строки
(Пышма 45, Первоуральск 8). Idempotent, UPDATE-only.

Побочно: поправлены 6 комментариев, неверно объяснявших вектор утечки
(radius_m=25000 → реальный вектор rgid, city-scoped выдача Яндекса).

Refs #2628
This commit is contained in:
bot-backend 2026-08-05 12:04:24 +05:00
parent 7d13e93792
commit d1ec6d02ec
5 changed files with 153 additions and 16 deletions

View file

@ -0,0 +1,124 @@
-- 207_backfill_yandex_cian_city_geo_cleanup.sql
-- Issue #2628 — бэкфилл-чистка неверных oblast-городских меток `listings.city`,
-- накопленных ДО write-time гео-guard'а (PR #2626, `save_listings(..., city_anchor=...,
-- city_radius_km=...)`, packages/scraper-kit/src/scraper_kit/base.py).
--
-- ПРОБЛЕМА: upsert `ON CONFLICT` делает `city = COALESCE(EXCLUDED.city, listings.city)`
-- (base.py:589 / base.py:701) — новый `NULL` от гео-guard'а НЕ перетирает уже записанную
-- неверную метку. Строки, помеченные до #2626 (когда guard'а ещё не было), несут
-- ошибочный город БЕСКОНЕЧНО (каждый повторный upsert сохраняет старое значение).
--
-- ⛔ ГЛАВНОЕ ОГРАНИЧЕНИЕ (issue #2628) — гео-критерий ТОЛЬКО для yandex/cian:
-- Координаты Avito на SERP-этапе — систематически ЕКБ-центроид геокодера
-- (lat=lon=None у карточки, `save_listings` пишет их ДО фазы деталей;
-- провайдерские координаты у Avito на этом этапе попросту отсутствуют/неверны).
-- При этом ТЕКСТ адреса у Avito город называет верно (Каменск 214/222,
-- Тагил 389/506, Серов 30/30, Пышма 24/26 строк). Наивный гео-критерий по ВСЕМ
-- источникам снёс бы ~640 корректных Avito-меток. yandex/cian отдают реальные
-- провайдерские координаты — точность гео-критерия там подтверждена (113/115).
--
-- Гео-критерий — ТОТ ЖЕ, что использует write-time guard (никакой параллельной
-- логики): per-city anchor (lat, lon) + радиус (км), haversine-расстояние.
-- Anchors/радиусы скопированы 1:1 из
-- packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py
-- (`CITY_ANCHORS`, `_DEFAULT_CITY_STAMP_RADIUS_KM` = 15.0,
-- `_CITY_STAMP_RADIUS_KM['verkhnyaya_pyshma']` = 8.0 — Пышма ~15.3км от ЕКБ,
-- дефолтный 15км-порог у неё никогда бы не сработал). Формула — та же
-- `_haversine_km` (сферическая Земля, r=6371.0км), что использует
-- `save_listings` в base.py.
--
-- Гео-guard write-time НИКОГДА не применяется к строкам с city="Екатеринбург"
-- (get_city_anchor_point возвращает None для ЕКБ/неизвестного slug — "в регионе
-- нет города КРУПНЕЕ ЕКБ, чей SERP мог бы её поглотить", pipeline.py docstring)
-- — здесь тем же принципом трогаем ТОЛЬКО 5 oblast-меток из CITY_DISPLAY_NAMES,
-- никогда не Екатеринбург.
--
-- Два известных ложных срабатывания ГОЛОГО гео-критерия (issue #2628, п.3) —
-- НЕ трогать:
-- - "Верхняя Пышма, улица Орджоникидзе, 1" — 14,4км (> 8км-порог Пышмы)
-- - "Первоуральск, Береговая улица, 7А" — 28,1км (> 15км-порог)
-- Оба — реальные адреса СВОЕГО города (городской округ географически больше
-- компактного ядра, для которого калиброван радиус), метка верна несмотря на
-- расстояние. Защита — issue #2628 п.2: если ТЕКСТ адреса называет город
-- метки, метку не трогаем НЕЗАВИСИМО от координат. Python-парсер топонимов
-- `_names_non_ekb_city` (app/services/geocoder.py) — word-boundary regex по
-- списку из 37 городов с district-префикс исключениями — НЕ переносится в SQL
-- один-в-один без дублирования списка/regex-семантики. Вместо этого — простой
-- `address ILIKE '%<город_метки>%'` (issue #2628, разрешённый fallback):
-- проверяем, что адрес называет ИМЕННО тот город, который уже стоит в
-- `listings.city` (не произвольный топоним) — этого достаточно, чтобы
-- накрыть оба false positive (оба явно начинаются с имени своего города в
-- тексте адреса) без переизобретения гео-парсера в SQL.
--
-- Масштаб (issue #2628): ~115 активных строк yandex/cian получат city = NULL.
-- НЕ удаляем строки — только обнуляем метку (NULL считается "своим" в
-- money-path `asking_to_sold_ratio.py:124`, `city IS NULL OR city ILIKE
-- :asking_city` — обнуление не выбрасывает лот из выборки, только убирает
-- его из ЧУЖОЙ (oblast) выборки).
--
-- Idempotency:
-- WHERE l.city = ca.city_name — после первого прогона обнулённые строки
-- (city IS NULL) больше не матчат ни один city_name → повторный прогон
-- обновляет 0 строк.
--
-- НЕ DDL — только UPDATE данных существующей колонки (196_listings_city.sql).
--
-- Dependencies: 196_listings_city.sql (колонка listings.city).
BEGIN;
-- Dry-run (READ-ONLY) — тот же WHERE, что и UPDATE ниже. Прогонять ОТДЕЛЬНО
-- (вне транзакции миграции) для верификации масштаба до/после мержа:
--
-- WITH city_anchor (city_name, anchor_lat, anchor_lon, radius_km) AS (VALUES
-- ('Нижний Тагил', 57.910::double precision, 59.980::double precision, 15.0::double precision),
-- ('Каменск-Уральский', 56.414::double precision, 61.918::double precision, 15.0::double precision),
-- ('Первоуральск', 56.908::double precision, 59.943::double precision, 15.0::double precision),
-- ('Верхняя Пышма', 56.976::double precision, 60.578::double precision, 8.0::double precision),
-- ('Серов', 59.604::double precision, 60.578::double precision, 15.0::double precision)
-- )
-- SELECT l.source, l.city, count(*)
-- FROM listings l
-- JOIN city_anchor ca ON ca.city_name = l.city
-- WHERE l.source IN ('yandex', 'cian')
-- AND l.is_active = true
-- AND l.lat IS NOT NULL
-- AND l.lon IS NOT NULL
-- AND l.address NOT ILIKE '%' || ca.city_name || '%'
-- AND 2 * 6371.0 * asin(sqrt(
-- power(sin(radians(ca.anchor_lat - l.lat) / 2), 2)
-- + cos(radians(l.lat)) * cos(radians(ca.anchor_lat))
-- * power(sin(radians(ca.anchor_lon - l.lon) / 2), 2)
-- )) > ca.radius_km
-- GROUP BY l.source, l.city
-- ORDER BY l.source, l.city;
WITH city_anchor (city_name, anchor_lat, anchor_lon, radius_km) AS (VALUES
('Нижний Тагил', 57.910::double precision, 59.980::double precision, 15.0::double precision),
('Каменск-Уральский', 56.414::double precision, 61.918::double precision, 15.0::double precision),
('Первоуральск', 56.908::double precision, 59.943::double precision, 15.0::double precision),
('Верхняя Пышма', 56.976::double precision, 60.578::double precision, 8.0::double precision),
('Серов', 59.604::double precision, 60.578::double precision, 15.0::double precision)
)
UPDATE listings l
SET city = NULL
FROM city_anchor ca
WHERE ca.city_name = l.city
AND l.source IN ('yandex', 'cian')
AND l.is_active = true
AND l.lat IS NOT NULL
AND l.lon IS NOT NULL
-- Адресный критерий (issue #2628 п.2/п.3) — если текст адреса называет
-- город метки, метка верна независимо от координат (защищает оба известных
-- false positive — Пышма/Орджоникидзе-1 и Первоуральск/Береговая-7А).
AND l.address NOT ILIKE '%' || ca.city_name || '%'
-- Гео-критерий — тот же haversine, что write-time guard в scraper_kit.base
-- (`_haversine_km`), anchors/радиусы из pipeline.py `CITY_ANCHORS` /
-- `_CITY_STAMP_RADIUS_KM`.
AND 2 * 6371.0 * asin(sqrt(
power(sin(radians(ca.anchor_lat - l.lat) / 2), 2)
+ cos(radians(l.lat)) * cos(radians(ca.anchor_lat))
* power(sin(radians(ca.anchor_lon - l.lon) / 2), 2)
)) > ca.radius_km;
COMMIT;

View file

@ -208,8 +208,9 @@ def test_save_listings_reconcile_update_coalesces_city() -> None:
# ── Geo-guard: соседний-город-в-развёртке ──────────────────────────────────────
#
# Замер на проде (см. PR): yandex-развёртка city_slug="verkhnyaya_pyshma"
# (radius_m=25000 вокруг anchor'а В.Пышмы, ~15.3км от центра ЕКБ) проставляла
# "Верхняя Пышма" 97% найденного — большинство физически лежит в Екатеринбурге.
# (city-scoped rgid, ~15.3км anchor'а В.Пышмы от центра ЕКБ — lat/lon/radius_m
# у yandex gate-API игнорируются, см. providers/yandex/serp.py:fetch_around)
# проставляла "Верхняя Пышма" 97% найденного — большинство физически в Екатеринбурге.
# save_listings(..., city_anchor=..., city_radius_km=...) режет city per-lot, если
# у лота ЕСТЬ координаты и они дальше city_radius_km от city_anchor.
_VP_ANCHOR = (56.976, 60.578) # CITY_ANCHORS["verkhnyaya_pyshma"][0][:2]

View file

@ -556,10 +556,12 @@ async def test_full_load_stamps_ekaterinburg(source: str) -> None:
# ── Гео-guard: соседний-город-в-развёртке — save_listings получает anchor+radius ──
#
# Замер на проде (см. PR): oblast city-sweep (yandex/cian) стамповал город-цель на
# лоты, физически лежащие в куда более крупном ЕКБ (yandex radius_m=25000 вокруг
# anchor'а В.Пышмы, ~15.3км от центра ЕКБ — захватывает почти весь город). Оркестратор
# обязан передать city_anchor/city_radius_km для oblast-города и НЕ передавать
# (None/None) для ЕКБ (нет большего соседа — guard там не нужен).
# лоты, физически лежащие в куда более крупном ЕКБ (у yandex дело не в radius_m
# запроса — gate-API скоупит city-scoped rgid и игнорирует lat/lon/radius_m целиком,
# см. providers/yandex/serp.py:fetch_around; anchor В.Пышмы всего ~15.3км от центра
# ЕКБ, соседние агломерации почти смыкаются). Оркестратор обязан передать
# city_anchor/city_radius_km для oblast-города и НЕ передавать (None/None) для ЕКБ
# (нет большего соседа — guard там не нужен).
@pytest.mark.asyncio

View file

@ -354,9 +354,12 @@ def save_listings(
NULL'ом, если какой-то caller ещё не передаёт city).
city_anchor: (lat, lon) референсной точки города-цели этого batch'агео-guard
(соседний-город-в-развёртке, замер на проде: 97% лотов, помеченных
"Верхняя Пышма" из yandex-развёртки radius_m=25000, физически лежат в
Екатеринбурге anchor города-цели всего ~15км от центра ЕКБ, широкий
radius_m захватывает весь ЕКБ). Если задан ВМЕСТЕ с `city_radius_km` для
"Верхняя Пышма" из yandex-развёртки, физически лежат в Екатеринбурге
anchor города-цели всего ~15км от центра ЕКБ, а провайдер (см.
`providers/yandex/serp.py:fetch_around`) скоупит выдачу city-scoped rgid,
НЕ радиусом (`lat/lon/radius_m` для yandex gate-API инертны) rgid
захватывает весь ЕКБ вместе с ближним спутником). Если задан ВМЕСТЕ с
`city_radius_km` для
каждого лота С координатами (lot.lat/lot.lon НЕ None) считаем haversine-
расстояние до `city_anchor`; лот ДАЛЬШЕ `city_radius_km` НЕ получает `city`
этого batch'а (пишется NULL, а не угадывается чужой город). Лоты БЕЗ

View file

@ -315,9 +315,11 @@ def get_city_anchor_point(city_slug: str | None) -> tuple[float, float] | None:
Соседний-город-в-развёртке (замер на проде): oblast city-sweep стамповал СВОЙ
город-цель на 100% найденного, включая лоты, физически лежащие в куда более
крупном соседнем городе, случайно захваченные широким radius_m/loose SERP-city-
фильтром провайдера (напр. Верхняя Пышма ~15км от ЕКБ, yandex radius_m=25000
97% "verkhnyaya_pyshma"-развёртки на проде физически в ЕКБ). Берём ПЕРВЫЙ (и пока
крупном соседнем городе, случайно захваченные loose SERP-city-фильтром
провайдера (напр. Верхняя Пышма ~15км от ЕКБ; у yandex дело не в `radius_m`
запроса gate-API скоупит выдачу city-scoped `rgid` и игнорирует lat/lon/
radius_m целиком, см. `providers/yandex/serp.py:fetch_around` 97%
"verkhnyaya_pyshma"-развёртки на проде физически в ЕКБ). Берём ПЕРВЫЙ (и пока
единственный) anchor CITY_ANCHORS[city_slug] та же точка, что реально ходит в
scraper (fetch_around/fetch_around_multi_room), поэтому guard сверяется с
РЕАЛЬНЫМ центром запроса, а не с отдельно захардкоженными координатами.
@ -404,8 +406,11 @@ def resolve_city_name(city_slug: str | None) -> str:
# Гео-guard радиус (км) от anchor'а города-цели (get_city_anchor_point), за пределами
# которого save_listings НЕ доверяет city этого batch'а — см. save_listings docstring
# (scraper_kit.base) и замер на проде в PR. НЕ путать с radius_m поисковых запросов
# scraper'а (avito/cian 1500м, yandex 25000м вокруг того же anchor'а — ЭТО определяет
# ЧТО скачано; guard-радиус — какому city-batch'у из скачанного верить).
# scraper'а — avito/cian реально используют radius_m (1500м вокруг того же anchor'а)
# как фильтр ЧТО скачано; yandex — НЕТ: gate-API скоупит выдачу city-scoped rgid и
# lat/lon/radius_m игнорирует целиком (providers/yandex/serp.py:fetch_around, param
# radius_m=25000 там — мёртвый default, не влияет на выдачу). guard-радиус ниже — про
# другое: какому city-batch'у из уже скачанного верить.
#
# Дефолт 15км — с запасом покрывает застройку города + ближние пригороды и остаётся
# НАМНОГО меньше дистанции до ЕКБ у 4 из 5 oblast-городов (Первоуральск ~41км,
@ -1899,8 +1904,10 @@ async def run_yandex_city_sweep(
"""Yandex.Недвижимость city sweep: rooms × price combos от центра ЕКБ → save → address-enrich.
Вместо 5 географических anchor'ов итерирует ВСЕ combinations (room × price_range)
из одной центральной точки ЕКБ (56.8400, 60.6050). radius_m=25000 охватывает весь
город; price/room segmentation обходит Yandex SERP ~575-card cap без geo-шрапнели.
из одной центральной точки ЕКБ (56.8400, 60.6050). Город на самом деле скоупит
`city_rgid` (см. ниже) lat/lon/radius_m у yandex gate-API игнорируются целиком
(providers/yandex/serp.py:fetch_around), `radius_m=25000` default здесь мёртв;
price/room segmentation обходит Yandex SERP ~575-card cap без geo-шрапнели.
Фазы (единственный центральный якорь):
1. SERP: YandexRealtyScraper.fetch_around_multi_room(...) combos mode (on_combo save).