diff --git a/tradein-mvp/backend/data/sql/207_backfill_yandex_cian_city_geo_cleanup.sql b/tradein-mvp/backend/data/sql/207_backfill_yandex_cian_city_geo_cleanup.sql new file mode 100644 index 00000000..70f0bd4b --- /dev/null +++ b/tradein-mvp/backend/data/sql/207_backfill_yandex_cian_city_geo_cleanup.sql @@ -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; diff --git a/tradein-mvp/backend/tests/test_listings_city_from_sweep.py b/tradein-mvp/backend/tests/test_listings_city_from_sweep.py index 1cad0855..3b52b0ac 100644 --- a/tradein-mvp/backend/tests/test_listings_city_from_sweep.py +++ b/tradein-mvp/backend/tests/test_listings_city_from_sweep.py @@ -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] diff --git a/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity2.py b/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity2.py index 87fbc11b..64a106e1 100644 --- a/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity2.py +++ b/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity2.py @@ -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 diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/base.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/base.py index dbab78d8..82eeba66 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/base.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/base.py @@ -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, а не угадывается чужой город). Лоты БЕЗ diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py index ea82ba9f..ac6f5a3e 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py @@ -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).