refactor(scraper-kit): unify price bisection engine across providers + dedup (#2134)
Some checks failed
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 14s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / build-frontend (push) Has been cancelled
Deploy Trade-In / test (push) Has been cancelled
Some checks failed
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 14s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / build-frontend (push) Has been cancelled
Deploy Trade-In / test (push) Has been cancelled
This commit is contained in:
parent
b8ed46d2dc
commit
62fa1f732a
8 changed files with 1069 additions and 610 deletions
455
tradein-mvp/backend/tests/test_scraper_kit_bisection_parity.py
Normal file
455
tradein-mvp/backend/tests/test_scraper_kit_bisection_parity.py
Normal file
|
|
@ -0,0 +1,455 @@
|
||||||
|
"""Golden-parity бисекции: старый боевой обход `app.services.scrapers.<provider>`
|
||||||
|
≡ новый единый движок `scraper_kit.pricing.walk_price_range` (#2134).
|
||||||
|
|
||||||
|
Четыре независимые копии адаптивной бисекции «комнатность × цена» были
|
||||||
|
консолидированы в один движок (`scraper_kit/pricing/bisection.py`). Провайдеры
|
||||||
|
(avito/cian/yandex/domclick) теперь передают в движок свои probe/paginate + свой
|
||||||
|
`BisectionConfig`. Этот тест доказывает, что unification НЕ изменил ОБХОД: для
|
||||||
|
каждого провайдера при одинаковом входе (детерминированный мок счётчика по
|
||||||
|
диапазону) старая боевая реализация и новая (движок) посещают ИДЕНТИЧНУЮ
|
||||||
|
последовательность бакетов — probe-запросы, leaf-пагинации, деления, ранние
|
||||||
|
возвраты на нуле, probe-fail-ветки.
|
||||||
|
|
||||||
|
Механика: на ОБЕ реализации (старую и новую) ставятся ОДИНАКОВЫЕ моки на
|
||||||
|
низкоуровневые методы (probe-fetch + leaf-пагинатор), которые (а) возвращают
|
||||||
|
счётчик из общего оракула `count(lo, hi)` и (б) пишут событие в общий список.
|
||||||
|
Совпадение списков событий = идентичный обход.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from collections.abc import Callable
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
||||||
|
|
||||||
|
from scraper_kit.price_brackets import get_price_seed_brackets
|
||||||
|
from scraper_kit.providers.avito.serp import AvitoScraper as NewAvito
|
||||||
|
from scraper_kit.providers.cian.serp import CianScraper as NewCian
|
||||||
|
from scraper_kit.providers.domclick.serp import DomClickScraper as NewDomClick
|
||||||
|
from scraper_kit.providers.yandex.serp import YandexRealtyScraper as NewYandex
|
||||||
|
|
||||||
|
from app.services.scrapers.avito import AvitoScraper as OldAvito
|
||||||
|
from app.services.scrapers.cian import CianScraper as OldCian
|
||||||
|
from app.services.scrapers.domclick import DomClickScraper as OldDomClick
|
||||||
|
from app.services.scrapers.yandex_realty import YandexRealtyScraper as OldYandex
|
||||||
|
|
||||||
|
# ── Оракул счётчиков ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Oracle = Callable[[Any, Any], int | None]
|
||||||
|
|
||||||
|
|
||||||
|
def make_oracle(
|
||||||
|
*,
|
||||||
|
divisor: int,
|
||||||
|
open_ceiling: int,
|
||||||
|
zeros: frozenset[tuple[Any, Any]] = frozenset(),
|
||||||
|
nones: frozenset[tuple[Any, Any]] = frozenset(),
|
||||||
|
) -> Oracle:
|
||||||
|
"""Детерминированный счётчик от (lo, hi): count = span // divisor.
|
||||||
|
|
||||||
|
span считается по клампнутым границам (None-lo → 0, None-hi → open_ceiling),
|
||||||
|
чтобы широкие брекеты делились, а узкие уходили в leaf. `zeros` форсит 0 (ранний
|
||||||
|
возврат / leaf-на-нуле у yandex), `nones` форсит None (probe-fail).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def oracle(lo: Any, hi: Any) -> int | None:
|
||||||
|
if (lo, hi) in nones:
|
||||||
|
return None
|
||||||
|
if (lo, hi) in zeros:
|
||||||
|
return 0
|
||||||
|
eff_lo = lo if lo is not None else 0
|
||||||
|
eff_hi = hi if hi is not None else open_ceiling
|
||||||
|
span = eff_hi - eff_lo
|
||||||
|
return max(0, span // divisor)
|
||||||
|
|
||||||
|
return oracle
|
||||||
|
|
||||||
|
|
||||||
|
# ── Провайдер-специфичные «сборщики событий» ─────────────────────────────────
|
||||||
|
#
|
||||||
|
# Каждый ставит одинаковые моки на старый/новый экземпляр и возвращает список
|
||||||
|
# событий обхода. `("probe", lo, hi)` — запрос счётчика бакета; `("leaf", ...)` —
|
||||||
|
# пагинация leaf-бакета.
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_avito(scraper: Any, oracle: Oracle, events: list[Any]) -> None:
|
||||||
|
def probe(room_slug: str, min_price: Any, max_price: Any) -> tuple[str | None, int | None]:
|
||||||
|
events.append(("probe", min_price, max_price))
|
||||||
|
return "H", oracle(min_price, max_price)
|
||||||
|
|
||||||
|
def leaf(**kw: Any) -> None:
|
||||||
|
events.append(("leaf", kw["lo"], kw["hi"], kw["max_pages"]))
|
||||||
|
|
||||||
|
scraper._probe_total = AsyncMock(side_effect=probe)
|
||||||
|
scraper._paginate_leaf_bucket = AsyncMock(side_effect=leaf)
|
||||||
|
scraper._rotate_ip = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_cian(scraper: Any, oracle: Oracle, events: list[Any]) -> None:
|
||||||
|
holder: dict[str, tuple[Any, Any]] = {}
|
||||||
|
|
||||||
|
def fph(rooms: Any, page: int, min_price: Any, max_price: Any) -> str:
|
||||||
|
events.append(("probe", min_price, max_price))
|
||||||
|
holder["b"] = (min_price, max_price)
|
||||||
|
return "H"
|
||||||
|
|
||||||
|
def eto(html: str) -> int | None:
|
||||||
|
return oracle(*holder["b"])
|
||||||
|
|
||||||
|
def leaf(**kw: Any) -> None:
|
||||||
|
events.append(("leaf", kw["lo"], kw["hi"], kw["total"]))
|
||||||
|
|
||||||
|
scraper._fetch_page_html = AsyncMock(side_effect=fph)
|
||||||
|
scraper._extract_total_offers = MagicMock(side_effect=eto)
|
||||||
|
scraper._paginate_leaf_bucket = AsyncMock(side_effect=leaf)
|
||||||
|
scraper._rotate_ip = AsyncMock(return_value=False)
|
||||||
|
scraper.sleep_between_requests = AsyncMock()
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_domclick(scraper: Any, oracle: Oracle, events: list[Any]) -> None:
|
||||||
|
def count(fetcher: Any, rooms: Any, gte: Any, lte: Any) -> int:
|
||||||
|
events.append(("probe", gte, lte))
|
||||||
|
val = oracle(gte, lte)
|
||||||
|
return val if val is not None else 0
|
||||||
|
|
||||||
|
def paginate(
|
||||||
|
fetcher: Any, rooms: Any, gte: Any, lte: Any, seen_ids: Any, out_lots: Any, pages: Any
|
||||||
|
) -> None:
|
||||||
|
events.append(("leaf", gte, lte))
|
||||||
|
|
||||||
|
scraper._count = AsyncMock(side_effect=count)
|
||||||
|
scraper._paginate = AsyncMock(side_effect=paginate)
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_yandex(
|
||||||
|
scraper: Any, oracle: Oracle, events: list[Any], *, record_pages: bool = False
|
||||||
|
) -> None:
|
||||||
|
"""Yandex: leaf-пагинация встроена в walk (нет отдельного метода-хука).
|
||||||
|
|
||||||
|
Стабим `_fetch_page_json` (page=1 отдаёт payload с totalItems из оракула;
|
||||||
|
page>1 — пустой payload) и передаём `on_bucket`, который пишет завершение
|
||||||
|
leaf/degraded бакета. probe = page-1-fetch; leaf-страницы 2..N идут через
|
||||||
|
asyncio.gather (порядок недетерминирован) — их НЕ пишем в норм-режиме; факт
|
||||||
|
leaf-обхода фиксируется через on_bucket. Для degraded-теста (пагинация
|
||||||
|
последовательная) `record_pages=True` пишет все страницы.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def gate(total: int | None, entities: list[Any] | None = None) -> dict[str, Any]:
|
||||||
|
pager: dict[str, Any] = {}
|
||||||
|
if total is not None:
|
||||||
|
pager["totalItems"] = total
|
||||||
|
return {"response": {"search": {"offers": {"entities": entities or [], "pager": pager}}}}
|
||||||
|
|
||||||
|
def fpj(rooms: Any, page: int, price_min: Any, price_max: Any, new_flat: str = "NO") -> Any:
|
||||||
|
if page == 1:
|
||||||
|
events.append(("probe", price_min, price_max))
|
||||||
|
return gate(oracle(price_min, price_max))
|
||||||
|
if record_pages:
|
||||||
|
events.append(("page", price_min, price_max, page))
|
||||||
|
return gate(0, entities=[])
|
||||||
|
|
||||||
|
scraper._fetch_page_json = AsyncMock(side_effect=fpj)
|
||||||
|
scraper._rotate_ip = AsyncMock(return_value=False)
|
||||||
|
scraper.request_delay_sec = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _yandex_on_bucket(events: list[Any]) -> Callable[[str, int], None]:
|
||||||
|
def on_bucket(bucket_key: str, count: int) -> None:
|
||||||
|
events.append(("bucket", bucket_key))
|
||||||
|
|
||||||
|
return on_bucket
|
||||||
|
|
||||||
|
|
||||||
|
# ── Конструкторы старых/новых экземпляров (offline, без DB) ───────────────────
|
||||||
|
|
||||||
|
_KIT_CONFIG = SimpleNamespace(
|
||||||
|
scraper_proxy_url=None,
|
||||||
|
yandex_proxy_rotate_url=None,
|
||||||
|
avito_proxy_rotate_url=None,
|
||||||
|
avito_proxy_max_rotations=0,
|
||||||
|
scraper_fetch_mode="browser",
|
||||||
|
browser_http_endpoint="http://tradein-browser:9000",
|
||||||
|
glitchtip_dsn=None,
|
||||||
|
avito_serp_ekb_only=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _old_avito() -> Any:
|
||||||
|
with patch("app.services.scrapers.avito.get_scraper_delay", return_value=7.0):
|
||||||
|
return OldAvito()
|
||||||
|
|
||||||
|
|
||||||
|
def _old_cian() -> Any:
|
||||||
|
with patch("app.services.scrapers.cian.get_scraper_delay", return_value=5.0):
|
||||||
|
return OldCian()
|
||||||
|
|
||||||
|
|
||||||
|
def _old_yandex() -> Any:
|
||||||
|
with patch("app.services.scrapers.yandex_realty.get_scraper_delay", return_value=5.0):
|
||||||
|
return OldYandex()
|
||||||
|
|
||||||
|
|
||||||
|
def _old_domclick() -> Any:
|
||||||
|
with patch("app.services.scrapers.domclick.get_scraper_delay", return_value=8.0):
|
||||||
|
return OldDomClick()
|
||||||
|
|
||||||
|
|
||||||
|
# ── avito / cian: одинаковый скелет (probe → split → leaf) ────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
# Провайдер-специфичные kwargs для _walk_price_range (avito идентифицирует бакет
|
||||||
|
# room_slug/room_label; cian — кортежем rooms).
|
||||||
|
_AVITO_WALK_EXTRA = {"room_slug": "room1", "room_label": "1к"}
|
||||||
|
_CIAN_WALK_EXTRA = {"rooms": (1,)}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"old_factory, new_factory, patch_fn, walk_extra",
|
||||||
|
[
|
||||||
|
(_old_avito, lambda: NewAvito(config=_KIT_CONFIG), _patch_avito, _AVITO_WALK_EXTRA),
|
||||||
|
(_old_cian, lambda: NewCian(config=_KIT_CONFIG), _patch_cian, _CIAN_WALK_EXTRA),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_bisection_parity_avito_cian(
|
||||||
|
old_factory: Any, new_factory: Any, patch_fn: Any, walk_extra: dict[str, Any]
|
||||||
|
) -> None:
|
||||||
|
"""avito/cian: split-дерево + leaf + ранний возврат на нуле — обход идентичен.
|
||||||
|
|
||||||
|
Оракул: divisor=1000, cap=1400 → брекет 8М делится 3 уровня (8М→4М→2М→1М-leaf);
|
||||||
|
один под-брекет форсим в 0 (ранний возврат без leaf).
|
||||||
|
"""
|
||||||
|
# После первого split (0,7_999_999): mid=3_999_999 → дети (None,3_999_999) и
|
||||||
|
# (4_000_000, 7_999_999). Форсим 0 на (4_000_000, 5_999_999) — под-брекет после
|
||||||
|
# второго split правой ветки.
|
||||||
|
oracle = make_oracle(
|
||||||
|
divisor=1000,
|
||||||
|
open_ceiling=30_000_000,
|
||||||
|
zeros=frozenset({(4_000_000, 5_999_999)}),
|
||||||
|
)
|
||||||
|
brackets: list[tuple[int, int | None]] = [(0, 7_999_999), (25_000_000, None)]
|
||||||
|
|
||||||
|
async def run(scraper: Any) -> list[Any]:
|
||||||
|
events: list[Any] = []
|
||||||
|
patch_fn(scraper, oracle, events)
|
||||||
|
for lo, hi in brackets:
|
||||||
|
await scraper._walk_price_range(
|
||||||
|
lo=lo,
|
||||||
|
hi=hi,
|
||||||
|
seen={},
|
||||||
|
price_cap_per_bucket=1400,
|
||||||
|
max_pages_per_bucket=100,
|
||||||
|
concurrency=5,
|
||||||
|
secondary_only=True,
|
||||||
|
on_bucket=None,
|
||||||
|
skip_buckets=None,
|
||||||
|
**walk_extra,
|
||||||
|
)
|
||||||
|
return events
|
||||||
|
|
||||||
|
old_events = await run(old_factory())
|
||||||
|
new_events = await run(new_factory())
|
||||||
|
assert old_events == new_events
|
||||||
|
# Санити: обход реально нетривиален (несколько probe + несколько leaf).
|
||||||
|
assert sum(1 for e in old_events if e[0] == "leaf") >= 4
|
||||||
|
assert sum(1 for e in old_events if e[0] == "probe") >= 6
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bisection_parity_avito_probe_fail() -> None:
|
||||||
|
"""avito probe-fail (total=None): SPLIT_OR_SKIP — принудительный split делимого
|
||||||
|
закрытого брекета, best-effort leaf открытого — идентичен старому."""
|
||||||
|
# (0, 7_999_999): probe None → делим (bracket>=MIN). Дети (None,4M),(4000001,...):
|
||||||
|
# им дадим реальные счётчики. Открытый (25M, None) probe None → best-effort leaf.
|
||||||
|
oracle = make_oracle(
|
||||||
|
divisor=1000,
|
||||||
|
open_ceiling=30_000_000,
|
||||||
|
nones=frozenset({(None, 7_999_999), (25_000_000, None)}),
|
||||||
|
)
|
||||||
|
brackets: list[tuple[int, int | None]] = [(0, 7_999_999), (25_000_000, None)]
|
||||||
|
|
||||||
|
async def run(scraper: Any) -> list[Any]:
|
||||||
|
events: list[Any] = []
|
||||||
|
_patch_avito(scraper, oracle, events)
|
||||||
|
for lo, hi in brackets:
|
||||||
|
await scraper._walk_price_range(
|
||||||
|
room_slug="room1",
|
||||||
|
room_label="1к",
|
||||||
|
lo=lo,
|
||||||
|
hi=hi,
|
||||||
|
seen={},
|
||||||
|
price_cap_per_bucket=1400,
|
||||||
|
max_pages_per_bucket=100,
|
||||||
|
concurrency=5,
|
||||||
|
secondary_only=True,
|
||||||
|
on_bucket=None,
|
||||||
|
skip_buckets=None,
|
||||||
|
)
|
||||||
|
return events
|
||||||
|
|
||||||
|
old_events = await run(_old_avito())
|
||||||
|
new_events = await run(NewAvito(config=_KIT_CONFIG))
|
||||||
|
assert old_events == new_events
|
||||||
|
# Открытый брекет с probe-fail → ровно один best-effort leaf на (25M, None).
|
||||||
|
assert ("leaf", 25_000_000, None, 100) in old_events
|
||||||
|
|
||||||
|
|
||||||
|
# ── domclick: count-endpoint + None-both-ends + split открытого корня ─────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bisection_parity_domclick() -> None:
|
||||||
|
"""domclick: unbounded-корень (None,None) клампится к [0,1e9] и делится;
|
||||||
|
count-endpoint; span<=MIN_PRICE_SPAN строго → leaf. Обход идентичен старому."""
|
||||||
|
oracle = make_oracle(divisor=100_000, open_ceiling=1_000_000_000)
|
||||||
|
|
||||||
|
async def run(scraper: Any) -> list[Any]:
|
||||||
|
events: list[Any] = []
|
||||||
|
_patch_domclick(scraper, oracle, events)
|
||||||
|
await scraper._sweep_bucket(
|
||||||
|
fetcher=None,
|
||||||
|
rooms="1",
|
||||||
|
price_gte=None,
|
||||||
|
price_lte=None,
|
||||||
|
seen_ids=set(),
|
||||||
|
out_lots=[],
|
||||||
|
pages=100,
|
||||||
|
)
|
||||||
|
return events
|
||||||
|
|
||||||
|
old_events = await run(_old_domclick())
|
||||||
|
new_events = await run(NewDomClick(_make_domclick_config()))
|
||||||
|
assert old_events == new_events
|
||||||
|
assert sum(1 for e in old_events if e[0] == "leaf") >= 4
|
||||||
|
# Корень (None,None) обязательно пробится первым.
|
||||||
|
assert old_events[0] == ("probe", None, None)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_domclick_config() -> Any:
|
||||||
|
return SimpleNamespace(browser_http_endpoint="http://tradein-browser:9000")
|
||||||
|
|
||||||
|
|
||||||
|
# ── yandex: max_depth=8, min_bracket=500k, count-based on_bucket ──────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bisection_parity_yandex_normal() -> None:
|
||||||
|
"""yandex: split-дерево (порог 500k, cap 500) + leaf-on-bucket + zero-as-leaf.
|
||||||
|
|
||||||
|
Оракул divisor=1000, cap=500 → 4М→2М→1М→500k-leaf. Один под-брекет=0
|
||||||
|
(yandex вызывает on_bucket даже на нуле — treat_zero_as_leaf).
|
||||||
|
"""
|
||||||
|
oracle = make_oracle(
|
||||||
|
divisor=1000,
|
||||||
|
open_ceiling=30_000_000,
|
||||||
|
zeros=frozenset({(2_000_000, 2_999_999)}),
|
||||||
|
)
|
||||||
|
brackets: list[tuple[int, int | None]] = [(0, 3_999_999), (25_000_000, None)]
|
||||||
|
|
||||||
|
async def run(scraper: Any) -> list[Any]:
|
||||||
|
events: list[Any] = []
|
||||||
|
_patch_yandex(scraper, oracle, events)
|
||||||
|
for lo, hi in brackets:
|
||||||
|
await scraper._walk_price_range(
|
||||||
|
rooms="1",
|
||||||
|
lo=lo,
|
||||||
|
hi=hi,
|
||||||
|
seen={},
|
||||||
|
price_cap_per_bucket=500,
|
||||||
|
max_pages_per_bucket=50,
|
||||||
|
concurrency=4,
|
||||||
|
on_bucket=_yandex_on_bucket(events),
|
||||||
|
skip_buckets=None,
|
||||||
|
)
|
||||||
|
return events
|
||||||
|
|
||||||
|
old_events = await run(_old_yandex())
|
||||||
|
new_events = await run(NewYandex(config=_KIT_CONFIG))
|
||||||
|
assert old_events == new_events
|
||||||
|
assert sum(1 for e in old_events if e[0] == "bucket") >= 4
|
||||||
|
assert sum(1 for e in old_events if e[0] == "probe") >= 6
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bisection_parity_yandex_degraded() -> None:
|
||||||
|
"""yandex probe-fail (totalItems неизвестен) → degraded paginate-until-empty.
|
||||||
|
|
||||||
|
Оракул форсит None на бакете → probe page1 payload без totalItems → total=None
|
||||||
|
→ degraded последовательно пагинирует. record_pages=True фиксирует все страницы.
|
||||||
|
"""
|
||||||
|
oracle = make_oracle(
|
||||||
|
divisor=1000,
|
||||||
|
open_ceiling=30_000_000,
|
||||||
|
nones=frozenset({(None, 3_999_999)}),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def run(scraper: Any) -> list[Any]:
|
||||||
|
events: list[Any] = []
|
||||||
|
_patch_yandex(scraper, oracle, events, record_pages=True)
|
||||||
|
await scraper._walk_price_range(
|
||||||
|
rooms="1",
|
||||||
|
lo=0,
|
||||||
|
hi=3_999_999,
|
||||||
|
seen={},
|
||||||
|
price_cap_per_bucket=500,
|
||||||
|
max_pages_per_bucket=50,
|
||||||
|
concurrency=4,
|
||||||
|
on_bucket=_yandex_on_bucket(events),
|
||||||
|
skip_buckets=None,
|
||||||
|
)
|
||||||
|
return events
|
||||||
|
|
||||||
|
old_events = await run(_old_yandex())
|
||||||
|
new_events = await run(NewYandex(config=_KIT_CONFIG))
|
||||||
|
assert old_events == new_events
|
||||||
|
# degraded: probe page1 + затем последовательная пагинация страниц 1..K до пустой.
|
||||||
|
assert ("probe", None, 3_999_999) in old_events
|
||||||
|
assert any(e[0] == "bucket" for e in old_events)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Full seed-grid обход (все seed-брекеты одной комнатности) ─────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"old_factory, new_factory, patch_fn, walk_extra",
|
||||||
|
[
|
||||||
|
(_old_avito, lambda: NewAvito(config=_KIT_CONFIG), _patch_avito, _AVITO_WALK_EXTRA),
|
||||||
|
(_old_cian, lambda: NewCian(config=_KIT_CONFIG), _patch_cian, _CIAN_WALK_EXTRA),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_bisection_parity_full_seed_grid(
|
||||||
|
old_factory: Any, new_factory: Any, patch_fn: Any, walk_extra: dict[str, Any]
|
||||||
|
) -> None:
|
||||||
|
"""Полный обход всех seed-брекетов (как в fetch_all_secondary) — включая
|
||||||
|
открытый catch-all — идентичен между старым и новым для avito/cian."""
|
||||||
|
oracle = make_oracle(divisor=1500, open_ceiling=300_000_000)
|
||||||
|
|
||||||
|
async def run(scraper: Any) -> list[Any]:
|
||||||
|
events: list[Any] = []
|
||||||
|
patch_fn(scraper, oracle, events)
|
||||||
|
for br_lo, br_hi in get_price_seed_brackets():
|
||||||
|
walk_hi = br_hi - 1 if br_hi is not None else None
|
||||||
|
await scraper._walk_price_range(
|
||||||
|
lo=br_lo,
|
||||||
|
hi=walk_hi,
|
||||||
|
seen={},
|
||||||
|
price_cap_per_bucket=1400,
|
||||||
|
max_pages_per_bucket=100,
|
||||||
|
concurrency=5,
|
||||||
|
secondary_only=True,
|
||||||
|
on_bucket=None,
|
||||||
|
skip_buckets=None,
|
||||||
|
**walk_extra,
|
||||||
|
)
|
||||||
|
return events
|
||||||
|
|
||||||
|
old_events = await run(old_factory())
|
||||||
|
new_events = await run(new_factory())
|
||||||
|
assert old_events == new_events
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
"""scraper_kit.pricing — общие примитивы ценового партиционирования SERP-обхода.
|
||||||
|
|
||||||
|
Единый движок адаптивной бисекции «комнатность × цена» для всех провайдеров
|
||||||
|
(avito / cian / yandex / domclick). См. `bisection.py`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from scraper_kit.pricing.bisection import (
|
||||||
|
BisectionConfig,
|
||||||
|
ProbeFailPolicy,
|
||||||
|
ProbeResult,
|
||||||
|
walk_price_range,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"BisectionConfig",
|
||||||
|
"ProbeFailPolicy",
|
||||||
|
"ProbeResult",
|
||||||
|
"walk_price_range",
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,253 @@
|
||||||
|
"""Единый движок адаптивной бисекции «комнатность × цена» для SERP-обхода.
|
||||||
|
|
||||||
|
Все четыре провайдера вторички (avito / cian / yandex / domclick) обходят
|
||||||
|
SERP-cap площадки одинаковым по СКЕЛЕТУ приёмом: для каждой комнатности берётся
|
||||||
|
ценовой диапазон [lo, hi], запрашивается «сколько всего результатов» (probe
|
||||||
|
page=1 или отдельный count-endpoint), и если результатов больше потолка бакета —
|
||||||
|
диапазон делится пополам (`mid = (lo + hi) // 2`) и обход рекурсивно спускается
|
||||||
|
в обе половины; иначе бакет пагинируется как leaf.
|
||||||
|
|
||||||
|
Исторически это было ЧЕТЫРЕ независимые копии (`_walk_price_range` ×3 +
|
||||||
|
`_sweep_bucket`), разошедшиеся в константах и краевых ветках. Здесь скелет
|
||||||
|
рекурсии вынесен в одну функцию `walk_price_range`, а РАЗЛИЧИЯ провайдеров
|
||||||
|
переданы в `BisectionConfig` (пороги, глубина, поведение на открытом брекете и
|
||||||
|
при неудачном probe) + три provider-callback'а. Ни одно различие не потеряно —
|
||||||
|
golden-parity тесты доказывают, что обход байт-в-байт совпадает со старой
|
||||||
|
боевой реализацией для каждого провайдера.
|
||||||
|
|
||||||
|
Provider-side (в callback'ах, движок их НЕ трогает):
|
||||||
|
* фактический HTTP-fetch + retry/rotation;
|
||||||
|
* парсинг счётчика (totalOffers / page-title-count / totalItems / snippetsCount);
|
||||||
|
* пагинация leaf-бакета, дедуп (`seen`), on_bucket-callback.
|
||||||
|
|
||||||
|
Engine-side (здесь):
|
||||||
|
* дерево рекурсии: какие [lo, hi] пробятся / делятся / пагинируются и в каком
|
||||||
|
порядке;
|
||||||
|
* арифметика split-решения (cap, min_bracket, max_depth, clamp None-границ);
|
||||||
|
* применение probe-fail-политики и обработка открытого (hi=None) брекета.
|
||||||
|
|
||||||
|
Семантические различия провайдеров (задокументировано в конфигах-инстансах на
|
||||||
|
стороне провайдеров), которые движок воспроизводит через `BisectionConfig`:
|
||||||
|
|
||||||
|
| поле | avito | cian | yandex | domclick |
|
||||||
|
|-------------------------|-----------|-----------|---------------|-----------------|
|
||||||
|
| cap | 1400 | 1400 | 500 | 2000 (OFFSET) |
|
||||||
|
| min_bracket | 50_000 | 50_000 | 500_000 | 100_000 |
|
||||||
|
| min_bracket_inclusive | True (>=) | True (>=) | True (>=) | False (>) |
|
||||||
|
| max_depth | None | None | 8 | None |
|
||||||
|
| probe_fail_policy | SPLIT/SKIP| SKIP | DEGRADE | SKIP (не рвётся)|
|
||||||
|
| open_split_ceiling | None(leaf)| None(leaf)| None(leaf) | 1_000_000_000 |
|
||||||
|
| treat_zero_as_leaf | False | False | True | False |
|
||||||
|
|
||||||
|
Различие «probe vs count»: avito/cian/yandex читают счётчик из probe page=1 и
|
||||||
|
ПЕРЕИСПОЛЬЗУЮТ HTML/лоты страницы 1 в leaf (payload); domclick дёргает отдельный
|
||||||
|
count-endpoint (payload=None, пагинация с offset=0). Это инкапсулировано в
|
||||||
|
provider-callback `probe` + `ProbeResult.payload`, движку прозрачно.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum, auto
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class ProbeFailPolicy(Enum):
|
||||||
|
"""Что делать, когда probe не вернул счётчик (`ProbeResult.count is None`).
|
||||||
|
|
||||||
|
* ``SKIP`` — бросить бакет целиком (cian: totalOffers недоступен → пропуск).
|
||||||
|
* ``SPLIT_OR_SKIP`` — открытый брекет пагинируем best-effort; закрытый ещё
|
||||||
|
делимый (span >= min_bracket) принудительно делим (тяжёлая страница станет
|
||||||
|
загружаемой в узком диапазоне); иначе пропуск (avito).
|
||||||
|
* ``DEGRADE`` — деградировать в «пагинировать-до-пустоты» без знания total
|
||||||
|
(yandex): вызывается ``on_degraded``.
|
||||||
|
|
||||||
|
domclick счётчик-endpoint при ошибке возвращает 0 (а не None), поэтому его
|
||||||
|
политика формально ``SKIP``, но ветка probe-fail недостижима (count==0 →
|
||||||
|
ранний return).
|
||||||
|
"""
|
||||||
|
|
||||||
|
SKIP = auto()
|
||||||
|
SPLIT_OR_SKIP = auto()
|
||||||
|
DEGRADE = auto()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ProbeResult:
|
||||||
|
"""Результат probe одного бакета.
|
||||||
|
|
||||||
|
* ``count`` — сколько всего результатов в [lo, hi] (или ``None`` при
|
||||||
|
captcha/ошибке probe). ``0`` — валидная пустая выборка.
|
||||||
|
* ``payload`` — provider-specific данные страницы 1 для переиспользования в
|
||||||
|
leaf-пагинации (avito: HTML; cian: HTML; yandex: list[ScrapedLot] page 1;
|
||||||
|
domclick: None — count-endpoint без карточек).
|
||||||
|
"""
|
||||||
|
|
||||||
|
count: int | None
|
||||||
|
payload: Any = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class BisectionConfig:
|
||||||
|
"""Per-provider константы/политики бисекции (все различия провайдеров тут).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cap: потолок результатов в бакете; ``count > cap`` — кандидат на split.
|
||||||
|
min_bracket: минимальный размах диапазона, ниже которого делить нельзя.
|
||||||
|
min_bracket_inclusive: split разрешён при ``span >= min_bracket`` (True,
|
||||||
|
avito/cian/yandex) или строго ``span > min_bracket`` (False,
|
||||||
|
domclick — исходное `span <= MIN_PRICE_SPAN → paginate`).
|
||||||
|
max_depth: жёсткий предел глубины рекурсии (yandex=8) либо ``None``.
|
||||||
|
probe_fail_policy: см. :class:`ProbeFailPolicy`.
|
||||||
|
open_split_ceiling: если ``None`` (avito/cian/yandex) — открытый брекет
|
||||||
|
(hi=None) НЕ делится, пагинируется напрямую как leaf. Если задан
|
||||||
|
(domclick=1e9) — открытые границы клампятся к
|
||||||
|
``[open_split_floor, open_split_ceiling]`` и диапазон делится.
|
||||||
|
open_split_floor: нижний кламп для None-lo при делении (domclick=0).
|
||||||
|
treat_zero_as_leaf: если True (yandex) — при ``count == 0`` всё равно
|
||||||
|
вызвать ``on_leaf`` (yandex on_bucket-callback срабатывает и на 0);
|
||||||
|
иначе (avito/cian/domclick) — ранний return без leaf.
|
||||||
|
"""
|
||||||
|
|
||||||
|
cap: int
|
||||||
|
min_bracket: int
|
||||||
|
max_depth: int | None = None
|
||||||
|
min_bracket_inclusive: bool = True
|
||||||
|
probe_fail_policy: ProbeFailPolicy = ProbeFailPolicy.SKIP
|
||||||
|
open_split_ceiling: int | None = None
|
||||||
|
open_split_floor: int = 0
|
||||||
|
treat_zero_as_leaf: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
# Provider-callback типы.
|
||||||
|
ProbeFn = Callable[[int | None, int | None, int], Awaitable[ProbeResult]]
|
||||||
|
LeafFn = Callable[[int | None, int | None, ProbeResult], Awaitable[None]]
|
||||||
|
DegradedFn = Callable[[int | None, int | None], Awaitable[None]]
|
||||||
|
|
||||||
|
|
||||||
|
def _can_split_span(span: int, config: BisectionConfig) -> bool:
|
||||||
|
"""Разрешает ли размах диапазона дальнейшее деление (по min_bracket)."""
|
||||||
|
if config.min_bracket_inclusive:
|
||||||
|
return span >= config.min_bracket
|
||||||
|
return span > config.min_bracket
|
||||||
|
|
||||||
|
|
||||||
|
async def walk_price_range(
|
||||||
|
*,
|
||||||
|
lo: int | None,
|
||||||
|
hi: int | None,
|
||||||
|
config: BisectionConfig,
|
||||||
|
probe: ProbeFn,
|
||||||
|
on_leaf: LeafFn,
|
||||||
|
on_degraded: DegradedFn | None = None,
|
||||||
|
depth: int = 0,
|
||||||
|
) -> None:
|
||||||
|
"""Рекурсивно обойти ценовой диапазон [lo, hi] адаптивной бисекцией.
|
||||||
|
|
||||||
|
Скелет (общий для всех провайдеров):
|
||||||
|
1. ``result = probe(lo, hi, depth)`` — provider I/O (fetch + retry).
|
||||||
|
2. ``result.count is None`` → применить ``probe_fail_policy``.
|
||||||
|
3. ``count == 0`` → return (кроме ``treat_zero_as_leaf``).
|
||||||
|
4. Открытый брекет (hi=None) при ``open_split_ceiling is None`` → leaf.
|
||||||
|
5. Иначе: если ``count > cap`` и размах делим и глубина в пределах —
|
||||||
|
``mid = (lo + hi) // 2``, рекурсия в обе половины; иначе leaf.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
lo / hi: границы диапазона (``None`` = без границы; клампится при делении
|
||||||
|
для провайдеров с ``open_split_ceiling``).
|
||||||
|
config: per-provider пороги/политики.
|
||||||
|
probe: async ``(lo, hi, depth) -> ProbeResult`` — счётчик + payload.
|
||||||
|
on_leaf: async ``(lo, hi, result) -> None`` — пагинация leaf-бакета
|
||||||
|
(fetch/parse/дедуп/on_bucket целиком на стороне провайдера).
|
||||||
|
on_degraded: async ``(lo, hi) -> None`` — обязателен для
|
||||||
|
``ProbeFailPolicy.DEGRADE`` (yandex «пагинировать-до-пустоты»).
|
||||||
|
depth: текущая глубина рекурсии (для ``max_depth``).
|
||||||
|
"""
|
||||||
|
result = await probe(lo, hi, depth)
|
||||||
|
total = result.count
|
||||||
|
|
||||||
|
# ── Probe-fail: счётчик недоступен ────────────────────────────────────────
|
||||||
|
if total is None:
|
||||||
|
policy = config.probe_fail_policy
|
||||||
|
if policy is ProbeFailPolicy.SKIP:
|
||||||
|
return
|
||||||
|
if policy is ProbeFailPolicy.DEGRADE:
|
||||||
|
if on_degraded is not None:
|
||||||
|
await on_degraded(lo, hi)
|
||||||
|
return
|
||||||
|
# SPLIT_OR_SKIP (avito): открытый брекет пагинируем best-effort; закрытый
|
||||||
|
# ещё делимый — принудительно делим; иначе пропускаем (хвост теряем).
|
||||||
|
if hi is None:
|
||||||
|
await on_leaf(lo, hi, result)
|
||||||
|
return
|
||||||
|
_lo = lo if lo is not None else config.open_split_floor
|
||||||
|
if _can_split_span(hi - _lo, config):
|
||||||
|
mid = (_lo + hi) // 2
|
||||||
|
await walk_price_range(
|
||||||
|
lo=_lo,
|
||||||
|
hi=mid,
|
||||||
|
config=config,
|
||||||
|
probe=probe,
|
||||||
|
on_leaf=on_leaf,
|
||||||
|
on_degraded=on_degraded,
|
||||||
|
depth=depth + 1,
|
||||||
|
)
|
||||||
|
await walk_price_range(
|
||||||
|
lo=mid + 1,
|
||||||
|
hi=hi,
|
||||||
|
config=config,
|
||||||
|
probe=probe,
|
||||||
|
on_leaf=on_leaf,
|
||||||
|
on_degraded=on_degraded,
|
||||||
|
depth=depth + 1,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# ── Счётчик получен ───────────────────────────────────────────────────────
|
||||||
|
if total == 0 and not config.treat_zero_as_leaf:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Открытый брекет без split-потолка (avito/cian/yandex): делить нельзя —
|
||||||
|
# пагинируем leaf напрямую (хвост люкса крошечный, tail-loss accepted).
|
||||||
|
if hi is None and config.open_split_ceiling is None:
|
||||||
|
await on_leaf(lo, hi, result)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Эффективные границы для арифметики деления: None-границы клампятся к
|
||||||
|
# [open_split_floor, open_split_ceiling] (domclick unbounded root).
|
||||||
|
eff_lo = lo if lo is not None else config.open_split_floor
|
||||||
|
eff_hi = hi if hi is not None else config.open_split_ceiling
|
||||||
|
|
||||||
|
can_split = (
|
||||||
|
total > config.cap
|
||||||
|
and eff_hi is not None
|
||||||
|
and _can_split_span(eff_hi - eff_lo, config)
|
||||||
|
and (config.max_depth is None or depth < config.max_depth)
|
||||||
|
)
|
||||||
|
|
||||||
|
if can_split:
|
||||||
|
assert eff_hi is not None # для mypy: гарантировано can_split
|
||||||
|
mid = (eff_lo + eff_hi) // 2
|
||||||
|
await walk_price_range(
|
||||||
|
lo=eff_lo,
|
||||||
|
hi=mid,
|
||||||
|
config=config,
|
||||||
|
probe=probe,
|
||||||
|
on_leaf=on_leaf,
|
||||||
|
on_degraded=on_degraded,
|
||||||
|
depth=depth + 1,
|
||||||
|
)
|
||||||
|
await walk_price_range(
|
||||||
|
lo=mid + 1,
|
||||||
|
hi=eff_hi,
|
||||||
|
config=config,
|
||||||
|
probe=probe,
|
||||||
|
on_leaf=on_leaf,
|
||||||
|
on_degraded=on_degraded,
|
||||||
|
depth=depth + 1,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Leaf-бакет: пагинация + дедуп + on_bucket — целиком на стороне провайдера.
|
||||||
|
await on_leaf(lo, hi, result)
|
||||||
|
|
@ -46,6 +46,7 @@ from scraper_kit.avito_exceptions import (
|
||||||
from scraper_kit.base import BaseScraper, ScrapedLot
|
from scraper_kit.base import BaseScraper, ScrapedLot
|
||||||
from scraper_kit.browser_fetcher import BrowserFetcher
|
from scraper_kit.browser_fetcher import BrowserFetcher
|
||||||
from scraper_kit.price_brackets import get_price_seed_brackets
|
from scraper_kit.price_brackets import get_price_seed_brackets
|
||||||
|
from scraper_kit.pricing import BisectionConfig, ProbeFailPolicy, ProbeResult, walk_price_range
|
||||||
from scraper_kit.providers.avito.shared import _unix_to_date
|
from scraper_kit.providers.avito.shared import _unix_to_date
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
|
@ -250,6 +251,21 @@ _AVITO_PRICE_SEED_BRACKETS: list[tuple[int, int | None]] = get_price_seed_bracke
|
||||||
# Avito SERP показывает ~50 карточек на страницу.
|
# Avito SERP показывает ~50 карточек на страницу.
|
||||||
_AVITO_OFFERS_PER_PAGE = 50
|
_AVITO_OFFERS_PER_PAGE = 50
|
||||||
|
|
||||||
|
|
||||||
|
def _avito_bisection_config(cap: int) -> BisectionConfig:
|
||||||
|
"""Конфиг единого движка бисекции (scraper_kit.pricing) для avito.
|
||||||
|
|
||||||
|
probe_fail=SPLIT_OR_SKIP: probe вернул None (captcha/browser-timeout) → открытый
|
||||||
|
брекет пагинируем best-effort, закрытый ещё делимый (bracket >= MIN) принудительно
|
||||||
|
делим (тяжёлая выдача станет загружаемой в узком диапазоне), иначе пропуск.
|
||||||
|
open_split_ceiling=None: открытый брекет (hi=None) пагинируется напрямую, не делится.
|
||||||
|
"""
|
||||||
|
return BisectionConfig(
|
||||||
|
cap=cap,
|
||||||
|
min_bracket=_AVITO_MIN_BRACKET,
|
||||||
|
probe_fail_policy=ProbeFailPolicy.SPLIT_OR_SKIP,
|
||||||
|
)
|
||||||
|
|
||||||
# HTTP 429 в curl_cffi-режиме через backconnect-прокси (mproxy.site) — НЕ IP-ban, а
|
# HTTP 429 в curl_cffi-режиме через backconnect-прокси (mproxy.site) — НЕ IP-ban, а
|
||||||
# transient «слишком много одновременных соединений» (лимит 5). Проходит на коротком
|
# transient «слишком много одновременных соединений» (лимит 5). Проходит на коротком
|
||||||
# retry без ротации IP. Делаем до _AVITO_429_MAX_RETRIES коротких пауз; если они
|
# retry без ротации IP. Делаем до _AVITO_429_MAX_RETRIES коротких пауз; если они
|
||||||
|
|
@ -370,7 +386,9 @@ class AvitoScraper(BaseScraper):
|
||||||
# FALLBACK — если браузер отдаёт firewall/пусто, _fetch_serp_html делает
|
# FALLBACK — если браузер отдаёт firewall/пусто, _fetch_serp_html делает
|
||||||
# один retry через curl_cffi. _build_cffi_session самодостаточен
|
# один retry через curl_cffi. _build_cffi_session самодостаточен
|
||||||
# (отдельный AsyncSession, не зависит от BaseScraper.__aenter__).
|
# (отдельный AsyncSession, не зависит от BaseScraper.__aenter__).
|
||||||
self._browser = BrowserFetcher(source="avito")
|
self._browser = BrowserFetcher(
|
||||||
|
source="avito", endpoint=self._config.browser_http_endpoint
|
||||||
|
)
|
||||||
await self._browser.__aenter__()
|
await self._browser.__aenter__()
|
||||||
self._cffi = self._build_cffi_session()
|
self._cffi = self._build_cffi_session()
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|
@ -1155,145 +1173,89 @@ class AvitoScraper(BaseScraper):
|
||||||
secondary_only: новостройки (listing_segment=="novostroyki") отбрасываются после
|
secondary_only: новостройки (listing_segment=="novostroyki") отбрасываются после
|
||||||
сбора bucket_lots, до дедупа в seen и вызова on_bucket.
|
сбора bucket_lots, до дедупа в seen и вызова on_bucket.
|
||||||
AvitoBlockedError/AvitoRateLimitedError из _fetch_serp_html пробрасываются наверх.
|
AvitoBlockedError/AvitoRateLimitedError из _fetch_serp_html пробрасываются наверх.
|
||||||
|
|
||||||
|
Реализация: единый движок `scraper_kit.pricing.walk_price_range`. Avito-spec
|
||||||
|
probe (page=1 + IP-rotate retry) и leaf-пагинация переданы callback'ами;
|
||||||
|
пороги (cap, _AVITO_MIN_BRACKET) и политика probe-fail (SPLIT_OR_SKIP —
|
||||||
|
принудительный split тяжёлой выдачи / best-effort для открытого) — в
|
||||||
|
BisectionConfig. AvitoBlockedError/AvitoRateLimitedError из probe/leaf
|
||||||
|
всплывают наверх (mark_banned).
|
||||||
"""
|
"""
|
||||||
_lo_param = lo if lo > 0 else None
|
|
||||||
_hi_repr = "open" if hi is None else str(hi)
|
|
||||||
|
|
||||||
# ── Шаг 1: probe page 1 ────────────────────────────────────────────────
|
async def _probe(plo: int | None, phi: int | None, depth: int) -> ProbeResult:
|
||||||
# _probe_total ловит browser-ошибки (HTTP 500 / timeout от tradein-browser
|
lo_param = plo if plo and plo > 0 else None
|
||||||
# на тяжёлой выдаче, напр. студии pmax=200M) → total=None вместо краха.
|
hi_repr = "open" if phi is None else str(phi)
|
||||||
# AvitoBlockedError/AvitoRateLimitedError всплывают наверх (mark_banned).
|
# _probe_total ловит browser-ошибки (HTTP 500 / timeout от tradein-browser
|
||||||
html, total = await self._probe_total(room_slug, _lo_param, hi)
|
# на тяжёлой выдаче) → total=None вместо краха. Block-исключения всплывают.
|
||||||
|
html, total = await self._probe_total(room_slug, lo_param, phi)
|
||||||
# Ретрай на captcha/missing-count/browser-ошибке: rotate IP + 1 retry.
|
if total is None:
|
||||||
if total is None:
|
|
||||||
logger.warning(
|
|
||||||
"avito: total=None for %s [%d, %s] depth=%d — rotating IP + retry",
|
|
||||||
room_label,
|
|
||||||
lo,
|
|
||||||
_hi_repr,
|
|
||||||
_depth,
|
|
||||||
)
|
|
||||||
rotated = await self._rotate_ip()
|
|
||||||
if rotated:
|
|
||||||
html, total = await self._probe_total(room_slug, _lo_param, hi)
|
|
||||||
|
|
||||||
if total is None:
|
|
||||||
# ОТКРЫТЫЙ брекет: делить нельзя (нет hi). Probe failed → нет total для
|
|
||||||
# ceil(); пагинируем до max_pages_per_bucket (best-effort, хвост 250М+
|
|
||||||
# крошечный) и WARNING. НЕ роняем прогон.
|
|
||||||
if hi is None:
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"avito: probe failed for OPEN bucket %s [%d, open] depth=%d — "
|
"avito: total=None for %s [%s, %s] depth=%d — rotating IP + retry",
|
||||||
|
room_label,
|
||||||
|
plo,
|
||||||
|
hi_repr,
|
||||||
|
depth,
|
||||||
|
)
|
||||||
|
rotated = await self._rotate_ip()
|
||||||
|
if rotated:
|
||||||
|
html, total = await self._probe_total(room_slug, lo_param, phi)
|
||||||
|
if total is not None:
|
||||||
|
logger.info(
|
||||||
|
"avito: %s [%s, %s] total=%d depth=%d",
|
||||||
|
room_label,
|
||||||
|
plo,
|
||||||
|
hi_repr,
|
||||||
|
total,
|
||||||
|
depth,
|
||||||
|
)
|
||||||
|
return ProbeResult(count=total, payload=html)
|
||||||
|
|
||||||
|
async def _leaf(plo: int | None, phi: int | None, result: ProbeResult) -> None:
|
||||||
|
_lo = plo if plo is not None else 0
|
||||||
|
total = result.count
|
||||||
|
if total is None:
|
||||||
|
# SPLIT_OR_SKIP на открытом брекете: probe провалился, делить нельзя —
|
||||||
|
# пагинируем best-effort до max_pages_per_bucket (tail-loss accepted).
|
||||||
|
html: str | None = None
|
||||||
|
max_pages = max_pages_per_bucket
|
||||||
|
logger.warning(
|
||||||
|
"avito: probe failed for OPEN bucket %s [%d, open] — "
|
||||||
"paginating up to max_pages (tail-loss accepted)",
|
"paginating up to max_pages (tail-loss accepted)",
|
||||||
room_label,
|
room_label,
|
||||||
lo,
|
_lo,
|
||||||
_depth,
|
|
||||||
)
|
)
|
||||||
await self._paginate_leaf_bucket(
|
else:
|
||||||
room_slug=room_slug,
|
if phi is None and total > price_cap_per_bucket:
|
||||||
room_label=room_label,
|
logger.warning(
|
||||||
lo=lo,
|
"avito: OPEN bucket %s [%d, open] total=%d > cap=%d — paginating "
|
||||||
hi=None,
|
"without split (tail-loss accepted, lux tail tiny)",
|
||||||
html=None,
|
room_label,
|
||||||
max_pages=max_pages_per_bucket,
|
_lo,
|
||||||
seen=seen,
|
total,
|
||||||
price_cap_per_bucket=price_cap_per_bucket,
|
price_cap_per_bucket,
|
||||||
max_pages_per_bucket=max_pages_per_bucket,
|
)
|
||||||
concurrency=concurrency,
|
pages_needed = math.ceil(total / _AVITO_OFFERS_PER_PAGE)
|
||||||
secondary_only=secondary_only,
|
max_pages = min(pages_needed, max_pages_per_bucket)
|
||||||
on_bucket=on_bucket,
|
if phi is not None and pages_needed > max_pages_per_bucket:
|
||||||
skip_buckets=skip_buckets,
|
tail_loss = total - max_pages_per_bucket * _AVITO_OFFERS_PER_PAGE
|
||||||
)
|
logger.warning(
|
||||||
return
|
"avito: %s [%d, %d] total=%d exceeds page cap "
|
||||||
bracket_size = hi - lo
|
"(max_pages=%d × %d=%d offers) — tail loss ~%d offers",
|
||||||
# Probe не удался даже после ретрая. Если бакет ещё делим — НЕ теряем
|
room_label,
|
||||||
# его целиком: тяжёлая страница (огромная выдача) скорее всего станет
|
_lo,
|
||||||
# загружаемой в более узком ценовом диапазоне → принудительный split.
|
phi,
|
||||||
if bracket_size >= _AVITO_MIN_BRACKET:
|
total,
|
||||||
logger.warning(
|
max_pages_per_bucket,
|
||||||
"avito: probe failed for %s [%d, %d] depth=%d — splitting (assume heavy)",
|
_AVITO_OFFERS_PER_PAGE,
|
||||||
room_label,
|
max_pages_per_bucket * _AVITO_OFFERS_PER_PAGE,
|
||||||
lo,
|
tail_loss,
|
||||||
hi,
|
)
|
||||||
_depth,
|
html = result.payload
|
||||||
)
|
|
||||||
mid = (lo + hi) // 2
|
|
||||||
await self._walk_price_range(
|
|
||||||
room_slug=room_slug,
|
|
||||||
room_label=room_label,
|
|
||||||
lo=lo,
|
|
||||||
hi=mid,
|
|
||||||
seen=seen,
|
|
||||||
price_cap_per_bucket=price_cap_per_bucket,
|
|
||||||
max_pages_per_bucket=max_pages_per_bucket,
|
|
||||||
concurrency=concurrency,
|
|
||||||
secondary_only=secondary_only,
|
|
||||||
on_bucket=on_bucket,
|
|
||||||
skip_buckets=skip_buckets,
|
|
||||||
_depth=_depth + 1,
|
|
||||||
)
|
|
||||||
await self._walk_price_range(
|
|
||||||
room_slug=room_slug,
|
|
||||||
room_label=room_label,
|
|
||||||
lo=mid + 1,
|
|
||||||
hi=hi,
|
|
||||||
seen=seen,
|
|
||||||
price_cap_per_bucket=price_cap_per_bucket,
|
|
||||||
max_pages_per_bucket=max_pages_per_bucket,
|
|
||||||
concurrency=concurrency,
|
|
||||||
secondary_only=secondary_only,
|
|
||||||
on_bucket=on_bucket,
|
|
||||||
skip_buckets=skip_buckets,
|
|
||||||
_depth=_depth + 1,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
# Бакет уже узкий (< MIN_BRACKET) — split не поможет, хвост теряем
|
|
||||||
# осознанно (как cian) и логируем.
|
|
||||||
logger.error(
|
|
||||||
"avito: skipping bucket %s [%d, %d] — total unavailable after retry "
|
|
||||||
"(bracket=%d < MIN_BRACKET=%d, tail loss accepted)",
|
|
||||||
room_label,
|
|
||||||
lo,
|
|
||||||
hi,
|
|
||||||
bracket_size,
|
|
||||||
_AVITO_MIN_BRACKET,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"avito: %s [%d, %s] total=%d depth=%d",
|
|
||||||
room_label,
|
|
||||||
lo,
|
|
||||||
_hi_repr,
|
|
||||||
total,
|
|
||||||
_depth,
|
|
||||||
)
|
|
||||||
|
|
||||||
if total == 0:
|
|
||||||
return
|
|
||||||
|
|
||||||
# ── ОТКРЫТЫЙ брекет (hi is None): делить нельзя — пагинируем leaf напрямую ─
|
|
||||||
# total известен из probe → max_pages = min(ceil(total/per_page), cap).
|
|
||||||
# Если total > cap (не должно для 250М+, но guard) — пагинируем как есть до
|
|
||||||
# max_pages_per_bucket с WARNING (хвост люкса крошечный, tail-loss accepted).
|
|
||||||
if hi is None:
|
|
||||||
if total > price_cap_per_bucket:
|
|
||||||
logger.warning(
|
|
||||||
"avito: OPEN bucket %s [%d, open] total=%d > cap=%d — paginating "
|
|
||||||
"without split (tail-loss accepted, lux tail tiny)",
|
|
||||||
room_label,
|
|
||||||
lo,
|
|
||||||
total,
|
|
||||||
price_cap_per_bucket,
|
|
||||||
)
|
|
||||||
pages_needed = math.ceil(total / _AVITO_OFFERS_PER_PAGE)
|
|
||||||
max_pages = min(pages_needed, max_pages_per_bucket)
|
|
||||||
await self._paginate_leaf_bucket(
|
await self._paginate_leaf_bucket(
|
||||||
room_slug=room_slug,
|
room_slug=room_slug,
|
||||||
room_label=room_label,
|
room_label=room_label,
|
||||||
lo=lo,
|
lo=_lo,
|
||||||
hi=None,
|
hi=phi,
|
||||||
html=html,
|
html=html,
|
||||||
max_pages=max_pages,
|
max_pages=max_pages,
|
||||||
seen=seen,
|
seen=seen,
|
||||||
|
|
@ -1304,95 +1266,14 @@ class AvitoScraper(BaseScraper):
|
||||||
on_bucket=on_bucket,
|
on_bucket=on_bucket,
|
||||||
skip_buckets=skip_buckets,
|
skip_buckets=skip_buckets,
|
||||||
)
|
)
|
||||||
return
|
|
||||||
|
|
||||||
# ── Шаг 2: деление или пагинация ──────────────────────────────────────
|
await walk_price_range(
|
||||||
bracket_size = hi - lo
|
|
||||||
need_split = total > price_cap_per_bucket
|
|
||||||
too_narrow = bracket_size < _AVITO_MIN_BRACKET
|
|
||||||
|
|
||||||
if need_split and too_narrow:
|
|
||||||
logger.warning(
|
|
||||||
"avito: %s [%d, %d] total=%d > cap=%d but bracket=%d < MIN_BRACKET=%d "
|
|
||||||
"— paginating as-is (tail loss ~%d)",
|
|
||||||
room_label,
|
|
||||||
lo,
|
|
||||||
hi,
|
|
||||||
total,
|
|
||||||
price_cap_per_bucket,
|
|
||||||
bracket_size,
|
|
||||||
_AVITO_MIN_BRACKET,
|
|
||||||
max(0, total - price_cap_per_bucket),
|
|
||||||
)
|
|
||||||
need_split = False # принудительно пагинируем
|
|
||||||
|
|
||||||
if need_split:
|
|
||||||
mid = (lo + hi) // 2
|
|
||||||
# [lo, mid]
|
|
||||||
await self._walk_price_range(
|
|
||||||
room_slug=room_slug,
|
|
||||||
room_label=room_label,
|
|
||||||
lo=lo,
|
|
||||||
hi=mid,
|
|
||||||
seen=seen,
|
|
||||||
price_cap_per_bucket=price_cap_per_bucket,
|
|
||||||
max_pages_per_bucket=max_pages_per_bucket,
|
|
||||||
concurrency=concurrency,
|
|
||||||
secondary_only=secondary_only,
|
|
||||||
on_bucket=on_bucket,
|
|
||||||
skip_buckets=skip_buckets,
|
|
||||||
_depth=_depth + 1,
|
|
||||||
)
|
|
||||||
# [mid+1, hi]
|
|
||||||
await self._walk_price_range(
|
|
||||||
room_slug=room_slug,
|
|
||||||
room_label=room_label,
|
|
||||||
lo=mid + 1,
|
|
||||||
hi=hi,
|
|
||||||
seen=seen,
|
|
||||||
price_cap_per_bucket=price_cap_per_bucket,
|
|
||||||
max_pages_per_bucket=max_pages_per_bucket,
|
|
||||||
concurrency=concurrency,
|
|
||||||
secondary_only=secondary_only,
|
|
||||||
on_bucket=on_bucket,
|
|
||||||
skip_buckets=skip_buckets,
|
|
||||||
_depth=_depth + 1,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
# ── Закрытый leaf-бакет: считаем max_pages + warning, делегируем helper'у ─
|
|
||||||
pages_needed = math.ceil(total / _AVITO_OFFERS_PER_PAGE)
|
|
||||||
max_pages = min(pages_needed, max_pages_per_bucket)
|
|
||||||
|
|
||||||
if pages_needed > max_pages_per_bucket:
|
|
||||||
tail_loss = total - max_pages_per_bucket * _AVITO_OFFERS_PER_PAGE
|
|
||||||
logger.warning(
|
|
||||||
"avito: %s [%d, %d] total=%d exceeds page cap "
|
|
||||||
"(max_pages=%d × %d=%d offers) — tail loss ~%d offers",
|
|
||||||
room_label,
|
|
||||||
lo,
|
|
||||||
hi,
|
|
||||||
total,
|
|
||||||
max_pages_per_bucket,
|
|
||||||
_AVITO_OFFERS_PER_PAGE,
|
|
||||||
max_pages_per_bucket * _AVITO_OFFERS_PER_PAGE,
|
|
||||||
tail_loss,
|
|
||||||
)
|
|
||||||
|
|
||||||
await self._paginate_leaf_bucket(
|
|
||||||
room_slug=room_slug,
|
|
||||||
room_label=room_label,
|
|
||||||
lo=lo,
|
lo=lo,
|
||||||
hi=hi,
|
hi=hi,
|
||||||
html=html,
|
config=_avito_bisection_config(price_cap_per_bucket),
|
||||||
max_pages=max_pages,
|
probe=_probe,
|
||||||
seen=seen,
|
on_leaf=_leaf,
|
||||||
price_cap_per_bucket=price_cap_per_bucket,
|
depth=_depth,
|
||||||
max_pages_per_bucket=max_pages_per_bucket,
|
|
||||||
concurrency=concurrency,
|
|
||||||
secondary_only=secondary_only,
|
|
||||||
on_bucket=on_bucket,
|
|
||||||
skip_buckets=skip_buckets,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _paginate_leaf_bucket(
|
async def _paginate_leaf_bucket(
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ from scraper_kit.browser_fetcher import BrowserFetcher
|
||||||
from scraper_kit.cian_state_parser import extract_state
|
from scraper_kit.cian_state_parser import extract_state
|
||||||
from scraper_kit.house_type_normalizer import normalize_house_type
|
from scraper_kit.house_type_normalizer import normalize_house_type
|
||||||
from scraper_kit.price_brackets import get_price_seed_brackets
|
from scraper_kit.price_brackets import get_price_seed_brackets
|
||||||
|
from scraper_kit.pricing import BisectionConfig, ProbeFailPolicy, ProbeResult, walk_price_range
|
||||||
from scraper_kit.repair_state_normalizer import (
|
from scraper_kit.repair_state_normalizer import (
|
||||||
infer_repair_state_from_text,
|
infer_repair_state_from_text,
|
||||||
normalize_repair_state,
|
normalize_repair_state,
|
||||||
|
|
@ -69,6 +70,17 @@ _MIN_BRACKET = 50_000
|
||||||
# Cian SERP показывает ~28 офферов на страницу.
|
# Cian SERP показывает ~28 офферов на страницу.
|
||||||
_CIAN_OFFERS_PER_PAGE = 28
|
_CIAN_OFFERS_PER_PAGE = 28
|
||||||
|
|
||||||
|
# Конфиг единого движка бисекции (scraper_kit.pricing) для cian. cap задаётся
|
||||||
|
# per-call (price_cap_per_bucket) — здесь только структурные константы/политики.
|
||||||
|
# probe_fail=SKIP: totalOffers недоступен после ретрая → бакет пропускается.
|
||||||
|
# open_split_ceiling=None: открытый брекет пагинируется напрямую (не делится).
|
||||||
|
def _cian_bisection_config(cap: int) -> BisectionConfig:
|
||||||
|
return BisectionConfig(
|
||||||
|
cap=cap,
|
||||||
|
min_bracket=_MIN_BRACKET,
|
||||||
|
probe_fail_policy=ProbeFailPolicy.SKIP,
|
||||||
|
)
|
||||||
|
|
||||||
# Комнатности по умолчанию для exhaustive-загрузки.
|
# Комнатности по умолчанию для exhaustive-загрузки.
|
||||||
# Коды Cian: room1=1к, room2=2к, room3=3к, room4=4к, room5=5к, room6=6+.
|
# Коды Cian: room1=1к, room2=2к, room3=3к, room4=4к, room5=5к, room6=6+.
|
||||||
# TODO: студии — Cian использует отдельный тип flat_type=studio, не room-param.
|
# TODO: студии — Cian использует отдельный тип flat_type=studio, не room-param.
|
||||||
|
|
@ -123,7 +135,9 @@ class CianScraper(BaseScraper):
|
||||||
# fingerprint через tradein-browser сервис (#1806). Сервер роутит /fetch на
|
# fingerprint через tradein-browser сервис (#1806). Сервер роутит /fetch на
|
||||||
# отдельный браузер+мобильный прокси для cian. Прокси ротирует IP сам, поэтому
|
# отдельный браузер+мобильный прокси для cian. Прокси ротирует IP сам, поэтому
|
||||||
# code-side warm-up cookies и changeip-ротация больше не нужны.
|
# code-side warm-up cookies и changeip-ротация больше не нужны.
|
||||||
self._browser = BrowserFetcher(source="cian")
|
self._browser = BrowserFetcher(
|
||||||
|
source="cian", endpoint=self._config.browser_http_endpoint
|
||||||
|
)
|
||||||
await self._browser.__aenter__()
|
await self._browser.__aenter__()
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
@ -398,76 +412,75 @@ class CianScraper(BaseScraper):
|
||||||
skip_buckets: если bucket_key в skip_buckets — пагинацию и on_bucket пропускаем.
|
skip_buckets: если bucket_key в skip_buckets — пагинацию и on_bucket пропускаем.
|
||||||
secondary_only: если True — новостройки (listing_segment=="novostroyki") отбрасываются
|
secondary_only: если True — новостройки (listing_segment=="novostroyki") отбрасываются
|
||||||
после сбора bucket_lots, до дедупа в seen и вызова on_bucket.
|
после сбора bucket_lots, до дедупа в seen и вызова on_bucket.
|
||||||
|
|
||||||
|
Реализация: единый движок `scraper_kit.pricing.walk_price_range`. Cian-spec
|
||||||
|
probe (fetch page 1 + retry) и leaf-пагинация переданы callback'ами; пороги
|
||||||
|
(cap, _MIN_BRACKET) и политика probe-fail (SKIP) — в BisectionConfig.
|
||||||
"""
|
"""
|
||||||
room_label = f"room{'_'.join(str(r) for r in rooms)}"
|
room_label = f"room{'_'.join(str(r) for r in rooms)}"
|
||||||
_lo_param = lo if lo > 0 else None
|
|
||||||
_hi_repr = "open" if hi is None else str(hi)
|
|
||||||
|
|
||||||
# ── Шаг 1: probe page 1 ────────────────────────────────────────────────
|
async def _probe(plo: int | None, phi: int | None, depth: int) -> ProbeResult:
|
||||||
html = await self._fetch_page_html(rooms, 1, _lo_param, hi)
|
lo_param = plo if plo and plo > 0 else None
|
||||||
await self.sleep_between_requests()
|
hi_repr = "open" if phi is None else str(phi)
|
||||||
|
# ── Шаг 1: probe page 1 ───────────────────────────────────────────
|
||||||
total: int | None = None
|
html = await self._fetch_page_html(rooms, 1, lo_param, phi)
|
||||||
if html is not None:
|
|
||||||
total = self._extract_total_offers(html)
|
|
||||||
|
|
||||||
# Ретрай на captcha/ошибку: 1 повторный fetch. Мобильный прокси в браузер-
|
|
||||||
# контейнере ротирует IP сам (#1806), поэтому отдельный changeip-вызов
|
|
||||||
# (_rotate_ip) больше не нужен — просто повторяем запрос на чистом IP.
|
|
||||||
if total is None:
|
|
||||||
logger.warning(
|
|
||||||
"cian: totalOffers=None for %s [%d, %s] depth=%d — retry (proxy auto-rotates IP)",
|
|
||||||
room_label,
|
|
||||||
lo,
|
|
||||||
_hi_repr,
|
|
||||||
_depth,
|
|
||||||
)
|
|
||||||
await self._rotate_ip() # no-op (back-compat); прокси ротирует IP сам
|
|
||||||
html = await self._fetch_page_html(rooms, 1, _lo_param, hi)
|
|
||||||
await self.sleep_between_requests()
|
await self.sleep_between_requests()
|
||||||
if html is not None:
|
total = self._extract_total_offers(html) if html is not None else None
|
||||||
total = self._extract_total_offers(html)
|
|
||||||
|
|
||||||
if total is None:
|
# Ретрай на captcha/ошибку: 1 повторный fetch. Мобильный прокси в браузер-
|
||||||
logger.error(
|
# контейнере ротирует IP сам (#1806), поэтому отдельный changeip-вызов
|
||||||
"cian: skipping bucket %s [%d, %s] — totalOffers unavailable after retry",
|
# (_rotate_ip) больше не нужен — просто повторяем запрос на чистом IP.
|
||||||
room_label,
|
if total is None:
|
||||||
lo,
|
|
||||||
_hi_repr,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"cian: %s [%d, %s] totalOffers=%d depth=%d",
|
|
||||||
room_label,
|
|
||||||
lo,
|
|
||||||
_hi_repr,
|
|
||||||
total,
|
|
||||||
_depth,
|
|
||||||
)
|
|
||||||
|
|
||||||
if total == 0:
|
|
||||||
return
|
|
||||||
|
|
||||||
# ── ОТКРЫТЫЙ брекет (hi is None): делить нельзя — пагинируем leaf напрямую ─
|
|
||||||
# total известен из probe → max_pages = min(ceil(total/per_page), cap).
|
|
||||||
# Если total > cap (не должно для 250М+, но guard) — пагинируем как есть до
|
|
||||||
# max_pages_per_bucket с WARNING (хвост люкса крошечный, tail-loss accepted).
|
|
||||||
if hi is None:
|
|
||||||
if total > price_cap_per_bucket:
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"cian: OPEN bucket %s [%d, open] totalOffers=%d > cap=%d — paginating "
|
"cian: totalOffers=None for %s [%s, %s] depth=%d — retry "
|
||||||
|
"(proxy auto-rotates IP)",
|
||||||
|
room_label,
|
||||||
|
plo,
|
||||||
|
hi_repr,
|
||||||
|
depth,
|
||||||
|
)
|
||||||
|
await self._rotate_ip() # no-op (back-compat); прокси ротирует IP сам
|
||||||
|
html = await self._fetch_page_html(rooms, 1, lo_param, phi)
|
||||||
|
await self.sleep_between_requests()
|
||||||
|
if html is not None:
|
||||||
|
total = self._extract_total_offers(html)
|
||||||
|
|
||||||
|
if total is None:
|
||||||
|
logger.error(
|
||||||
|
"cian: skipping bucket %s [%s, %s] — totalOffers unavailable after retry",
|
||||||
|
room_label,
|
||||||
|
plo,
|
||||||
|
hi_repr,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info(
|
||||||
|
"cian: %s [%s, %s] totalOffers=%d depth=%d",
|
||||||
|
room_label,
|
||||||
|
plo,
|
||||||
|
hi_repr,
|
||||||
|
total,
|
||||||
|
depth,
|
||||||
|
)
|
||||||
|
return ProbeResult(count=total, payload=html)
|
||||||
|
|
||||||
|
async def _leaf(plo: int | None, phi: int | None, result: ProbeResult) -> None:
|
||||||
|
total = result.count
|
||||||
|
assert total is not None # SKIP-политика: leaf не вызывается при None
|
||||||
|
if phi is None and total > price_cap_per_bucket:
|
||||||
|
logger.warning(
|
||||||
|
"cian: OPEN bucket %s [%s, open] totalOffers=%d > cap=%d — paginating "
|
||||||
"without split (tail-loss accepted, lux tail tiny)",
|
"without split (tail-loss accepted, lux tail tiny)",
|
||||||
room_label,
|
room_label,
|
||||||
lo,
|
plo,
|
||||||
total,
|
total,
|
||||||
price_cap_per_bucket,
|
price_cap_per_bucket,
|
||||||
)
|
)
|
||||||
await self._paginate_open_bucket(
|
await self._paginate_leaf_bucket(
|
||||||
rooms=rooms,
|
rooms=rooms,
|
||||||
room_label=room_label,
|
room_label=room_label,
|
||||||
lo=lo,
|
lo=plo if plo is not None else 0,
|
||||||
html=html,
|
hi=phi,
|
||||||
|
html=result.payload,
|
||||||
total=total,
|
total=total,
|
||||||
seen=seen,
|
seen=seen,
|
||||||
max_pages_per_bucket=max_pages_per_bucket,
|
max_pages_per_bucket=max_pages_per_bucket,
|
||||||
|
|
@ -476,105 +489,14 @@ class CianScraper(BaseScraper):
|
||||||
on_bucket=on_bucket,
|
on_bucket=on_bucket,
|
||||||
skip_buckets=skip_buckets,
|
skip_buckets=skip_buckets,
|
||||||
)
|
)
|
||||||
return
|
|
||||||
|
|
||||||
# ── Шаг 2: деление или пагинация (только ЗАКРЫТЫЙ брекет, hi: int) ─────
|
await walk_price_range(
|
||||||
bracket_size = hi - lo
|
|
||||||
need_split = total > price_cap_per_bucket
|
|
||||||
too_narrow = bracket_size < _MIN_BRACKET
|
|
||||||
|
|
||||||
if need_split and too_narrow:
|
|
||||||
logger.warning(
|
|
||||||
"cian: %s [%d, %d] totalOffers=%d > cap=%d but bracket=%d < MIN_BRACKET=%d "
|
|
||||||
"— paginating as-is (tail loss ~%d)",
|
|
||||||
room_label,
|
|
||||||
lo,
|
|
||||||
hi,
|
|
||||||
total,
|
|
||||||
price_cap_per_bucket,
|
|
||||||
bracket_size,
|
|
||||||
_MIN_BRACKET,
|
|
||||||
max(0, total - price_cap_per_bucket),
|
|
||||||
)
|
|
||||||
need_split = False # принудительно пагинируем
|
|
||||||
|
|
||||||
if need_split:
|
|
||||||
mid = (lo + hi) // 2
|
|
||||||
# [lo, mid]
|
|
||||||
await self._walk_price_range(
|
|
||||||
rooms=rooms,
|
|
||||||
lo=lo,
|
|
||||||
hi=mid,
|
|
||||||
seen=seen,
|
|
||||||
price_cap_per_bucket=price_cap_per_bucket,
|
|
||||||
max_pages_per_bucket=max_pages_per_bucket,
|
|
||||||
concurrency=concurrency,
|
|
||||||
secondary_only=secondary_only,
|
|
||||||
on_bucket=on_bucket,
|
|
||||||
skip_buckets=skip_buckets,
|
|
||||||
_depth=_depth + 1,
|
|
||||||
)
|
|
||||||
# [mid+1, hi]
|
|
||||||
await self._walk_price_range(
|
|
||||||
rooms=rooms,
|
|
||||||
lo=mid + 1,
|
|
||||||
hi=hi,
|
|
||||||
seen=seen,
|
|
||||||
price_cap_per_bucket=price_cap_per_bucket,
|
|
||||||
max_pages_per_bucket=max_pages_per_bucket,
|
|
||||||
concurrency=concurrency,
|
|
||||||
secondary_only=secondary_only,
|
|
||||||
on_bucket=on_bucket,
|
|
||||||
skip_buckets=skip_buckets,
|
|
||||||
_depth=_depth + 1,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Закрытый leaf-бакет — общая пагинация (см. _paginate_leaf_bucket).
|
|
||||||
await self._paginate_leaf_bucket(
|
|
||||||
rooms=rooms,
|
|
||||||
room_label=room_label,
|
|
||||||
lo=lo,
|
lo=lo,
|
||||||
hi=hi,
|
hi=hi,
|
||||||
html=html,
|
config=_cian_bisection_config(price_cap_per_bucket),
|
||||||
total=total,
|
probe=_probe,
|
||||||
seen=seen,
|
on_leaf=_leaf,
|
||||||
max_pages_per_bucket=max_pages_per_bucket,
|
depth=_depth,
|
||||||
concurrency=concurrency,
|
|
||||||
secondary_only=secondary_only,
|
|
||||||
on_bucket=on_bucket,
|
|
||||||
skip_buckets=skip_buckets,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _paginate_open_bucket(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
rooms: tuple[int, ...],
|
|
||||||
room_label: str,
|
|
||||||
lo: int,
|
|
||||||
html: str | None,
|
|
||||||
total: int,
|
|
||||||
seen: dict[str, ScrapedLot],
|
|
||||||
max_pages_per_bucket: int,
|
|
||||||
concurrency: int,
|
|
||||||
secondary_only: bool,
|
|
||||||
on_bucket: Callable[..., Any] | None,
|
|
||||||
skip_buckets: set[str] | None,
|
|
||||||
) -> None:
|
|
||||||
"""Пагинация ОТКРЫТОГО leaf-бакета (hi=None, без maxprice)."""
|
|
||||||
await self._paginate_leaf_bucket(
|
|
||||||
rooms=rooms,
|
|
||||||
room_label=room_label,
|
|
||||||
lo=lo,
|
|
||||||
hi=None,
|
|
||||||
html=html,
|
|
||||||
total=total,
|
|
||||||
seen=seen,
|
|
||||||
max_pages_per_bucket=max_pages_per_bucket,
|
|
||||||
concurrency=concurrency,
|
|
||||||
secondary_only=secondary_only,
|
|
||||||
on_bucket=on_bucket,
|
|
||||||
skip_buckets=skip_buckets,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _paginate_leaf_bucket(
|
async def _paginate_leaf_bucket(
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ from urllib.parse import urlencode
|
||||||
|
|
||||||
from scraper_kit.base import BaseScraper, ScrapedLot
|
from scraper_kit.base import BaseScraper, ScrapedLot
|
||||||
from scraper_kit.domclick_exceptions import DomClickBlockedError
|
from scraper_kit.domclick_exceptions import DomClickBlockedError
|
||||||
|
from scraper_kit.pricing import BisectionConfig, ProbeResult, walk_price_range
|
||||||
from scraper_kit.repair_state_normalizer import infer_repair_state_from_text
|
from scraper_kit.repair_state_normalizer import infer_repair_state_from_text
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
|
@ -61,6 +62,24 @@ PAGE_SIZE: int = 20 # items per page (жёстко задан API)
|
||||||
LTE_MAX: int = 1_000_000_000
|
LTE_MAX: int = 1_000_000_000
|
||||||
MIN_PRICE_SPAN: int = 100_000 # ниже этого span прекращаем делить
|
MIN_PRICE_SPAN: int = 100_000 # ниже этого span прекращаем делить
|
||||||
|
|
||||||
|
# Конфиг единого движка бисекции (scraper_kit.pricing) для domclick. Отличия от
|
||||||
|
# avito/cian/yandex закодированы явно:
|
||||||
|
# * cap = OFFSET_CAP (2000) — потолок offset BFF API, не «результатов бакета»;
|
||||||
|
# * count через отдельный count-endpoint (probe.payload=None, пагинация с offset=0);
|
||||||
|
# * min_bracket_inclusive=False — исходное `span <= MIN_PRICE_SPAN → paginate`
|
||||||
|
# (делим только пока span СТРОГО > MIN_PRICE_SPAN);
|
||||||
|
# * open_split_ceiling=LTE_MAX / floor=0 — unbounded-бакет (price_gte/lte=None)
|
||||||
|
# клампится к [0, LTE_MAX] и ДЕЛИТСЯ (в отличие от открытого seed-брекета
|
||||||
|
# avito/cian/yandex, который пагинируется напрямую);
|
||||||
|
# * seed-брекетов нет — каждый ROOM_BUCKET стартует с одного (None, None).
|
||||||
|
_DOMCLICK_BISECTION = BisectionConfig(
|
||||||
|
cap=OFFSET_CAP,
|
||||||
|
min_bracket=MIN_PRICE_SPAN,
|
||||||
|
min_bracket_inclusive=False,
|
||||||
|
open_split_ceiling=LTE_MAX,
|
||||||
|
open_split_floor=0,
|
||||||
|
)
|
||||||
|
|
||||||
# ── QRATOR block detection ────────────────────────────────────────────────────
|
# ── QRATOR block detection ────────────────────────────────────────────────────
|
||||||
|
|
||||||
_QRATOR_MARKERS: tuple[str, ...] = (
|
_QRATOR_MARKERS: tuple[str, ...] = (
|
||||||
|
|
@ -406,49 +425,27 @@ class DomClickScraper(BaseScraper):
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
DomClickBlockedError: если QRATOR-блок — propagate наверх.
|
DomClickBlockedError: если QRATOR-блок — propagate наверх.
|
||||||
"""
|
|
||||||
count = await self._count(fetcher, rooms, price_gte, price_lte)
|
|
||||||
if count == 0:
|
|
||||||
return
|
|
||||||
|
|
||||||
if count > OFFSET_CAP:
|
Реализация: единый движок `scraper_kit.pricing.walk_price_range`. Domclick-spec
|
||||||
_gte = price_gte if price_gte is not None else 0
|
count-endpoint (probe) и offset-пагинация (leaf) переданы callback'ами; отличия
|
||||||
_lte = price_lte if price_lte is not None else LTE_MAX
|
(OFFSET_CAP, MIN_PRICE_SPAN строго-больше, clamp unbounded к [0, LTE_MAX] с
|
||||||
span = _lte - _gte
|
делением) закодированы в _DOMCLICK_BISECTION.
|
||||||
if span <= MIN_PRICE_SPAN:
|
"""
|
||||||
logger.warning(
|
|
||||||
"domklik: rooms=%r price=[%s,%s] count=%d > cap=%d "
|
async def _probe(plo: int | None, phi: int | None, depth: int) -> ProbeResult:
|
||||||
"but span=%d <= min=%d — paginating as-is (bucket truncated at %d)",
|
count = await self._count(fetcher, rooms, plo, phi)
|
||||||
rooms,
|
return ProbeResult(count=count, payload=None)
|
||||||
price_gte,
|
|
||||||
price_lte,
|
async def _leaf(plo: int | None, phi: int | None, result: ProbeResult) -> None:
|
||||||
count,
|
await self._paginate(fetcher, rooms, plo, phi, seen_ids, out_lots, pages)
|
||||||
OFFSET_CAP,
|
|
||||||
span,
|
await walk_price_range(
|
||||||
MIN_PRICE_SPAN,
|
lo=price_gte,
|
||||||
OFFSET_CAP,
|
hi=price_lte,
|
||||||
)
|
config=_DOMCLICK_BISECTION,
|
||||||
await self._paginate(
|
probe=_probe,
|
||||||
fetcher, rooms, price_gte, price_lte, seen_ids, out_lots, pages
|
on_leaf=_leaf,
|
||||||
)
|
)
|
||||||
return
|
|
||||||
mid = (_gte + _lte) // 2
|
|
||||||
logger.debug(
|
|
||||||
"domklik: rooms=%r count=%d > cap=%d — price-split [%d,%d] → [%d,%d]+[%d,%d]",
|
|
||||||
rooms,
|
|
||||||
count,
|
|
||||||
OFFSET_CAP,
|
|
||||||
_gte,
|
|
||||||
_lte,
|
|
||||||
_gte,
|
|
||||||
mid,
|
|
||||||
mid + 1,
|
|
||||||
_lte,
|
|
||||||
)
|
|
||||||
await self._sweep_bucket(fetcher, rooms, _gte, mid, seen_ids, out_lots, pages)
|
|
||||||
await self._sweep_bucket(fetcher, rooms, mid + 1, _lte, seen_ids, out_lots, pages)
|
|
||||||
else:
|
|
||||||
await self._paginate(fetcher, rooms, price_gte, price_lte, seen_ids, out_lots, pages)
|
|
||||||
|
|
||||||
async def _paginate(
|
async def _paginate(
|
||||||
self,
|
self,
|
||||||
|
|
|
||||||
|
|
@ -48,8 +48,9 @@ from curl_cffi.requests import AsyncSession as _CurlCffiSession
|
||||||
|
|
||||||
from scraper_kit.base import BaseScraper, ScrapedLot
|
from scraper_kit.base import BaseScraper, ScrapedLot
|
||||||
from scraper_kit.browser_fetcher import BrowserFetcher
|
from scraper_kit.browser_fetcher import BrowserFetcher
|
||||||
|
from scraper_kit.house_type_normalizer import normalize_house_type
|
||||||
from scraper_kit.price_brackets import get_price_seed_brackets
|
from scraper_kit.price_brackets import get_price_seed_brackets
|
||||||
from scraper_kit.providers.yandex.shared import normalize_house_type
|
from scraper_kit.pricing import BisectionConfig, ProbeFailPolicy, ProbeResult, walk_price_range
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from scraper_kit.contracts import ScraperConfig
|
from scraper_kit.contracts import ScraperConfig
|
||||||
|
|
@ -90,7 +91,15 @@ DEFAULT_PRICE_RANGES: list[tuple[int | None, int | None]] = [
|
||||||
]
|
]
|
||||||
|
|
||||||
_YANDEX_MAX_PRICE = 200_000_000
|
_YANDEX_MAX_PRICE = 200_000_000
|
||||||
|
# ВНИМАНИЕ: реальный порог split у yandex — 500_000 (см. _YANDEX_SPLIT_MIN_BRACKET),
|
||||||
|
# а НЕ 50_000. Историческая _YANDEX_MIN_BRACKET=50_000 никогда не использовалась в
|
||||||
|
# _walk_price_range (там был локальный `_min_bracket = 500_000`), оставлена для
|
||||||
|
# обратной совместимости импортов; движок бисекции использует 500_000.
|
||||||
_YANDEX_MIN_BRACKET = 50_000
|
_YANDEX_MIN_BRACKET = 50_000
|
||||||
|
# Минимальный размах диапазона для деления (yandex делит только пока (hi-lo) >= 500k)
|
||||||
|
# и жёсткий предел глубины рекурсии — переносим в единый движок как есть.
|
||||||
|
_YANDEX_SPLIT_MIN_BRACKET = 500_000
|
||||||
|
_YANDEX_MAX_SPLIT_DEPTH = 8
|
||||||
_GATE_PAGE_SIZE = 20
|
_GATE_PAGE_SIZE = 20
|
||||||
_YANDEX_TARPIT_MAX_RETRIES: int = 2
|
_YANDEX_TARPIT_MAX_RETRIES: int = 2
|
||||||
|
|
||||||
|
|
@ -478,7 +487,9 @@ class YandexRealtyScraper(BaseScraper):
|
||||||
на один тайм-аутовый запрос: worst-case 30s×(1+retries)=90s на combo вместо 360s.
|
на один тайм-аутовый запрос: worst-case 30s×(1+retries)=90s на combo вместо 360s.
|
||||||
"""
|
"""
|
||||||
self._browser = BrowserFetcher(
|
self._browser = BrowserFetcher(
|
||||||
source="yandex", fetch_timeout_s=_YANDEX_BROWSER_FETCH_TIMEOUT_S
|
source="yandex",
|
||||||
|
fetch_timeout_s=_YANDEX_BROWSER_FETCH_TIMEOUT_S,
|
||||||
|
endpoint=self._config.browser_http_endpoint,
|
||||||
)
|
)
|
||||||
await self._browser.__aenter__()
|
await self._browser.__aenter__()
|
||||||
return self
|
return self
|
||||||
|
|
@ -958,55 +969,66 @@ class YandexRealtyScraper(BaseScraper):
|
||||||
split (no mid) -> paginate leaf directly (lux tail tiny, tail-loss accepted).
|
split (no mid) -> paginate leaf directly (lux tail tiny, tail-loss accepted).
|
||||||
Fallback: totalItems=None -> paginate-until-empty (safe degradation).
|
Fallback: totalItems=None -> paginate-until-empty (safe degradation).
|
||||||
skip_buckets: resume checkpoint -- skip leaf buckets already done.
|
skip_buckets: resume checkpoint -- skip leaf buckets already done.
|
||||||
|
|
||||||
|
Реализация: единый движок `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,
|
||||||
|
depth<8) и политика DEGRADE — в BisectionConfig.
|
||||||
"""
|
"""
|
||||||
_lo_param = lo if lo > 0 else None
|
|
||||||
_hi_repr = "open" if hi is None else str(hi)
|
|
||||||
|
|
||||||
probe_payload = await self._fetch_page_json(rooms, 1, _lo_param, hi)
|
async def _probe(plo: int | None, phi: int | None, depth: int) -> ProbeResult:
|
||||||
await asyncio.sleep(self.request_delay_sec)
|
lo_param = plo if plo and plo > 0 else None
|
||||||
|
hi_repr = "open" if phi is None else str(phi)
|
||||||
|
probe_payload = await self._fetch_page_json(rooms, 1, lo_param, phi)
|
||||||
|
await asyncio.sleep(self.request_delay_sec)
|
||||||
|
|
||||||
total: int | None = None
|
if probe_payload is None:
|
||||||
probe_lots: list[ScrapedLot] = []
|
|
||||||
|
|
||||||
if probe_payload is None:
|
|
||||||
logger.warning(
|
|
||||||
"yandex gate: probe failed rooms=%s [%d, %s] depth=%d -- rotating + retry",
|
|
||||||
rooms,
|
|
||||||
lo,
|
|
||||||
_hi_repr,
|
|
||||||
_depth,
|
|
||||||
)
|
|
||||||
rotated = await self._rotate_ip()
|
|
||||||
if rotated:
|
|
||||||
probe_payload = await self._fetch_page_json(rooms, 1, _lo_param, hi)
|
|
||||||
await asyncio.sleep(self.request_delay_sec)
|
|
||||||
|
|
||||||
if probe_payload is not None:
|
|
||||||
result = _extract_gate_data(probe_payload)
|
|
||||||
if result is not None:
|
|
||||||
_, pager = result
|
|
||||||
total = pager.get("totalItems")
|
|
||||||
probe_lots = _parse_gate_json(probe_payload, page_param=1)
|
|
||||||
else:
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"yandex gate: probe data extract failed rooms=%s [%d, %s]", rooms, lo, _hi_repr
|
"yandex gate: probe failed rooms=%s [%s, %s] depth=%d -- rotating + retry",
|
||||||
|
rooms,
|
||||||
|
plo,
|
||||||
|
hi_repr,
|
||||||
|
depth,
|
||||||
)
|
)
|
||||||
|
rotated = await self._rotate_ip()
|
||||||
|
if rotated:
|
||||||
|
probe_payload = await self._fetch_page_json(rooms, 1, lo_param, phi)
|
||||||
|
await asyncio.sleep(self.request_delay_sec)
|
||||||
|
|
||||||
if total is None:
|
total: int | None = None
|
||||||
# Degraded path: paginate-until-empty without knowing total
|
probe_lots: list[ScrapedLot] = []
|
||||||
|
if probe_payload is not None:
|
||||||
|
extracted = _extract_gate_data(probe_payload)
|
||||||
|
if extracted is not None:
|
||||||
|
_, pager = extracted
|
||||||
|
total = pager.get("totalItems")
|
||||||
|
probe_lots = _parse_gate_json(probe_payload, page_param=1)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"yandex gate: probe data extract failed rooms=%s [%s, %s]",
|
||||||
|
rooms,
|
||||||
|
plo,
|
||||||
|
hi_repr,
|
||||||
|
)
|
||||||
|
return ProbeResult(count=total, payload=probe_lots)
|
||||||
|
|
||||||
|
async def _degraded(plo: int | None, phi: int | None) -> None:
|
||||||
|
# totalItems неизвестен → пагинируем до пустоты, не зная total.
|
||||||
|
hi_repr = "open" if phi is None else str(phi)
|
||||||
|
lo_param = plo if plo and plo > 0 else None
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"yandex gate: totalItems unknown rooms=%s [%d, %s] -- paginate until empty",
|
"yandex gate: totalItems unknown rooms=%s [%s, %s] -- paginate until empty",
|
||||||
rooms,
|
rooms,
|
||||||
lo,
|
plo,
|
||||||
_hi_repr,
|
hi_repr,
|
||||||
)
|
)
|
||||||
page = 1
|
bucket_key = _combo_label(rooms, plo, phi)
|
||||||
bucket_key = _combo_label(rooms, lo, hi)
|
|
||||||
if skip_buckets and bucket_key in skip_buckets:
|
if skip_buckets and bucket_key in skip_buckets:
|
||||||
return
|
return
|
||||||
|
page = 1
|
||||||
pages_fetched = 0
|
pages_fetched = 0
|
||||||
while pages_fetched < max_pages_per_bucket:
|
while pages_fetched < max_pages_per_bucket:
|
||||||
payload = await self._fetch_page_json(rooms, page, _lo_param, hi)
|
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)
|
||||||
if payload is None:
|
if payload is None:
|
||||||
break
|
break
|
||||||
|
|
@ -1020,84 +1042,72 @@ class YandexRealtyScraper(BaseScraper):
|
||||||
pages_fetched += 1
|
pages_fetched += 1
|
||||||
if on_bucket is not None:
|
if on_bucket is not None:
|
||||||
on_bucket(bucket_key, len(seen))
|
on_bucket(bucket_key, len(seen))
|
||||||
return
|
|
||||||
|
|
||||||
# Split or paginate. ОТКРЫТЫЙ брекет (hi is None) делить нельзя (нет mid) —
|
async def _leaf(plo: int | None, phi: int | None, result: ProbeResult) -> None:
|
||||||
# сразу пагинируем leaf напрямую (хвост люкса крошечный, tail-loss accepted).
|
total = result.count
|
||||||
cap = price_cap_per_bucket
|
assert total is not None # DEGRADE-политика уводит None в _degraded
|
||||||
_min_bracket = 500_000
|
lo_param = plo if plo and plo > 0 else None
|
||||||
|
probe_lots: list[ScrapedLot] = result.payload or []
|
||||||
|
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)
|
||||||
|
return
|
||||||
|
|
||||||
if hi is not None and total > cap and (hi - lo) >= _min_bracket and _depth < 8:
|
total_pages = min(
|
||||||
mid = (lo + hi) // 2
|
math.ceil(total / 20),
|
||||||
await self._walk_price_range(
|
_GATE_MAX_PAGES_CAP,
|
||||||
rooms=rooms,
|
max_pages_per_bucket,
|
||||||
lo=lo,
|
|
||||||
hi=mid,
|
|
||||||
seen=seen,
|
|
||||||
price_cap_per_bucket=cap,
|
|
||||||
max_pages_per_bucket=max_pages_per_bucket,
|
|
||||||
concurrency=concurrency,
|
|
||||||
on_bucket=on_bucket,
|
|
||||||
skip_buckets=skip_buckets,
|
|
||||||
_depth=_depth + 1,
|
|
||||||
)
|
)
|
||||||
await self._walk_price_range(
|
logger.info(
|
||||||
rooms=rooms,
|
"yandex gate: leaf bucket %s total=%d pages=%d", bucket_key, total, total_pages
|
||||||
lo=mid + 1,
|
|
||||||
hi=hi,
|
|
||||||
seen=seen,
|
|
||||||
price_cap_per_bucket=cap,
|
|
||||||
max_pages_per_bucket=max_pages_per_bucket,
|
|
||||||
concurrency=concurrency,
|
|
||||||
on_bucket=on_bucket,
|
|
||||||
skip_buckets=skip_buckets,
|
|
||||||
_depth=_depth + 1,
|
|
||||||
)
|
)
|
||||||
return
|
|
||||||
|
|
||||||
# Leaf bucket: paginate
|
# Add probe lots (page 1 already fetched)
|
||||||
bucket_key = _combo_label(rooms, lo, hi)
|
for lot in probe_lots:
|
||||||
if skip_buckets and bucket_key in skip_buckets:
|
|
||||||
logger.debug("yandex gate: skip bucket %s (checkpoint)", bucket_key)
|
|
||||||
return
|
|
||||||
|
|
||||||
total_pages = min(
|
|
||||||
math.ceil(total / 20),
|
|
||||||
_GATE_MAX_PAGES_CAP,
|
|
||||||
max_pages_per_bucket,
|
|
||||||
)
|
|
||||||
logger.info("yandex gate: leaf bucket %s total=%d pages=%d", bucket_key, total, total_pages)
|
|
||||||
|
|
||||||
# Add probe lots (page 1 already fetched)
|
|
||||||
for lot in probe_lots:
|
|
||||||
if lot.source_id and lot.source_id not in seen:
|
|
||||||
seen[lot.source_id] = lot
|
|
||||||
|
|
||||||
if total_pages <= 1:
|
|
||||||
if on_bucket is not None:
|
|
||||||
on_bucket(bucket_key, len(seen))
|
|
||||||
return
|
|
||||||
|
|
||||||
# Paginate pages 2..total_pages with concurrency
|
|
||||||
sem = asyncio.Semaphore(concurrency)
|
|
||||||
|
|
||||||
async def _fetch_leaf_page(pg: int) -> list[ScrapedLot]:
|
|
||||||
async with sem:
|
|
||||||
payload = await self._fetch_page_json(rooms, pg, _lo_param, hi)
|
|
||||||
await asyncio.sleep(self.request_delay_sec)
|
|
||||||
if payload is None:
|
|
||||||
return []
|
|
||||||
return _parse_gate_json(payload, page_param=pg)
|
|
||||||
|
|
||||||
tasks = [_fetch_leaf_page(pg) for pg in range(2, total_pages + 1)]
|
|
||||||
results = await asyncio.gather(*tasks)
|
|
||||||
for page_lots in results:
|
|
||||||
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
|
||||||
|
|
||||||
if on_bucket is not None:
|
if total_pages <= 1:
|
||||||
on_bucket(bucket_key, len(seen))
|
if on_bucket is not None:
|
||||||
|
on_bucket(bucket_key, len(seen))
|
||||||
|
return
|
||||||
|
|
||||||
|
# Paginate pages 2..total_pages with concurrency
|
||||||
|
sem = asyncio.Semaphore(concurrency)
|
||||||
|
|
||||||
|
async def _fetch_leaf_page(pg: int) -> list[ScrapedLot]:
|
||||||
|
async with sem:
|
||||||
|
payload = await self._fetch_page_json(rooms, pg, lo_param, phi)
|
||||||
|
await asyncio.sleep(self.request_delay_sec)
|
||||||
|
if payload is None:
|
||||||
|
return []
|
||||||
|
return _parse_gate_json(payload, page_param=pg)
|
||||||
|
|
||||||
|
tasks = [_fetch_leaf_page(pg) for pg in range(2, total_pages + 1)]
|
||||||
|
results = await asyncio.gather(*tasks)
|
||||||
|
for page_lots in results:
|
||||||
|
for lot in page_lots:
|
||||||
|
if lot.source_id and lot.source_id not in seen:
|
||||||
|
seen[lot.source_id] = lot
|
||||||
|
|
||||||
|
if on_bucket is not None:
|
||||||
|
on_bucket(bucket_key, len(seen))
|
||||||
|
|
||||||
|
await walk_price_range(
|
||||||
|
lo=lo,
|
||||||
|
hi=hi,
|
||||||
|
config=BisectionConfig(
|
||||||
|
cap=price_cap_per_bucket,
|
||||||
|
min_bracket=_YANDEX_SPLIT_MIN_BRACKET,
|
||||||
|
max_depth=_YANDEX_MAX_SPLIT_DEPTH,
|
||||||
|
probe_fail_policy=ProbeFailPolicy.DEGRADE,
|
||||||
|
treat_zero_as_leaf=True,
|
||||||
|
),
|
||||||
|
probe=_probe,
|
||||||
|
on_leaf=_leaf,
|
||||||
|
on_degraded=_degraded,
|
||||||
|
depth=_depth,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _combo_label(rooms: str | None, lo: int | None, hi: int | None) -> str:
|
def _combo_label(rooms: str | None, lo: int | None, hi: int | None) -> str:
|
||||||
|
|
|
||||||
|
|
@ -1,95 +1,15 @@
|
||||||
"""Нормализация house_type → каноничный enum для listings.house_type.
|
"""Re-export `normalize_house_type` из core-слоя scraper_kit (#2134 dedup).
|
||||||
|
|
||||||
Strangler-копия `app.services.scrapers.house_type_normalizer` (#2133). Локальная в
|
Раньше здесь лежала ПОЛНАЯ копия нормализатора house_type (strangler-шаг #2133).
|
||||||
yandex-провайдере, потому что house_type_normalizer ещё не перенесён в core-слой
|
Теперь консолидировано: единственная реализация — `scraper_kit.house_type_normalizer`
|
||||||
scraper_kit (это отдельный шаг консолидации E). Копия — pure-функция без `app.*`
|
(идентична бывшей копии, сверено картой _RAW_TO_CANON). Модуль сохранён как тонкий
|
||||||
зависимостей; идентична источнику.
|
re-export, чтобы не ломать существующие импорты
|
||||||
|
(`from scraper_kit.providers.yandex.shared import normalize_house_type`) и
|
||||||
Целевой канон (сверено с estimator._IMV_HOUSE_TYPE_MAP, estimator.py:146):
|
golden-parity тест.
|
||||||
panel / brick / monolith / monolith_brick / block / wood.
|
|
||||||
|
|
||||||
Зачем (#2007): estimator применяет soft-penalty по house_type — аналог с
|
|
||||||
house_type != target штрафуется. Yandex SERP отдаёт SCREAMING-значения
|
|
||||||
(MONOLIT / BRICK / PANEL / ...), которые НИКОГДА не равны каноничным
|
|
||||||
(monolith / brick / panel / ...) → ~70% yandex-аналогов получали ложный штраф
|
|
||||||
и фактически выпадали из скоринга. Нормализация на ингесте чинит это.
|
|
||||||
|
|
||||||
Важно про None: неизвестное / 'other' / '' → None, НЕ 'other'. В estimator
|
|
||||||
`house_type IS NULL` нейтрально (без штрафа), а любое не-канон значение всегда
|
|
||||||
!= target → ложный штраф. Поэтому unknown лучше схлопнуть в NULL.
|
|
||||||
|
|
||||||
Источники raw-значений:
|
|
||||||
- yandex SERP: building.buildingType — SCREAMING_SNAKE (MONOLIT, MONOLIT_BRICK, ...)
|
|
||||||
- cian SERP: building.materialType — camelCase (monolith, monolithBrick,
|
|
||||||
gasSilicateBlock, ...) — переиспользуется в #2008 (cian.py:815).
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
from scraper_kit.house_type_normalizer import normalize_house_type
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
__all__ = ["normalize_house_type"]
|
||||||
|
|
||||||
# Канон listings.house_type — выровнен по estimator._IMV_HOUSE_TYPE_MAP.
|
|
||||||
_CANON: frozenset[str] = frozenset(
|
|
||||||
{"panel", "brick", "monolith", "monolith_brick", "block", "wood"}
|
|
||||||
)
|
|
||||||
|
|
||||||
# raw-токен → канон. Ключи — точные значения вокабуляров источников; сравнение
|
|
||||||
# регистронезависимое (см. _LOOKUP ниже), но строго по полному токену енума,
|
|
||||||
# без fuzzy-матчинга. Неизвестные токены сюда НЕ попадают → normalize вернёт None.
|
|
||||||
_RAW_TO_CANON: dict[str, str] = {
|
|
||||||
# ── yandex SERP (SCREAMING_SNAKE buildingType) ───────────────────────────
|
|
||||||
"MONOLIT": "monolith",
|
|
||||||
"BRICK": "brick",
|
|
||||||
"PANEL": "panel",
|
|
||||||
"MONOLIT_BRICK": "monolith_brick",
|
|
||||||
"BLOCK": "block",
|
|
||||||
"WOOD": "wood",
|
|
||||||
# ── cian SERP (camelCase materialType) — reuse в #2008 ───────────────────
|
|
||||||
"monolith": "monolith",
|
|
||||||
"brick": "brick",
|
|
||||||
"panel": "panel",
|
|
||||||
"block": "block",
|
|
||||||
"wood": "wood",
|
|
||||||
"monolithBrick": "monolith_brick",
|
|
||||||
"gasSilicateBlock": "block",
|
|
||||||
"aerocreteBlock": "block",
|
|
||||||
"foamConcreteBlock": "block",
|
|
||||||
"stalin": "brick", # «сталинка» — кирпич
|
|
||||||
}
|
|
||||||
|
|
||||||
# Регистронезависимый lookup. Лоуэркейс-ключи не коллизят между вокабулярами:
|
|
||||||
# 'MONOLIT'→'monolit' и 'monolith'→'monolith' — разные ключи.
|
|
||||||
_LOOKUP: dict[str, str] = {k.lower(): v for k, v in _RAW_TO_CANON.items()}
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_house_type(raw: str | None) -> str | None:
|
|
||||||
"""Преобразовать raw house_type в каноничный enum.
|
|
||||||
|
|
||||||
Уже-каноничное значение возвращается as-is (идемпотентность — нужна при
|
|
||||||
ре-обработке и для backfill-миграции). Неизвестное / 'other' / пустое /
|
|
||||||
None → None (НЕ 'other': см. docstring модуля про estimator soft-penalty).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
raw: сырое значение из парсера (e.g. «MONOLIT», «monolithBrick») или None.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Одно из panel / brick / monolith / monolith_brick / block / wood, либо None.
|
|
||||||
"""
|
|
||||||
if raw is None:
|
|
||||||
return None
|
|
||||||
stripped = raw.strip()
|
|
||||||
if not stripped:
|
|
||||||
return None
|
|
||||||
# Pass-through: уже каноничное значение (idempotency — в т.ч. 'monolith_brick',
|
|
||||||
# которого нет среди raw-ключей карты).
|
|
||||||
if stripped in _CANON:
|
|
||||||
return stripped
|
|
||||||
# Регистронезависимый exact-token lookup по обоим вокабулярам.
|
|
||||||
canon = _LOOKUP.get(stripped) or _LOOKUP.get(stripped.lower())
|
|
||||||
if canon is None:
|
|
||||||
# Неизвестное / 'other' — нормально (NULL нейтрально для estimator).
|
|
||||||
# debug, не warning: 'other' встречается массово, warning засорил бы лог.
|
|
||||||
logger.debug("house_type_normalizer: unmapped raw value %r — stored as NULL", raw)
|
|
||||||
return canon
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue