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
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:
parent
447fbbd3a5
commit
0a0b126fa3
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
|
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
|
||||||
|
|
|
||||||
|
|
@ -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