fix(tradein/db): разовая чистка 1123 адресов Авито с приклеенным рейтингом (#2814) (#2818)
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m9s
Deploy Trade-In / build-backend (push) Successful in 31s
Deploy Trade-In / deploy (push) Successful in 1m42s

This commit is contained in:
bot-backend 2026-08-10 10:22:33 +00:00
parent 5ee4126ed0
commit 405d2f2eec
2 changed files with 156 additions and 0 deletions

View file

@ -0,0 +1,155 @@
-- 254_listings_backfill_avito_rating_glued_address.sql
-- Разовая чистка адресов Авито, в которые уехал рейтинг дома (#2814).
--
-- WHY. С 27.07.2026 Авито рендерит рейтинг дома и число отзывов ВНУТРИ того же <p>
-- в data-marker="item-location", откуда serp.py берёт адрес: «ул. Ткачей,17·5,0 · 4
-- отзыва». Парсер починен в #2815 (merged, прод-verified 2026-08-10 09:57 UTC), но
-- УЖЕ ЗАПИСАННЫЕ строки сами не вылечатся: апсерт пишет
-- `address = COALESCE(listings.address, EXCLUDED.address)` (base.py:614) — при
-- конфликте адрес осознанно НЕ перезаписывается (#2777: свежий сырой адрес от
-- площадки откатил бы чистку миграций 062/108/124). Эта миграция — единственный
-- путь, которым старые строки могут стать чистыми.
--
-- ЗАМЕР НА ПРОДЕ 2026-08-10, после деплоя #2815 (не «по релиз-метке», а по данным):
--
-- класс адреса (source='avito', is_active) строк с координатами
-- ------------------------------------------ ------ --------------
-- чистый 7892 6111 (77.4%)
-- загрязнён рейтингом (address ~ '·\s*\d') 1123 0 (0.0%)
-- NULL 360 0 (0.0%)
--
-- 1123 не изменились после деплоя парсера — ни одной из этих строк свип не касался
-- с 09:57 (max(last_seen_at) = 2026-08-09 16:53), и не коснётся с толком: COALESCE.
-- Все 1123 — source='avito', все is_active. Других источников с таким хвостом нет.
-- Цена простоя: строка без geom молча выпадает из радиусного отбора аналогов
-- (Tier W, ST_DWithin — NULL не проходит предикат и нигде не считается).
--
-- ПРАВИЛО РЕЗКИ — ДОСЛОВНО ПАРСЕРНОЕ, не изобретённое здесь.
-- providers/avito/serp.py: _NOT_ADDRESS_TAIL_RE = re.compile(
-- r"\s*(Площадь \d|от \d+\s?мин\.|css-[a-z0-9_-]+|·\s*\d)", flags=re.I)
-- _clean_address: split(maxsplit=1)[0] → _deglue_house_marker → strip(" ,.\n\t")
-- → `return cleaned or None`.
-- Ниже — тот же альтернатив-набор, флаг 'i' = flags=re.I, `.*$` + regexp_replace =
-- взять текст ДО первого совпадения (обе реализации leftmost), тот же набор символов
-- в trim, NULLIF(...,'') = `or None`.
-- Ключевая тонкость (#1773): резать по «·» можно ТОЛЬКО когда за ней идёт ЦИФРА.
-- За буквой идёт район — «улица Бебеля, 138 · р-н Железнодорожный», и этот хвост
-- сохраняется намеренно. На проде таких строк 296, и они обязаны остаться целыми
-- (проверено в dry-run: 296 до = 296 после).
-- _deglue_house_marker в SQL НЕ повторяется — замерено, что он здесь no-op: после
-- резки хвоста ни одна из 1123 строк не содержит слипшегося «29р-н» (0 совпадений
-- паттерном _DEGLUE_RE). Повторять в SQL лукахеды ради нуля строк незачем.
--
-- ПАРИТЕТ ПРОВЕРЕН ТЕМ ЖЕ КОДОМ, А НЕ ПО ГЛАЗАМ. Все 1123 сырых адреса выгружены с
-- прода и прогнаны через ЖИВОЙ парсер в боевом контейнере:
-- docker exec tradein-scraper python /tmp/m2814-parity.py
-- → rows=1123 mismatches=0
-- т.е. SQL-выражение ниже даёт побайтово то же, что `_clean_address` в проде.
--
-- DRY-RUN НА ПРОДЕ (BEGIN … ROLLBACK, 2026-08-10):
-- UPDATE 1123 · осталось загрязнённых 0 · районных «·» сохранено 296/296
-- ул. Ткачей,17·5,0 · 4 отзыва → ул. Ткачей,17
-- ул. Свердлова,32Б·4,2 · 5 отзывов → ул. Свердлова,32Б
-- ул. Щорса,103·4,3 · 15 отзывов → ул. Щорса,103
-- Уральская ул.,5·4,8 · 15 отзывов → Уральская ул.,5
-- Селькоровская ул.,60·5,0 · 3 отзыва → Селькоровская ул.,60
-- ул. Азина,22/2·4,6 · 17 отзывов → ул. Азина,22/2
-- ул. 8 Марта,204Г/2·4,3 · 3 отзыва → ул. 8 Марта,204Г/2
-- жилой район Сортировочный, мкр-н Старая Сортировка, Кунарская ул.,14к2·4,3 · 6 отзывов
-- → жилой район Сортировочный, мкр-н Старая
-- Сортировка, Кунарская ул.,14к2
-- мкр-н Широкая Речка, ул. Анатолия Муранова,18·4,7 · 11 отзывов
-- → мкр-н Широкая Речка, ул. Анатолия Муранова,18
-- ·3,1 · 11 отзывов → NULL (id 10377315, ровно 1 строка: адрес
-- состоял ИЗ рейтинга целиком. Парсер на такой строке возвращает None — здесь то
-- же самое через NULLIF. Оставлять «·3,1 · 11 отзывов» в колонке хуже пустоты:
-- NULL апсерт теперь ДОзаполняет (#2777), мусор — нет.)
--
-- ОБРАТИМОСТЬ — без новой таблицы и без новой колонки: прежнее значение УЖЕ хранится.
-- `listings.raw_payload->>'address'` пишется скрейпером на INSERT и НЕ входит в
-- `ON CONFLICT DO UPDATE SET` (проверено по base.py: raw_payload отсутствует в SET) —
-- т.е. переживает любой свип. Замерено на проде: у 1123 из 1123 строк
-- raw_payload->>'address' = address ПОБАЙТОВО, NULL-ов нет ни одного.
-- Откат (idempotent, безопасен к повторному запуску):
--
-- UPDATE listings
-- SET address = raw_payload->>'address'
-- WHERE source = 'avito'
-- AND raw_payload->>'address' ~ '·\s*\d'
-- AND address IS NOT DISTINCT FROM NULLIF(trim(both E' ,.\n\t' FROM
-- regexp_replace(raw_payload->>'address',
-- '\s*(Площадь \d|от \d+\s?мин\.|css-[a-z0-9_-]+|·\s*\d).*$', '', 'i')), '');
--
-- Предикат самоидентифицирующий, список id хранить не нужно, и это ПРОВЕРЕНО, а не
-- предположено. В dry-run (BEGIN…ROLLBACK) после UPDATE он дал по всей таблице ровно
-- 1123 совпадения, все 1123 — наши; restored = before побайтово у 1123 из 1123.
-- Ложных срабатываний нет и на строках-соседях: есть 10 строк, где raw_payload грязный,
-- а address уже чистый (их адрес позже перезаписал avito_detail полным «Свердловская
-- обл., Первоуральск, …») — второе условие их не берёт (замерено: 0), и это ПРАВИЛЬНО:
-- возвращать рейтинг поверх нормализованного адреса не надо. Со временем предикат сам
-- перестаёт брать строки, у которых address улучшил detail-путь, — откат не деградирует
-- в порчу.
-- `geocode_tried_at` откатывать нечего: это метка backoff'а, не данные.
--
-- ПОЧЕМУ geocode_tried_at = NULL. Очередь geocode_missing_listings отбирает по
-- `geocode_tried_at IS NULL OR < NOW() - 7 days`, и метка привязана к ТЕКСТУ
-- (address, city). У 711 из 1123 строк она стоит (у 370 — свежее 7 суток) — но стоит
-- она на СТАРОМ, заведомо негеокодируемом тексте. После смены текста она смысла не
-- имеет и лишь держала бы вычищенный адрес вне очереди до 7 суток. Сброс — это не
-- «попробовать ещё раз то же самое», а «текст другой». Побочный расход честно измерен:
-- 19 пар из 854 имеют соседа, которому геокодер отказал за последние 7 суток, т.е. до
-- 19 лишних запросов к Nominatim — цена ниже, чем неделя ожидания у 370 строк.
--
-- ЧТО БУДЕТ ДАЛЬШЕ (и чего НЕ будет). Чистый адрес координат сам не даёт. После миграции
-- 1122 строки (854 уникальные пары address+city; 1123-я — та самая NULL) попадают в
-- выборку geocode_missing_listings: `lat IS NULL AND is_active AND address IS NOT NULL
-- AND length(trim(address)) >= 5 AND (geocode_tried_at IS NULL OR < 7 days)`. Очередь
-- станет 1938 строк / 1370 пар против 1569 / 1241 сейчас (+369 строк: 753 из 1123 уже
-- стояли в ней СО СВОИМ ГРЯЗНЫМ адресом и жгли бюджет Nominatim впустую — этот расход
-- миграция тоже снимает). Расписание: enabled, окно 0-23 UTC, batch_size=200,
-- budget_sec=1800, ближайший next_run_at = 2026-08-10 17:45 UTC.
-- Гарантированный низ (замер по живому geocode_cache тем же ключом, что строит
-- `_cache_key`): 138 из 854 пар уже лежат в кэше с координатами и не истекли → 245
-- строк получат geom мгновенно, без единого внешнего запроса. Остальное — как повезёт
-- тирам (кадастровый FDW → Nominatim): последние 5 ночных прогонов давали 17-53%
-- успеха на адрес, гадать точнее не буду.
--
-- ЧЕГО ЭТА МИГРАЦИЯ НЕ ДЕЛАЕТ, СОЗНАТЕЛЬНО:
-- * не трогает COALESCE в апсерте — поведение осознанное (#2777);
-- * не трогает 360 строк с address IS NULL — их #2777 ДОзаполняет сам на ближайшем
-- свипе (замерено: пустых строк '' среди них 0, все именно NULL);
-- * не переносит координаты с соседних строк того же адреса. Такая возможность есть
-- (789 из 1123 строк имеют соседа с координатами по тому же cleaned address+city),
-- но у 88 из 548 донорских пар соседи расходятся между собой больше чем на 50 м, у
-- 32 — больше 250 м, худший разброс 15 км. Выбирать победителя между ними — это
-- новая политика, а не бэкфилл; отдельным решением, не тихо здесь.
--
-- Dependencies: 002_core_tables.sql (listings), 089_listings_geo_precision.sql
-- (geocode_tried_at). Триггер listings_set_geom_trg тут не участвует: он BEFORE
-- INSERT OR UPDATE OF lat, lon — эта миграция координат не пишет.
-- Идемпотентность: по построению. Второй прогон видит 0 строк с '·<цифра>' и не делает
-- ничего (WHERE самоисчерпывающийся). Новые вставки чисты с #2815.
-- lock_timeout: блокирующего DDL здесь нет, но UPDATE по «горячей» listings берёт
-- ROW EXCLUSIVE, и ждать его выдачи за чужой ACCESS EXCLUSIVE сессией — ровно та
-- очередь перед приложением, из-за которой заведён #2752. Пусть лучше деплой упадёт
-- громко (ON_ERROR_STOP=on), чем встанет тихо.
BEGIN;
SET LOCAL lock_timeout = '5s';
UPDATE listings
SET address = NULLIF(
trim(both E' ,.\n\t' FROM
regexp_replace(
address,
'\s*(Площадь \d|от \d+\s?мин\.|css-[a-z0-9_-]+|·\s*\d).*$',
'',
'i'
)),
''),
geocode_tried_at = NULL
WHERE source = 'avito'
AND address ~ '·\s*\d';
COMMIT;

View file

@ -244,3 +244,4 @@
240_trade_in_estimates_retain_until.sql
250_drop_duplicate_expires_at_index.sql
251_listings_drop_ceiling_height.sql
254_listings_backfill_avito_rating_glued_address.sql