gendesign/tradein-mvp/backend/tests/scrapers/test_admin_yandex_kit_parity.py
bot-backend ea24289f47
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
feat(tradein/scraper-kit): migrate admin.py debug routes + domclick ingest to kit (#2305)
Group A of the scraper_kit migration epic (#2277). Switches admin.py's manual
"run parser" debug endpoints and scripts/ingest_domclick_jsonl.py from direct
app.services.scrapers.* imports to their scraper_kit.providers.* equivalents,
using the DI adapters (RealScraperConfig/RealMatcherAdapter/RealProxyProvider,
app.services.scraper_settings.get_scraper_delay) already established by
app.scheduler_main._run_kit_scheduler.

Migrated: /scrape (AvitoScraper/CianScraper/YandexRealtyScraper + save_listings),
scrape_avito_house, scrape_avito_detail, scrape_avito_imv, scrape_yandex_detail,
scrape_yandex_valuation, scrape_cian_detail, cian_auto_login's BrowserFetcher.
scripts/ingest_domclick_jsonl.py: ScrapedLot/save_listings + (now that #2307/
Group D ported a kit equivalent while this was in flight) DomClickDetailEnrichment/
save_detail_enrichment.

Deliberately NOT migrated (documented in admin.py): scrape_yandex_newbuilding /
scrape_cian_newbuilding — their kit equivalents
(providers/{yandex,cian}/newbuilding.py) construct an internal BrowserFetcher(...)
without the mandatory `endpoint` kwarg, so any call crashes/silently-fails
regardless of caller-side DI. Bug lives in scraper_kit provider code, out of
scope here (only consuming, not touching provider logic) — flagged as follow-up.

Parity proven via tests/support/parity.py (assert_parity) against the exact
functions each debug route now calls, on offline fixtures — no live network/DB.

Refs #2305
2026-07-04 00:27:01 +03:00

151 lines
6.2 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.

"""Parity-proof: admin.py Yandex debug-роуты (#2305, Group A) — legacy vs scraper_kit.
`app/api/v1/admin.py::scrape_yandex_detail` / `scrape_yandex_valuation` теперь
зовут `scraper_kit.providers.yandex.{detail,valuation}` вместо
`app.services.scrapers.yandex_{detail,valuation}` (тот же `/scrape` root-роут —
`scraper_kit.providers.yandex.serp.YandexRealtyScraper`). Каждая функция ниже —
чистое, offline-тестируемое parsing-ядро (см. `tests/support/README.md`:
"Live network/DB в parity-тестах избегайте").
- `_parse_card_fields` — `scrape_yandex_detail` → `YandexDetailScraper.fetch_detail`
извлекает (rooms, area, living, kitchen, ceiling, floor, total_floors) этой функцией.
- `YandexValuationScraper._parse_house_meta` (@staticmethod) — `scrape_yandex_valuation`
→ `fetch_house_history` извлекает house-level метаданные этой функцией.
- `_entity_to_lot` — `/scrape` root-роут → `YandexRealtyScraper.fetch_around`
конвертирует gate-API entity → ScrapedLot этой функцией (также доказывает
ScrapedLot-контракт, общий с `save_listings`/`scripts/ingest_domclick_jsonl.py`).
"""
from __future__ import annotations
import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
from scraper_kit.providers.yandex.detail import _parse_card_fields as kit_parse_card_fields
from scraper_kit.providers.yandex.serp import _entity_to_lot as kit_entity_to_lot
from scraper_kit.providers.yandex.valuation import (
YandexValuationScraper as KitYandexValuationScraper,
)
from app.services.scrapers.yandex_detail import _parse_card_fields as legacy_parse_card_fields
from app.services.scrapers.yandex_realty import _entity_to_lot as legacy_entity_to_lot
from app.services.scrapers.yandex_valuation import (
YandexValuationScraper as LegacyYandexValuationScraper,
)
from tests.support.parity import assert_parity
_CARD = {
"roomsTotal": 2,
"area": {"value": 54.3},
"livingSpace": {"value": 32.1},
"kitchenSpace": {"value": 9.5},
"ceilingHeight": 2.7,
"floorsOffered": [5],
"floorsTotal": 9,
}
_CARD_STUDIO = {
"house": {"studio": True},
"area": {"value": 25.0},
"floorsOffered": [1],
"floorsTotal": 5,
}
_VALUATION_BODY_TEXT = (
"Дом 2015 года. 9 этажей. 2,7 м потолки. Лифт есть. 120 объектов продажи. Панорама двора."
)
# gate-API entity (realty.yandex.ru), без creationDate/author — исключает
# time-dependent поля (days_on_market) из фикстуры (deterministic offline).
_YANDEX_ENTITY = {
"offerId": "12345",
"url": "//realty.yandex.ru/offer/12345",
"price": {"value": 8500000, "valuePerPart": 157000},
"area": {"value": 54.3},
"livingSpace": {"value": 32.1},
"kitchenSpace": {"value": 9.5},
"roomsTotal": 2,
"floorsOffered": [5],
"floorsTotal": 9,
"ceilingHeight": 2.7,
"building": {"builtYear": 2015, "buildingType": "MONOLIT"},
"location": {
"point": {"latitude": 56.83, "longitude": 60.6},
"geocoderAddress": "Екатеринбург, ул. Тестовая, 1",
},
"mainImages": ["//avatars.mds.yandex.net/img1.jpg"],
}
def test_parity_yandex_detail_card_fields() -> None:
"""`scrape_yandex_detail` (admin.py) → fetch_detail → _parse_card_fields."""
assert_parity(
legacy_fn=legacy_parse_card_fields,
kit_fn=kit_parse_card_fields,
fixtures=[(_CARD,), (_CARD_STUDIO,)],
)
def test_yandex_valuation_house_meta_are_class_distinct() -> None:
"""Sanity: legacy/kit ValuationHouseMeta — разные классы, обычный `==` даёт False."""
legacy_result = LegacyYandexValuationScraper._parse_house_meta(_VALUATION_BODY_TEXT)
kit_result = KitYandexValuationScraper._parse_house_meta(_VALUATION_BODY_TEXT)
assert type(legacy_result) is not type(kit_result)
assert legacy_result.model_dump() == kit_result.model_dump()
def test_parity_yandex_valuation_house_meta() -> None:
"""`scrape_yandex_valuation` (admin.py) → fetch_house_history → _parse_house_meta."""
assert_parity(
legacy_fn=LegacyYandexValuationScraper._parse_house_meta,
kit_fn=KitYandexValuationScraper._parse_house_meta,
fixtures=[_VALUATION_BODY_TEXT],
)
def test_yandex_entity_to_lot_are_class_distinct() -> None:
"""Sanity: legacy/kit ScrapedLot — разные классы (pydantic), обычный `==` даёт False."""
legacy_result = legacy_entity_to_lot(_YANDEX_ENTITY)
kit_result = kit_entity_to_lot(_YANDEX_ENTITY)
assert legacy_result is not None
assert kit_result is not None
assert type(legacy_result) is not type(kit_result)
assert legacy_result.model_dump() == kit_result.model_dump()
def test_parity_yandex_serp_entity_to_lot() -> None:
"""`/scrape` root-роут (admin.py) → YandexRealtyScraper.fetch_around → _entity_to_lot.
Также доказывает ScrapedLot-контракт, общий с `save_listings`
(используется и `/scrape`, и `scripts/ingest_domclick_jsonl.py`).
"""
assert_parity(
legacy_fn=legacy_entity_to_lot,
kit_fn=kit_entity_to_lot,
fixtures=[(_YANDEX_ENTITY,)],
)
def test_parity_harness_catches_yandex_entity_divergence() -> None:
"""Harness реально ЛОВИТ расхождение — мутируем price_rub в kit-результате."""
import pytest
from tests.support.parity import ParityMismatchError
legacy_result = legacy_entity_to_lot(_YANDEX_ENTITY)
kit_result = kit_entity_to_lot(_YANDEX_ENTITY)
assert legacy_result is not None
assert kit_result is not None
# ScrapedLot — pydantic BaseModel (НЕ dataclass) → model_copy, не dataclasses.replace.
mutated_kit_result = kit_result.model_copy(update={"price_rub": kit_result.price_rub + 1})
with pytest.raises(ParityMismatchError) as exc_info:
assert_parity(
legacy_fn=lambda: legacy_result,
kit_fn=lambda: mutated_kit_result,
fixtures=[()],
)
assert "price_rub" in str(exc_info.value)