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: соседний-город-в-развёртке ──────────────────────────────────────
|
# ── Geo-guard: соседний-город-в-развёртке ──────────────────────────────────────
|
||||||
#
|
#
|
||||||
# Замер на проде (см. PR): yandex-развёртка city_slug="verkhnyaya_pyshma"
|
# Замер на проде (см. PR): yandex-развёртка city_slug="verkhnyaya_pyshma"
|
||||||
# (radius_m=25000 вокруг anchor'а В.Пышмы, ~15.3км от центра ЕКБ) проставляла
|
# (city-scoped rgid, ~15.3км anchor'а В.Пышмы от центра ЕКБ — lat/lon/radius_m
|
||||||
# "Верхняя Пышма" 97% найденного — большинство физически лежит в Екатеринбурге.
|
# у yandex gate-API игнорируются, см. providers/yandex/serp.py:fetch_around)
|
||||||
|
# проставляла "Верхняя Пышма" 97% найденного — большинство физически в Екатеринбурге.
|
||||||
# save_listings(..., city_anchor=..., city_radius_km=...) режет city per-lot, если
|
# save_listings(..., city_anchor=..., city_radius_km=...) режет city per-lot, если
|
||||||
# у лота ЕСТЬ координаты и они дальше city_radius_km от city_anchor.
|
# у лота ЕСТЬ координаты и они дальше city_radius_km от city_anchor.
|
||||||
_VP_ANCHOR = (56.976, 60.578) # CITY_ANCHORS["verkhnyaya_pyshma"][0][:2]
|
_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 ──
|
# ── Гео-guard: соседний-город-в-развёртке — save_listings получает anchor+radius ──
|
||||||
#
|
#
|
||||||
# Замер на проде (см. PR): oblast city-sweep (yandex/cian) стамповал город-цель на
|
# Замер на проде (см. PR): oblast city-sweep (yandex/cian) стамповал город-цель на
|
||||||
# лоты, физически лежащие в куда более крупном ЕКБ (yandex radius_m=25000 вокруг
|
# лоты, физически лежащие в куда более крупном ЕКБ (у yandex дело не в radius_m
|
||||||
# anchor'а В.Пышмы, ~15.3км от центра ЕКБ — захватывает почти весь город). Оркестратор
|
# запроса — gate-API скоупит city-scoped rgid и игнорирует lat/lon/radius_m целиком,
|
||||||
# обязан передать city_anchor/city_radius_km для oblast-города и НЕ передавать
|
# см. providers/yandex/serp.py:fetch_around; anchor В.Пышмы всего ~15.3км от центра
|
||||||
# (None/None) для ЕКБ (нет большего соседа — guard там не нужен).
|
# ЕКБ, соседние агломерации почти смыкаются). Оркестратор обязан передать
|
||||||
|
# city_anchor/city_radius_km для oblast-города и НЕ передавать (None/None) для ЕКБ
|
||||||
|
# (нет большего соседа — guard там не нужен).
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
|
||||||
|
|
@ -354,9 +354,12 @@ def save_listings(
|
||||||
NULL'ом, если какой-то caller ещё не передаёт city).
|
NULL'ом, если какой-то caller ещё не передаёт city).
|
||||||
city_anchor: (lat, lon) референсной точки города-цели этого batch'а — гео-guard
|
city_anchor: (lat, lon) референсной точки города-цели этого batch'а — гео-guard
|
||||||
(соседний-город-в-развёртке, замер на проде: 97% лотов, помеченных
|
(соседний-город-в-развёртке, замер на проде: 97% лотов, помеченных
|
||||||
"Верхняя Пышма" из yandex-развёртки radius_m=25000, физически лежат в
|
"Верхняя Пышма" из yandex-развёртки, физически лежат в Екатеринбурге —
|
||||||
Екатеринбурге — anchor города-цели всего ~15км от центра ЕКБ, широкий
|
anchor города-цели всего ~15км от центра ЕКБ, а провайдер (см.
|
||||||
radius_m захватывает весь ЕКБ). Если задан ВМЕСТЕ с `city_radius_km` — для
|
`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-
|
каждого лота С координатами (lot.lat/lot.lon НЕ None) считаем haversine-
|
||||||
расстояние до `city_anchor`; лот ДАЛЬШЕ `city_radius_km` НЕ получает `city`
|
расстояние до `city_anchor`; лот ДАЛЬШЕ `city_radius_km` НЕ получает `city`
|
||||||
этого batch'а (пишется NULL, а не угадывается чужой город). Лоты БЕЗ
|
этого batch'а (пишется NULL, а не угадывается чужой город). Лоты БЕЗ
|
||||||
|
|
|
||||||
|
|
@ -315,9 +315,11 @@ def get_city_anchor_point(city_slug: str | None) -> tuple[float, float] | None:
|
||||||
|
|
||||||
Соседний-город-в-развёртке (замер на проде): oblast city-sweep стамповал СВОЙ
|
Соседний-город-в-развёртке (замер на проде): oblast city-sweep стамповал СВОЙ
|
||||||
город-цель на 100% найденного, включая лоты, физически лежащие в куда более
|
город-цель на 100% найденного, включая лоты, физически лежащие в куда более
|
||||||
крупном соседнем городе, случайно захваченные широким radius_m/loose SERP-city-
|
крупном соседнем городе, случайно захваченные loose SERP-city-фильтром
|
||||||
фильтром провайдера (напр. Верхняя Пышма ~15км от ЕКБ, yandex radius_m=25000 —
|
провайдера (напр. Верхняя Пышма ~15км от ЕКБ; у yandex дело не в `radius_m`
|
||||||
97% "verkhnyaya_pyshma"-развёртки на проде физически в ЕКБ). Берём ПЕРВЫЙ (и пока
|
запроса — gate-API скоупит выдачу city-scoped `rgid` и игнорирует lat/lon/
|
||||||
|
radius_m целиком, см. `providers/yandex/serp.py:fetch_around` — 97%
|
||||||
|
"verkhnyaya_pyshma"-развёртки на проде физически в ЕКБ). Берём ПЕРВЫЙ (и пока
|
||||||
единственный) anchor CITY_ANCHORS[city_slug] — та же точка, что реально ходит в
|
единственный) anchor CITY_ANCHORS[city_slug] — та же точка, что реально ходит в
|
||||||
scraper (fetch_around/fetch_around_multi_room), поэтому guard сверяется с
|
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), за пределами
|
# Гео-guard радиус (км) от anchor'а города-цели (get_city_anchor_point), за пределами
|
||||||
# которого save_listings НЕ доверяет city этого batch'а — см. save_listings docstring
|
# которого save_listings НЕ доверяет city этого batch'а — см. save_listings docstring
|
||||||
# (scraper_kit.base) и замер на проде в PR. НЕ путать с radius_m поисковых запросов
|
# (scraper_kit.base) и замер на проде в PR. НЕ путать с radius_m поисковых запросов
|
||||||
# scraper'а (avito/cian 1500м, yandex 25000м вокруг того же anchor'а — ЭТО определяет
|
# scraper'а — avito/cian реально используют radius_m (1500м вокруг того же anchor'а)
|
||||||
# ЧТО скачано; guard-радиус — какому city-batch'у из скачанного верить).
|
# как фильтр ЧТО скачано; 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км — с запасом покрывает застройку города + ближние пригороды и остаётся
|
# Дефолт 15км — с запасом покрывает застройку города + ближние пригороды и остаётся
|
||||||
# НАМНОГО меньше дистанции до ЕКБ у 4 из 5 oblast-городов (Первоуральск ~41км,
|
# НАМНОГО меньше дистанции до ЕКБ у 4 из 5 oblast-городов (Первоуральск ~41км,
|
||||||
|
|
@ -1899,8 +1904,10 @@ async def run_yandex_city_sweep(
|
||||||
"""Yandex.Недвижимость city sweep: rooms × price combos от центра ЕКБ → save → address-enrich.
|
"""Yandex.Недвижимость city sweep: rooms × price combos от центра ЕКБ → save → address-enrich.
|
||||||
|
|
||||||
Вместо 5 географических anchor'ов итерирует ВСЕ combinations (room × price_range)
|
Вместо 5 географических anchor'ов итерирует ВСЕ combinations (room × price_range)
|
||||||
из одной центральной точки ЕКБ (56.8400, 60.6050). radius_m=25000 охватывает весь
|
из одной центральной точки ЕКБ (56.8400, 60.6050). Город на самом деле скоупит
|
||||||
город; price/room segmentation обходит Yandex SERP ~575-card cap без geo-шрапнели.
|
`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).
|
1. SERP: YandexRealtyScraper.fetch_around_multi_room(...) — combos mode (on_combo save).
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue