diff --git a/tradein-mvp/backend/app/services/scrapers/yandex_realty.py b/tradein-mvp/backend/app/services/scrapers/yandex_realty.py index a2344ff9..640a97f2 100644 --- a/tradein-mvp/backend/app/services/scrapers/yandex_realty.py +++ b/tradein-mvp/backend/app/services/scrapers/yandex_realty.py @@ -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/` (уникален на + оффер). Для новостроек/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/ оставляем как есть. + source_url = _canonical_source_url(entity.get("url") or "", offer_id) price_dict = entity.get("price") or {} price_val = price_dict.get("value") diff --git a/tradein-mvp/backend/data/sql/164_yandex_url_canonicalize_active_dups.sql b/tradein-mvp/backend/data/sql/164_yandex_url_canonicalize_active_dups.sql new file mode 100644 index 00000000..46850fb2 --- /dev/null +++ b/tradein-mvp/backend/data/sql/164_yandex_url_canonicalize_active_dups.sql @@ -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/ и дублей не даёт. +-- +-- ПОЧЕМУ НЕ «СХЛОПЫВАНИЕ» (вариант из 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 <> ` +-- пропускает уже-канонические строки. +-- - Только 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; diff --git a/tradein-mvp/backend/data/sql/_manifest_applied.txt b/tradein-mvp/backend/data/sql/_manifest_applied.txt index c132bc40..c44f42a6 100644 --- a/tradein-mvp/backend/data/sql/_manifest_applied.txt +++ b/tradein-mvp/backend/data/sql/_manifest_applied.txt @@ -167,3 +167,4 @@ 160_seed_deactivate_stale_domklik_n1.sql 161_backfill_scraped_at_active_recent.sql 163_disable_deactivate_stale_domklik.sql +164_yandex_url_canonicalize_active_dups.sql diff --git a/tradein-mvp/backend/tests/test_migration_164_yandex_url_canonicalize.py b/tradein-mvp/backend/tests/test_migration_164_yandex_url_canonicalize.py new file mode 100644 index 00000000..b200de8e --- /dev/null +++ b/tradein-mvp/backend/tests/test_migration_164_yandex_url_canonicalize.py @@ -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 diff --git a/tradein-mvp/backend/tests/test_scraper_kit_yandex_golden_parity.py b/tradein-mvp/backend/tests/test_scraper_kit_yandex_golden_parity.py index 561337c3..244878d8 100644 --- a/tradein-mvp/backend/tests/test_scraper_kit_yandex_golden_parity.py +++ b/tradein-mvp/backend/tests/test_scraper_kit_yandex_golden_parity.py @@ -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: diff --git a/tradein-mvp/backend/tests/test_yandex_realty_serp.py b/tradein-mvp/backend/tests/test_yandex_realty_serp.py index 87d8a37c..1fdf0d40 100644 --- a/tradein-mvp/backend/tests/test_yandex_realty_serp.py +++ b/tradein-mvp/backend/tests/test_yandex_realty_serp.py @@ -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/ 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) diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/yandex/serp.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/yandex/serp.py index e93b4985..62b7e3e8 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/yandex/serp.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/yandex/serp.py @@ -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/` (уникален на + оффер). Для новостроек/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")