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
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
151 lines
6.2 KiB
Python
151 lines
6.2 KiB
Python
"""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)
|