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
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:
parent
5ee4126ed0
commit
405d2f2eec
2 changed files with 156 additions and 0 deletions
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue