fix(tradein/db): разовая чистка 1123 адресов Авито с приклеенным рейтингом (#2814) #2818

Merged
bot-backend merged 1 commit from fix/2814-address-backfill into main 2026-08-10 10:22:34 +00:00
Collaborator

Разовое лечение 1123 строк из #2814. Парсер починен в #2815 (merged, прод-verified), но уже записанные адреса сами не вылечатся: base.py:614 пишет address = COALESCE(listings.address, EXCLUDED.address) — при конфликте адрес осознанно НЕ перезаписывается (#2777). Миграция — единственный путь.

Перемерено на проде 2026-08-10 (после деплоя #2815, по данным а не по релиз-метке)

класс адреса (source='avito', is_active) строк с координатами
чистый 7892 6111 (77.4%)
загрязнён рейтингом (address ~ '·\s*\d') 1123 0 (0.0%)
NULL 360 0 (0.0%)

Ничего не «рассосалось» свипом после #2815: max(last_seen_at) у этих 1123 = 2026-08-09 16:53, тронуто с 09:57 — 0 строк. Все 1123 — avito, все активные; в других источниках такого хвоста нет.

Правило резки — дословно парсерное

Парсер (providers/avito/serp.py):

_NOT_ADDRESS_TAIL_RE = re.compile(
    r"\s*(Площадь \d|от \d+\s?мин\.|css-[a-z0-9_-]+|·\s*\d)", flags=re.I)

Миграция:

regexp_replace(address, '\s*(Площадь \d|от \d+\s?мин\.|css-[a-z0-9_-]+|·\s*\d).*$', '', 'i')

плюс trim(both E' ,.\n\t') = strip(" ,.\n\t") и NULLIF(…,'') = return cleaned or None.

Паритет проверен тем же кодом, а не по глазам: 1123 сырых адреса выгружены с прода и прогнаны через живой _clean_address в боевом контейнере — rows=1123 mismatches=0.

_deglue_house_marker в SQL не повторяется: замерено, что после резки хвоста слипшегося «29р-н» нет ни в одной из 1123 строк (0 совпадений _DEGLUE_RE).

Районные хвосты (#1773) сохранены: резать по «·» можно только когда за ней цифра. 296 строк вида «улица Бебеля, 138 · р-н Железнодорожный» — 296 до, 296 после.

Dry-run на проде (BEGIN … ROLLBACK, экзактным файлом миграции)

UPDATE 1123 · загрязнённых после — 0 · районных «·» — 296/296 · psql_exit=0

было стало
ул. Ткачей,17·5,0 · 4 отзыва ул. Ткачей,17
ул. Свердлова,32Б·4,2 · 5 отзывов ул. Свердлова,32Б
ул. Шаумяна,93·5,0 · 4 отзыва ул. Шаумяна,93
ул. Щорса,103·4,3 · 15 отзывов ул. Щорса,103
Уральская ул.,5·4,8 · 15 отзывов Уральская ул.,5
Селькоровская ул.,60·5,0 · 3 отзыва Селькоровская ул.,60
Волгоградская ул.,18·1 отзыв Волгоградская ул.,18
ул. Азина,22/2·4,6 · 17 отзывов ул. Азина,22/2
ул. 8 Марта,204Г/2·4,3 · 3 отзыва ул. 8 Марта,204Г/2
мкр-н Широкая Речка, ул. Анатолия Муранова,18·4,7 · 11 отзывов мкр-н Широкая Речка, ул. Анатолия Муранова,18
·3,1 · 11 отзывов (id 10377315) NULL

Последняя строка — единственная во всём наборе, где адрес состоял ИЗ рейтинга целиком. Парсер на ней возвращает None; миграция делает то же через NULLIF. Мусор в колонке хуже пустоты: NULL апсерт теперь дозаполняет (#2777), «·3,1 · 11 отзывов» — нет.

Обратимость — без новой таблицы и колонки

Прежнее значение уже хранится: raw_payload->>'address' пишется скрейпером на INSERT и отсутствует в ON CONFLICT DO UPDATE SET (проверено по base.py) — переживает любой свип. Замер: у 1123 из 1123 raw_payload->>'address' = address побайтово, NULL-ов нет.

Откат-предикат (полный текст в шапке миграции) проверен в том же dry-run:

  • по всей таблице даёт ровно 1123 совпадения, все 1123 — наши;
  • restored = before побайтово у 1123 из 1123;
  • 10 строк, где raw_payload грязный, а address уже перезаписан avito_detail полным «Свердловская обл., Первоуральск, …», он не берёт (0) — и это правильно.

Геокодирование — отдельный вопрос, числа честные

Выборка 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), GROUP BY (address, city).

  • Подберёт: 1122 из 1123 строк (854 уникальные пары); 1123-я — та, что стала NULL.
  • geocode_tried_at = NULL в миграции обязателен: метка стоит у 711 строк (у 370 — свежее 7 суток), но стоит она на СТАРОМ негеокодируемом тексте. Цена сброса измерена: до 19 лишних запросов Nominatim (19 пар из 854 имеют соседа с недавним отказом).
  • Очередь после миграции: 1938 строк / 1370 пар против 1569 / 1241 сейчас. Рост всего +369 строк, потому что 753 из 1123 уже стояли в очереди со своим грязным адресом и жгли бюджет Nominatim впустую — этот расход миграция тоже снимает.
  • Ближайший прогон: next_run_at = 2026-08-10 17:45 UTC, окно 0-23, batch_size=200, budget_sec=1800.
  • Гарантированный низ: 138 из 854 пар уже лежат в geocode_cache с координатами и не истекли (ключ считан той же формулой, что строит _cache_key) → 245 строк получат geom мгновенно. Остальное — как повезёт тирам (кадастровый FDW → Nominatim): последние 5 ночных прогонов дали 17-53% успеха на адрес.

Критерий приёмки, записанный ДО факта (проверять 2026-08-11 утром):

  1. сразу после деплоя — SELECT count(*) FROM listings WHERE source='avito' AND address ~ '·\s*\d'0, откат-предикат → 1123;
  2. после прогона 2026-08-10 17:45 UTC — из 1122 вычищенных строк ≥ 245 имеют lat IS NOT NULL (жёсткий низ из кэша). Меньше 245 = геокодер не подобрал их, и это баг, а не «не повезло».

Чего эта миграция НЕ делает, сознательно

  • не трогает COALESCE в апсерте (#2777);
  • не трогает 360 строк с address IS NULL — они лечатся сами (#2777). Замерено: пустых строк '' среди них 0, все именно NULL;
  • не переносит координаты с соседних строк того же адреса. Возможность есть — 789 из 1123 строк имеют соседа с координатами по тому же cleaned address+city. Но у 88 из 548 донорских пар соседи расходятся больше чем на 50 м, у 32 — больше 250 м, худший разброс 15 км. Выбор победителя между ними — новая политика, а не бэкфилл. Если решение «переносить» будет принято — это отдельная миграция, числа готовы.

Гейт scripts/check-migration-lock-timeout.py: selftest OK + ✓ блокирующий DDL прикрыт lock_timeout (проверено новых миграций: 4).

Closes #2814

Разовое лечение 1123 строк из #2814. Парсер починен в #2815 (merged, прод-verified), но уже записанные адреса сами не вылечатся: `base.py:614` пишет `address = COALESCE(listings.address, EXCLUDED.address)` — при конфликте адрес осознанно НЕ перезаписывается (#2777). Миграция — единственный путь. ## Перемерено на проде 2026-08-10 (после деплоя #2815, по данным а не по релиз-метке) | класс адреса (`source='avito'`, `is_active`) | строк | с координатами | |---|---|---| | чистый | 7892 | 6111 (77.4%) | | загрязнён рейтингом (`address ~ '·\s*\d'`) | **1123** | **0 (0.0%)** | | NULL | 360 | 0 (0.0%) | Ничего не «рассосалось» свипом после #2815: `max(last_seen_at)` у этих 1123 = 2026-08-09 16:53, тронуто с 09:57 — **0 строк**. Все 1123 — avito, все активные; в других источниках такого хвоста нет. ## Правило резки — дословно парсерное Парсер (`providers/avito/serp.py`): ```python _NOT_ADDRESS_TAIL_RE = re.compile( r"\s*(Площадь \d|от \d+\s?мин\.|css-[a-z0-9_-]+|·\s*\d)", flags=re.I) ``` Миграция: ```sql regexp_replace(address, '\s*(Площадь \d|от \d+\s?мин\.|css-[a-z0-9_-]+|·\s*\d).*$', '', 'i') ``` плюс `trim(both E' ,.\n\t')` = `strip(" ,.\n\t")` и `NULLIF(…,'')` = `return cleaned or None`. **Паритет проверен тем же кодом, а не по глазам**: 1123 сырых адреса выгружены с прода и прогнаны через живой `_clean_address` в боевом контейнере — `rows=1123 mismatches=0`. `_deglue_house_marker` в SQL не повторяется: замерено, что после резки хвоста слипшегося «29р-н» нет ни в одной из 1123 строк (0 совпадений `_DEGLUE_RE`). Районные хвосты (#1773) сохранены: резать по «·» можно только когда за ней **цифра**. 296 строк вида «улица Бебеля, 138 · р-н Железнодорожный» — 296 до, 296 после. ## Dry-run на проде (BEGIN … ROLLBACK, экзактным файлом миграции) `UPDATE 1123` · загрязнённых после — `0` · районных «·» — `296/296` · `psql_exit=0` | было | стало | |---|---| | `ул. Ткачей,17·5,0 · 4 отзыва` | `ул. Ткачей,17` | | `ул. Свердлова,32Б·4,2 · 5 отзывов` | `ул. Свердлова,32Б` | | `ул. Шаумяна,93·5,0 · 4 отзыва` | `ул. Шаумяна,93` | | `ул. Щорса,103·4,3 · 15 отзывов` | `ул. Щорса,103` | | `Уральская ул.,5·4,8 · 15 отзывов` | `Уральская ул.,5` | | `Селькоровская ул.,60·5,0 · 3 отзыва` | `Селькоровская ул.,60` | | `Волгоградская ул.,18·1 отзыв` | `Волгоградская ул.,18` | | `ул. Азина,22/2·4,6 · 17 отзывов` | `ул. Азина,22/2` | | `ул. 8 Марта,204Г/2·4,3 · 3 отзыва` | `ул. 8 Марта,204Г/2` | | `мкр-н Широкая Речка, ул. Анатолия Муранова,18·4,7 · 11 отзывов` | `мкр-н Широкая Речка, ул. Анатолия Муранова,18` | | `·3,1 · 11 отзывов` (id 10377315) | `NULL` | Последняя строка — единственная во всём наборе, где адрес состоял ИЗ рейтинга целиком. Парсер на ней возвращает `None`; миграция делает то же через `NULLIF`. Мусор в колонке хуже пустоты: NULL апсерт теперь дозаполняет (#2777), «·3,1 · 11 отзывов» — нет. ## Обратимость — без новой таблицы и колонки Прежнее значение уже хранится: `raw_payload->>'address'` пишется скрейпером на INSERT и **отсутствует** в `ON CONFLICT DO UPDATE SET` (проверено по `base.py`) — переживает любой свип. Замер: у **1123 из 1123** `raw_payload->>'address' = address` побайтово, NULL-ов нет. Откат-предикат (полный текст в шапке миграции) проверен в том же dry-run: - по всей таблице даёт ровно **1123** совпадения, все 1123 — наши; - `restored = before` побайтово у **1123 из 1123**; - 10 строк, где `raw_payload` грязный, а `address` уже перезаписан `avito_detail` полным «Свердловская обл., Первоуральск, …», он **не берёт** (0) — и это правильно. ## Геокодирование — отдельный вопрос, числа честные Выборка `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)`, GROUP BY (address, city). - Подберёт: **1122** из 1123 строк (854 уникальные пары); 1123-я — та, что стала NULL. - `geocode_tried_at = NULL` в миграции обязателен: метка стоит у 711 строк (у 370 — свежее 7 суток), но стоит она на СТАРОМ негеокодируемом тексте. Цена сброса измерена: до 19 лишних запросов Nominatim (19 пар из 854 имеют соседа с недавним отказом). - Очередь после миграции: 1938 строк / 1370 пар против 1569 / 1241 сейчас. Рост всего +369 строк, потому что **753 из 1123 уже стояли в очереди со своим грязным адресом** и жгли бюджет Nominatim впустую — этот расход миграция тоже снимает. - Ближайший прогон: `next_run_at = 2026-08-10 17:45 UTC`, окно 0-23, `batch_size=200`, `budget_sec=1800`. - **Гарантированный низ**: 138 из 854 пар уже лежат в `geocode_cache` с координатами и не истекли (ключ считан той же формулой, что строит `_cache_key`) → **245 строк** получат geom мгновенно. Остальное — как повезёт тирам (кадастровый FDW → Nominatim): последние 5 ночных прогонов дали 17-53% успеха на адрес. **Критерий приёмки, записанный ДО факта** (проверять 2026-08-11 утром): 1. сразу после деплоя — `SELECT count(*) FROM listings WHERE source='avito' AND address ~ '·\s*\d'` → **0**, откат-предикат → **1123**; 2. после прогона 2026-08-10 17:45 UTC — из 1122 вычищенных строк **≥ 245** имеют `lat IS NOT NULL` (жёсткий низ из кэша). Меньше 245 = геокодер не подобрал их, и это баг, а не «не повезло». ## Чего эта миграция НЕ делает, сознательно - не трогает `COALESCE` в апсерте (#2777); - не трогает 360 строк с `address IS NULL` — они лечатся сами (#2777). Замерено: пустых строк `''` среди них **0**, все именно NULL; - **не переносит координаты с соседних строк того же адреса.** Возможность есть — 789 из 1123 строк имеют соседа с координатами по тому же cleaned `address+city`. Но у 88 из 548 донорских пар соседи расходятся больше чем на 50 м, у 32 — больше 250 м, худший разброс **15 км**. Выбор победителя между ними — новая политика, а не бэкфилл. Если решение «переносить» будет принято — это отдельная миграция, числа готовы. Гейт `scripts/check-migration-lock-timeout.py`: `selftest OK` + `✓ блокирующий DDL прикрыт lock_timeout (проверено новых миграций: 4)`. Closes #2814
bot-backend added 1 commit 2026-08-10 10:16:45 +00:00
fix(tradein/db): разовая чистка 1123 адресов Авито с приклеенным рейтингом (#2814)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 13s
CI / changes (pull_request) Successful in 13s
CI Trade-In / browser-tests (pull_request) Has been skipped
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 4m8s
0a99555351
Парсер починен в #2815, но уже записанные строки сами не вылечатся: апсерт пишет
address = COALESCE(listings.address, EXCLUDED.address) — при конфликте адрес
осознанно не перезаписывается (#2777). Миграция 254 режет хвост «·<цифра>» по
ДОСЛОВНО парсерному правилу _NOT_ADDRESS_TAIL_RE.

Паритет проверен не по глазам: все 1123 сырых адреса выгружены с прода и
прогнаны через живой _clean_address в боевом контейнере tradein-scraper —
rows=1123 mismatches=0. Dry-run на проде (BEGIN…ROLLBACK) экзактным файлом:
UPDATE 1123, загрязнённых после 0, районные хвосты «· р-н Академический»
сохранены 296/296.

Обратимость без новой таблицы: прежнее значение уже лежит в
raw_payload->>'address' (пишется на INSERT, отсутствует в ON CONFLICT DO UPDATE
SET, совпадает с address побайтово у 1123 из 1123). Откат-предикат в шапке
миграции проверен в dry-run: 1123 совпадения по всей таблице, все наши,
восстановление побайтовое; 10 строк с грязным raw_payload и уже нормализованным
адресом он не берёт.

geocode_tried_at сбрасывается: метка backoff'а привязана к тексту адреса, а
текст сменился. После миграции 1122 строки (854 пары) попадают в выборку
geocode_missing_listings; 245 строк закрываются мгновенно из geocode_cache.
bot-backend merged commit 405d2f2eec into main 2026-08-10 10:22:34 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#2818
No description provided.