gendesign/tradein-mvp/backend/tests/test_cian_serp_scraper.py
bot-backend 08509b1b78
Some checks failed
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / changes (push) Successful in 5s
Deploy Trade-In / test (push) Failing after 25s
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / deploy (push) Has been skipped
fix(scrapers): normalize cian descriptionMinhash list[int]→str + silent-0 guard (Refs #836) (#840)
Co-authored-by: bot-backend <bot-backend@gendsgn.local>
Co-committed-by: bot-backend <bot-backend@gendsgn.local>
2026-05-30 20:28:56 +00:00

480 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Smoke тесты для cian.py SERP scraper (Stage 3 рефактор).
Проверяют парсинг 137 полей из синтетического Redux state без сетевых запросов.
Fixture формирует минимальный JS push() conforming to _cianConfig pattern.
"""
import json
import pytest
from app.services.scrapers.cian import CianScraper, _format_address
# ── Fixture helpers ──────────────────────────────────────────────────────────
def _make_cian_serp_html(offers: list[dict]) -> str:
"""Обернуть offers в валидный _cianConfig['frontend-serp'].push() HTML."""
state = {
"results": {
"totalOffers": len(offers),
"offers": offers,
}
}
state_json = json.dumps(state, ensure_ascii=False)
return f"""<html><body>
<script>
window._cianConfig['frontend-serp'] = window._cianConfig['frontend-serp'] || [];
window._cianConfig['frontend-serp'].push({{
key: 'initialState',
value: {state_json},
priority: 10,
filter: null
}});
</script>
</body></html>"""
def _make_full_offer(
cian_id: int = 328739308,
price_rur: int = 6500000,
rooms: int = 2,
total_area: str = "52.0",
living_area: str = "35.0",
kitchen_area: str = "11.5",
floor: int = 7,
floors_count: int = 16,
year_built: int = 2010,
material_type: str = "panel",
lat: float = 56.8385,
lng: float = 60.5940,
cadastral_number: str = "66:41:0204016:1234",
building_cadastral_number: str = "66:41:0204016:100",
description: str = "Продаётся отличная квартира",
description_minhash: str | list[int] = "abc123def456abc1",
newbuilding_id: int = 0,
has_phones: bool = True,
is_homeowner: bool = True,
is_pro: bool = False,
bargain_allowed: bool = True,
sale_type: str = "free",
) -> dict:
"""Синтетический Cian offer с полным набором полей (137 полей subset)."""
offer: dict = {
"cianId": cian_id,
"id": cian_id,
"fullUrl": f"https://ekb.cian.ru/sale/flat/{cian_id}/",
"category": "flatSale",
"dealType": "sale",
"offerType": "flat",
"flatType": "rooms",
"isApartments": False,
"roomsCount": rooms,
"status": "published",
# Area
"totalArea": total_area,
"livingArea": living_area,
"kitchenArea": kitchen_area,
"balconiesCount": 1,
"loggiasCount": 0,
"bedroomsCount": None,
"hasFurniture": False,
"isSoldFurnished": False,
"decoration": None,
# Floor / building
"floorNumber": floor,
"building": {
"floorsCount": floors_count,
"materialType": material_type,
"buildYear": year_built,
"deadline": None,
"parking": None,
},
# Cadastral
"cadastralNumber": cadastral_number,
"buildingCadastralNumber": building_cadastral_number,
# Price
"bargainTerms": {
"priceRur": price_rur,
"price": price_rur,
"currency": "rur",
"saleType": sale_type,
"mortgageAllowed": True,
"bargainAllowed": bargain_allowed,
"priceType": "all",
},
"formattedShortPrice": f"{price_rur // 1000000} млн ₽",
# Geo
"geo": {
"coordinates": {"lat": lat, "lng": lng},
"address": [
{"type": "location", "name": "Свердловская область"},
{"type": "city", "name": "Екатеринбург"},
{"type": "street", "name": "улица Постовского"},
{"type": "house", "name": "17А"},
],
"undergrounds": [
{
"name": "Геологическая",
"time": 10,
"transportType": "walk",
"lineId": 10,
"lineColor": "FF0000",
"isDefault": True,
"id": 100,
}
],
"districts": [],
},
# Newbuilding
"newbuilding": {"id": newbuilding_id, "name": ""},
"buildersIds": [],
# Seller
"phones": [{"number": "9012345678", "countryCode": "7"}] if has_phones else [],
"userId": 13332612,
"cianUserId": 13332612,
"publishedUserId": 13332612,
"isByHomeowner": is_homeowner,
"isByCommercialOwner": False,
"isCianPartner": False,
"isPro": is_pro,
"isCalltrackingEnabled": False,
# Description
"description": description,
"descriptionMinhash": description_minhash,
"isDuplicatedDescription": False,
# Time
"addedTimestamp": 1716451200, # 2024-05-23 00:00:00 UTC
"added": "вчера",
# Photos
"photos": [
{
"id": 1001,
"fullUrl": f"https://images.cdn-cian.ru/images/{cian_id}-1.jpg",
"thumbnailUrl": f"https://images.cdn-cian.ru/images/{cian_id}-2.jpg",
}
],
# Verification
"isRosreestrChecked": True,
"isLayoutApproved": None,
# Misc
"isNew": False,
"isTop3": False,
"isPremium": False,
}
return offer
# ── Tests ────────────────────────────────────────────────────────────────────
def test_cian_serp_parses_full_offer():
"""Базовый тест: полный offer → ScrapedLot с ключевыми полями."""
offer = _make_full_offer()
html = _make_cian_serp_html([offer])
scraper = CianScraper()
lots = scraper._parse_serp_html(html)
assert len(lots) == 1
lot = lots[0]
assert lot.source == "cian"
assert lot.source_id == "328739308"
assert lot.source_url == "https://ekb.cian.ru/sale/flat/328739308/"
def test_cian_serp_coords_no_jitter():
"""Координаты берутся точно из state — NO anchor jitter."""
offer = _make_full_offer(lat=56.8385, lng=60.5940)
html = _make_cian_serp_html([offer])
scraper = CianScraper()
lots = scraper._parse_serp_html(html)
assert len(lots) == 1
lot = lots[0]
assert lot.lat == pytest.approx(56.8385, abs=1e-9)
assert lot.lon == pytest.approx(60.5940, abs=1e-9)
def test_cian_serp_kitchen_area_in_raw_payload():
"""kitchen_area_m2 не в ScrapedLot, но сохраняется в raw_payload."""
offer = _make_full_offer(kitchen_area="11.5")
html = _make_cian_serp_html([offer])
scraper = CianScraper()
lots = scraper._parse_serp_html(html)
lot = lots[0]
assert lot.raw_payload is not None
assert lot.raw_payload["kitchen_area_m2"] == "11.5"
def test_cian_serp_description_minhash():
"""description_minhash берётся из Cian state, если есть."""
offer = _make_full_offer(
description_minhash="cian_minhash_abc123",
description="Тест описания",
)
html = _make_cian_serp_html([offer])
scraper = CianScraper()
lots = scraper._parse_serp_html(html)
lot = lots[0]
assert lot.description_minhash == "cian_minhash_abc123"
def test_cian_serp_description_minhash_fallback():
"""Если Cian не дал minhash — вычисляем SHA1 из description."""
offer = _make_full_offer(description_minhash="", description="Уникальное описание квартиры")
offer["descriptionMinhash"] = "" # явно пусто
html = _make_cian_serp_html([offer])
scraper = CianScraper()
lots = scraper._parse_serp_html(html)
lot = lots[0]
assert lot.description_minhash is not None
assert len(lot.description_minhash) == 32 # SHA1 hex [:32]
def test_cian_serp_description_minhash_list_int():
"""descriptionMinhash как list[int] (новый формат Cian ~2026-05-23) → строка через запятую.
Порядок элементов сохраняется как-есть — sorted() НЕ применяется,
т.к. порядок LSH-band'ов значим для dedup/cross-source matching.
Offer должен быть принят (не отброшен) — возвращает ScrapedLot, не None.
"""
minhash_list = [8439545, 5781654, 555882, 407717]
offer = _make_full_offer(
description_minhash=minhash_list, # type: ignore[arg-type]
description="Продаётся квартира с видом на парк",
)
html = _make_cian_serp_html([offer])
scraper = CianScraper()
lots = scraper._parse_serp_html(html)
assert len(lots) == 1, "offer с list[int] minhash должен быть принят, не отброшен"
lot = lots[0]
assert lot.description_minhash == "8439545,5781654,555882,407717"
def test_cian_serp_phones():
"""phones — список dicts из state."""
offer = _make_full_offer(has_phones=True)
html = _make_cian_serp_html([offer])
scraper = CianScraper()
lots = scraper._parse_serp_html(html)
lot = lots[0]
assert isinstance(lot.phones, list)
assert len(lot.phones) == 1
assert lot.phones[0]["countryCode"] == "7"
assert lot.phones[0]["number"] == "9012345678"
def test_cian_serp_house_ext_id_newbuilding():
"""house_ext_id заполнен если offer.newbuilding.id > 0."""
offer = _make_full_offer(newbuilding_id=54016)
html = _make_cian_serp_html([offer])
scraper = CianScraper()
lots = scraper._parse_serp_html(html)
lot = lots[0]
assert lot.house_source == "cian_newbuilding"
assert lot.house_ext_id == "54016"
assert lot.listing_segment == "novostroyki"
def test_cian_serp_vtorichka_no_house_ext_id():
"""Вторичка (newbuilding.id == 0) — house_ext_id None."""
offer = _make_full_offer(newbuilding_id=0)
html = _make_cian_serp_html([offer])
scraper = CianScraper()
lots = scraper._parse_serp_html(html)
lot = lots[0]
assert lot.house_source == "cian"
assert lot.house_ext_id is None
assert lot.listing_segment == "vtorichka"
def test_cian_serp_metro_stations():
"""metro_stations — список dicts с name/time/mode/line_color."""
offer = _make_full_offer()
html = _make_cian_serp_html([offer])
scraper = CianScraper()
lots = scraper._parse_serp_html(html)
lot = lots[0]
assert isinstance(lot.metro_stations, list)
assert len(lot.metro_stations) == 1
station = lot.metro_stations[0]
assert station["name"] == "Геологическая"
assert station["time"] == 10
assert station["mode"] == "walk"
assert station["line_color"] == "FF0000"
def test_cian_serp_cadastral_numbers():
"""Кадастровые номера квартиры и здания."""
offer = _make_full_offer(
cadastral_number="66:41:0204016:1234",
building_cadastral_number="66:41:0204016:100",
)
html = _make_cian_serp_html([offer])
scraper = CianScraper()
lots = scraper._parse_serp_html(html)
lot = lots[0]
assert lot.cadastral_number == "66:41:0204016:1234"
assert lot.building_cadastral_number == "66:41:0204016:100"
# kadastr_num (base field) тоже заполнен
assert lot.kadastr_num == "66:41:0204016:1234"
def test_cian_serp_is_homeowner_seller():
"""is_homeowner и is_pro_seller из offer."""
offer = _make_full_offer(is_homeowner=True, is_pro=False)
html = _make_cian_serp_html([offer])
scraper = CianScraper()
lots = scraper._parse_serp_html(html)
lot = lots[0]
assert lot.is_homeowner is True
assert lot.is_pro_seller is False
def test_cian_serp_bargain_and_sale_type():
"""bargain_allowed и sale_type из bargainTerms."""
offer = _make_full_offer(bargain_allowed=True, sale_type="alternative")
html = _make_cian_serp_html([offer])
scraper = CianScraper()
lots = scraper._parse_serp_html(html)
lot = lots[0]
assert lot.bargain_allowed is True
assert lot.sale_type == "alternative"
def test_cian_serp_building_fields():
"""Поля здания: year_built, house_type, total_floors."""
offer = _make_full_offer(year_built=1988, material_type="brick", floors_count=9)
html = _make_cian_serp_html([offer])
scraper = CianScraper()
lots = scraper._parse_serp_html(html)
lot = lots[0]
assert lot.year_built == 1988
assert lot.house_type == "brick"
assert lot.total_floors == 9
def test_cian_serp_living_area():
"""living_area_m2 из offer.livingArea."""
offer = _make_full_offer(living_area="35.0")
html = _make_cian_serp_html([offer])
scraper = CianScraper()
lots = scraper._parse_serp_html(html)
lot = lots[0]
assert lot.living_area_m2 == pytest.approx(35.0)
def test_cian_serp_state_missing_returns_empty():
"""Если state нет в HTML — возвращаем пустой список."""
html = "<html><body><p>no cian state here</p></body></html>"
scraper = CianScraper()
lots = scraper._parse_serp_html(html)
assert lots == []
def test_cian_serp_skips_offer_without_price():
"""Offer без цены — пропускаем."""
offer = _make_full_offer()
del offer["bargainTerms"]["priceRur"]
del offer["bargainTerms"]["price"]
html = _make_cian_serp_html([offer])
scraper = CianScraper()
lots = scraper._parse_serp_html(html)
assert lots == []
def test_cian_serp_multiple_offers():
"""Несколько offers → список ScrapedLot."""
offers = [_make_full_offer(cian_id=100 + i) for i in range(5)]
html = _make_cian_serp_html(offers)
scraper = CianScraper()
lots = scraper._parse_serp_html(html)
assert len(lots) == 5
ids = [lot.source_id for lot in lots]
assert sorted(ids) == ["100", "101", "102", "103", "104"]
def test_cian_serp_photo_urls():
"""photo_urls — полные URL из offer.photos[].fullUrl."""
offer = _make_full_offer(cian_id=999)
html = _make_cian_serp_html([offer])
scraper = CianScraper()
lots = scraper._parse_serp_html(html)
lot = lots[0]
assert len(lot.photo_urls) == 1
assert "cdn-cian.ru" in lot.photo_urls[0]
assert "999-1.jpg" in lot.photo_urls[0]
def test_cian_serp_no_coords_does_not_crash():
"""Если координаты отсутствуют — lot.lat/lon = None, не краш."""
offer = _make_full_offer()
del offer["geo"]["coordinates"]
html = _make_cian_serp_html([offer])
scraper = CianScraper()
lots = scraper._parse_serp_html(html)
assert len(lots) == 1
assert lots[0].lat is None
assert lots[0].lon is None
# ── Address formatter tests ──────────────────────────────────────────────────
def test_format_address_standard():
"""Стандартный geo.address[]."""
parts = [
{"type": "location", "name": "Свердловская область"},
{"type": "city", "name": "Екатеринбург"},
{"type": "district", "name": "Чкаловский"},
{"type": "street", "name": "улица Постовского"},
{"type": "house", "name": "17А"},
{"type": "metro", "name": "Геологическая"},
]
result = _format_address(parts)
# location и metro пропускаем
assert "Свердловская" not in result
assert "Геологическая" not in result
assert "Екатеринбург" in result
assert "17А" in result
def test_format_address_empty():
"""Пустой address[] → fallback string."""
assert _format_address([]) == "Екатеринбург (Cian)"