gendesign/tradein-mvp/backend/tests/test_scraper_kit_cian_golden_parity.py
bot-backend 575c233e51
All checks were successful
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 1m2s
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
refactor(tradein/scrapers): удалить 6 осиротевших legacy scraper-модулей + тест-хирургия (#2397 Part E2)
app.services.scrapers.{cian,cian_detail,yandex_realty,domclick,domclick_exceptions,
avito_houses} имели 0 runtime-импортёров после E1 (scrape_pipeline.py удалён,
#2405) — admin.py/cian_history_backfill.py уже на scraper_kit.providers.*.

Удаление:
- 6 модулей целиком (avito_houses.py, cian.py, cian_detail.py, domclick.py,
  domclick_exceptions.py, yandex_realty.py)
- 3 осиротевших dev-sweep скрипта (local-sweep-ekb{,-cian,-yandex}.py) —
  эксклюзивно драйвили удалённые легаси-скраперы, kit-путь их заменяет

Тесты (18 файлов DELETE — эксклюзивно тестировали удалённые модули или были
чистым legacy-vs-kit parity, чей легаси-эталон исчез):
test_cian_detail.py, test_cian_serp_scraper.py, tests/scrapers/test_cian_exhaustive.py,
test_yandex_realty_serp.py, tests/scrapers/test_domclick.py,
test_avito_houses_dedup.py, test_avito_houses_parse.py,
test_houses_rating_filter_entries_1789.py, test_scraper_kit_domclick_golden_parity.py,
tests/scrapers/test_admin_{avito,cian,yandex}_kit_parity.py.

CONVERT (файл покрывает СМЕШАННЫЙ live+legacy набор — вырезаны только
секции удалённых модулей, live-покрытие сохранено):
- test_scraper_kit_cian_golden_parity.py — SERP/`_format_address`/detail
  (cian.py/cian_detail.py) убраны; extract_state (cian_state_parser, live)/
  valuation (cian_valuation, live)/newbuilding (cian_newbuilding, live)/
  no-app-imports guard — остались
- test_scraper_kit_yandex_golden_parity.py — SERP `_entity_to_lot`/
  `_parse_gate_json`/`_build_url` (yandex_realty) убраны; normalize_house_type
  (live)/newbuilding parse (yandex_newbuilding, live)/no-app-imports guard —
  остались
- test_scraper_kit_bisection_parity.py — cian/domclick/yandex-ветки убраны;
  avito-ветка (live, единственная оставшаяся легаси-бисекция) — осталась
  полностью, включая full-seed-grid
- test_scraper_kit_group_c_backfill_kit_parity.py — cian fetch_detail
  browser_fetcher parity-тест убран; avito/house_imv_backfill/
  cian_history_backfill/ekb_geoportal тесты — остались
- test_scraper_kit_pricehistory_session_parity.py — fetch_detail
  config-injection/proxy-wiring тесты (cian_detail) убраны; extract_state
  (cian_state_parser, live)/ScrapedLot (base, live) parity — остались
- test_yandex_scrapers_delay_wiring.py — yandex_realty delay-тест убран
  (kit-эквивалент уже покрыт test_kit_serp_proxy_pool.py); yandex_newbuilding
  (live)/yandex_detail/yandex_valuation (kit) тесты — остались
- test_scraper_proxy.py — yandex_realty BrowserFetcher-тест убран (redundant
  с test_kit_serp_proxy_pool.py::test_yandex_serp_no_pool_by_default)

RETARGET на kit (символ живёт, только источник поменялся):
- test_domclick_sweep.py — `_map_item` → scraper_kit.providers.domclick.serp
- test_yandex_city_sweep.py — DEFAULT_PRICE_RANGES/ROOM_PATH →
  scraper_kit.providers.yandex.serp
- test_pipeline_browser_routing.py — fetch_house_catalog →
  scraper_kit.providers.avito.houses (+ KitAvitoBlockedError для firewall-теста)
- test_snapshot_writer.py — DetailEnrichment/save_detail_enrichment →
  scraper_kit.providers.cian.detail (byte-identical strangler-копия)
- test_backfill_wave2.py — DetailEnrichment → scraper_kit.providers.cian.detail
- tests/scrapers/test_avito_unix_date_tz_consistency.py — avito_houses →
  scraper_kit.providers.avito.houses (houses-ветка _unix_to_date/RUS_MONTHS
  consolidation-проверки)

Stale-комментарии поправлены в cian_price_history.py, tests/scrapers/
test_domclick_detail.py (описывали ещё-живой domclick.py/domclick_exceptions.py).

grep -rn scrapers\.\(cian\|cian_detail\|yandex_realty\|domclick\|domclick_exceptions\|
avito_houses\) app/ scripts/ packages/ — только historical footnotes
("strangler-копия app.services.scrapers.X", past-tense "удалён/deleted").

Backend suite: 2811 passed, 6 skipped, 1 failed (known flake test_search_cache_hit
#2208, не регрессия). ruff check чист на всех изменённых файлах.
2026-07-04 14:37:19 +03:00

369 lines
15 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.

"""Golden-parity: `scraper_kit.providers.cian.*` парсинг ≡ ещё живым `app.services.scrapers.cian*`.
Strangler-инвариант (#2133 cian): новая scraper_kit-копия cian-провайдера должна давать
БАЙТ-ИДЕНТИЧНЫЙ результат парсинга старому боевому коду на одинаковом входе. Развязка
(settings→ScraperConfig, get_scraper_delay→delay_provider, cian_session→локальная копия)
НЕ меняет распарсенные данные.
`app.services.scrapers.cian` / `cian_detail` удалены (#2397 Part E2, 0 runtime-
импортёров — admin.py/cian_history_backfill.py уже на kit). Parity-тесты SERP
`_parse_serp_html`/`_offer_to_lot`/`_extract_total_offers`/`_format_address` и
detail `fetch_detail` удалены вместе с ними (легаси-эталон исчез).
Оставшееся здесь сравнивает kit ≡ ЕЩЁ ЖИВЫЕ легаси-модули:
- `extract_state` — `app.services.scrapers.cian_state_parser` (живой, общий парсер state);
- valuation `_parse_valuation_state` / `estimate_via_cian_valuation` —
`app.services.scrapers.cian_valuation` (живой);
- newbuilding pure-экстракторы — `app.services.scrapers.cian_newbuilding` (живой);
- kit mandatory-config guards (`estimate_via_cian_valuation`, `cian.session`) — kit-only;
- strangler-guard: kit-провайдер не импортирует `app.*`.
Идентичность результата = kit-парсер верно скопирован (развязка не задела логику).
"""
from __future__ import annotations
import asyncio
import dataclasses
import json
import os
from types import SimpleNamespace
from typing import Any
from unittest.mock import MagicMock
import pytest
# Старый app.services.scrapers.cian_state_parser/cian_valuation/cian_newbuilding
# импортируют app.core.config.settings=Settings(), которому нужен DATABASE_URL.
# Офлайн-парсинг БД не трогает — фиктивный DSN достаточен.
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
# НОВЫЙ (scraper_kit) cian-провайдер.
from scraper_kit.cian_state_parser import extract_state as new_extract_state
from scraper_kit.providers.cian.newbuilding import _extract_chart as new_extract_chart
from scraper_kit.providers.cian.newbuilding import (
_extract_nested_offers as new_extract_nested_offers,
)
from scraper_kit.providers.cian.newbuilding import (
_extract_reliability_checks as new_extract_reliability,
)
from scraper_kit.providers.cian.newbuilding import (
_extract_zhk_url_from_serp as new_extract_zhk_url,
)
from scraper_kit.providers.cian.valuation import (
_parse_valuation_state as new_parse_valuation,
)
from scraper_kit.providers.cian.valuation import (
estimate_via_cian_valuation as new_estimate_via_cian_valuation,
)
# #2335 (Group E2): estimate_via_cian_valuation full-pipeline parity + mandatory-config
# regression guard. RealScraperConfig — тот же read-only адаптер над settings, что и
# #2306's cian_price_history.py (см. app/services/scraper_adapters.py).
from app.services.scraper_adapters import RealScraperConfig
# СТАРЫЙ (app.services.scrapers) — ещё живые cian-модули (cian.py/cian_detail.py удалены).
from app.services.scrapers.cian_newbuilding import _extract_chart as old_extract_chart
from app.services.scrapers.cian_newbuilding import (
_extract_nested_offers as old_extract_nested_offers,
)
from app.services.scrapers.cian_newbuilding import (
_extract_reliability_checks as old_extract_reliability,
)
from app.services.scrapers.cian_newbuilding import (
_extract_zhk_url_from_serp as old_extract_zhk_url,
)
from app.services.scrapers.cian_state_parser import extract_state as old_extract_state
from app.services.scrapers.cian_valuation import (
_parse_valuation_state as old_parse_valuation,
)
from app.services.scrapers.cian_valuation import (
estimate_via_cian_valuation as old_estimate_via_cian_valuation,
)
from tests.support.parity import assert_parity
# ── extract_state parity (state-парсер, живой в cian_state_parser.py) ────────
def _serp_html_for_state(state: dict[str, Any]) -> str:
"""Обернуть произвольный state в валидный _cianConfig['frontend-serp'].push() HTML."""
state_json = json.dumps(state, ensure_ascii=False)
return (
"<html><body><script>\n"
"window._cianConfig['frontend-serp'] = window._cianConfig['frontend-serp'] || [];\n"
"window._cianConfig['frontend-serp'].push({"
f"key: 'initialState', value: {state_json}, priority: 10, filter: null"
"});\n</script></body></html>"
)
def test_extract_state_parity() -> None:
"""app.extract_state ≡ scraper_kit.extract_state на SERP-фикстуре."""
html = _serp_html_for_state({"results": {"totalOffers": 1, "offers": []}})
old_state = old_extract_state(html, mfe="frontend-serp", key="initialState")
new_state = new_extract_state(html, mfe="frontend-serp", key="initialState")
assert old_state == new_state
assert new_state is not None
# ── valuation _parse_valuation_state parity (живой cian_valuation.py) ────────
def _valuation_state() -> dict[str, Any]:
return {
"user": {"isAuthenticated": True, "userId": 42},
"estimation": {
"sale": {
"data": {
"price": 6800000,
"accuracy": 87,
"priceFrom": 6400000,
"priceTo": 7200000,
"priceSqm": 130000,
"filtersHash": "hash-abc",
}
},
"rent": {
"data": {
"price": 42000,
"accuracy": 80,
"priceFrom": 39000,
"priceTo": 45000,
"taxPrice": 5460,
}
},
},
"estimationChart": {
"data": {
"title": {"change": "increase", "changeValue": "8,3%"},
"chartData": {
"data": [
{"date": 1714521600000, "price": 6700000, "priceFormatted": "6,7 млн"},
{"date": 1717200000000, "price": 6800000, "priceFormatted": "6,8 млн"},
]
},
}
},
"houseInfo": {
"data": {
"items": [
{"title": "Год постройки", "value": "2010"},
{"title": "Тип дома", "value": "монолит"},
]
}
},
"filters": {"houseId": 998877},
"managementCompany": {"data": {"name": "УК Ромашка", "phones": ["+73430000000"]}},
}
def test_valuation_parse_state_parity() -> None:
"""_parse_valuation_state → CianValuationResult идентичен (все поля)."""
state = _valuation_state()
old_res = old_parse_valuation(state)
new_res = new_parse_valuation(state)
assert dataclasses.asdict(old_res) == dataclasses.asdict(new_res)
# ── estimate_via_cian_valuation full-pipeline parity (#2335, Group E2) ───────
#
# test_valuation_parse_state_parity above only covers the pure parsing helper.
# These tests exercise the FULL async call path (cache lookup → load_session →
# curl_cffi fetch → extract_state → auth-check → parse → sanity-check → cache
# write), with config=RealScraperConfig() wired per the #2306 convention
# (app/services/cian_price_history.py), proving the kit path is safe to switch
# to whenever #2337 wires estimator.py's call site.
def _valuation_mfe_html(state: dict[str, Any]) -> str:
"""Wrap a valuation state dict as a valid _cianConfig push() HTML page."""
state_json = json.dumps(state, ensure_ascii=False)
return (
"<html><body><script>\n"
"window._cianConfig['valuation-for-agent-frontend'] = "
"window._cianConfig['valuation-for-agent-frontend'] || [];\n"
"window._cianConfig['valuation-for-agent-frontend'].push({"
f"key: 'initialState', value: {state_json}, priority: 10, filter: null"
"});\n</script></body></html>"
)
def _make_fake_async_session_cls(html: str) -> type:
"""Stand-in for curl_cffi.requests.AsyncSession — returns canned HTML on .get()."""
class _FakeAsyncSession:
def __init__(self, *args: Any, **kwargs: Any) -> None:
pass
async def __aenter__(self) -> _FakeAsyncSession:
return self
async def __aexit__(self, *exc: object) -> bool:
return False
async def get(self, url: str, allow_redirects: bool = True) -> SimpleNamespace:
return SimpleNamespace(status_code=200, text=html)
return _FakeAsyncSession
def test_estimate_via_cian_valuation_full_pipeline_parity(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Full estimate_via_cian_valuation() pipeline parity — not just the pure parser.
Mocks curl_cffi.AsyncSession (canned MFE HTML) + load_session (fixed cookies) on
BOTH legacy and kit modules, cache-miss on a MagicMock db, and drives both async
call paths with identical inputs (kit wired with config=RealScraperConfig()).
"""
html = _valuation_mfe_html(_valuation_state())
fake_session_cls = _make_fake_async_session_cls(html)
def _run_legacy() -> Any:
monkeypatch.setattr("app.services.scrapers.cian_valuation.AsyncSession", fake_session_cls)
monkeypatch.setattr(
"app.services.scrapers.cian_valuation.load_session",
lambda db: {"DMIR_AUTH": "token"},
)
db = MagicMock()
db.execute.return_value.mappings.return_value.first.return_value = None # cache miss
return asyncio.run(
old_estimate_via_cian_valuation(
db,
address="Свердловская обл, Екатеринбург",
total_area=50.0,
rooms_count=2,
floor=5,
total_floors=16,
use_cache=False,
)
)
def _run_kit() -> Any:
monkeypatch.setattr("scraper_kit.providers.cian.valuation.AsyncSession", fake_session_cls)
monkeypatch.setattr(
"scraper_kit.providers.cian.valuation.load_session",
lambda db, *, config: {"DMIR_AUTH": "token"},
)
db = MagicMock()
db.execute.return_value.mappings.return_value.first.return_value = None # cache miss
return asyncio.run(
new_estimate_via_cian_valuation(
db,
config=RealScraperConfig(),
address="Свердловская обл, Екатеринбург",
total_area=50.0,
rooms_count=2,
floor=5,
total_floors=16,
use_cache=False,
)
)
assert_parity(
legacy_fn=_run_legacy,
kit_fn=_run_kit,
fixtures=[()],
)
def test_kit_estimate_via_cian_valuation_requires_config() -> None:
"""Kit's estimate_via_cian_valuation MUST require config= (no default) — mandatory.
Regression guard (#2335): config: ScraperConfig has NO default in the kit
signature, so calling it without config= raises TypeError immediately (at
argument-binding time, before any coroutine runs). This is intentional and
must stay this way: estimator.py's call site (#2337) sits inside a blanket
`except Exception` used for graceful multi-source degradation — a future
change that gave config= a default would let a wiring mistake silently
degrade into "source unavailable" instead of raising loudly at call-time.
See #2306 for the precedent this guards against (silent-degradation footgun
from an optional dependency parameter).
"""
db = MagicMock()
with pytest.raises(TypeError, match="config"):
new_estimate_via_cian_valuation( # not awaited — TypeError raises at call time
db,
address="Свердловская обл, Екатеринбург",
total_area=50.0,
rooms_count=2,
floor=5,
)
def test_kit_cian_session_config_requirement_asymmetry() -> None:
"""scraper_kit.providers.cian.session: load_session requires config=, but
mark_session_invalid does NOT.
Confirmed by direct inspection for #2335 — the two helpers estimate_via_cian_valuation
calls internally are NOT symmetric re: config, unlike the assumption in the original
task brief ("the kit version of those also requires config mandatorily"). Documented
here as a regression guard so a future refactor that changes either signature is a
deliberate, visible decision rather than an accidental drift.
"""
from scraper_kit.providers.cian.session import load_session, mark_session_invalid
db = MagicMock()
with pytest.raises(TypeError, match="config"):
load_session(db) # missing mandatory config=
db2 = MagicMock()
mark_session_invalid(db2, 42) # no config parameter at all — must NOT raise
db2.execute.assert_called_once()
# ── newbuilding pure-экстракторы parity (живой cian_newbuilding.py) ──────────
def test_newbuilding_extractors_parity() -> None:
rv_state = {
"data": {
"priceDynamics": {
"chart": {
"data": {"labels": ["2026-01-01", "2026-02-01"], "values": [120000, 125000]}
}
}
}
}
assert old_extract_chart(rv_state) == new_extract_chart(rv_state)
rel_state = {
"checkStatus": {"status": "reliable", "title": "Надёжный застройщик"},
"details": [{"title": "Проектная декларация", "type": "doc"}],
}
assert old_extract_reliability(rel_state) == new_extract_reliability(rel_state)
offers_state = {
"data": {
"total": {
"fromDeveloperRooms": [
{"roomType": "1", "layouts": [{"id": 1, "area": 38}]},
{"roomType": "2", "layouts": [{"id": 2, "area": 55}]},
]
}
}
}
assert old_extract_nested_offers(offers_state) == new_extract_nested_offers(offers_state)
serp_html = (
'<h1 data-name="Title">Купить квартиру в '
'<a href="https://zhk-parkovyy-kvartal-ekb-i.cian.ru/">ЖК «Парковый квартал»</a></h1>'
)
assert old_extract_zhk_url(serp_html) == new_extract_zhk_url(serp_html)
# ── strangler-guard: kit-провайдер не импортирует app.* ──────────────────────
def test_kit_cian_has_no_app_imports() -> None:
"""Ни один модуль scraper_kit.providers.cian.* не импортирует app.* (развязка)."""
import inspect
from scraper_kit.providers.cian import detail, newbuilding, serp, session, valuation
for mod in (serp, detail, newbuilding, valuation, session):
source = inspect.getsource(mod)
for line in source.splitlines():
stripped = line.strip()
assert not stripped.startswith("from app"), f"{mod.__name__}: {line!r}"
assert not stripped.startswith("import app"), f"{mod.__name__}: {line!r}"