Merge pull request 'fix(tradein/data): yandex url-«дубли» — каноникализация URL (скрейпер + миграция 164) + manifest 162 (#2235, #2216)' (#2271) from fix/tradein-yandex-url-dedup into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
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 1m38s
Deploy Trade-In / build-backend (push) Successful in 1m44s
Deploy Trade-In / deploy (push) Successful in 3m3s
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
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 1m38s
Deploy Trade-In / build-backend (push) Successful in 1m44s
Deploy Trade-In / deploy (push) Successful in 3m3s
This commit is contained in:
commit
bbe596747d
7 changed files with 300 additions and 11 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue