Add warm-up GET /evaluation/realty + production browser header set (Sec-Fetch, X-Requested-With, 25s timeout) mirroring avito.py, чтобы защитить 3-step IMV flow от server-IP анти-бот 403. Refactor step B/C парсинга в pure helpers (_parse_geo_position/_parse_price/_parse_placement_history/_parse_suggestions) + fixture-driven offline тесты из живых JSON-captures. Честный диагноз: контракт (geoFieldsHash, price-блок, categoryId=24) НЕ менялся — проверено живым capture'ом с этой машины (A→B→C, recommended 7.39M). Реальная причина #565 — скорее анти-бот по PROD-IP, не код → если #565 останется после deploy, настоящий фикс = ротация egress IP воркера (devops, отдельно). code-reviewer: ✅ APPROVE no critical — warm-up non-fatal, injected-session path unchanged, refactor preserves shapes, graceful-None сохранён, fixtures без PII. Tests: 12 new (27 total). Ruff clean. Closes #565
442 lines
14 KiB
Python
442 lines
14 KiB
Python
"""Offline smoke tests для avito_imv модуля.
|
||
|
||
Без сети — только cache_key вычисление, парсинг заголовков, unix→date, dataclass конструирование.
|
||
"""
|
||
|
||
import json
|
||
from datetime import date
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
from app.services.scrapers.avito_imv import (
|
||
IMVAddressNotFoundError,
|
||
IMVEvaluation,
|
||
IMVGeo,
|
||
IMVPlacementHistoryItem,
|
||
_parse_geo_position,
|
||
_parse_placement_history,
|
||
_parse_placement_item,
|
||
_parse_price,
|
||
_parse_suggestion,
|
||
_parse_suggestions,
|
||
_parse_title,
|
||
_unix_to_date,
|
||
compute_imv_cache_key,
|
||
)
|
||
|
||
_FIXTURES = Path(__file__).parent / "fixtures"
|
||
|
||
|
||
def _load_fixture(name: str) -> dict:
|
||
with (_FIXTURES / name).open(encoding="utf-8") as f:
|
||
return json.load(f)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# _parse_geo_position (step B) — driven by live-captured fixture (issue #565)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_parse_geo_position_from_fixture():
|
||
"""Real /js/v2/geo/position response (ЕКБ, ул. Малышева 125) → IMVGeo."""
|
||
data_b = _load_fixture("avito_imv_geo_position.json")
|
||
geo = _parse_geo_position(data_b, lat=56.840872, lon=60.654077)
|
||
|
||
assert geo.geo_hash # non-empty JWT
|
||
assert geo.geo_hash.startswith("eyJ") # base64url JWT header
|
||
assert geo.geo_hash.count(".") == 2 # header.payload.signature
|
||
assert geo.avito_address_id == 22319477
|
||
assert geo.avito_location_id == 654070
|
||
assert geo.avito_district_id == 789
|
||
assert geo.lat == pytest.approx(56.840872)
|
||
assert geo.lon == pytest.approx(60.654077)
|
||
|
||
|
||
def test_parse_geo_position_ekb_has_no_metro():
|
||
"""ЕКБ-окраина не отдаёт metroId — должен быть None, не падать."""
|
||
data_b = _load_fixture("avito_imv_geo_position.json")
|
||
geo = _parse_geo_position(data_b, lat=56.84, lon=60.65)
|
||
assert geo.avito_metro_id is None
|
||
|
||
|
||
def test_parse_geo_position_missing_hash_raises():
|
||
"""Пустой geoFieldsHash → IMVAddressNotFoundError (graceful skip в estimator)."""
|
||
with pytest.raises(IMVAddressNotFoundError):
|
||
_parse_geo_position(
|
||
{"address": "X", "errorText": "not found", "geoFieldsHash": ""},
|
||
lat=1.0,
|
||
lon=2.0,
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# _parse_price / placement / suggestions (step C) — live-captured fixture
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_parse_price_from_fixture():
|
||
"""Real realty-imv/get-data price block populates all three prices + count."""
|
||
data = _load_fixture("avito_imv_getdata.json")
|
||
recommended, lower, higher, count = _parse_price(data)
|
||
|
||
assert recommended == 7370000
|
||
assert lower == 7148900
|
||
assert higher == 7738500
|
||
assert count == 800
|
||
assert lower < recommended < higher
|
||
|
||
|
||
def test_parse_price_missing_block_defaults_zero():
|
||
"""Нет price блока (degenerate ответ) → нули, не KeyError."""
|
||
recommended, lower, higher, count = _parse_price({})
|
||
assert (recommended, lower, higher) == (0, 0, 0)
|
||
assert count is None
|
||
|
||
|
||
def test_parse_placement_history_from_fixture():
|
||
"""placementHistory.items парсятся в IMVPlacementHistoryItem с removed_date."""
|
||
data = _load_fixture("avito_imv_getdata.json")
|
||
items = _parse_placement_history(data)
|
||
|
||
assert len(items) >= 1
|
||
first = items[0]
|
||
assert first.ext_item_id # str(id)
|
||
assert first.start_price and first.last_price
|
||
assert isinstance(first.start_price_date, date)
|
||
assert isinstance(first.removed_date, date)
|
||
# raw_payload очищен от тяжёлого itemImage
|
||
assert first.raw_payload is not None
|
||
assert "itemImage" not in first.raw_payload
|
||
|
||
|
||
def test_parse_placement_history_absent_block_returns_empty():
|
||
"""placementHistory отсутствует (как у проспект Ленина) → [] без падения."""
|
||
data = _load_fixture("avito_imv_getdata.json")
|
||
data.pop("placementHistory", None)
|
||
assert _parse_placement_history(data) == []
|
||
|
||
|
||
def test_parse_suggestions_from_fixture():
|
||
"""suggestions.items парсятся + searchSimilarUrl извлекается."""
|
||
data = _load_fixture("avito_imv_getdata.json")
|
||
suggestions, search_url = _parse_suggestions(data)
|
||
|
||
assert len(suggestions) >= 1
|
||
assert all(s.ext_item_id for s in suggestions)
|
||
assert all(s.price_rub for s in suggestions)
|
||
# raw_payload очищен от imageLink
|
||
assert "imageLink" not in (suggestions[0].raw_payload or {})
|
||
assert search_url is not None
|
||
assert search_url.startswith("/")
|
||
|
||
|
||
def test_fixtures_feed_full_evaluation_shape():
|
||
"""Сквозной оффлайн-тест: fixtures B+C → заполненный IMVEvaluation.
|
||
|
||
Зеркалит то, что _geocode + _imv_evaluate собирают из живого ответа,
|
||
без сети. Регрессионный guard для контракта step B/C (issue #565).
|
||
"""
|
||
data_b = _load_fixture("avito_imv_geo_position.json")
|
||
data_c = _load_fixture("avito_imv_getdata.json")
|
||
|
||
geo = _parse_geo_position(data_b, lat=56.840872, lon=60.654077)
|
||
recommended, lower, higher, count = _parse_price(data_c)
|
||
history = _parse_placement_history(data_c)
|
||
suggestions, search_url = _parse_suggestions(data_c)
|
||
|
||
e = IMVEvaluation(
|
||
cache_key="c" * 64,
|
||
address="Екатеринбург, улица Малышева, 125",
|
||
rooms=2,
|
||
area_m2=54.0,
|
||
floor=4,
|
||
floor_at_home=9,
|
||
house_type="panel",
|
||
renovation_type="cosmetic",
|
||
has_balcony=True,
|
||
has_loggia=False,
|
||
geo=geo,
|
||
recommended_price=recommended,
|
||
lower_price=lower,
|
||
higher_price=higher,
|
||
market_count=count,
|
||
placement_history=history,
|
||
suggestions=suggestions,
|
||
search_similar_url=search_url,
|
||
raw_response=data_c,
|
||
)
|
||
|
||
assert e.geo.geo_hash # non-empty
|
||
assert e.recommended_price == 7370000
|
||
assert e.lower_price == 7148900
|
||
assert e.higher_price == 7738500
|
||
assert e.market_count == 800
|
||
assert len(e.suggestions) >= 1
|