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]
|
s._rotate_ip = no_rotate # type: ignore[method-assign]
|
||||||
marked: list[tuple[str, bool]] = []
|
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))
|
marked.append((key, complete))
|
||||||
|
|
||||||
asyncio.run(
|
asyncio.run(
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,7 @@ def _walk(
|
||||||
s._fetch_page_json = fake_fetch # type: ignore[method-assign]
|
s._fetch_page_json = fake_fetch # type: ignore[method-assign]
|
||||||
marked: list[tuple[str, bool]] = []
|
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))
|
marked.append((key, complete))
|
||||||
|
|
||||||
seen: dict[str, ScrapedLot] = {}
|
seen: dict[str, ScrapedLot] = {}
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ import pytest
|
||||||
|
|
||||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
||||||
|
|
||||||
|
from scraper_kit.base import ScrapedLot
|
||||||
from scraper_kit.orchestration.pipeline import (
|
from scraper_kit.orchestration.pipeline import (
|
||||||
run_avito_full_load,
|
run_avito_full_load,
|
||||||
run_avito_newbuilding_sweep,
|
run_avito_newbuilding_sweep,
|
||||||
|
|
@ -40,6 +41,7 @@ from scraper_kit.orchestration.pipeline import (
|
||||||
run_yandex_full_load,
|
run_yandex_full_load,
|
||||||
)
|
)
|
||||||
from scraper_kit.providers.domclick.serp import ROOM_BUCKETS
|
from scraper_kit.providers.domclick.serp import ROOM_BUCKETS
|
||||||
|
from scraper_kit.providers.yandex.serp import YandexRealtyScraper
|
||||||
|
|
||||||
PFX = "scraper_kit.orchestration.pipeline"
|
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.asyncio
|
||||||
@pytest.mark.parametrize("source", ["avito", "cian", "yandex"])
|
@pytest.mark.parametrize("source", ["avito", "cian", "yandex"])
|
||||||
async def test_full_load_smoke(source: str) -> None:
|
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)
|
counters, calls = await _drive_full_load(source=source)
|
||||||
assert counters["unique_fetched"] == 3
|
assert counters["unique_fetched"] == 3
|
||||||
assert counters["saved_inserted"] == 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"
|
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 ─────
|
# ── #2616: run_avito_full_load прокидывает proxy_provider в AvitoScraper ─────
|
||||||
#
|
#
|
||||||
# run_avito_full_load — единственное из мест создания AvitoScraper в pipeline.py, где
|
# run_avito_full_load — единственное из мест создания AvitoScraper в pipeline.py, где
|
||||||
|
|
|
||||||
|
|
@ -1139,7 +1139,8 @@ class YandexRealtyScraper(BaseScraper):
|
||||||
|
|
||||||
Реализация: единый движок `scraper_kit.pricing.walk_price_range`. Yandex-spec
|
Реализация: единый движок `scraper_kit.pricing.walk_price_range`. Yandex-spec
|
||||||
(probe gate-JSON page=1, degraded paginate-until-empty, leaf с first-wins
|
(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.
|
depth<8) и политика DEGRADE — в BisectionConfig.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
@ -1194,6 +1195,7 @@ class YandexRealtyScraper(BaseScraper):
|
||||||
return
|
return
|
||||||
page = 1
|
page = 1
|
||||||
pages_fetched = 0
|
pages_fetched = 0
|
||||||
|
bucket_lots: list[ScrapedLot] = []
|
||||||
while pages_fetched < max_pages_per_bucket:
|
while pages_fetched < max_pages_per_bucket:
|
||||||
payload = await self._fetch_page_json(rooms, page, lo_param, phi)
|
payload = await self._fetch_page_json(rooms, page, lo_param, phi)
|
||||||
await asyncio.sleep(self.request_delay_sec)
|
await asyncio.sleep(self.request_delay_sec)
|
||||||
|
|
@ -1205,6 +1207,7 @@ class YandexRealtyScraper(BaseScraper):
|
||||||
for lot in lots:
|
for lot in lots:
|
||||||
if lot.source_id and lot.source_id not in seen:
|
if lot.source_id and lot.source_id not in seen:
|
||||||
seen[lot.source_id] = lot
|
seen[lot.source_id] = lot
|
||||||
|
bucket_lots.append(lot)
|
||||||
page += 1
|
page += 1
|
||||||
pages_fetched += 1
|
pages_fetched += 1
|
||||||
if on_bucket is not None:
|
if on_bucket is not None:
|
||||||
|
|
@ -1213,13 +1216,14 @@ class YandexRealtyScraper(BaseScraper):
|
||||||
# бакет НЕ пишется в done-леджер (как у cian), иначе его интервал
|
# бакет НЕ пишется в done-леджер (как у cian), иначе его интервал
|
||||||
# слился бы с соседними в containment-гейте (#3359) и резюм уже не
|
# слился бы с соседними в 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:
|
async def _leaf(plo: int | None, phi: int | None, result: ProbeResult) -> None:
|
||||||
total = result.count
|
total = result.count
|
||||||
assert total is not None # DEGRADE-политика уводит None в _degraded
|
assert total is not None # DEGRADE-политика уводит None в _degraded
|
||||||
lo_param = plo if plo and plo > 0 else None
|
lo_param = plo if plo and plo > 0 else None
|
||||||
probe_lots: list[ScrapedLot] = result.payload or []
|
probe_lots: list[ScrapedLot] = result.payload or []
|
||||||
|
bucket_lots: list[ScrapedLot] = []
|
||||||
bucket_key = _combo_label(rooms, plo, phi)
|
bucket_key = _combo_label(rooms, plo, phi)
|
||||||
if skip_buckets and bucket_key in skip_buckets:
|
if skip_buckets and bucket_key in skip_buckets:
|
||||||
logger.debug("yandex gate: skip bucket %s (checkpoint)", bucket_key)
|
logger.debug("yandex gate: skip bucket %s (checkpoint)", bucket_key)
|
||||||
|
|
@ -1262,10 +1266,11 @@ class YandexRealtyScraper(BaseScraper):
|
||||||
for lot in probe_lots:
|
for lot in probe_lots:
|
||||||
if lot.source_id and lot.source_id not in seen:
|
if lot.source_id and lot.source_id not in seen:
|
||||||
seen[lot.source_id] = lot
|
seen[lot.source_id] = lot
|
||||||
|
bucket_lots.append(lot)
|
||||||
|
|
||||||
if total_pages <= 1:
|
if total_pages <= 1:
|
||||||
if on_bucket is not None:
|
if on_bucket is not None:
|
||||||
on_bucket(bucket_key, len(seen), not capped)
|
on_bucket(bucket_key, bucket_lots, not capped)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Paginate pages 2..total_pages with concurrency
|
# Paginate pages 2..total_pages with concurrency
|
||||||
|
|
@ -1288,6 +1293,7 @@ class YandexRealtyScraper(BaseScraper):
|
||||||
for lot in page_lots:
|
for lot in page_lots:
|
||||||
if lot.source_id and lot.source_id not in seen:
|
if lot.source_id and lot.source_id not in seen:
|
||||||
seen[lot.source_id] = lot
|
seen[lot.source_id] = lot
|
||||||
|
bucket_lots.append(lot)
|
||||||
|
|
||||||
complete = not capped and dropped_pages == 0
|
complete = not capped and dropped_pages == 0
|
||||||
if dropped_pages:
|
if dropped_pages:
|
||||||
|
|
@ -1299,13 +1305,15 @@ class YandexRealtyScraper(BaseScraper):
|
||||||
total_pages - 1,
|
total_pages - 1,
|
||||||
)
|
)
|
||||||
if on_bucket is not None:
|
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`,
|
# #3359: гейт ПЕРЕД probe (как у avito, #3315). Ключи yandex'а — `_combo_label`,
|
||||||
# т.е. «rooms:lo-hi» с «None» вместо открытого потолка: другой разделитель и
|
# т.е. «rooms:lo-hi» с «None» вместо открытого потолка: другой разделитель и
|
||||||
# другой open-токен, чем у avito/cian, поэтому парсер параметризуется, а ключи
|
# другой 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:
|
def _skip_done(plo: int | None, phi: int | None) -> bool:
|
||||||
if _covered is None or not _covered(plo, phi):
|
if _covered is None or not _covered(plo, phi):
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue