fix(tradein/yandex): 3535 source_url ведут на сайт застройщика — переписываем из offerId (#2840)
All checks were successful
Deploy Trade-In / changes (push) Successful in 13s
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 3m17s
Deploy Trade-In / build-backend (push) Successful in 30s
Deploy Trade-In / deploy (push) Successful in 2m14s
All checks were successful
Deploy Trade-In / changes (push) Successful in 13s
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 3m17s
Deploy Trade-In / build-backend (push) Successful in 30s
Deploy Trade-In / deploy (push) Successful in 2m14s
This commit is contained in:
parent
447fbbd3a5
commit
b5f3366577
3 changed files with 230 additions and 0 deletions
|
|
@ -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;
|
||||
|
|
@ -245,3 +245,4 @@
|
|||
250_drop_duplicate_expires_at_index.sql
|
||||
251_listings_drop_ceiling_height.sql
|
||||
254_listings_backfill_avito_rating_glued_address.sql
|
||||
257_listings_backfill_yandex_source_url.sql
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Loading…
Add table
Reference in a new issue