fix(tradein/scrapers): migrate cian_price_history/cian_session/yandex_price_history imports to scraper_kit (#2306)

Group B of the scraper_kit migration epic (#2277): switch the three files'
internal dependency imports from legacy app/services/scrapers/* to their
byte/structurally-identical scraper_kit equivalents, proven via the new
parity harness (tests/support/parity.assert_parity, #2304). Public API of
all three files is unchanged, so no callers needed updating.

- cian_price_history.py: fetch_detail/save_detail_enrichment now from
  scraper_kit.providers.cian.detail. Wires config=RealScraperConfig() at
  the call site — kit's fetch_detail only reads cian_proxy_url when an
  explicit config is passed, unlike legacy's always-on settings read;
  without this the admin-triggered backfill would silently drop its
  datacenter-IP proxy (#806) after the import swap.
- cian_session.py: extract_state now from scraper_kit.cian_state_parser
  (byte-identical to legacy).
- yandex_price_history.py: ScrapedLot now from scraper_kit.base
  (byte-identical fields/methods; record_yandex_price_history only reads
  lot.* via duck-typing, no isinstance checks).

New tests/test_scraper_kit_pricehistory_session_parity.py proves the
migration delta specifically (extract_state, ScrapedLot.model_dump(),
fetch_detail+DetailEnrichment with config injection, and the own-session
proxy-kwarg wiring). Full backend suite passes (3145 passed, 6 skipped,
1 pre-existing unrelated failure confirmed on unmodified forgejo/main).
This commit is contained in:
bot-backend 2026-07-04 00:13:07 +03:00
parent a7677b0fba
commit db3afa3176
4 changed files with 235 additions and 5 deletions

View file

@ -15,11 +15,17 @@ import logging
import time
from dataclasses import dataclass, field
# #2306: fetch_detail/save_detail_enrichment migrated to scraper_kit (golden-parity
# with legacy app.services.scrapers.cian_detail, см. tests/test_scraper_kit_cian_golden_parity.py
# + tests/test_scraper_kit_pricehistory_session_parity.py). RealScraperConfig — тот же
# read-only адаптер над settings, что и остальные kit-инжекции (#2131) — сохраняет
# proxy-поведение (config.cian_proxy_url) идентичным прежнему прямому импорту settings.
from scraper_kit.providers.cian.detail import fetch_detail, save_detail_enrichment
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.services.scraper_adapters import RealScraperConfig
from app.services.scraper_settings import get_scraper_delay
from app.services.scrapers.cian_detail import fetch_detail, save_detail_enrichment
logger = logging.getLogger(__name__)
@ -99,7 +105,9 @@ async def backfill_cian_price_history(
url: str = row["source_url"]
try:
enrichment = await fetch_detail(url)
# config= обязателен — kit fetch_detail без него не читает cian_proxy_url
# (direct connection), а без прокси datacenter-IP блокируется Cian (#806).
enrichment = await fetch_detail(url, config=RealScraperConfig())
except Exception as exc:
logger.warning(
"cian_price_history: fetch failed listing_id=%s url=%s: %s",

View file

@ -11,11 +11,15 @@ import logging
from typing import Any
from curl_cffi.requests import AsyncSession
# #2306: extract_state migrated to scraper_kit (byte-identical to legacy
# app.services.scrapers.cian_state_parser, см. tests/test_scraper_kit_cian_golden_parity.py
# + tests/test_scraper_kit_pricehistory_session_parity.py).
from scraper_kit.cian_state_parser import extract_state
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.config import settings
from app.services.scrapers.cian_state_parser import extract_state
logger = logging.getLogger(__name__)

View file

@ -26,11 +26,15 @@ from __future__ import annotations
import logging
from datetime import UTC, datetime, timedelta
# #2306: ScrapedLot migrated to scraper_kit (byte-identical field/method surface to
# legacy app.services.scrapers.base, см. tests/test_scraper_kit_pricehistory_session_parity.py).
# record_yandex_price_history reads lot.* via duck-typing only — no isinstance checks —
# так что kit- и legacy-скрапперы (оба конструируют ScrapedLot по (source, source_id,
# price_rub, price_previous_rub, price_trend)) остаются взаимозаменяемы для этой функции.
from scraper_kit.base import ScrapedLot
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.services.scrapers.base import ScrapedLot
logger = logging.getLogger(__name__)
_SOURCE = "yandex"

View file

@ -0,0 +1,214 @@
"""Parity tests for issue #2306 (Group B of scraper_kit migration epic #2277).
Migrates the INTERNAL legacy-scraper imports of three product-side files to their
`scraper_kit` equivalents (public API of the three files is unchanged no caller
elsewhere in the codebase needed updating, see vault audit
`Scraper_Kit_Legacy_Dependency_Audit_0703`):
- app/services/cian_price_history.py fetch_detail/save_detail_enrichment now from
`scraper_kit.providers.cian.detail` (was `app.services.scrapers.cian_detail`).
- app/services/cian_session.py extract_state now from
`scraper_kit.cian_state_parser` (was `app.services.scrapers.cian_state_parser`).
- app/services/yandex_price_history.py ScrapedLot now from `scraper_kit.base`
(was `app.services.scrapers.base`).
This file proves the DELTA introduced by the migration specifically general
cian-provider / ScrapedLot golden-parity is already covered by
`tests/test_scraper_kit_cian_golden_parity.py` and
`tests/test_scraper_kit_base_golden_parity.py`. Uses `tests/support/parity.assert_parity`
per the epic's parity-gate convention (issue #2304).
"""
from __future__ import annotations
import asyncio
import dataclasses
import json
import os
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
# Старый app.services.scrapers.cian* / app.core.config.settings требует DATABASE_URL
# при импорте (тот же паттерн, что в test_scraper_kit_cian_golden_parity.py).
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
# Kit — то, на что теперь ссылаются мигрированные файлы (#2306).
from scraper_kit.base import ScrapedLot as KitScrapedLot
from scraper_kit.cian_state_parser import extract_state as kit_extract_state
from scraper_kit.providers.cian.detail import fetch_detail as kit_fetch_detail
from app.core.config import settings
from app.services.scraper_adapters import RealScraperConfig
# Legacy — эталон сравнения (эти модули НЕ меняются в #2306).
from app.services.scrapers.base import ScrapedLot as LegacyScrapedLot
from app.services.scrapers.cian_detail import fetch_detail as legacy_fetch_detail
from app.services.scrapers.cian_state_parser import extract_state as legacy_extract_state
from tests.support.parity import assert_parity
# ── extract_state parity (cian_session.py dependency) ─────────────────────────
def _auth_state_html() -> str:
state = {"user": {"isAuthenticated": True, "userId": 42}}
state_json = json.dumps(state, ensure_ascii=False)
return (
"<html><body><script>\n"
"window._cianConfig['header-frontend'] = window._cianConfig['header-frontend'] || [];\n"
"window._cianConfig['header-frontend'].push({"
f"key: 'initialState', value: {state_json}, priority: 10, filter: null"
"});\n</script></body></html>"
)
def test_extract_state_parity_on_match() -> None:
"""cian_session.py._classify_verify_response reads this exact (mfe, key) pair."""
html = _auth_state_html()
assert_parity(
legacy_fn=legacy_extract_state,
kit_fn=kit_extract_state,
fixtures=[(html, "header-frontend", "initialState")],
)
def test_extract_state_parity_on_miss() -> None:
"""Missing mfe/key → both return None identically (not just on the happy path)."""
html = _auth_state_html()
assert_parity(
legacy_fn=legacy_extract_state,
kit_fn=kit_extract_state,
fixtures=[(html, "frontend-offer-card", "defaultState")],
)
# ── ScrapedLot parity (yandex_price_history.py dependency) ────────────────────
def _lot_kwargs() -> dict[str, Any]:
return {
"source": "yandex",
"source_url": "https://realty.yandex.ru/offer/123/",
"source_id": "123",
"price_rub": 5_000_000,
"price_previous_rub": 5_200_000,
"price_trend": "DECREASED",
}
def test_scraped_lot_model_dump_parity() -> None:
"""record_yandex_price_history duck-types lot.source/.source_id/.price_rub/...
Legacy- и kit-ScrapedLot должны давать идентичный model_dump() на одном входе
доказывает, что смена типа импорта в yandex_price_history.py не меняет runtime-
поведение (функция принимает оба класса взаимозаменяемо).
"""
kwargs = _lot_kwargs()
assert_parity(
legacy_fn=lambda kw: LegacyScrapedLot(**kw).model_dump(),
kit_fn=lambda kw: KitScrapedLot(**kw).model_dump(),
fixtures=[kwargs],
)
# ── fetch_detail / DetailEnrichment parity (cian_price_history.py dependency) ──
def _detail_html() -> str:
default_state = {
"offerData": {
"offer": {
"cianId": 555111,
"priceChanges": [
{"changeTime": "2026-05-01T00:00:00Z", "price": 6100000, "diffPercent": -1.2}
],
},
"stats": {},
}
}
ds_json = json.dumps(default_state, ensure_ascii=False)
return (
"<html><body><script>\n"
"window._cianConfig['frontend-offer-card'] = "
"window._cianConfig['frontend-offer-card'] || [];\n"
"window._cianConfig['frontend-offer-card'].push({"
f"key: 'defaultState', value: {ds_json}, priority: 10, filter: null"
"});\n</script></body></html>"
)
def _mock_session(html: str) -> MagicMock:
response = MagicMock()
response.status_code = 200
response.text = html
session = MagicMock()
session.get = AsyncMock(return_value=response)
session.close = AsyncMock()
return session
def _run_legacy_fetch(url: str, html: str) -> Any:
return dataclasses.asdict(asyncio.run(legacy_fetch_detail(url, session=_mock_session(html))))
def _run_kit_fetch(url: str, html: str) -> Any:
# Точно тот вызов, что теперь делает cian_price_history.backfill_cian_price_history:
# config=RealScraperConfig() добавлен по миграции (#2306); session= общий mock здесь
# только чтобы избежать сетевого I/O — сам config не участвует в session-пути, так
# что этот тест доказывает: инжекция config= не меняет результат парсинга.
result = asyncio.run(
kit_fetch_detail(url, config=RealScraperConfig(), session=_mock_session(html))
)
return dataclasses.asdict(result)
def test_fetch_detail_with_config_injection_parity() -> None:
html = _detail_html()
url = "https://ekb.cian.ru/sale/flat/555111/"
assert_parity(
legacy_fn=_run_legacy_fetch,
kit_fn=_run_kit_fetch,
fixtures=[(url, html)],
)
def test_fetch_detail_own_session_proxy_wiring_parity(monkeypatch: Any) -> None:
"""cian_price_history calls fetch_detail WITHOUT session= (own-session curl_cffi path).
Kit fetch_detail without config= silently drops the proxy (direct connection) a
real behaviour regression risk (#806: datacenter-IP gets banned by Cian without a
proxy). This proves config=RealScraperConfig() (our actual call site) constructs
AsyncSession(proxies=...) identically to the legacy direct-settings path.
"""
monkeypatch.setattr(settings, "cian_proxy_url_env", "http://test-proxy.local:8080")
captured: dict[str, dict[str, Any]] = {}
def _make_fake_session(name: str) -> Any:
class _FakeSession:
def __init__(self, **kwargs: Any) -> None:
captured[name] = kwargs
async def get(self, *_args: Any, **_kwargs: Any) -> Any:
response = MagicMock()
response.status_code = 200
response.text = _detail_html()
return response
async def close(self) -> None:
pass
return _FakeSession
url = "https://ekb.cian.ru/sale/flat/1/"
with patch("app.services.scrapers.cian_detail.AsyncSession", _make_fake_session("legacy")):
asyncio.run(legacy_fetch_detail(url))
with patch("scraper_kit.providers.cian.detail.AsyncSession", _make_fake_session("kit")):
asyncio.run(kit_fetch_detail(url, config=RealScraperConfig()))
assert captured["legacy"].get("proxies") == captured["kit"].get("proxies")
assert captured["kit"]["proxies"] == {
"http": "http://test-proxy.local:8080",
"https": "http://test-proxy.local:8080",
}