fix(tradein/yandex): 3535 source_url ведут на сайт застройщика — переписываем из offerId (#2838)
All checks were successful
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m7s

source_url пишется только при вставке: его нет ни в ON CONFLICT DO UPDATE, ни в
reconcile-UPDATE. Поэтому канонизация продюсера (#2235) вылечила лишь новые
строки, а миграция 164 — лишь те легаси, чей URL делили несколько строк. Строки
с уникальной ссылкой на карточку застройщика не попали никуда: парсер деталей
требует '/offer/<цифры>/' и отказывает им до обращения к HTML, а
yandex_address_backfill ходит по ним на чужие сайты.

Замер на проде 2026-08-12: 3535 строк, у всех числовой offerId в source_id,
0 с NULL-адресом, 3522 активны. Коллизий нового URL — 0 (и с существующими
строками, и внутри множества). Dry-run (BEGIN…ROLLBACK) тем же телом:
UPDATE 3535, непарсимых после 0, дублей source_url у yandex 0;
url_from_offer_id 3535 → 0; кандидаты yandex_address_backfill с непарсимым
URL 1777 → 0.

Форма адреса взята у продюсера (_canonical_source_url) через CANONICAL_URL_SQL,
тест держит сцепление. Прежние адреса сохранены поимённо: raw_payload ключа
'url' не содержит (0 из 3535), а listing_sources покрывает лишь 3529 и не
отделяется предикатом от 9 чужих строк — поэтому откат опирается на таблицу,
а не на догадку.
This commit is contained in:
bot-backend 2026-08-12 20:28:16 +05:00
parent 447fbbd3a5
commit 0a0b126fa3
3 changed files with 230 additions and 0 deletions

View file

@ -0,0 +1,150 @@
-- 257_listings_backfill_yandex_source_url.sql
-- Разовое лечение source_url у yandex-строк, чей адрес ведёт на сайт застройщика (#2838).
--
-- WHY. `source_url` пишется ТОЛЬКО при вставке: его нет ни в `ON CONFLICT DO UPDATE`,
-- ни в reconcile-UPDATE (`scraper_kit/base.py`). Поэтому починка продюсера (#2235,
-- `_canonical_source_url` в providers/yandex/serp.py) вылечила только НОВЫЕ строки,
-- а миграция 164 — только те легаси, чей URL ДЕЛИЛИ несколько строк (её CTE `shared`
-- искал дубли URL, а не непарсимость адреса). Строки с УНИКАЛЬНОЙ ссылкой на карточку
-- застройщика не попали ни туда, ни туда и носят адрес, замороженный в момент вставки.
-- Цена простоя: `YandexDetailScraper.parse` первым делом ищет в URL `/offer/<цифры>/`
-- и без него возвращает None ещё ДО обращения к HTML — такие строки не обогащаются
-- никогда, а `yandex_address_backfill` вдобавок ходит по ним на чужие сайты.
-- PR #2838 научил ОЧЕРЕДЬ адресовать их по source_id; колонку чинит эта миграция.
--
-- ЗАМЕР НА ПРОДЕ 2026-08-12 (SELECT-only, не «по описанию из issue»):
--
-- source='yandex' AND source_url !~ '/offer/[0-9]+' строк
-- ------------------------------------------------------- -----
-- всего 3535
-- из них source_id ~ '^[0-9]+$' (адрес восстановим) 3535
-- из них source_id NULL/нечисловой (нечем адресовать) 0
-- из них source_url IS NULL 0
-- из них is_active 3522
--
-- хосты: macroserver.ru 912, macro.sbercrm.com 440, akademicheskiy.org 356,
-- na100.pro 331, strana.com 318, ten-stroy.ru 189, ecologica.ru 167,
-- xn--b1agbiqxpe4gxa.xn--p1ai 145, sinara-development.ru 114,
-- ekaterinburg.razum.life 111, www.lsr.ru 81, samolet.ru 68, хвост.
--
-- Множество ЗАМКНУТО (важно: значит список ниже не устареет между PR и деплоем):
-- самая свежая его строка — id 2583989, после неё вставлено 6892 yandex-строк,
-- и НИ ОДНА в множество не попала — продюсер после #2235 таких адресов не пишет.
-- Множество может только уменьшаться (удаление строк), не расти.
--
-- ФОРМА АДРЕСА — ДОСЛОВНО ПРОДЮСЕРНАЯ, не изобретённая здесь.
-- scraper_kit/providers/yandex/serp.py::_canonical_source_url:
-- return f"https://realty.yandex.ru/offer/{offer_id}/" # ветка «url не ведёт на realty.yandex»
-- тот же литерал живёт в app/tasks/yandex_detail_backfill.py::CANONICAL_URL_SQL
-- "'https://realty.yandex.ru/offer/' || source_id || '/'"
-- и та же формула стоит в миграции 164. Ниже — она же, посимвольно;
-- tests/test_migration_257_yandex_source_url_backfill.py держит это сцепление
-- (сравнивает выражение из ЭТОГО файла с CANONICAL_URL_SQL, который, в свою
-- очередь, уже сверен с продюсером в test_yandex_detail_backfill.py).
-- Условия отбора — те же строковые константы OFFER_URL_PATTERN ('/offer/[0-9]+')
-- и OFFER_ID_PATTERN ('^[0-9]+$'), которыми очередь #2838 отбирает эти же строки.
--
-- КОЛЛИЗИЙ НЕТ — ПРОВЕРЕНО, А НЕ ЗАЯВЛЕНО:
-- * новый URL, уже занятый ДРУГОЙ строкой listings (любой источник): 0;
-- * два кандидата с одинаковым новым URL внутри самого множества: 0
-- (source_id уникален по constraint 133_listings_uq_source_source_id.sql);
-- * после UPDATE в dry-run дублей source_url среди ВСЕХ yandex-строк: 0.
--
-- DRY-RUN НА ПРОДЕ (BEGIN … ROLLBACK, 2026-08-12, тем же телом, что ниже):
-- UPDATE 3535 · осталось непарсимых 0 · дублей source_url у yandex 0
-- счётчики очереди #2838 после: url_from_offer_id 3535 → 0, unenrichable_pending 0
-- yandex_address_backfill (кандидаты 5545): с непарсимым URL 1777 → 0
--
-- было → стало (10 строк, взяты по id DESC):
-- 2583989 https://ekaterinburg.razum.life/flats/7228451 → .../offer/7087563582288224501/
-- 2583986 https://sinara-development.ru/#/macrocatalog/… → .../offer/6990986462977811151/
-- 2583940 https://www.an-nks.ru/catalog/38/4241/ → .../offer/7567121745684380093/
-- 2583938 https://ten-stroy.ru/parametric/osnovinskiye-… → .../offer/5227777077487552091/
-- 2583931 https://ekaterinburg.razum.life/flats/7225097 → .../offer/7087563582288131414/
-- 2583929 https://samolet.ru/ekaterinburg/project/payer/… → .../offer/1827858605736006765/
-- 2583923 https://samolet.ru/ekaterinburg/project/auruum/… → .../offer/2812449412758148821/
-- 2583917 http://na100.pro/go.php?link=uRy09YqcU9pqegrRc… → .../offer/895871493295352384/
-- 2583891 https://macroserver.ru/id/8783797/ → .../offer/6378643964567459685/
-- 2583878 https://strana.com/ekb/uralskij-sad/flats/14986370→ .../offer/6591508026346911121/
-- (префикс «стало» везде один: https://realty.yandex.ru/offer/<source_id>/)
-- Живая проба прод-трактом 2026-08-12 (тот же прокси, curl_cffi chrome120, тот же
-- parse) по таким восстановленным адресам: 6 из 6 — HTTP 200 и parse OK.
--
-- ОБРАТИМОСТЬ — ТАБЛИЦА, А НЕ ПРЕДИКАТ, И ВОТ ПОЧЕМУ (проверено, а не предположено).
-- Ход «прежнее значение уже где-то лежит» (как в 254, где им был
-- raw_payload->>'address') здесь НЕ работает:
-- * listings.raw_payload ключа 'url' НЕ содержит: 0 из 3535. Ключи там
-- ceiling_height, kitchen_area_m2, offer_id, page_param, raw_building_type,
-- site_name — адреса нет ни под одним именем;
-- * listings.house_url / newbuilding_url у всех 3535 = NULL;
-- * listing_sources.source_url (тоже insert-only: в его ON CONFLICT DO UPDATE
-- source_url отсутствует) хранит прежний адрес у 3529 из 3535 — но восстановить
-- ПО НЕМУ нельзя точно: самоидентифицирующий предикат «ls.source_url не
-- realty.yandex» берёт 4832 строки, из которых наши только 3529; сузив его
-- уникальностью URL, всё равно получаем 3529 наших + 9 чужих (это строки,
-- чей listings.source_url канонизировала ещё 164 — вернуть им URL застройщика
-- значило бы отменить чужую починку). Плюс 6 наших строк не покрыты вовсе
-- (у 3 нет строки в listing_sources, у 3 там уже канонический адрес).
-- Поэтому прежние значения сохраняются ЯВНО и поимённо — таблица ниже. Откат:
--
-- UPDATE listings l
-- SET source_url = b.old_source_url
-- FROM yandex_source_url_backfill_257 b
-- WHERE l.id = b.listing_id
-- AND l.source_url = 'https://realty.yandex.ru/offer/' || l.source_id || '/';
--
-- (второе условие — чтобы откат не затирал адрес, который к тому моменту записал
-- кто-то другой; повторный прогон отката безвреден). Таблица маленькая
-- (3535 строк) и одноразовая: когда откат больше не нужен, её можно просто
-- удалить — на приложение она не влияет, читателей у неё нет.
--
-- ЧЕГО ЭТА МИГРАЦИЯ НЕ ДЕЛАЕТ, СОЗНАТЕЛЬНО:
-- * не трогает `ON CONFLICT DO UPDATE` / reconcile в scraper_kit/base.py —
-- дописывание source_url в апсерт это отдельное решение (прецедент #2818: там
-- COALESCE в апсерте так же намеренно не трогали);
-- * не трогает listing_sources.source_url — читателей у колонки нет (grep по
-- app/: единственное обращение — тот самый INSERT), а в ней остаётся живая
-- история того, что отдал gate-API;
-- * не трогает строки с source_url IS NULL — их 0, а не «на всякий случай»
-- (`!~` на NULL даёт NULL, такие строки предикат и так не берёт);
-- * не гасит и не удаляет ни одной строки: меняется ровно одна колонка.
--
-- Dependencies: 002_core_tables.sql (listings), 133_listings_uq_source_source_id.sql
-- (уникальность source_id, на ней держится «коллизий 0»), 164 (та же формула).
-- Идемпотентность: по построению. Второй прогон видит 0 строк с непарсимым URL →
-- UPDATE и INSERT берут пустое множество; CREATE TABLE IF NOT EXISTS + ON CONFLICT
-- DO NOTHING делают повтор безопасным и при частичном откате.
-- lock_timeout: блокирующего DDL здесь нет (гейт check-migration-lock-timeout.py
-- про CREATE TABLE молчит), но UPDATE по «горячей» listings берёт ROW EXCLUSIVE, и
-- ждать его выдачи за чужой ACCESS EXCLUSIVE-сессией — ровно та очередь перед
-- приложением, из-за которой заведён #2752. Пусть лучше деплой упадёт громко.
BEGIN;
SET LOCAL lock_timeout = '5s';
CREATE TABLE IF NOT EXISTS yandex_source_url_backfill_257 (
listing_id bigint PRIMARY KEY,
old_source_url text NOT NULL,
changed_at timestamptz NOT NULL DEFAULT now()
);
COMMENT ON TABLE yandex_source_url_backfill_257 IS
'Прежние (застройщицкие) listings.source_url, переписанные миграцией 257 (#2838). '
'Только для отката; читателей в приложении нет, удаляется без последствий.';
INSERT INTO yandex_source_url_backfill_257 (listing_id, old_source_url)
SELECT id, source_url
FROM listings
WHERE source = 'yandex'
AND source_url !~ '/offer/[0-9]+'
AND source_id ~ '^[0-9]+$'
ON CONFLICT (listing_id) DO NOTHING;
UPDATE listings
SET source_url = 'https://realty.yandex.ru/offer/' || source_id || '/'
WHERE source = 'yandex'
AND source_url !~ '/offer/[0-9]+'
AND source_id ~ '^[0-9]+$';
COMMIT;

View file

@ -245,3 +245,4 @@
250_drop_duplicate_expires_at_index.sql 250_drop_duplicate_expires_at_index.sql
251_listings_drop_ceiling_height.sql 251_listings_drop_ceiling_height.sql
254_listings_backfill_avito_rating_glued_address.sql 254_listings_backfill_avito_rating_glued_address.sql
257_listings_backfill_yandex_source_url.sql

View file

@ -0,0 +1,79 @@
"""Static guards for migration 257 (#2838): лечение yandex source_url.
Живой БД у юнит-теста нет, поэтому проверяем то, что ломается тихо и навсегда:
1. Форма адреса не разъехалась с продюсером. В файле стоит ДОСЛОВНО
`CANONICAL_URL_SQL` из app/tasks/yandex_detail_backfill.py, а тот уже сверен с
`_canonical_source_url` продюсера в test_yandex_detail_backfill.py. Сцепление
транзитивное: поменяют форму у продюсера красным станет тот тест, поменяют в
миграции этот.
2. Отбор теми же константами, что у очереди (#2838), а не «похожими» regexp'ами.
3. Бэкап прежних значений пишется ДО UPDATE. Переставь местами таблица отката
наполнится уже канонизированными адресами, и откат станет no-op'ом, который
выглядит рабочим.
4. Недеструктивность и транзакционность: одна колонка, BEGIN/COMMIT, lock_timeout.
"""
from __future__ import annotations
import re
from pathlib import Path
from app.tasks.yandex_detail_backfill import (
CANONICAL_URL_SQL,
OFFER_ID_PATTERN,
OFFER_URL_PATTERN,
)
_MIGRATION = (
Path(__file__).resolve().parents[1]
/ "data"
/ "sql"
/ "257_listings_backfill_yandex_source_url.sql"
)
def _executable_sql() -> str:
"""SQL без `--`-комментариев: шапка объясняет прозой и упоминает всё подряд."""
lines = [raw.split("--", 1)[0] for raw in _MIGRATION.read_text(encoding="utf-8").splitlines()]
return "\n".join(line for line in lines if line.strip())
def _flat(text: str) -> str:
return re.sub(r"\s+", " ", text).strip()
def test_migration_exists() -> None:
assert _MIGRATION.exists(), f"missing migration: {_MIGRATION}"
def test_canonical_url_matches_producer_formula() -> None:
"""Выражение UPDATE — тот же литерал, что CANONICAL_URL_SQL (и, значит, продюсер)."""
assert _flat(CANONICAL_URL_SQL) in _flat(_executable_sql())
def test_selects_rows_by_the_same_constants_as_the_queue() -> None:
"""Отбор — константами очереди #2838, иначе миграция чинит не то множество."""
sql = _executable_sql()
assert f"'{OFFER_URL_PATTERN}'" in sql
assert f"'{OFFER_ID_PATTERN}'" in sql
assert "source = 'yandex'" in sql
def test_backup_is_written_before_the_update() -> None:
sql = _executable_sql()
assert sql.index("INSERT INTO yandex_source_url_backfill_257") < sql.index("UPDATE listings")
def test_transactional_and_lock_timeout_after_begin() -> None:
sql = _executable_sql()
assert "BEGIN;" in sql and "COMMIT;" in sql
assert sql.index("BEGIN;") < sql.index("SET LOCAL lock_timeout")
assert sql.index("SET LOCAL lock_timeout") < sql.index("UPDATE listings")
def test_touches_only_source_url_and_deletes_nothing() -> None:
sql = _executable_sql().upper()
for forbidden in ("DELETE", "DROP", "TRUNCATE", "IS_ACTIVE"):
assert forbidden not in sql, forbidden
assert sql.count("SET SOURCE_URL") == 1