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 — чисто на всех изменённых файлах.
156 lines
6.2 KiB
Python
156 lines
6.2 KiB
Python
"""Tests for YandexDetailScraper structural-source extraction (#1792).
|
|
|
|
Area / ceiling / kitchen / living / floor are extracted from the embedded
|
|
``window.INITIAL_STATE → offerCard.card`` object rather than the h1 title.
|
|
The title misses non-standard layouts (студия / свободная планировка) and
|
|
never carries ceiling/kitchen — which is why DB area coverage sat at ~63%.
|
|
|
|
Fixtures are trimmed real offer pages fetched via the headless browser:
|
|
- yandex_offer_3402418396407468801.html — 1-комн. вторичка (full chars)
|
|
- yandex_offer_7811691822126445498.html — студия / новостройка
|
|
|
|
Легаси `app.services.scrapers.yandex_detail` удалён (#2277 финальный шаг
|
|
scraper_kit-миграции, 0 runtime importers) — тесты переведены на kit-эквивалент
|
|
`scraper_kit.providers.yandex.detail` без изменения покрытия.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from scraper_kit.providers.yandex.detail import (
|
|
YandexDetailScraper,
|
|
_extract_offer_card,
|
|
_parse_card_fields,
|
|
)
|
|
|
|
FIXTURES = Path(__file__).parent / "fixtures"
|
|
SCRAPER = YandexDetailScraper()
|
|
|
|
# offer_id -> expected structural values
|
|
_SECONDARY_ID = "3402418396407468801"
|
|
_STUDIO_ID = "7811691822126445498"
|
|
|
|
|
|
def _load(offer_id: str) -> str:
|
|
return (FIXTURES / f"yandex_offer_{offer_id}.html").read_text(encoding="utf-8")
|
|
|
|
|
|
def _url(offer_id: str) -> str:
|
|
return f"https://realty.yandex.ru/offer/{offer_id}/"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Secondary (1-комнатная) — все структурные поля присутствуют
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSecondaryOfferStructural:
|
|
def test_area_from_structural_source(self) -> None:
|
|
r = SCRAPER.parse(_load(_SECONDARY_ID), offer_url=_url(_SECONDARY_ID))
|
|
assert r is not None
|
|
assert r.area_m2 == pytest.approx(35.5)
|
|
|
|
def test_rooms_floor_from_structural_source(self) -> None:
|
|
r = SCRAPER.parse(_load(_SECONDARY_ID), offer_url=_url(_SECONDARY_ID))
|
|
assert r is not None
|
|
assert r.rooms == 1
|
|
# floor from floorsOffered[0]; total from floorsTotal
|
|
assert r.floor == 4
|
|
assert r.total_floors == 9
|
|
|
|
def test_ceiling_kitchen_living_present(self) -> None:
|
|
r = SCRAPER.parse(_load(_SECONDARY_ID), offer_url=_url(_SECONDARY_ID))
|
|
assert r is not None
|
|
# These never come from the title — only the structural block carries them.
|
|
assert r.ceiling_height == pytest.approx(2.55)
|
|
assert r.kitchen_area_m2 == pytest.approx(9.4)
|
|
assert r.living_area_m2 == pytest.approx(16.9)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Studio / новостройка — area present even when title says «квартира-студия»
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestStudioOfferStructural:
|
|
def test_area_extracted_for_studio(self) -> None:
|
|
r = SCRAPER.parse(_load(_STUDIO_ID), offer_url=_url(_STUDIO_ID))
|
|
assert r is not None
|
|
# The key regression: studio area must NOT be None.
|
|
assert r.area_m2 is not None
|
|
assert r.area_m2 == pytest.approx(24.92)
|
|
|
|
def test_studio_rooms_zero_from_house_flag(self) -> None:
|
|
r = SCRAPER.parse(_load(_STUDIO_ID), offer_url=_url(_STUDIO_ID))
|
|
assert r is not None
|
|
# roomsTotal is null for studios; house.studio == true → rooms = 0.
|
|
assert r.rooms == 0
|
|
|
|
def test_studio_floor_extracted(self) -> None:
|
|
r = SCRAPER.parse(_load(_STUDIO_ID), offer_url=_url(_STUDIO_ID))
|
|
assert r is not None
|
|
assert r.floor == 9
|
|
assert r.total_floors == 25
|
|
|
|
def test_studio_ceiling_kitchen_legitimately_absent(self) -> None:
|
|
# This is a newbuilding offer — Yandex does not expose ceiling/kitchen/
|
|
# living for it. None here is legitimate, not a parse miss.
|
|
r = SCRAPER.parse(_load(_STUDIO_ID), offer_url=_url(_STUDIO_ID))
|
|
assert r is not None
|
|
assert r.ceiling_height is None
|
|
assert r.kitchen_area_m2 is None
|
|
assert r.living_area_m2 is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helper-level coverage
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestStructuralHelpers:
|
|
def test_extract_offer_card_matches_offer_id(self) -> None:
|
|
card = _extract_offer_card(_load(_SECONDARY_ID), _SECONDARY_ID)
|
|
assert card is not None
|
|
assert card["offerId"] == _SECONDARY_ID
|
|
|
|
def test_extract_offer_card_missing_state_returns_none(self) -> None:
|
|
assert _extract_offer_card("<html><body>no state</body></html>", "123") is None
|
|
|
|
def test_parse_card_fields_space_objects(self) -> None:
|
|
card = {
|
|
"roomsTotal": 2,
|
|
"area": {"value": 55.3, "unit": "SQUARE_METER"},
|
|
"livingSpace": {"value": 30.0, "unit": "SQUARE_METER"},
|
|
"kitchenSpace": {"value": 12.5, "unit": "SQUARE_METER"},
|
|
"ceilingHeight": 2.7,
|
|
"floorsTotal": 18,
|
|
"floorsOffered": [7],
|
|
"house": {"studio": False},
|
|
}
|
|
rooms, area, living, kitchen, ceiling, floor, total = _parse_card_fields(card)
|
|
assert rooms == 2
|
|
assert area == pytest.approx(55.3)
|
|
assert living == pytest.approx(30.0)
|
|
assert kitchen == pytest.approx(12.5)
|
|
assert ceiling == pytest.approx(2.7)
|
|
assert floor == 7
|
|
assert total == 18
|
|
|
|
def test_parse_card_fields_studio_flag_sets_rooms_zero(self) -> None:
|
|
card = {
|
|
"roomsTotal": None,
|
|
"area": {"value": 24.92, "unit": "SQUARE_METER"},
|
|
"floorsOffered": [9],
|
|
"floorsTotal": 25,
|
|
"house": {"studio": True},
|
|
}
|
|
rooms, area, living, kitchen, ceiling, floor, total = _parse_card_fields(card)
|
|
assert rooms == 0
|
|
assert area == pytest.approx(24.92)
|
|
assert living is None
|
|
assert kitchen is None
|
|
assert ceiling is None
|
|
assert floor == 9
|
|
assert total == 25
|