fix(cian): recover new-build house via cadastral anchor + listings (source,source_id) dedup (#1773) (#1912)
Some checks failed
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 1m28s
Deploy Trade-In / deploy (push) Failing after 46s
Deploy Trade-In / build-backend (push) Successful in 50s
Some checks failed
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 1m28s
Deploy Trade-In / deploy (push) Failing after 46s
Deploy Trade-In / build-backend (push) Successful in 50s
cian new-build cadastral anchor + listings (source,source_id) dedup (#1773): migration 133 (deep-reviewed data-loss-safe). Refs #1773
This commit is contained in:
parent
3f1925052b
commit
1d5b081cb4
4 changed files with 481 additions and 8 deletions
|
|
@ -355,6 +355,13 @@ def save_listings(
|
||||||
:geo_precision, :card_hash,
|
:geo_precision, :card_hash,
|
||||||
NOW(), NOW()
|
NOW(), NOW()
|
||||||
)
|
)
|
||||||
|
-- Конфликт-арбитр — dedup_hash (sha256(source|source_id)).
|
||||||
|
-- Для одного (source, source_id) формула даёт ОДИН dedup_hash,
|
||||||
|
-- поэтому этот апсерт уже не вставит второй раз → согласован с
|
||||||
|
-- частичным UNIQUE (source, source_id) WHERE source_id IS NOT NULL
|
||||||
|
-- (133_listings_uq_source_source_id.sql). Менять арбитр на
|
||||||
|
-- (source, source_id) НЕЛЬЗЯ: yandex/url-only дают source_id=NULL,
|
||||||
|
-- а NULL не годится как conflict-target → дубли вернулись бы (#1773).
|
||||||
ON CONFLICT (dedup_hash) DO UPDATE
|
ON CONFLICT (dedup_hash) DO UPDATE
|
||||||
SET last_seen_at = NOW(),
|
SET last_seen_at = NOW(),
|
||||||
is_active = true,
|
is_active = true,
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ import hashlib
|
||||||
import inspect
|
import inspect
|
||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
|
import re
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
@ -839,8 +840,21 @@ class CianScraper(BaseScraper):
|
||||||
lat = lon = None
|
lat = lon = None
|
||||||
|
|
||||||
# ── Адрес из geo.address[] ───────────────────────────────────────
|
# ── Адрес из geo.address[] ───────────────────────────────────────
|
||||||
|
# newbuilding нужен ДО _format_address: для ЖК geo.address[] часто без
|
||||||
|
# части {type:"house"} → дом-номер восстанавливаем из building/кадастра/
|
||||||
|
# названия ЖК (#1773), иначе все корпуса схлопываются в house-less
|
||||||
|
# catch-all и same-building anchor рвётся.
|
||||||
address_parts = geo.get("address") or []
|
address_parts = geo.get("address") or []
|
||||||
address = _format_address(address_parts)
|
newbuilding: dict[str, Any] = offer.get("newbuilding") or {}
|
||||||
|
nb_id = newbuilding.get("id")
|
||||||
|
is_newbuilding = bool(nb_id) and int(nb_id) > 0
|
||||||
|
address = _format_address(
|
||||||
|
address_parts,
|
||||||
|
building=building,
|
||||||
|
building_cadastral_number=building_cadastral_number,
|
||||||
|
newbuilding=newbuilding,
|
||||||
|
is_newbuilding=is_newbuilding,
|
||||||
|
)
|
||||||
|
|
||||||
# ── Metro stations ───────────────────────────────────────────────
|
# ── Metro stations ───────────────────────────────────────────────
|
||||||
undergrounds: list[dict[str, Any]] = geo.get("undergrounds") or []
|
undergrounds: list[dict[str, Any]] = geo.get("undergrounds") or []
|
||||||
|
|
@ -858,16 +872,24 @@ class CianScraper(BaseScraper):
|
||||||
]
|
]
|
||||||
|
|
||||||
# ── Newbuilding link ─────────────────────────────────────────────
|
# ── Newbuilding link ─────────────────────────────────────────────
|
||||||
newbuilding: dict[str, Any] = offer.get("newbuilding") or {}
|
# newbuilding / nb_id / is_newbuilding уже вычислены выше (до адреса).
|
||||||
nb_id = newbuilding.get("id")
|
|
||||||
# id == 0 — sentinel для вторички (per Schema sec 20.5)
|
# id == 0 — sentinel для вторички (per Schema sec 20.5)
|
||||||
house_source: str | None = None
|
house_source: str | None = None
|
||||||
house_ext_id: str | None = None
|
house_ext_id: str | None = None
|
||||||
listing_segment: str | None = None
|
listing_segment: str | None = None
|
||||||
|
|
||||||
if nb_id and int(nb_id) > 0:
|
if is_newbuilding:
|
||||||
house_source = "cian_newbuilding"
|
# Предпочитаем кадастр ДОМА как same-building anchor (#1773): nb_id
|
||||||
house_ext_id = str(nb_id)
|
# группирует по ЖК целиком, а один ЖК — это несколько корпусов
|
||||||
|
# (6679/11110/7019). buildingCadastralNumber стабильно идентифицирует
|
||||||
|
# ФИЗИЧЕСКИЙ дом → Tier 0 cadastr_exact в match_or_create_house
|
||||||
|
# схлопывает раздробленные корпуса. nb_id оставляем fallback'ом.
|
||||||
|
if building_cadastral_number:
|
||||||
|
house_source = "cian_building_cad"
|
||||||
|
house_ext_id = building_cadastral_number
|
||||||
|
else:
|
||||||
|
house_source = "cian_newbuilding"
|
||||||
|
house_ext_id = str(nb_id)
|
||||||
listing_segment = "novostroyki"
|
listing_segment = "novostroyki"
|
||||||
else:
|
else:
|
||||||
house_source = "cian"
|
house_source = "cian"
|
||||||
|
|
@ -1020,27 +1042,113 @@ class CianScraper(BaseScraper):
|
||||||
|
|
||||||
# ── Address formatter ────────────────────────────────────────────────────────
|
# ── Address formatter ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Дом-токен в хвосте строки: «29», «29А», «12 к1», «5/2», «10 стр.3», «1С7».
|
||||||
|
# Якорим в конец строки (\Z) — берём именно завершающий номер, не цифры из
|
||||||
|
# названия ЖК («Суходольский квартал»). Не матчим «р-н», «мкр» и пр. слова.
|
||||||
|
_HOUSE_TOKEN_RE = re.compile(
|
||||||
|
r"(\d+[А-Яа-яA-Za-z]?" # базовый номер: 29 / 29А / 1С
|
||||||
|
r"(?:\s*(?:к|корп\.?|стр\.?|с)\s*\d+[А-Яа-яA-Za-z]?)?" # корпус/строение
|
||||||
|
r"(?:\s*/\s*\d+[А-Яа-яA-Za-z]?)?)" # дробь 5/2
|
||||||
|
r"\s*\Z"
|
||||||
|
)
|
||||||
|
|
||||||
def _format_address(address_parts: list[dict[str, Any]]) -> str:
|
|
||||||
|
def _recover_newbuilding_house(
|
||||||
|
*,
|
||||||
|
building: dict[str, Any] | None,
|
||||||
|
building_cadastral_number: str | None,
|
||||||
|
newbuilding: dict[str, Any] | None,
|
||||||
|
) -> str | None:
|
||||||
|
"""Восстановить дом-токен для новостройки, когда geo.address[] без {type:"house"}.
|
||||||
|
|
||||||
|
Приоритет (#1773):
|
||||||
|
(a) building.houseNumber / building.address — если SERP-state их отдал;
|
||||||
|
(b) building_cadastral_number — стабильный same-building anchor: если ни
|
||||||
|
номер, ни название не дали дом, добавляем «кад. 66:41:...:11396» как
|
||||||
|
ключ-якорь, чтобы раздробленные корпуса схлопывались по кадастру;
|
||||||
|
(c) хвостовой дом-токен из newbuilding.name («… 29» / «… 29А к1»).
|
||||||
|
|
||||||
|
НЕ выдумываем catch-all номер: если ничего из (a)/(b)/(c) не нашли — None.
|
||||||
|
Кадастр (b) предпочтительнее как house anchor (см. _offer_to_lot).
|
||||||
|
"""
|
||||||
|
building = building or {}
|
||||||
|
newbuilding = newbuilding or {}
|
||||||
|
|
||||||
|
# (a) building.houseNumber / building.address — прямые поля SERP-state
|
||||||
|
house_number = building.get("houseNumber")
|
||||||
|
if house_number is not None:
|
||||||
|
token = str(house_number).strip()
|
||||||
|
if token:
|
||||||
|
return token
|
||||||
|
b_addr = (building.get("address") or "").strip()
|
||||||
|
if b_addr:
|
||||||
|
m = _HOUSE_TOKEN_RE.search(b_addr)
|
||||||
|
if m:
|
||||||
|
return m.group(1).strip()
|
||||||
|
|
||||||
|
# (c) хвостовой дом-токен из названия ЖК (до кадастра — он читабельнее)
|
||||||
|
nb_name = (newbuilding.get("name") or "").strip()
|
||||||
|
if nb_name:
|
||||||
|
m = _HOUSE_TOKEN_RE.search(nb_name)
|
||||||
|
if m:
|
||||||
|
return m.group(1).strip()
|
||||||
|
|
||||||
|
# (b) кадастр дома как anchor-ключ (последний приоритет в строке адреса,
|
||||||
|
# но самый надёжный для матчинга — Tier 0 cadastr_exact)
|
||||||
|
if building_cadastral_number:
|
||||||
|
cad = building_cadastral_number.strip()
|
||||||
|
if cad:
|
||||||
|
return f"кад. {cad}"
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _format_address(
|
||||||
|
address_parts: list[dict[str, Any]],
|
||||||
|
*,
|
||||||
|
building: dict[str, Any] | None = None,
|
||||||
|
building_cadastral_number: str | None = None,
|
||||||
|
newbuilding: dict[str, Any] | None = None,
|
||||||
|
is_newbuilding: bool = False,
|
||||||
|
) -> str:
|
||||||
"""Сформировать читаемый адрес из geo.address[] Cian.
|
"""Сформировать читаемый адрес из geo.address[] Cian.
|
||||||
|
|
||||||
Пропускаем location (страна/регион) и metro — берём раион/улицу/дом.
|
Пропускаем location (страна/регион) и metro — берём раион/улицу/дом.
|
||||||
Пример: [location=Москва, raion=Пресненский, street=..., house=1С7, metro=Москва-Сити]
|
Пример: [location=Москва, raion=Пресненский, street=..., house=1С7, metro=Москва-Сити]
|
||||||
→ 'Пресненский, улица ..., 1С7'
|
→ 'Пресненский, улица ..., 1С7'
|
||||||
|
|
||||||
|
#1773: для новостроек (is_newbuilding) geo.address[] часто без {type:"house"}.
|
||||||
|
Если части house нет — восстанавливаем номер дома через _recover_newbuilding_house
|
||||||
|
(building → название ЖК → кадастр) и дописываем его в конец строки, чтобы
|
||||||
|
same-building anchor (fingerprint/кадастр) не рвался по house-less улице.
|
||||||
"""
|
"""
|
||||||
if not address_parts:
|
if not address_parts:
|
||||||
return "Екатеринбург (Cian)"
|
return "Екатеринбург (Cian)"
|
||||||
|
|
||||||
skip_types = {"location", "metro"}
|
skip_types = {"location", "metro"}
|
||||||
parts: list[str] = []
|
parts: list[str] = []
|
||||||
|
has_house = False
|
||||||
for part in address_parts:
|
for part in address_parts:
|
||||||
if not isinstance(part, dict):
|
if not isinstance(part, dict):
|
||||||
continue
|
continue
|
||||||
ptype = part.get("type", "")
|
ptype = part.get("type", "")
|
||||||
|
if ptype == "house":
|
||||||
|
has_house = True
|
||||||
if ptype in skip_types:
|
if ptype in skip_types:
|
||||||
continue
|
continue
|
||||||
name = (part.get("fullName") or part.get("name") or "").strip()
|
name = (part.get("fullName") or part.get("name") or "").strip()
|
||||||
if name:
|
if name:
|
||||||
parts.append(name)
|
parts.append(name)
|
||||||
|
|
||||||
|
# Восстановление дома только для новостроек без части house — вторичка с
|
||||||
|
# house-less адресом не трогается (не выдумываем номер).
|
||||||
|
if is_newbuilding and not has_house:
|
||||||
|
recovered = _recover_newbuilding_house(
|
||||||
|
building=building,
|
||||||
|
building_cadastral_number=building_cadastral_number,
|
||||||
|
newbuilding=newbuilding,
|
||||||
|
)
|
||||||
|
if recovered:
|
||||||
|
parts.append(recovered)
|
||||||
|
|
||||||
return ", ".join(parts) if parts else "Екатеринбург (Cian)"
|
return ", ".join(parts) if parts else "Екатеринбург (Cian)"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,207 @@
|
||||||
|
-- 133_listings_uq_source_source_id.sql
|
||||||
|
-- Дедуп дублей listings по (source, source_id) + UNIQUE-констрейнт против повторов.
|
||||||
|
--
|
||||||
|
-- WHY (root cause — #1773):
|
||||||
|
-- Один и тот же оффер задваивается в listings: cian 330047129 лежит в ДВУХ
|
||||||
|
-- строках — одна с house_id_fk (привязана к дому), другая с house_id_fk=NULL.
|
||||||
|
-- Корень — историческая смена формулы dedup_hash (#753): раньше hash включал
|
||||||
|
-- цену, поэтому каждое изменение цены порождало новую строку с другим
|
||||||
|
-- dedup_hash. Текущий UPSERT идёт ON CONFLICT (dedup_hash), а старые «ghost»-
|
||||||
|
-- строки несут price-inclusive hash, который новым апсертом уже не матчится →
|
||||||
|
-- живут вечно как дубль того же (source, source_id). 113_deactivate_ghost_
|
||||||
|
-- duplicate_listings.sql лишь ГАСИЛ (is_active=false) лишние строки, но
|
||||||
|
-- физически их не удалял и не запрещал появление новых → дубли остаются и
|
||||||
|
-- рвут same-building anchor / раздувают инвентарь.
|
||||||
|
--
|
||||||
|
-- WHAT:
|
||||||
|
-- 1) Внутри каждой группы (source, source_id) с >1 строк выбираем «лучшую»
|
||||||
|
-- строку-keeper и схлопываем группу на неё:
|
||||||
|
-- keeper = приоритет (house_id_fk IS NOT NULL) DESC,
|
||||||
|
-- затем is_active DESC, затем last_seen_at DESC, затем id DESC.
|
||||||
|
-- То есть оставляем строку, уже привязанную к дому и самую свежую.
|
||||||
|
-- 2) Перепривязываем дочерние записи (offer_price_history, listings_snapshots,
|
||||||
|
-- listing_sources, external_valuations) и self-ref listings.merged_into
|
||||||
|
-- с loser-строк на keeper — collision-safe (WHERE NOT EXISTS), чтобы не
|
||||||
|
-- нарушить их собственные UNIQUE/PK. Остаток (коллизии — у keeper уже есть
|
||||||
|
-- эквивалент) уедет CASCADE при удалении loser.
|
||||||
|
-- 3) Удаляем loser-строки (FK ON DELETE CASCADE подчищает остаточных детей).
|
||||||
|
-- 4) Вешаем UNIQUE-индекс на (source, source_id) WHERE source_id IS NOT NULL —
|
||||||
|
-- частичный, т.к. источники без source_id (yandex/url-only) дают source_id
|
||||||
|
-- NULL, а NULL в Postgres-UNIQUE считаются различными → их не трогаем.
|
||||||
|
--
|
||||||
|
-- IDEMPOTENCY / SAFETY (важно для прода):
|
||||||
|
-- - Весь файл в одной транзакции BEGIN/COMMIT — атомарно.
|
||||||
|
-- - Дедуп НЕДЕСТРУКТИВЕН для keeper: его дети сохраняются полностью, удаляются
|
||||||
|
-- только дубли-loser и их (коллизионные) дети, у которых keeper уже имеет
|
||||||
|
-- эквивалент.
|
||||||
|
-- - Повторный прогон: после первого раза дублей нет → дедуп-шаги no-op;
|
||||||
|
-- индекс создаётся через CREATE UNIQUE INDEX IF NOT EXISTS → no-op.
|
||||||
|
-- - source_id IS NULL строки полностью игнорируются (частичный индекс).
|
||||||
|
--
|
||||||
|
-- RELATION к app/services/scrapers/base.py save_listings:
|
||||||
|
-- save_listings апсертит ON CONFLICT (dedup_hash). Текущая формула dedup_hash =
|
||||||
|
-- sha256(source || '|' || source_id) детерминирована из (source, source_id),
|
||||||
|
-- поэтому одинаковый (source, source_id) всегда даёт ОДИН dedup_hash →
|
||||||
|
-- ON CONFLICT (dedup_hash) и так не вставит второй раз. Новый UNIQUE
|
||||||
|
-- (source, source_id) согласован с этим (страхует только от
|
||||||
|
-- историко-формульных дублей), НЕ конфликтует с апсертом и НЕ требует менять
|
||||||
|
-- save_listings на ON CONFLICT (source, source_id). source_id=NULL не попадает
|
||||||
|
-- под частичный индекс → апсерты yandex/url-only не ломаются.
|
||||||
|
--
|
||||||
|
-- DEPENDENCIES:
|
||||||
|
-- - listings (id, source, source_id, house_id_fk, is_active, last_seen_at,
|
||||||
|
-- merged_into) -- 002_core_tables.sql + 011_listings_alter.sql + 028
|
||||||
|
-- - offer_price_history.listing_id -> listings(id) ON DELETE CASCADE -- 023
|
||||||
|
-- - listings_snapshots PK (listing_id, snapshot_date) -- 016
|
||||||
|
-- - listing_sources UNIQUE (ext_source, ext_id) -- 028
|
||||||
|
-- - external_valuations partial UNIQUE (source, house_id, listing_id) -- 044
|
||||||
|
--
|
||||||
|
-- Deploy order: after 131_fix_diff_percent_overflow.sql (latest revision).
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
v_groups bigint;
|
||||||
|
v_losers bigint;
|
||||||
|
v_deleted bigint;
|
||||||
|
v_remaining_dupes bigint;
|
||||||
|
BEGIN
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- 0. Материализуем keeper-per-group и список loser-строк во временную
|
||||||
|
-- таблицу. keeper выбираем оконной функцией с детерминированным
|
||||||
|
-- ORDER BY: привязка к дому > активность > свежесть > id.
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
CREATE TEMP TABLE _ld_map ON COMMIT DROP AS
|
||||||
|
WITH ranked AS (
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
source,
|
||||||
|
source_id,
|
||||||
|
first_value(id) OVER (
|
||||||
|
PARTITION BY source, source_id
|
||||||
|
ORDER BY
|
||||||
|
(house_id_fk IS NOT NULL) DESC,
|
||||||
|
is_active DESC,
|
||||||
|
last_seen_at DESC NULLS LAST,
|
||||||
|
id DESC
|
||||||
|
) AS keeper_id
|
||||||
|
FROM listings
|
||||||
|
WHERE source_id IS NOT NULL
|
||||||
|
AND (source, source_id) IN (
|
||||||
|
SELECT source, source_id
|
||||||
|
FROM listings
|
||||||
|
WHERE source_id IS NOT NULL
|
||||||
|
GROUP BY source, source_id
|
||||||
|
HAVING count(*) > 1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
SELECT id AS loser_id, keeper_id
|
||||||
|
FROM ranked
|
||||||
|
WHERE id <> keeper_id;
|
||||||
|
|
||||||
|
SELECT count(*) INTO v_losers FROM _ld_map;
|
||||||
|
SELECT count(DISTINCT keeper_id) INTO v_groups FROM _ld_map;
|
||||||
|
|
||||||
|
IF v_losers = 0 THEN
|
||||||
|
RAISE NOTICE 'migration 133: no duplicate (source, source_id) groups — dedup no-op';
|
||||||
|
ELSE
|
||||||
|
-- -----------------------------------------------------------------
|
||||||
|
-- 1. Перепривязка дочерних записей loser -> keeper (collision-safe).
|
||||||
|
-- Где у keeper уже есть запись с тем же UNIQUE/PK-ключом —
|
||||||
|
-- loser-строку НЕ двигаем (она уедет CASCADE на шаге 3).
|
||||||
|
-- -----------------------------------------------------------------
|
||||||
|
|
||||||
|
-- offer_price_history: нет UNIQUE на listing_id → двигаем все.
|
||||||
|
UPDATE offer_price_history o
|
||||||
|
SET listing_id = m.keeper_id
|
||||||
|
FROM _ld_map m
|
||||||
|
WHERE o.listing_id = m.loser_id;
|
||||||
|
|
||||||
|
-- listings_snapshots: PK (listing_id, snapshot_date) → не двигаем дни,
|
||||||
|
-- которые у keeper уже есть.
|
||||||
|
UPDATE listings_snapshots s
|
||||||
|
SET listing_id = m.keeper_id
|
||||||
|
FROM _ld_map m
|
||||||
|
WHERE s.listing_id = m.loser_id
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM listings_snapshots s2
|
||||||
|
WHERE s2.listing_id = m.keeper_id
|
||||||
|
AND s2.snapshot_date = s.snapshot_date
|
||||||
|
);
|
||||||
|
|
||||||
|
-- listing_sources: UNIQUE (ext_source, ext_id) → не двигаем те, что
|
||||||
|
-- у keeper уже есть.
|
||||||
|
UPDATE listing_sources ls
|
||||||
|
SET listing_id = m.keeper_id
|
||||||
|
FROM _ld_map m
|
||||||
|
WHERE ls.listing_id = m.loser_id
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM listing_sources ls2
|
||||||
|
WHERE ls2.listing_id = m.keeper_id
|
||||||
|
AND ls2.ext_source = ls.ext_source
|
||||||
|
AND ls2.ext_id = ls.ext_id
|
||||||
|
);
|
||||||
|
|
||||||
|
-- external_valuations: partial UNIQUE (source, house_id, listing_id)
|
||||||
|
-- WHERE house_id IS NOT NULL AND listing_id IS NOT NULL.
|
||||||
|
UPDATE external_valuations ev
|
||||||
|
SET listing_id = m.keeper_id
|
||||||
|
FROM _ld_map m
|
||||||
|
WHERE ev.listing_id = m.loser_id
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM external_valuations ev2
|
||||||
|
WHERE ev2.listing_id = m.keeper_id
|
||||||
|
AND ev2.source = ev.source
|
||||||
|
AND ev2.house_id IS NOT DISTINCT FROM ev.house_id
|
||||||
|
);
|
||||||
|
|
||||||
|
-- self-ref: listings.merged_into указывает на loser → переводим на keeper.
|
||||||
|
UPDATE listings l
|
||||||
|
SET merged_into = m.keeper_id
|
||||||
|
FROM _ld_map m
|
||||||
|
WHERE l.merged_into = m.loser_id;
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------
|
||||||
|
-- 2. Удаляем loser-строки. FK ON DELETE CASCADE подчистит остаточных
|
||||||
|
-- детей (коллизии, не перенесённые на шаге 1) — у keeper для них
|
||||||
|
-- уже есть эквивалент.
|
||||||
|
-- -----------------------------------------------------------------
|
||||||
|
DELETE FROM listings l
|
||||||
|
USING _ld_map m
|
||||||
|
WHERE l.id = m.loser_id;
|
||||||
|
|
||||||
|
GET DIAGNOSTICS v_deleted = ROW_COUNT;
|
||||||
|
|
||||||
|
RAISE NOTICE
|
||||||
|
'migration 133: dedup done — groups=% losers=% deleted=% (kept best row per group)',
|
||||||
|
v_groups, v_losers, v_deleted;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Контроль: после дедупа не должно остаться групп с дублями.
|
||||||
|
SELECT count(*) INTO v_remaining_dupes
|
||||||
|
FROM (
|
||||||
|
SELECT 1
|
||||||
|
FROM listings
|
||||||
|
WHERE source_id IS NOT NULL
|
||||||
|
GROUP BY source, source_id
|
||||||
|
HAVING count(*) > 1
|
||||||
|
) d;
|
||||||
|
|
||||||
|
IF v_remaining_dupes > 0 THEN
|
||||||
|
RAISE EXCEPTION
|
||||||
|
'migration 133: still % duplicate (source, source_id) groups after dedup — aborting',
|
||||||
|
v_remaining_dupes;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- -------------------------------------------------------------------------
|
||||||
|
-- 3. UNIQUE-индекс против будущих дублей. Частичный (source_id IS NOT NULL):
|
||||||
|
-- источники без source_id (yandex/url-only) дают NULL и не должны падать.
|
||||||
|
-- IF NOT EXISTS → идемпотентно при повторном прогоне.
|
||||||
|
-- -------------------------------------------------------------------------
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS listings_source_source_id_uq
|
||||||
|
ON listings (source, source_id)
|
||||||
|
WHERE source_id IS NOT NULL;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
@ -279,13 +279,31 @@ def test_cian_serp_phones():
|
||||||
|
|
||||||
|
|
||||||
def test_cian_serp_house_ext_id_newbuilding():
|
def test_cian_serp_house_ext_id_newbuilding():
|
||||||
"""house_ext_id заполнен если offer.newbuilding.id > 0."""
|
"""Новостройка с кадастром дома → anchor = building cad (#1773), не nb_id.
|
||||||
|
|
||||||
|
_make_full_offer несёт building_cadastral_number, поэтому same-building anchor
|
||||||
|
предпочитает кадастр дома (надёжнее, чем nb_id, который группирует весь ЖК)."""
|
||||||
offer = _make_full_offer(newbuilding_id=54016)
|
offer = _make_full_offer(newbuilding_id=54016)
|
||||||
html = _make_cian_serp_html([offer])
|
html = _make_cian_serp_html([offer])
|
||||||
|
|
||||||
scraper = CianScraper()
|
scraper = CianScraper()
|
||||||
lots = scraper._parse_serp_html(html)
|
lots = scraper._parse_serp_html(html)
|
||||||
|
|
||||||
|
lot = lots[0]
|
||||||
|
assert lot.house_source == "cian_building_cad"
|
||||||
|
assert lot.house_ext_id == "66:41:0204016:100"
|
||||||
|
assert lot.listing_segment == "novostroyki"
|
||||||
|
|
||||||
|
|
||||||
|
def test_cian_serp_house_ext_id_newbuilding_nb_id_fallback():
|
||||||
|
"""Без кадастра дома anchor падает на nb_id (ЖК) — back-compat fallback."""
|
||||||
|
offer = _make_full_offer(newbuilding_id=54016, building_cadastral_number="")
|
||||||
|
offer["buildingCadastralNumber"] = None
|
||||||
|
html = _make_cian_serp_html([offer])
|
||||||
|
|
||||||
|
scraper = CianScraper()
|
||||||
|
lots = scraper._parse_serp_html(html)
|
||||||
|
|
||||||
lot = lots[0]
|
lot = lots[0]
|
||||||
assert lot.house_source == "cian_newbuilding"
|
assert lot.house_source == "cian_newbuilding"
|
||||||
assert lot.house_ext_id == "54016"
|
assert lot.house_ext_id == "54016"
|
||||||
|
|
@ -576,3 +594,136 @@ def test_format_address_standard():
|
||||||
def test_format_address_empty():
|
def test_format_address_empty():
|
||||||
"""Пустой address[] → fallback string."""
|
"""Пустой address[] → fallback string."""
|
||||||
assert _format_address([]) == "Екатеринбург (Cian)"
|
assert _format_address([]) == "Екатеринбург (Cian)"
|
||||||
|
|
||||||
|
|
||||||
|
# ── #1773: новостройка без part{type:"house"} → восстановление номера дома ─────
|
||||||
|
|
||||||
|
|
||||||
|
def _make_newbuilding_houseless_offer(
|
||||||
|
cian_id: int = 330047129,
|
||||||
|
newbuilding_id: int = 54016,
|
||||||
|
newbuilding_name: str = "Суходольский квартал",
|
||||||
|
building_cadastral_number: str | None = "66:41:0313010:11396",
|
||||||
|
building_house_number: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Cian-оффер новостройки: geo.address[] БЕЗ {type:"house"} (ЖК Суходольский,
|
||||||
|
ул. Евгения Савкова 29, cian 330047129 — реальный кейс из #1773)."""
|
||||||
|
offer = _make_full_offer(
|
||||||
|
cian_id=cian_id,
|
||||||
|
newbuilding_id=newbuilding_id,
|
||||||
|
building_cadastral_number=building_cadastral_number or "",
|
||||||
|
)
|
||||||
|
# geo.address[] без части house — корень бага: «…улица Евгения Савкова» без «29»
|
||||||
|
offer["geo"]["address"] = [
|
||||||
|
{"type": "location", "name": "Свердловская область"},
|
||||||
|
{"type": "city", "name": "Екатеринбург"},
|
||||||
|
{"type": "district", "name": "Академический"},
|
||||||
|
{"type": "street", "name": "улица Евгения Савкова"},
|
||||||
|
]
|
||||||
|
offer["newbuilding"] = {"id": newbuilding_id, "name": newbuilding_name}
|
||||||
|
building = offer.setdefault("building", {})
|
||||||
|
if building_house_number is not None:
|
||||||
|
building["houseNumber"] = building_house_number
|
||||||
|
if building_cadastral_number is None:
|
||||||
|
offer["buildingCadastralNumber"] = None
|
||||||
|
return offer
|
||||||
|
|
||||||
|
|
||||||
|
def test_cian_newbuilding_cad_number_anchor():
|
||||||
|
"""Новостройка с кадастром дома → house_source/house_ext_id = building cad
|
||||||
|
(same-building anchor #1773), адрес дополнен кадастром."""
|
||||||
|
offer = _make_newbuilding_houseless_offer(
|
||||||
|
building_cadastral_number="66:41:0313010:11396",
|
||||||
|
)
|
||||||
|
html = _make_cian_serp_html([offer])
|
||||||
|
|
||||||
|
scraper = CianScraper()
|
||||||
|
lots = scraper._parse_serp_html(html)
|
||||||
|
|
||||||
|
assert len(lots) == 1
|
||||||
|
lot = lots[0]
|
||||||
|
# Кадастр дома предпочтён как anchor-ключ вместо nb_id (ЖК целиком)
|
||||||
|
assert lot.house_source == "cian_building_cad"
|
||||||
|
assert lot.house_ext_id == "66:41:0313010:11396"
|
||||||
|
assert lot.building_cadastral_number == "66:41:0313010:11396"
|
||||||
|
assert lot.listing_segment == "novostroyki"
|
||||||
|
# geo.address[] был без house → кадастр дописан в адрес как якорь
|
||||||
|
assert "Евгения Савкова" in (lot.address or "")
|
||||||
|
assert "66:41:0313010:11396" in (lot.address or "")
|
||||||
|
|
||||||
|
|
||||||
|
def test_cian_newbuilding_recovers_house_from_building_number():
|
||||||
|
"""building.houseNumber имеет приоритет (a): «29» восстановлен в адрес,
|
||||||
|
кадастр остаётся anchor-ключом."""
|
||||||
|
offer = _make_newbuilding_houseless_offer(
|
||||||
|
building_cadastral_number="66:41:0313010:11396",
|
||||||
|
building_house_number="29",
|
||||||
|
)
|
||||||
|
html = _make_cian_serp_html([offer])
|
||||||
|
|
||||||
|
scraper = CianScraper()
|
||||||
|
lots = scraper._parse_serp_html(html)
|
||||||
|
|
||||||
|
lot = lots[0]
|
||||||
|
addr = lot.address or ""
|
||||||
|
assert "Евгения Савкова" in addr
|
||||||
|
assert "29" in addr # номер дома восстановлен из building.houseNumber
|
||||||
|
# анкор по-прежнему кадастр дома (надёжнее nb_id)
|
||||||
|
assert lot.house_ext_id == "66:41:0313010:11396"
|
||||||
|
|
||||||
|
|
||||||
|
def test_cian_newbuilding_recovers_house_from_name_when_no_cad():
|
||||||
|
"""Без кадастра дома: дом-токен «29» вытягивается из названия ЖК (приоритет c),
|
||||||
|
anchor падает на nb_id."""
|
||||||
|
offer = _make_newbuilding_houseless_offer(
|
||||||
|
newbuilding_name="ЖК на Савкова 29",
|
||||||
|
building_cadastral_number=None,
|
||||||
|
)
|
||||||
|
html = _make_cian_serp_html([offer])
|
||||||
|
|
||||||
|
scraper = CianScraper()
|
||||||
|
lots = scraper._parse_serp_html(html)
|
||||||
|
|
||||||
|
lot = lots[0]
|
||||||
|
assert lot.building_cadastral_number is None
|
||||||
|
# нет кадастра → anchor = nb_id (ЖК)
|
||||||
|
assert lot.house_source == "cian_newbuilding"
|
||||||
|
assert lot.house_ext_id == "54016"
|
||||||
|
addr = lot.address or ""
|
||||||
|
assert "29" in addr # дом восстановлен из названия ЖК
|
||||||
|
|
||||||
|
|
||||||
|
def test_cian_newbuilding_no_house_no_fabrication():
|
||||||
|
"""Ни building, ни кадастра, ни номера в названии → номер НЕ выдумываем."""
|
||||||
|
offer = _make_newbuilding_houseless_offer(
|
||||||
|
newbuilding_name="Суходольский квартал", # без числа-хвоста
|
||||||
|
building_cadastral_number=None,
|
||||||
|
)
|
||||||
|
html = _make_cian_serp_html([offer])
|
||||||
|
|
||||||
|
scraper = CianScraper()
|
||||||
|
lots = scraper._parse_serp_html(html)
|
||||||
|
|
||||||
|
lot = lots[0]
|
||||||
|
addr = lot.address or ""
|
||||||
|
assert "Евгения Савкова" in addr
|
||||||
|
# никаких фейковых catch-all номеров (например 7019) в адресе
|
||||||
|
assert not addr.rstrip().endswith("7019")
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_address_vtorichka_houseless_untouched():
|
||||||
|
"""Вторичка с house-less адресом НЕ трогается — номер не выдумываем."""
|
||||||
|
parts = [
|
||||||
|
{"type": "city", "name": "Екатеринбург"},
|
||||||
|
{"type": "street", "name": "улица Евгения Савкова"},
|
||||||
|
]
|
||||||
|
result = _format_address(
|
||||||
|
parts,
|
||||||
|
building={"houseNumber": "29"},
|
||||||
|
building_cadastral_number="66:41:0313010:11396",
|
||||||
|
newbuilding={"id": 0, "name": ""},
|
||||||
|
is_newbuilding=False,
|
||||||
|
)
|
||||||
|
# вторичка: is_newbuilding=False → ни «29», ни кадастр не дописываются
|
||||||
|
assert "29" not in result
|
||||||
|
assert "66:41" not in result
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue