gendesign/tradein-mvp/backend/tests/test_avito_imv_parse.py
Light1YT 81bf7e6def fix(tradein-imv): harden Avito IMV anti-bot path + offline contract tests (#565)
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
2026-05-28 16:29:05 +05:00

442 lines
14 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.

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