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

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:
bot-backend 2026-06-26 07:45:22 +00:00
parent 3f1925052b
commit 1d5b081cb4
4 changed files with 481 additions and 8 deletions

View file

@ -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,

View file

@ -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)"

View file

@ -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;

View file

@ -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