fix(tradein/data): yandex url-«дубли» — каноникализация URL (скрейпер + миграция 164) + manifest 162 (#2235, #2216) #2271

Merged
lekss361 merged 2 commits from fix/tradein-yandex-url-dedup into main 2026-07-03 06:53:27 +00:00
7 changed files with 300 additions and 11 deletions

View file

@ -236,7 +236,8 @@ def _parse_gate_json(
Verified field mapping (from .issdump/yandex_gate_api_sample.json + live prod 2026-06-17):
offerId -> source_id
url (//realty.yandex.ru/offer/) -> source_url (prepend "https:")
url (//realty.yandex.ru/offer/) -> source_url (canonical per-offer, #2235:
partner/redirect url -> offer_id-based)
price.value -> price_rub
price.valuePerPart -> price_per_m2
area.value -> area_m2
@ -275,6 +276,23 @@ def _parse_gate_json(
return lots
def _canonical_source_url(url_raw: str, offer_id: str) -> str:
"""Каноничный per-offer source_url для yandex-оффера (#2235).
gate-API `url` для вторички = `//realty.yandex.ru/offer/<id>` (уникален на
оффер). Для новостроек/partner-офферов там НЕканоническая ссылка на сайт
застройщика или рекламный редирект (na100.pro/go.php, atlasdevelopment.su, ),
которую делят МНОГО разных квартир одного ЖК source_url перестаёт быть
уникальным (204 «дубля» среди is_active). Возвращаем стабильный уникальный
URL из offer_id, если исходный не ведёт на realty.yandex; иначе исходный.
"""
source_url = f"https:{url_raw}" if url_raw.startswith("//") else url_raw
is_yandex_realty = "realty.yandex" in source_url or "realty.ya.ru" in source_url
if not source_url or not is_yandex_realty:
return f"https://realty.yandex.ru/offer/{offer_id}/"
return source_url
def _entity_to_lot(
entity: dict[str, Any], page_param: int = 1, new_flat: str = "NO"
) -> ScrapedLot | None:
@ -284,10 +302,14 @@ def _entity_to_lot(
if not offer_id:
return None
url_raw = entity.get("url") or ""
source_url = f"https:{url_raw}" if url_raw.startswith("//") else url_raw
if not source_url:
source_url = f"https://realty.yandex.ru/offer/{offer_id}/"
# #2235: для новостроек gate-API кладёт в `url` НЕканоническую ссылку —
# сайт застройщика / рекламный редирект (na100.pro/go.php, atlasdevelopment.su,
# unistroyrf.ru, …). Много разных офферов одного ЖК делят ОДНУ такую страницу
# → 204 «дубля» source_url среди is_active (разные source_id, разные квартиры).
# Канонизируем: если `url` не ведёт на realty.yandex — строим стабильный
# per-offer URL из offer_id (он всегда есть, см. проверку выше). Вторичный
# //realty.yandex.ru/offer/<id> оставляем как есть.
source_url = _canonical_source_url(entity.get("url") or "", offer_id)
price_dict = entity.get("price") or {}
price_val = price_dict.get("value")

View file

@ -0,0 +1,88 @@
-- 164_yandex_url_canonicalize_active_dups.sql
-- Issue #2235 — yandex: 204 «активных URL-дубля» среди is_active строк listings
-- (один source_url делит >1 активной строки).
--
-- ROOT CAUSE (проверено на локальной БД tradein 2026-07-03, SELECT-only):
-- Все 204 группы дублей — yandex listing_segment='novostroyki', суммарно 1253
-- активные строки. В КАЖДОЙ группе source_id РАЗНЫЕ (nid=n, 0 NULL), цены/квартиры
-- разные — это ГЕНУИННО РАЗНЫЕ офферы, а НЕ дубли одной строки. dedup_hash =
-- sha256(source|source_id) уникален на каждый оффер, row-level дедуп работает
-- корректно. Дублируется ТОЛЬКО значение source_url.
--
-- Причина: для новостроек gate-API yandex возвращает в поле `url` НЕканоническую
-- ссылку — сайт застройщика / рекламный редирект (na100.pro/go.php?link=…, 732
-- строки; atlasdevelopment.su/ — 279 на 1 URL; unistroyrf.ru/ — 127 на 1;
-- vtmn.ru/complexes/… — 74 на 1; и т.п.). _entity_to_lot клал этот partner-URL
-- в source_url как есть. МНОГО разных квартир одного ЖК делят ОДНУ посадочную
-- страницу → 204 общих source_url. Вторичка (4193 строки) несёт корректный
-- //realty.yandex.ru/offer/<id> и дублей не даёт.
--
-- ПОЧЕМУ НЕ «СХЛОПЫВАНИЕ» (вариант из issue отклонён по данным):
-- Схлопнуть группу на свежайшую строку и погасить остальные (is_active=false)
-- было бы ДЕСТРУКТИВНО: удалило бы ~1049 реальных активных новостроечных
-- офферов из инвентаря (coverage/estimator/UI). Это не дубли-строки, а разные
-- объекты. Вместо этого КАНОНИЗИРУЕМ source_url: каждому офферу его собственный
-- стабильный per-offer URL из source_id → «дубль URL» исчезает БЕЗ потери строк.
--
-- ПОЧЕМУ НЕ partial UNIQUE index ON (source, source_url) WHERE is_active
-- (вариант Б из issue — уникальность в ETL, индекс НЕ добавляем):
-- 1) Конфликт-арбитр апсерта save_listings — dedup_hash (sha256(source|source_id)),
-- НЕ source_url; reconcile-SAVEPOINT ловит только UniqueViolation по
-- (source, source_id). UNIQUE по (source, source_url) фаерил бы на INSERT
-- ВТОРОГО распознанного оффера того же ЖК с общей posadka-страницей — путь
-- апсерта его НЕ реконсайлит → падение всего скрейп-лота.
-- (app/services/scrapers/base.py:455 ON CONFLICT (dedup_hash);
-- reconcile UPDATE by (source, source_id) на base.py:564-628.)
-- 2) Разные офферы легитимно могут делить не-yandex URL — индекс запрещал бы
-- это как «дубль», хотя канонический ключ оффера = source_id (constraint
-- 133_listings_uq_source_source_id.sql), а не URL.
-- Инвариант вместо индекса: канонизация URL в yandex-скрейпере
-- (app/services/scrapers/yandex_realty.py + scraper-kit serp.py) + юнит-тест.
--
-- WHAT (эта миграция):
-- Для каждой активной yandex-строки, чей source_url делится >1 строкой
-- (искомые «дубли»), переписываем source_url на канонический
-- 'https://realty.yandex.ru/offer/' || source_id || '/'. Строки НЕ гасим —
-- остаются is_active=true. source_id внутри группы различны (проверено) и
-- глобально уникальны (constraint 133) → канонические URL различны → группа
-- рассыпается, дублей не остаётся. Коллизий с существующими активными
-- realty.yandex-URL нет (проверено: 0).
--
-- IDEMPOTENCY / SAFETY (важно для прода — deploy-tradein.yml применяет строго,
-- ON_ERROR_STOP):
-- - BEGIN/COMMIT — атомарно.
-- - Non-destructive: меняется ТОЛЬКО значение source_url, is_active не трогаем.
-- - Повторный прогон = no-op: после первого раза каждый оффер несёт свой
-- уникальный канонический URL → CTE `shared` (source_url с count>1) пуст →
-- UPDATE матчит 0 строк. Плюс страховочный guard `source_url <> <canonical>`
-- пропускает уже-канонические строки.
-- - Только source_id ~ '^[0-9]+$' (числовой yandex offerId) переписывается;
-- source_id IS NULL/нечисловой не трогаем.
--
-- Ожидаемый объём ~1253 строки (быстрый single UPDATE, is_active индексируем).
--
-- DEPENDENCIES: listings (source, source_url, source_id, is_active).
BEGIN;
WITH shared AS (
-- Активные yandex source_url, которые делит >1 строка = искомые «дубли».
SELECT source_url
FROM listings
WHERE is_active
AND source = 'yandex'
AND source_url IS NOT NULL
GROUP BY source_url
HAVING count(*) > 1
)
UPDATE listings l
SET source_url = 'https://realty.yandex.ru/offer/' || l.source_id || '/'
FROM shared s
WHERE l.is_active
AND l.source = 'yandex'
AND l.source_url = s.source_url
AND l.source_id IS NOT NULL
AND l.source_id ~ '^[0-9]+$'
AND l.source_url <> 'https://realty.yandex.ru/offer/' || l.source_id || '/';
COMMIT;

View file

@ -166,4 +166,6 @@
159_houses_fias_idx.sql
160_seed_deactivate_stale_domklik_n1.sql
161_backfill_scraped_at_active_recent.sql
162_seed_deals_freshness_monitor.sql
163_disable_deactivate_stale_domklik.sql
164_yandex_url_canonicalize_active_dups.sql

View file

@ -0,0 +1,83 @@
"""Static guards for migration 164 (issue #2235).
Прод применяет data/sql построчно строго (ON_ERROR_STOP). Полный DB-прогон
идемпотентности требует живой БД; здесь мы фиксируем структурные инварианты,
которые ГАРАНТИРУЮТ идемпотентность и НЕдеструктивность по построению:
1. Транзакционность (BEGIN/COMMIT) атомарный апдейт.
2. Идемпотентность: апдейт ограничен строками, чей source_url ещё НЕ канонический
(`source_url <> '…offer/'||source_id||'/'`) И входит в группу с >1 строкой
(CTE `shared`, HAVING count(*)>1). После первого прогона таких строк нет
повторный прогон = no-op.
3. НЕдеструктивность: миграция канонизирует source_url, а НЕ гасит строки
`is_active = false` в файле быть НЕ должно (защита от регресса к
деструктивному «схлопыванию», отвергнутому по данным: дубли это РАЗНЫЕ
офферы, не дубли-строки).
4. Скоуп только yandex.
"""
from __future__ import annotations
import re
from pathlib import Path
_SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql"
_MIGRATION_164 = _SQL_DIR / "164_yandex_url_canonicalize_active_dups.sql"
def _sql() -> str:
return _MIGRATION_164.read_text(encoding="utf-8")
def _executable_sql() -> str:
"""SQL без комментариев — только исполняемые строки (`--`-комменты вырезаны).
Заголовок миграции в прозе объясняет, ПОЧЕМУ отвергнуто «схлопывание»
(is_active=false), поэтому destructive-проверки должны смотреть на код, а не
на комментарии. В этой миграции `--` не встречается внутри строковых литералов.
"""
lines = []
for raw in _MIGRATION_164.read_text(encoding="utf-8").splitlines():
code = raw.split("--", 1)[0]
if code.strip():
lines.append(code)
return "\n".join(lines)
def _flat(text: str) -> str:
return re.sub(r"\s+", " ", text).strip()
def test_migration_164_exists() -> None:
assert _MIGRATION_164.exists(), f"missing migration: {_MIGRATION_164}"
def test_migration_164_is_transactional() -> None:
sql = _sql()
assert "BEGIN;" in sql
assert "COMMIT;" in sql
def test_migration_164_is_idempotent_by_construction() -> None:
"""Апдейт исключает уже-канонические строки → повторный прогон no-op."""
flat = _flat(_executable_sql())
# Группировка дублей (HAVING count(*) > 1) + пропуск уже-канонических строк.
assert "having count(*) > 1" in flat.lower()
assert "source_url <> 'https://realty.yandex.ru/offer/'" in flat
def test_migration_164_is_non_destructive() -> None:
"""Канонизация source_url, НЕ гашение строк: никакого is_active=false в коде."""
flat = _flat(_executable_sql()).lower()
assert "is_active = false" not in flat
assert "is_active=false" not in flat
# Мутируется именно source_url.
assert "set source_url = 'https://realty.yandex.ru/offer/'" in flat
def test_migration_164_scoped_to_yandex_active() -> None:
flat = _flat(_executable_sql())
assert "l.source = 'yandex'" in flat
assert "l.is_active" in flat
# Только числовой yandex offerId идёт в канонический URL.
assert "l.source_id ~ '^[0-9]+$'" in flat

View file

@ -162,7 +162,31 @@ _ENTITY_RICH_AGENCY: dict = {
"mainImages": [],
}
_ALL_ENTITIES = [_ENTITY_FULL, _ENTITY_STUDIO, _ENTITY_RICH_OWNER, _ENTITY_RICH_AGENCY]
# #2235: novostroyka offer whose gate-API `url` is a developer/redirect landing
# (shared by many flats). Both parsers must canonicalise it to offer_id-based url.
_ENTITY_PARTNER_URL: dict = {
"offerId": 7416316697684447350,
"url": "http://na100.pro/go.php?link=LtWgA6kWd3xjlrUBCLl6VGjo",
"price": {"value": 7790000, "valuePerPart": 130000},
"area": {"value": 60.0},
"roomsTotal": 2,
"floorsOffered": [5],
"floorsTotal": 16,
"building": {"builtYear": 2024, "buildingType": "MONOLIT", "siteId": 999001},
"location": {
"geocoderAddress": "Ekaterinburg, ulitsa Titova, 1",
"point": {"latitude": 56.8, "longitude": 60.6},
},
"mainImages": [],
}
_ALL_ENTITIES = [
_ENTITY_FULL,
_ENTITY_STUDIO,
_ENTITY_RICH_OWNER,
_ENTITY_RICH_AGENCY,
_ENTITY_PARTNER_URL,
]
def _make_gate_payload(entities: list, pager: dict | None = None) -> dict:

View file

@ -16,6 +16,7 @@ from app.services.scrapers.yandex_realty import (
DEFAULT_PRICE_RANGES,
ROOM_PATH,
YandexRealtyScraper,
_canonical_source_url,
_combo_label,
_CurlResponse,
_entity_to_lot,
@ -324,6 +325,58 @@ def test_entity_to_lot_url_fallback_when_missing():
assert lot.source_url == f"https://realty.yandex.ru/offer/{expected_id}/"
# ---------------------------------------------------------------------------
# #2235: source_url canonicalisation — partner/redirect urls of novostroyki
# offers must NOT be stored verbatim (many flats of one ЖК share one url →
# duplicate active source_url). They must become offer_id-based canonical urls.
# ---------------------------------------------------------------------------
def test_canonical_source_url_keeps_yandex_realty_url():
"""A real //realty.yandex.ru/offer/<id> url is preserved verbatim (vtorichka)."""
assert (
_canonical_source_url("//realty.yandex.ru/offer/123", "123")
== "https://realty.yandex.ru/offer/123"
)
@pytest.mark.parametrize(
"partner_url",
[
"http://na100.pro/go.php?link=LtWgA6kWd3xjlrUBCLl6VGjo", # ad redirect
"https://atlasdevelopment.su/", # ЖК landing shared by many flats
"https://unistroyrf.ru/",
"https://vtmn.ru/complexes/vitamin-kvartal-na-titova",
"", # missing url
],
)
def test_canonical_source_url_rewrites_partner_urls(partner_url):
"""Non-realty.yandex partner/redirect urls become offer_id-based canonical urls."""
assert (
_canonical_source_url(partner_url, "777888999")
== "https://realty.yandex.ru/offer/777888999/"
)
def test_entity_to_lot_novostroyka_partner_url_is_canonicalised():
"""Two distinct offers sharing one developer url get DISTINCT canonical source_urls.
Reproduces the #2235 root cause: the gate-API `url` for novostroyki points to
the developer site (shared by many flats). Verbatim storage produced 204
active source_url duplicates. After canonicalisation each offer_id yields its
own unique url, so the duplication disappears at the source.
"""
shared = "http://na100.pro/go.php?link=LtWgA6kWd3xjlrUBCLl6VGjo"
ent_a = {**_ENTITY_FULL, "offerId": 1111111111111111111, "url": shared}
ent_b = {**_ENTITY_FULL, "offerId": 2222222222222222222, "url": shared}
lot_a = _entity_to_lot(ent_a, new_flat="YES")
lot_b = _entity_to_lot(ent_b, new_flat="YES")
assert lot_a is not None and lot_b is not None
assert lot_a.source_url == "https://realty.yandex.ru/offer/1111111111111111111/"
assert lot_b.source_url == "https://realty.yandex.ru/offer/2222222222222222222/"
assert lot_a.source_url != lot_b.source_url
def test_entity_to_lot_no_house_when_site_id_absent():
"""Entity without siteId produces house_source=None, house_ext_id=None."""
entity = dict(_ENTITY_FULL)

View file

@ -255,7 +255,8 @@ def _parse_gate_json(
Verified field mapping (from .issdump/yandex_gate_api_sample.json + live prod 2026-06-17):
offerId -> source_id
url (//realty.yandex.ru/offer/) -> source_url (prepend "https:")
url (//realty.yandex.ru/offer/) -> source_url (canonical per-offer, #2235:
partner/redirect url -> offer_id-based)
price.value -> price_rub
price.valuePerPart -> price_per_m2
area.value -> area_m2
@ -294,6 +295,23 @@ def _parse_gate_json(
return lots
def _canonical_source_url(url_raw: str, offer_id: str) -> str:
"""Каноничный per-offer source_url для yandex-оффера (#2235).
gate-API `url` для вторички = `//realty.yandex.ru/offer/<id>` (уникален на
оффер). Для новостроек/partner-офферов там НЕканоническая ссылка на сайт
застройщика или рекламный редирект (na100.pro/go.php, atlasdevelopment.su, ),
которую делят МНОГО разных квартир одного ЖК source_url перестаёт быть
уникальным (204 «дубля» среди is_active). Возвращаем стабильный уникальный
URL из offer_id, если исходный не ведёт на realty.yandex; иначе исходный.
"""
source_url = f"https:{url_raw}" if url_raw.startswith("//") else url_raw
is_yandex_realty = "realty.yandex" in source_url or "realty.ya.ru" in source_url
if not source_url or not is_yandex_realty:
return f"https://realty.yandex.ru/offer/{offer_id}/"
return source_url
def _entity_to_lot(
entity: dict[str, Any], page_param: int = 1, new_flat: str = "NO"
) -> ScrapedLot | None:
@ -303,10 +321,9 @@ def _entity_to_lot(
if not offer_id:
return None
url_raw = entity.get("url") or ""
source_url = f"https:{url_raw}" if url_raw.startswith("//") else url_raw
if not source_url:
source_url = f"https://realty.yandex.ru/offer/{offer_id}/"
# #2235: канонизируем source_url — partner/redirect url новостроек делят
# много офферов одного ЖК → дубли source_url. См. _canonical_source_url.
source_url = _canonical_source_url(entity.get("url") or "", offer_id)
price_dict = entity.get("price") or {}
price_val = price_dict.get("value")