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
Удалены (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 — чисто на всех изменённых файлах.
441 lines
15 KiB
Python
441 lines
15 KiB
Python
"""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
|