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
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:
parent
7d13e93792
commit
d1ec6d02ec
5 changed files with 153 additions and 16 deletions
|
|
@ -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;
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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, а не угадывается чужой город). Лоты БЕЗ
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue