From 6a2f73435619b2c5c31571f4b4aaed0446af9de5 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sat, 23 May 2026 12:16:47 +0000 Subject: [PATCH] =?UTF-8?q?feat(tradein):=20avito=5Fimv.py=20=E2=80=94=20A?= =?UTF-8?q?vito=20IMV=20evaluation=20API=20client=20(2=20requests)=20(#444?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/services/scrapers/avito_imv.py | 608 ++++++++++++++++++ tradein-mvp/backend/tests/__init__.py | 0 .../backend/tests/test_avito_imv_parse.py | 287 +++++++++ 3 files changed, 895 insertions(+) create mode 100644 tradein-mvp/backend/app/services/scrapers/avito_imv.py create mode 100644 tradein-mvp/backend/tests/__init__.py create mode 100644 tradein-mvp/backend/tests/test_avito_imv_parse.py diff --git a/tradein-mvp/backend/app/services/scrapers/avito_imv.py b/tradein-mvp/backend/app/services/scrapers/avito_imv.py new file mode 100644 index 00000000..c56c3562 --- /dev/null +++ b/tradein-mvp/backend/app/services/scrapers/avito_imv.py @@ -0,0 +1,608 @@ +"""avito_imv.py — Avito IMV evaluation API client (2-request flow). + +Stage 2d: geocode + IMV evaluation. + +Flow: + 1) GET /web/1/coords/by_address?address= → geoHash + geo IDs + 2) POST /web/1/realty-imv/get-data → price + placementHistory + suggestions + +Таблицы: + avito_imv_evaluations (018_avito_imv_evaluations.sql) + house_placement_history (017_house_placement_history.sql) +""" + +from __future__ import annotations + +import hashlib +import json +import logging +import re +from dataclasses import dataclass, field +from datetime import date, datetime +from typing import Any +from urllib.parse import urlencode +from uuid import UUID + +from sqlalchemy import text +from sqlalchemy.orm import Session + +logger = logging.getLogger(__name__) + +AVITO_BASE = "https://www.avito.ru" +COORDS_ENDPOINT = "/web/1/coords/by_address" +IMV_ENDPOINT = "/web/1/realty-imv/get-data" + +# Заголовки имитируют браузер на странице evaluation/realty +_COMMON_HEADERS = { + "Accept": "application/json, text/plain, */*", + "Accept-Language": "ru-RU,ru;q=0.9", + "Referer": "https://www.avito.ru/evaluation/realty", +} + + +# --------------------------------------------------------------------------- +# Exceptions +# --------------------------------------------------------------------------- + + +class IMVAddressNotFoundError(Exception): + """Raised when /coords/by_address returns no geoHash for given address.""" + + +class IMVCityMismatchError(Exception): + """Raised when locationId не входит в ожидаемый город (sanity check).""" + + +# --------------------------------------------------------------------------- +# Dataclasses +# --------------------------------------------------------------------------- + + +@dataclass +class IMVGeo: + geo_hash: str # JWT — не сохраняем как отдельную колонку, только в raw_response + lat: float | None = None + lon: float | None = None + avito_address_id: int | None = None + avito_location_id: int | None = None + avito_metro_id: int | None = None + avito_district_id: int | None = None + + +@dataclass +class IMVPlacementHistoryItem: + ext_item_id: str # str(item['id']) + title: str | None = None + rooms: int | None = None # парсится из title (опционально) + area_m2: float | None = None # парсится из title (опционально) + floor: int | None = None # парсится из title (опционально) + total_floors: int | None = None # парсится из title (опционально) + start_price: int | None = None + start_price_date: date | None = None # unix → date + last_price: int | None = None + last_price_date: date | None = None + removed_date: date | None = None # только IMV API (в widget housePlacementHistory нет) + exposure_days: int | None = None + raw_payload: dict[str, Any] | None = None + + +@dataclass +class IMVSuggestion: + ext_item_id: str + title: str | None = None + address: str | None = None + price_rub: int | None = None + exposure_days: int | None = None + publish_date: date | None = None + item_url: str | None = None + metro_name: str | None = None + metro_distance: str | None = None + metro_color: str | None = None + has_good_price_badge: bool = False + raw_payload: dict[str, Any] | None = None + + +@dataclass +class IMVEvaluation: + cache_key: str # sha256 + address: str + rooms: int + area_m2: float + floor: int + floor_at_home: int + house_type: str + renovation_type: str + has_balcony: bool + has_loggia: bool + + geo: IMVGeo + recommended_price: int + lower_price: int + higher_price: int + market_count: int | None = None + + placement_history: list[IMVPlacementHistoryItem] = field(default_factory=list) + suggestions: list[IMVSuggestion] = field(default_factory=list) + search_similar_url: str | None = None + raw_response: dict[str, Any] | None = None + + +# --------------------------------------------------------------------------- +# Cache key +# --------------------------------------------------------------------------- + + +def compute_imv_cache_key( + address: str, + rooms: int, + area_m2: float, + floor: int, + floor_at_home: int, + house_type: str, + renovation_type: str, + has_balcony: bool, + has_loggia: bool, +) -> str: + """Детерминированный sha256 ключ для 24h-кэша IMV оценки.""" + key_src = ( + f"{address}|{rooms}|{area_m2}|{floor}|{floor_at_home}" + f"|{house_type}|{renovation_type}|{int(has_balcony)}|{int(has_loggia)}" + ) + return hashlib.sha256(key_src.encode("utf-8")).hexdigest() + + +# --------------------------------------------------------------------------- +# Parse helpers +# --------------------------------------------------------------------------- + + +def _unix_to_date(ts: int | float | None) -> date | None: + """Конвертирует unix timestamp в date. Возвращает None для 0 и None.""" + if not ts: + return None + try: + return datetime.fromtimestamp(int(ts)).date() + except (ValueError, OSError): + return None + + +# Паттерн для заголовка вида "2-к. квартира, 42 м², 4/5 эт." +_TITLE_RE = re.compile( + r"^(?P\d+)-к[.\s].*?(?P[\d,]+)\s*м².*?(?P\d+)/(?P\d+)\s*эт", + re.IGNORECASE, +) + + +def _parse_title(title: str | None) -> dict[str, int | float | None]: + """Парсит rooms, area_m2, floor, total_floors из Avito-заголовка объявления.""" + result: dict[str, int | float | None] = { + "rooms": None, + "area_m2": None, + "floor": None, + "total_floors": None, + } + if not title: + return result + m = _TITLE_RE.match(title.strip()) + if not m: + return result + try: + result["rooms"] = int(m.group("rooms")) + result["area_m2"] = float(m.group("area").replace(",", ".")) + result["floor"] = int(m.group("floor")) + result["total_floors"] = int(m.group("total")) + except (ValueError, AttributeError): + pass + return result + + +def _parse_placement_item(raw: dict[str, Any]) -> IMVPlacementHistoryItem: + """Парсит один элемент из placementHistory.items.""" + title = raw.get("title") + parsed = _parse_title(title) + + # Фильтруем itemImage из raw_payload (не нужны) + clean_raw = {k: v for k, v in raw.items() if k not in ("itemImage",)} + + return IMVPlacementHistoryItem( + ext_item_id=str(raw["id"]), + title=title, + rooms=parsed["rooms"], # type: ignore[arg-type] + area_m2=parsed["area_m2"], # type: ignore[arg-type] + floor=parsed["floor"], # type: ignore[arg-type] + total_floors=parsed["total_floors"], # type: ignore[arg-type] + start_price=raw.get("startPrice"), + start_price_date=_unix_to_date(raw.get("startPriceDate")), + last_price=raw.get("lastPrice"), + last_price_date=_unix_to_date(raw.get("lastPriceDate")), + removed_date=_unix_to_date(raw.get("removedDate")), + exposure_days=raw.get("exposure"), + raw_payload=clean_raw, + ) + + +def _parse_suggestion(raw: dict[str, Any]) -> IMVSuggestion: + """Парсит один элемент из suggestions.items.""" + metro = raw.get("metro") or {} + colors: list[str] = metro.get("colors") or [] + + # Фильтруем imageLink из raw_payload + clean_raw = {k: v for k, v in raw.items() if k not in ("imageLink",)} + + return IMVSuggestion( + ext_item_id=str(raw["id"]), + title=raw.get("title"), + address=raw.get("address"), + price_rub=raw.get("price"), + exposure_days=raw.get("exposure"), + publish_date=_unix_to_date(raw.get("publishDate")), + item_url=raw.get("itemLink"), + metro_name=metro.get("name"), + metro_distance=metro.get("distance"), + metro_color=colors[0] if colors else None, + has_good_price_badge=bool(raw.get("hasGoodPriceBadge", False)), + raw_payload=clean_raw, + ) + + +# --------------------------------------------------------------------------- +# Main async function +# --------------------------------------------------------------------------- + + +async def evaluate_via_imv( + address: str, + rooms: int, + area_m2: float, + floor: int, + floor_at_home: int, + house_type: str, # panel/brick/monolith/monolith_brick/block/wood + renovation_type: str, # required/cosmetic/euro/designer + has_balcony: bool, + has_loggia: bool, + *, + cffi_session: Any | None = None, +) -> IMVEvaluation: + """Avito IMV — 2 HTTP requests: + + 1) GET /web/1/coords/by_address?address= → geoHash + geo IDs + 2) POST /web/1/realty-imv/get-data → price + placementHistory + suggestions + + Returns IMVEvaluation с уже вычисленным cache_key. + + Raises: + IMVAddressNotFoundError если geoHash пустой / не вернулся + httpx.HTTPStatusError на 4xx/5xx + """ + # Импортируем здесь чтобы избежать циклических зависимостей при тестах без сети + try: + from curl_cffi.requests import AsyncSession as CffiAsyncSession + + _own_session = False + if cffi_session is None: + cffi_session = CffiAsyncSession(impersonate="chrome120") + _own_session = True + except ImportError as exc: + raise RuntimeError( + "curl_cffi не установлен. Добавь 'curl-cffi>=0.7.0' в pyproject.toml." + ) from exc + + try: + geo = await _geocode(cffi_session, address) + evaluation = await _imv_evaluate( + cffi_session, + geo=geo, + address=address, + rooms=rooms, + area_m2=area_m2, + floor=floor, + floor_at_home=floor_at_home, + house_type=house_type, + renovation_type=renovation_type, + has_balcony=has_balcony, + has_loggia=has_loggia, + ) + finally: + if _own_session: + await cffi_session.close() + + return evaluation + + +async def _geocode(session: Any, address: str) -> IMVGeo: + """Request 1: GET /web/1/coords/by_address → IMVGeo.""" + params = urlencode({"address": address}) + url = f"{AVITO_BASE}{COORDS_ENDPOINT}?{params}" + logger.info("IMV geocode: address=%r", address) + + resp = await session.get(url, headers=_COMMON_HEADERS) + resp.raise_for_status() + data: dict[str, Any] = resp.json() + + geo_hash = data.get("geoHash") + if not geo_hash: + raise IMVAddressNotFoundError( + f"Avito /coords/by_address не вернул geoHash для адреса: {address!r}" + ) + + logger.info( + "IMV geocode OK: locationId=%s lat=%s lon=%s", + data.get("locationId"), + data.get("latitude"), + data.get("longitude"), + ) + + return IMVGeo( + geo_hash=geo_hash, + lat=data.get("latitude"), + lon=data.get("longitude"), + avito_address_id=data.get("addressId"), + avito_location_id=data.get("locationId"), + avito_metro_id=data.get("metroId"), + avito_district_id=data.get("districtId"), + ) + + +async def _imv_evaluate( + session: Any, + *, + geo: IMVGeo, + address: str, + rooms: int, + area_m2: float, + floor: int, + floor_at_home: int, + house_type: str, + renovation_type: str, + has_balcony: bool, + has_loggia: bool, +) -> IMVEvaluation: + """Request 2: POST /web/1/realty-imv/get-data → IMVEvaluation.""" + url = f"{AVITO_BASE}{IMV_ENDPOINT}" + body = { + "geoHash": geo.geo_hash, + "floor": floor, + "floorAtHome": floor_at_home, + "rooms": rooms, + "area": area_m2, + "houseType": house_type, + "renovationType": renovation_type, + "hasLoggia": has_loggia, + "hasBalcony": has_balcony, + } + headers = {**_COMMON_HEADERS, "Content-Type": "application/json"} + + logger.info( + "IMV evaluate: address=%r rooms=%d area=%.1f floor=%d/%d", + address, + rooms, + area_m2, + floor, + floor_at_home, + ) + + resp = await session.post(url, json=body, headers=headers) + resp.raise_for_status() + data: dict[str, Any] = resp.json() + + price_block: dict[str, Any] = data.get("price") or {} + recommended_price: int = price_block.get("recommendedPrice") or 0 + lower_price: int = price_block.get("lowerPrice") or 0 + higher_price: int = price_block.get("higherPrice") or 0 + market_count: int | None = price_block.get("count") + + # placementHistory + history_block: dict[str, Any] = data.get("placementHistory") or {} + placement_history: list[IMVPlacementHistoryItem] = [] + for raw_item in history_block.get("items") or []: + try: + placement_history.append(_parse_placement_item(raw_item)) + except (KeyError, TypeError) as exc: + logger.warning("IMV: не удалось распарсить placementHistory item: %s", exc) + + # suggestions + suggestions_block: dict[str, Any] = data.get("suggestions") or {} + suggestions: list[IMVSuggestion] = [] + for raw_sugg in suggestions_block.get("items") or []: + try: + suggestions.append(_parse_suggestion(raw_sugg)) + except (KeyError, TypeError) as exc: + logger.warning("IMV: не удалось распарсить suggestion: %s", exc) + + search_similar_url: str | None = suggestions_block.get("searchSimilarUrl") + + logger.info( + "IMV evaluate OK: recommended=%d range=(%d, %d) count=%s history=%d suggestions=%d", + recommended_price, + lower_price, + higher_price, + market_count, + len(placement_history), + len(suggestions), + ) + + cache_key = compute_imv_cache_key( + address=address, + rooms=rooms, + area_m2=area_m2, + floor=floor, + floor_at_home=floor_at_home, + house_type=house_type, + renovation_type=renovation_type, + has_balcony=has_balcony, + has_loggia=has_loggia, + ) + + return IMVEvaluation( + cache_key=cache_key, + address=address, + rooms=rooms, + area_m2=area_m2, + floor=floor, + floor_at_home=floor_at_home, + house_type=house_type, + renovation_type=renovation_type, + has_balcony=has_balcony, + has_loggia=has_loggia, + geo=geo, + recommended_price=recommended_price, + lower_price=lower_price, + higher_price=higher_price, + market_count=market_count, + placement_history=placement_history, + suggestions=suggestions, + search_similar_url=search_similar_url, + raw_response=data, + ) + + +# --------------------------------------------------------------------------- +# DB save functions +# --------------------------------------------------------------------------- + + +def save_imv_evaluation( + db: Session, + e: IMVEvaluation, + *, + estimate_id: UUID | None = None, +) -> int: + """INSERT в avito_imv_evaluations ON CONFLICT(cache_key) DO UPDATE. + + Колонки строго по 018_avito_imv_evaluations.sql DDL. + estimate_id — nullable FK на trade_in_estimates. + + Returns: id вставленной/обновлённой строки. + """ + row = db.execute( + text(""" + INSERT INTO avito_imv_evaluations ( + cache_key, estimate_id, + address, rooms, area_m2, floor, floor_at_home, + house_type, renovation_type, has_balcony, has_loggia, + geo_hash, + lat, lon, avito_address_id, avito_location_id, + avito_metro_id, avito_district_id, + recommended_price, lower_price, higher_price, market_count, + raw_response, fetched_at + ) VALUES ( + :cache_key, CAST(:estimate_id AS uuid), + :address, :rooms, :area_m2, :floor, :floor_at_home, + :house_type, :renovation_type, :has_balcony, :has_loggia, + :geo_hash, + :lat, :lon, :avito_address_id, :avito_location_id, + :avito_metro_id, :avito_district_id, + :recommended_price, :lower_price, :higher_price, :market_count, + CAST(:raw_response AS jsonb), NOW() + ) + ON CONFLICT (cache_key) DO UPDATE SET + estimate_id = COALESCE(EXCLUDED.estimate_id, avito_imv_evaluations.estimate_id), + recommended_price = EXCLUDED.recommended_price, + lower_price = EXCLUDED.lower_price, + higher_price = EXCLUDED.higher_price, + market_count = EXCLUDED.market_count, + raw_response = EXCLUDED.raw_response, + fetched_at = NOW() + RETURNING id + """), + { + "cache_key": e.cache_key, + "estimate_id": str(estimate_id) if estimate_id else None, + "address": e.address, + "rooms": e.rooms, + "area_m2": e.area_m2, + "floor": e.floor, + "floor_at_home": e.floor_at_home, + "house_type": e.house_type, + "renovation_type": e.renovation_type, + "has_balcony": e.has_balcony, + "has_loggia": e.has_loggia, + "geo_hash": e.geo.geo_hash, + "lat": e.geo.lat, + "lon": e.geo.lon, + "avito_address_id": e.geo.avito_address_id, + "avito_location_id": e.geo.avito_location_id, + "avito_metro_id": e.geo.avito_metro_id, + "avito_district_id": e.geo.avito_district_id, + "recommended_price": e.recommended_price, + "lower_price": e.lower_price, + "higher_price": e.higher_price, + "market_count": e.market_count, + "raw_response": ( + json.dumps(e.raw_response, ensure_ascii=False) if e.raw_response else None + ), + }, + ).fetchone() + db.commit() + return row.id if row else 0 + + +def save_imv_placement_history( + db: Session, + eval_id: int, + items: list[IMVPlacementHistoryItem], +) -> int: + """INSERT в house_placement_history (source='avito_imv') с removed_date. + + Колонки строго по 017_house_placement_history.sql DDL. + Колонка scraped_at (не fetched_at) — по DDL таблицы. + house_id — NULL (backfill через отдельный процесс). + eval_id принимается для логирования (не сохраняется — нет FK в 017). + + ON CONFLICT (source, ext_item_id) DO UPDATE — refresh dates. + Returns: количество вставленных/обновлённых строк. + """ + count = 0 + for item in items: + db.execute( + text(""" + INSERT INTO house_placement_history ( + source, ext_item_id, + title, rooms, area_m2, floor, total_floors, + start_price, start_price_date, + last_price, last_price_date, + removed_date, exposure_days, + raw_payload, scraped_at + ) VALUES ( + 'avito_imv', :ext_item_id, + :title, :rooms, :area_m2, :floor, :total_floors, + :start_price, :start_price_date, + :last_price, :last_price_date, + :removed_date, :exposure_days, + CAST(:raw_payload AS jsonb), NOW() + ) + ON CONFLICT (source, ext_item_id) DO UPDATE SET + last_price = EXCLUDED.last_price, + last_price_date = EXCLUDED.last_price_date, + removed_date = EXCLUDED.removed_date, + exposure_days = EXCLUDED.exposure_days, + raw_payload = EXCLUDED.raw_payload, + scraped_at = NOW() + """), + { + "ext_item_id": item.ext_item_id, + "title": item.title, + "rooms": item.rooms, + "area_m2": item.area_m2, + "floor": item.floor, + "total_floors": item.total_floors, + "start_price": item.start_price, + "start_price_date": item.start_price_date, + "last_price": item.last_price, + "last_price_date": item.last_price_date, + "removed_date": item.removed_date, + "exposure_days": item.exposure_days, + "raw_payload": ( + json.dumps(item.raw_payload, ensure_ascii=False) + if item.raw_payload + else None + ), + }, + ) + count += 1 + + if count: + db.commit() + logger.info("IMV placement history saved: eval_id=%d items=%d", eval_id, count) + + return count diff --git a/tradein-mvp/backend/tests/__init__.py b/tradein-mvp/backend/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tradein-mvp/backend/tests/test_avito_imv_parse.py b/tradein-mvp/backend/tests/test_avito_imv_parse.py new file mode 100644 index 00000000..ea132f51 --- /dev/null +++ b/tradein-mvp/backend/tests/test_avito_imv_parse.py @@ -0,0 +1,287 @@ +"""Offline smoke tests для avito_imv модуля. + +Без сети — только cache_key вычисление, парсинг заголовков, unix→date, dataclass конструирование. +""" + +from datetime import date + +import pytest + +from app.services.scrapers.avito_imv import ( + IMVEvaluation, + IMVGeo, + IMVPlacementHistoryItem, + IMVSuggestion, + _parse_placement_item, + _parse_suggestion, + _parse_title, + _unix_to_date, + compute_imv_cache_key, +) + + +# --------------------------------------------------------------------------- +# cache_key +# --------------------------------------------------------------------------- + + +def test_cache_key_deterministic(): + k1 = compute_imv_cache_key( + "ЕКБ ул. Чайковского, 66А", 2, 42.0, 4, 5, "panel", "cosmetic", True, False + ) + k2 = compute_imv_cache_key( + "ЕКБ ул. Чайковского, 66А", 2, 42.0, 4, 5, "panel", "cosmetic", True, False + ) + assert k1 == k2 + assert len(k1) == 64 # sha256 hex + + +def test_cache_key_differs_by_rooms(): + k1 = compute_imv_cache_key( + "ЕКБ ул. Чайковского, 66А", 2, 42.0, 4, 5, "panel", "cosmetic", True, False + ) + k3 = compute_imv_cache_key( + "ЕКБ ул. Чайковского, 66А", 3, 42.0, 4, 5, "panel", "cosmetic", True, False + ) + assert k1 != k3 + + +def test_cache_key_differs_by_balcony(): + k_with = compute_imv_cache_key( + "ул. Ленина, 1", 1, 30.0, 1, 9, "brick", "euro", True, False + ) + k_without = compute_imv_cache_key( + "ул. Ленина, 1", 1, 30.0, 1, 9, "brick", "euro", False, False + ) + assert k_with != k_without + + +def test_cache_key_differs_by_address(): + k1 = compute_imv_cache_key("ул. А, 1", 2, 42.0, 4, 5, "panel", "cosmetic", True, False) + k2 = compute_imv_cache_key("ул. Б, 2", 2, 42.0, 4, 5, "panel", "cosmetic", True, False) + assert k1 != k2 + + +# --------------------------------------------------------------------------- +# _unix_to_date +# --------------------------------------------------------------------------- + + +def test_unix_date_none(): + assert _unix_to_date(None) is None + + +def test_unix_date_zero(): + assert _unix_to_date(0) is None + + +def test_unix_date_positive(): + result = _unix_to_date(1715000000) + assert isinstance(result, date) + # 1715000000 примерно 2024-05-06 UTC+5 (Екатеринбург) + assert result.year == 2024 + assert result.month in (5, 6) # зависит от локали + + +def test_unix_date_float(): + result = _unix_to_date(1697000000.0) + assert isinstance(result, date) + assert result.year == 2023 + + +# --------------------------------------------------------------------------- +# _parse_title +# --------------------------------------------------------------------------- + + +def test_parse_title_full(): + parsed = _parse_title("2-к. квартира, 42 м², 4/5 эт.") + assert parsed["rooms"] == 2 + assert parsed["area_m2"] == 42.0 + assert parsed["floor"] == 4 + assert parsed["total_floors"] == 5 + + +def test_parse_title_comma_decimal(): + parsed = _parse_title("1-к. квартира, 27,1 м², 2/5 эт.") + assert parsed["rooms"] == 1 + assert parsed["area_m2"] == pytest.approx(27.1) + assert parsed["floor"] == 2 + assert parsed["total_floors"] == 5 + + +def test_parse_title_none(): + parsed = _parse_title(None) + assert parsed["rooms"] is None + assert parsed["area_m2"] is None + + +def test_parse_title_no_match(): + parsed = _parse_title("студия, 20 м²") + assert parsed["rooms"] is None + + +# --------------------------------------------------------------------------- +# _parse_placement_item +# --------------------------------------------------------------------------- + + +def test_parse_placement_item_full(): + raw = { + "id": 9876543210, + "title": "2-к. квартира, 42 м², 4/5 эт.", + "startPrice": 6500000, + "startPriceDate": 1697000000, + "lastPrice": 6290000, + "lastPriceDate": 1715000000, + "removedDate": 1715500000, + "exposure": 210, + "itemImage": {"url": "https://avito.ru/img.jpg"}, # должен быть отфильтрован + } + item = _parse_placement_item(raw) + + assert item.ext_item_id == "9876543210" + assert item.title == "2-к. квартира, 42 м², 4/5 эт." + assert item.rooms == 2 + assert item.area_m2 == 42.0 + assert item.floor == 4 + assert item.total_floors == 5 + assert item.start_price == 6500000 + assert isinstance(item.start_price_date, date) + assert item.last_price == 6290000 + assert isinstance(item.last_price_date, date) + assert isinstance(item.removed_date, date) + assert item.exposure_days == 210 + # itemImage должен быть отфильтрован из raw_payload + assert item.raw_payload is not None + assert "itemImage" not in item.raw_payload + + +def test_parse_placement_item_no_removed_date(): + raw = { + "id": 111, + "title": "1-к. квартира, 30 м², 2/9 эт.", + "startPrice": 3000000, + "startPriceDate": 1697000000, + "lastPrice": 2900000, + "lastPriceDate": 1715000000, + "exposure": 50, + } + item = _parse_placement_item(raw) + assert item.removed_date is None + assert item.ext_item_id == "111" + + +# --------------------------------------------------------------------------- +# _parse_suggestion +# --------------------------------------------------------------------------- + + +def test_parse_suggestion_full(): + raw = { + "id": 7986882804, + "title": "2-к. квартира, 42 м², 4/5 эт.", + "address": "Авиационная улица, 63к2, Екатеринбург", + "price": 6300000, + "exposure": 14, + "publishDate": 1716000000, + "itemLink": "/ekaterinburg/kvartiry/some-path", + "metro": {"name": "Чкаловская", "distance": "2,7 км", "colors": ["#cf2734"]}, + "hasGoodPriceBadge": False, + "imageLink": "https://avito.ru/img.jpg", # должен быть отфильтрован + } + sugg = _parse_suggestion(raw) + + assert sugg.ext_item_id == "7986882804" + assert sugg.price_rub == 6300000 + assert sugg.metro_name == "Чкаловская" + assert sugg.metro_distance == "2,7 км" + assert sugg.metro_color == "#cf2734" + assert sugg.item_url == "/ekaterinburg/kvartiry/some-path" + assert sugg.has_good_price_badge is False + assert isinstance(sugg.publish_date, date) + # imageLink должен быть отфильтрован + assert sugg.raw_payload is not None + assert "imageLink" not in sugg.raw_payload + + +def test_parse_suggestion_no_metro(): + raw = { + "id": 999, + "title": "1-к. квартира, 25 м², 3/5 эт.", + "price": 4000000, + "exposure": 5, + "publishDate": 1716000000, + "itemLink": "/path", + } + sugg = _parse_suggestion(raw) + assert sugg.metro_name is None + assert sugg.metro_color is None + + +# --------------------------------------------------------------------------- +# IMVEvaluation dataclass конструирование +# --------------------------------------------------------------------------- + + +def test_dataclass_minimal_construct(): + e = IMVEvaluation( + cache_key="a" * 64, + address="X", + rooms=2, + area_m2=42.0, + floor=4, + floor_at_home=5, + house_type="panel", + renovation_type="cosmetic", + has_balcony=True, + has_loggia=False, + geo=IMVGeo(geo_hash="JWT.TOKEN.HERE"), + recommended_price=6_290_000, + lower_price=6_100_000, + higher_price=6_600_000, + ) + assert e.recommended_price == 6_290_000 + assert e.lower_price == 6_100_000 + assert e.higher_price == 6_600_000 + assert e.geo.geo_hash == "JWT.TOKEN.HERE" + assert e.placement_history == [] + assert e.suggestions == [] + assert e.market_count is None + assert e.raw_response is None + + +def test_dataclass_with_history(): + item = IMVPlacementHistoryItem( + ext_item_id="123", + title="2-к. квартира, 42 м², 4/5 эт.", + rooms=2, + area_m2=42.0, + floor=4, + total_floors=5, + start_price=6500000, + last_price=6290000, + removed_date=date(2024, 5, 12), + exposure_days=210, + ) + e = IMVEvaluation( + cache_key="b" * 64, + address="Y", + rooms=2, + area_m2=42.0, + floor=4, + floor_at_home=5, + house_type="panel", + renovation_type="euro", + has_balcony=False, + has_loggia=True, + geo=IMVGeo(geo_hash="JWT2"), + recommended_price=6_000_000, + lower_price=5_800_000, + higher_price=6_300_000, + market_count=800, + placement_history=[item], + ) + assert len(e.placement_history) == 1 + assert e.placement_history[0].removed_date == date(2024, 5, 12) + assert e.market_count == 800