gendesign/tradein-mvp/backend/tests/test_avito_imv_parse.py
bot-backend a9b87aa311
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 1m36s
chore(tradein/scraper-kit): удалить 5 orphaned legacy-модулей (#2277 финальный шаг, частичный)
Удалены (0 runtime importers в app/, scripts/, packages/; проверено grep'ом,
включая intra-legacy импорты в оставшихся 21 legacy-модуле scrapers/):
- backend/app/services/scrapers/avito_imv.py
- backend/app/services/scrapers/domclick_detail.py
- backend/app/services/scrapers/ekb_geoportal_client.py
- backend/app/services/scrapers/yandex_detail.py
- backend/app/services/scrapers/yandex_valuation.py

Тесты — удалены (pure legacy-vs-kit parity, kit-only coverage уже есть в другом
месте):
- tests/scrapers/test_ekb_geoportal_client_kit_parity.py
- tests/scrapers/test_domclick_detail_kit_parity.py

Тесты — конвертированы в kit-only (импорт легаси заменён на scraper_kit.*,
покрытие сохранено без потерь):
- tests/scrapers/test_avito_imv_kit_parity.py (parity-тесты убраны, config-footgun
  regression на kit-стороне оставлен)
- tests/scrapers/test_avito_imv_browser_transport.py
- tests/scrapers/test_yandex_valuation_kit_migration.py (parity убрана, footgun-
  регрессия mandatory config/delay_provider оставлена)
- tests/scrapers/test_domclick_detail.py (+ exceptions переведены на
  scraper_kit.domclick_exceptions, т.к. kit parse_detail_html поднимает их)
- tests/test_avito_imv_parse.py
- tests/test_ekb_geoportal_ingest.py (client-часть; ingest/geocoder-часть не
  трогали — не зависят от удалённого модуля)
- tests/test_yandex_detail.py
- tests/test_yandex_detail_structural.py
- tests/test_yandex_valuation.py (YandexValuationScraper() → config=_KIT_CONFIG,
  kit конструктор требует обязательный config)
- tests/test_yandex_valuation_save.py
- tests/test_extval_house_id_write_path.py (только yandex_valuation-часть;
  cian_valuation остался нетронутым — живой легаси-модуль)
- tests/test_yandex_history_area_filter.py

Тесты — частично отредактированы (убраны только части про удалённые модули,
живые легаси-модули/их parity не тронуты):
- tests/scrapers/test_admin_domclick_ingest_kit_parity.py (base.py/ScrapedLot
  parity остался)
- tests/scrapers/test_admin_yandex_kit_parity.py (yandex_realty parity остался)
- tests/scrapers/test_admin_avito_kit_parity.py (avito.py/avito_houses.py parity
  остался; _parse_price parity убран)
- tests/scrapers/test_avito_unix_date_tz_consistency.py (IMV-ветка переведена на
  kit avito.imv, легаси avito/avito_houses/avito_shared/avito_detail не тронуты)
- tests/tasks/test_yandex_detail_backfill.py (subject — kit-задача, уже вызывает
  scraper_kit.providers.yandex.detail; save_detail_enrichment coverage-тесты
  внизу файла переведены на kit)
- tests/test_scraper_kit_yandex_golden_parity.py (detail/valuation parity убраны;
  serp/newbuilding/house_type_normalizer/build_url для yandex_realty/
  yandex_newbuilding не тронуты)
- tests/test_scraper_proxy.py (YandexValuation proxy-тесты переведены на kit
  config= DI; test_avito_imv_own_session_receives_proxies удалён — дублирует
  test_avito_imv_kit_parity.py footgun-тесты)
- tests/test_yandex_scrapers_delay_wiring.py (yandex_detail/yandex_valuation →
  kit delay_provider=; yandex_realty/yandex_newbuilding не тронуты)
- tests/test_scraper_kit_group_c_backfill_kit_parity.py (docstring уточнение,
  без функциональных изменений)

Полный backend-suite зелёный (3253 passed, 6 skipped) кроме известного
pre-existing flake test_search_api.py::test_search_cache_hit (#2208, не
регрессия этого PR). ruff check + ruff format — чисто на всех изменённых файлах.
2026-07-04 10:41:46 +03:00

441 lines
15 KiB
Python
Raw Permalink 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 для scraper_kit avito IMV провайдера.
Без сети — только cache_key вычисление, парсинг заголовков, unix→date, dataclass
конструирование. Легаси `app.services.scrapers.avito_imv` удалён (#2277 финальный шаг
scraper_kit-миграции, 0 runtime importers) — тесты переведены на kit-эквивалент
`scraper_kit.providers.avito.imv`, регрессия по контракту step B/C (issue #565)
сохранена без изменений.
"""
import json
from datetime import date
from pathlib import Path
import pytest
from scraper_kit.providers.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