fix(scraper-kit): yandex on_bucket отдаёт лоты бакета, а не len(seen)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 12s
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 5m3s
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 12s
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 5m3s
`_leaf`/`_degraded` звали on_bucket(bucket_key, len(seen), complete) — int уезжал в run_yandex_full_load._on_bucket и дальше в save_listings (`for lot in lots`) → TypeError, ручной full-load Яндекса не сохранял ничего. Контракт выровнен по cian/avito: провайдер копит лоты бакета (новые в seen) и отдаёт список. Parity-фикстура подменяла скрапер целиком и слала list по построению — добавлен прогон run_yandex_full_load через НАСТОЯЩИЙ YandexRealtyScraper (замокан только gate-JSON транспорт). Closes #3375
This commit is contained in:
parent
357c4348ac
commit
e04e315203
4 changed files with 106 additions and 8 deletions
|
|
@ -142,7 +142,7 @@ def _walk_degraded(skip_buckets: set[str] | None) -> tuple[list[tuple[str, bool]
|
|||
s._rotate_ip = no_rotate # type: ignore[method-assign]
|
||||
marked: list[tuple[str, bool]] = []
|
||||
|
||||
def on_bucket(key: str, _count: int, complete: bool = True) -> None:
|
||||
def on_bucket(key: str, _lots: list[Any], complete: bool = True) -> None:
|
||||
marked.append((key, complete))
|
||||
|
||||
asyncio.run(
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ def _walk(
|
|||
s._fetch_page_json = fake_fetch # type: ignore[method-assign]
|
||||
marked: list[tuple[str, bool]] = []
|
||||
|
||||
def on_bucket(key: str, _count: int, complete: bool = True) -> None:
|
||||
def on_bucket(key: str, _lots: list[ScrapedLot], complete: bool = True) -> None:
|
||||
marked.append((key, complete))
|
||||
|
||||
seen: dict[str, ScrapedLot] = {}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import pytest
|
|||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
||||
|
||||
from scraper_kit.base import ScrapedLot
|
||||
from scraper_kit.orchestration.pipeline import (
|
||||
run_avito_full_load,
|
||||
run_avito_newbuilding_sweep,
|
||||
|
|
@ -40,6 +41,7 @@ from scraper_kit.orchestration.pipeline import (
|
|||
run_yandex_full_load,
|
||||
)
|
||||
from scraper_kit.providers.domclick.serp import ROOM_BUCKETS
|
||||
from scraper_kit.providers.yandex.serp import YandexRealtyScraper
|
||||
|
||||
PFX = "scraper_kit.orchestration.pipeline"
|
||||
|
||||
|
|
@ -619,7 +621,12 @@ async def _drive_full_load(*, source: str, capture: dict[str, Any] | None = None
|
|||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("source", ["avito", "cian", "yandex"])
|
||||
async def test_full_load_smoke(source: str) -> None:
|
||||
"""Full load: 2 бакета через on_bucket → save + heartbeat → mark_done."""
|
||||
"""Full load: 2 бакета через on_bucket → save + heartbeat → mark_done.
|
||||
|
||||
ВНИМАНИЕ: `_full_load_scraper` шлёт в on_bucket список ПО ПОСТРОЕНИЮ — контракт
|
||||
«что провайдер реально кладёт в колбэк» здесь не проверяется вовсе (#3375).
|
||||
Настоящий контракт — в тесте ниже.
|
||||
"""
|
||||
counters, calls = await _drive_full_load(source=source)
|
||||
assert counters["unique_fetched"] == 3
|
||||
assert counters["saved_inserted"] == 3
|
||||
|
|
@ -627,6 +634,89 @@ async def test_full_load_smoke(source: str) -> None:
|
|||
assert calls[-1][0] == "mark_done"
|
||||
|
||||
|
||||
# ── #3375: yandex full-load — контракт on_bucket через НАСТОЯЩИЙ провайдер ────
|
||||
#
|
||||
# Фикстура выше подменяет скрапер целиком, поэтому в save_listings всегда приезжал
|
||||
# список — а yandex/serp.py звал `on_bucket(bucket_key, len(seen), complete)` (int),
|
||||
# и `_on_bucket` отдавал это в `save_listings` (`for lot in lots`) → TypeError:
|
||||
# ручной full-load Яндекса не сохранял ничего. Ниже гоняется НАСТОЯЩИЙ
|
||||
# YandexRealtyScraper (fetch_all_secondary → _walk_price_range → _leaf), подменён
|
||||
# только gate-JSON транспорт. Фальсификация: вернуть в serp.py `len(seen)` — тест
|
||||
# краснеет TypeError'ом на save_listings, как прод.
|
||||
|
||||
|
||||
class _StubbedYandexScraper(YandexRealtyScraper):
|
||||
"""Настоящий скрапер яндекса, у которого замокан ТОЛЬКО gate-JSON транспорт."""
|
||||
|
||||
async def __aenter__(self) -> _StubbedYandexScraper:
|
||||
return self # без camoufox/BrowserFetcher
|
||||
|
||||
async def __aexit__(self, *_exc: Any) -> bool:
|
||||
return False
|
||||
|
||||
async def fetch_all_secondary(self, **kwargs: Any) -> list[Any]:
|
||||
"""Одна комнатность вместо пяти — путь до `_leaf` тот же, прогон короче."""
|
||||
return await super().fetch_all_secondary(rooms_buckets=["2"], **kwargs)
|
||||
|
||||
async def _fetch_page_json(
|
||||
self,
|
||||
rooms: str | None,
|
||||
page: int,
|
||||
price_min: int | None = None,
|
||||
price_max: int | None = None,
|
||||
new_flat: str = "NO",
|
||||
) -> dict[str, Any]:
|
||||
"""totalItems=1 → бисекция не делит, leaf сразу. Оффер уникален на бакет."""
|
||||
offer_id = f"y{price_min or 0}_{price_max or 0}"
|
||||
return {
|
||||
"response": {
|
||||
"search": {
|
||||
"offers": {
|
||||
"entities": [{"offerId": offer_id, "price": {"value": 5_000_000}}],
|
||||
"pager": {"totalItems": 1, "totalPages": 1, "page": 0},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_yandex_full_load_saves_lot_list_from_real_provider() -> None:
|
||||
"""#3375: save_listings получает СПИСОК лотов, ровно один вызов на бакет."""
|
||||
recorder = _RunsRecorder()
|
||||
save_mock = MagicMock(return_value=(1, 0))
|
||||
enrichment = MagicMock()
|
||||
enrichment.record_yandex_price_history = MagicMock(return_value=0)
|
||||
with (
|
||||
patch(f"{PFX}.YandexRealtyScraper", _StubbedYandexScraper),
|
||||
patch(f"{PFX}.save_listings", save_mock),
|
||||
patch(f"{PFX}.runs", recorder),
|
||||
):
|
||||
counters = await run_yandex_full_load(
|
||||
MagicMock(),
|
||||
run_id=1,
|
||||
config=_config(),
|
||||
matcher=MagicMock(),
|
||||
enrichment=enrichment,
|
||||
request_delay_sec=0.0,
|
||||
)
|
||||
|
||||
assert save_mock.call_count > 0, "save_listings не вызван — прогон ничего не сохранил"
|
||||
for call in save_mock.call_args_list:
|
||||
lots = call.args[1]
|
||||
assert isinstance(lots, list), (
|
||||
f"в save_listings уехал {type(lots).__name__} вместо списка лотов — "
|
||||
"провайдер и pipeline разошлись контрактом (#3375)"
|
||||
)
|
||||
assert lots and all(isinstance(lot, ScrapedLot) for lot in lots), (
|
||||
f"бакет отдал {lots!r} — save_listings пишет не лоты"
|
||||
)
|
||||
# По одному новому лоту на бакет (offer_id уникален на ценовой диапазон).
|
||||
assert counters.unique_fetched == save_mock.call_count > 0
|
||||
assert counters.saved_inserted == save_mock.call_count
|
||||
assert _normalize(recorder.calls)[-1][0] == "mark_done"
|
||||
|
||||
|
||||
# ── #2616: run_avito_full_load прокидывает proxy_provider в AvitoScraper ─────
|
||||
#
|
||||
# run_avito_full_load — единственное из мест создания AvitoScraper в pipeline.py, где
|
||||
|
|
|
|||
|
|
@ -1139,7 +1139,8 @@ class YandexRealtyScraper(BaseScraper):
|
|||
|
||||
Реализация: единый движок `scraper_kit.pricing.walk_price_range`. Yandex-spec
|
||||
(probe gate-JSON page=1, degraded paginate-until-empty, leaf с first-wins
|
||||
дедупом и count-based on_bucket) — в callback'ах; пороги (cap, 500k min,
|
||||
дедупом; on_bucket отдаёт ЛОТЫ бакета — новые в `seen`, — как cian/avito:
|
||||
pipeline._on_bucket кладёт их в save_listings, #3375) — в callback'ах; пороги (cap, 500k min,
|
||||
depth<8) и политика DEGRADE — в BisectionConfig.
|
||||
"""
|
||||
|
||||
|
|
@ -1194,6 +1195,7 @@ class YandexRealtyScraper(BaseScraper):
|
|||
return
|
||||
page = 1
|
||||
pages_fetched = 0
|
||||
bucket_lots: list[ScrapedLot] = []
|
||||
while pages_fetched < max_pages_per_bucket:
|
||||
payload = await self._fetch_page_json(rooms, page, lo_param, phi)
|
||||
await asyncio.sleep(self.request_delay_sec)
|
||||
|
|
@ -1205,6 +1207,7 @@ class YandexRealtyScraper(BaseScraper):
|
|||
for lot in lots:
|
||||
if lot.source_id and lot.source_id not in seen:
|
||||
seen[lot.source_id] = lot
|
||||
bucket_lots.append(lot)
|
||||
page += 1
|
||||
pages_fetched += 1
|
||||
if on_bucket is not None:
|
||||
|
|
@ -1213,13 +1216,14 @@ class YandexRealtyScraper(BaseScraper):
|
|||
# бакет НЕ пишется в done-леджер (как у cian), иначе его интервал
|
||||
# слился бы с соседними в containment-гейте (#3359) и резюм уже не
|
||||
# переобошёл бы недобранную полосу.
|
||||
on_bucket(bucket_key, len(seen), False)
|
||||
on_bucket(bucket_key, bucket_lots, False)
|
||||
|
||||
async def _leaf(plo: int | None, phi: int | None, result: ProbeResult) -> None:
|
||||
total = result.count
|
||||
assert total is not None # DEGRADE-политика уводит None в _degraded
|
||||
lo_param = plo if plo and plo > 0 else None
|
||||
probe_lots: list[ScrapedLot] = result.payload or []
|
||||
bucket_lots: list[ScrapedLot] = []
|
||||
bucket_key = _combo_label(rooms, plo, phi)
|
||||
if skip_buckets and bucket_key in skip_buckets:
|
||||
logger.debug("yandex gate: skip bucket %s (checkpoint)", bucket_key)
|
||||
|
|
@ -1262,10 +1266,11 @@ class YandexRealtyScraper(BaseScraper):
|
|||
for lot in probe_lots:
|
||||
if lot.source_id and lot.source_id not in seen:
|
||||
seen[lot.source_id] = lot
|
||||
bucket_lots.append(lot)
|
||||
|
||||
if total_pages <= 1:
|
||||
if on_bucket is not None:
|
||||
on_bucket(bucket_key, len(seen), not capped)
|
||||
on_bucket(bucket_key, bucket_lots, not capped)
|
||||
return
|
||||
|
||||
# Paginate pages 2..total_pages with concurrency
|
||||
|
|
@ -1288,6 +1293,7 @@ class YandexRealtyScraper(BaseScraper):
|
|||
for lot in page_lots:
|
||||
if lot.source_id and lot.source_id not in seen:
|
||||
seen[lot.source_id] = lot
|
||||
bucket_lots.append(lot)
|
||||
|
||||
complete = not capped and dropped_pages == 0
|
||||
if dropped_pages:
|
||||
|
|
@ -1299,13 +1305,15 @@ class YandexRealtyScraper(BaseScraper):
|
|||
total_pages - 1,
|
||||
)
|
||||
if on_bucket is not None:
|
||||
on_bucket(bucket_key, len(seen), complete)
|
||||
on_bucket(bucket_key, bucket_lots, complete)
|
||||
|
||||
# #3359: гейт ПЕРЕД probe (как у avito, #3315). Ключи yandex'а — `_combo_label`,
|
||||
# т.е. «rooms:lo-hi» с «None» вместо открытого потолка: другой разделитель и
|
||||
# другой open-токен, чем у avito/cian, поэтому парсер параметризуется, а ключи
|
||||
# остаются как есть (живые чекпоинты не ломаем).
|
||||
_covered = done_range_skipper(skip_buckets, rooms or "any", range_sep="-", open_token="None")
|
||||
_covered = done_range_skipper(
|
||||
skip_buckets, rooms or "any", range_sep="-", open_token="None"
|
||||
)
|
||||
|
||||
def _skip_done(plo: int | None, phi: int | None) -> bool:
|
||||
if _covered is None or not _covered(plo, phi):
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue