feat(tradein): exhaustive Yandex realty load — adaptive room×price + concurrency #931

Merged
bot-reviewer merged 1 commit from feat/yandex-exhaustive-price-room into main 2026-05-31 21:10:26 +00:00
Owner

Апгрейд Yandex Realty scraper до EXHAUSTIVE-загрузки по образцу Cian (#926 + #929). Адаптивное room×price партиционирование + параллельная пагинация + инкрементальный save + выделенный прокси + IP-rotation + endpoint.

Что сделано

  1. config.py — выделенный Yandex-прокси (зеркало cian_proxy_url): yandex_proxy_url_env (alias YANDEX_PROXY_URL), yandex_proxy_rotate_url (YANDEX_PROXY_ROTATE_URL), property yandex_proxy_url = YANDEX_PROXY_URL or scraper_proxy_url. __aenter__ переключён scraper_proxy_urlyandex_proxy_url.
  2. yandex_realty.py_extract_total_count, _rotate_ip, _fetch_page_html, fetch_all_secondary, _walk_price_range (рекурсивный адаптивный binary-split [lo,hi] с MIN_BRACKET guard, параллельная пагинация leaf через asyncio.gather+Semaphore, captcha-aware probe → _rotate_ip + 1 retry, on_bucket per leaf). fetch_around_multi_room/MAX_PAGES (city-sweep) НЕ тронуты.
  3. scrape_pipeline.pyYandexFullLoadCounters + run_yandex_full_load (региональный, on_bucket → save_listings per-bucket commit + heartbeat + cooperative cancel → mark_done partial, не mark_failed).
  4. admin.pyYandexFullLoadRequest (price_cap_per_bucket=500 ge100 le575, request_delay_sec=2.0 ge1 le15, concurrency=4 ge1 le8), POST /scrape/yandex-full-load + /{run_id}/cancel, source=yandex_full_load.

⚠️ totalItems — UNVERIFIED, есть fallback

Yandex scraper парсит отрендеренный DOM (state «chunked + evaporates after hydration» per docstring). _extract_total_count пробует 6 state-путей (pageData.pager.totalItems, …pager.total, searchResults.pager.totalItems, pager.totalItems, pager.total, pageData.searchQuery.total) + DOM-regex fallback. Поля не подтверждены на живой prod-выдаче. Когда total недоступен → _walk_price_range переходит на paginate-until-empty (probe-HTML page=0 переиспользуется, страницы 1,2,… до пустой / max_pages_per_bucket). Это основной safety-net и наиболее вероятный реальный путь. # TODO(yandex-total) оставлен в коде. После первого prod-прогона стоит свериться с логами totalItems=… vs paginate-until-empty fallback и при необходимости поправить пути.

Параметры

  • ROOM_PATH buckets: studio, 1, 2, 3, 4+ (slugs studiya/odnokomnatnaya/dvuhkomnatnaya/trehkomnatnaya/4-i-bolee).
  • price_cap=500 (< Yandex ~575 cards/query cap).
  • delay default 2.0 (консервативнее Cian — Yandex агрессивнее банит), concurrency 4.
  • Captcha: _rotate_ip на probe (yandex_proxy_rotate_url or avito_proxy_rotate_url, GET changeip, sleep 9), 1 retry; внутри leaf-gather captcha-страница → skip.

Деплой (оператору)

Прокси креды НЕ в коде — задать в .env.runtime: YANDEX_PROXY_URL + YANDEX_PROXY_ROTATE_URL. Без них — fallback на общий scraper_proxy_url (не падает).

Тесты

  • ruff check + format: All checks passed (4 файла).
  • pytest -k "yandex or full_load": 304 passed, 1369 deselected. Существующие yandex-тесты не сломаны.
  • 5 новых тестов: adaptive split, on_bucket per-bucket, total=None fallback.

Не запускалось против prod.

Апгрейд Yandex Realty scraper до EXHAUSTIVE-загрузки по образцу Cian (#926 + #929). Адаптивное room×price партиционирование + параллельная пагинация + инкрементальный save + выделенный прокси + IP-rotation + endpoint. ## Что сделано 1. **config.py** — выделенный Yandex-прокси (зеркало `cian_proxy_url`): `yandex_proxy_url_env` (alias `YANDEX_PROXY_URL`), `yandex_proxy_rotate_url` (`YANDEX_PROXY_ROTATE_URL`), property `yandex_proxy_url = YANDEX_PROXY_URL or scraper_proxy_url`. `__aenter__` переключён `scraper_proxy_url` → `yandex_proxy_url`. 2. **yandex_realty.py** — `_extract_total_count`, `_rotate_ip`, `_fetch_page_html`, `fetch_all_secondary`, `_walk_price_range` (рекурсивный адаптивный binary-split [lo,hi] с MIN_BRACKET guard, параллельная пагинация leaf через `asyncio.gather`+`Semaphore`, captcha-aware probe → `_rotate_ip` + 1 retry, `on_bucket` per leaf). `fetch_around_multi_room`/`MAX_PAGES` (city-sweep) НЕ тронуты. 3. **scrape_pipeline.py** — `YandexFullLoadCounters` + `run_yandex_full_load` (региональный, on_bucket → `save_listings` per-bucket commit + heartbeat + cooperative cancel → `mark_done` partial, не `mark_failed`). 4. **admin.py** — `YandexFullLoadRequest` (price_cap_per_bucket=500 ge100 le575, request_delay_sec=2.0 ge1 le15, concurrency=4 ge1 le8), `POST /scrape/yandex-full-load` + `/{run_id}/cancel`, source=`yandex_full_load`. ## ⚠️ totalItems — UNVERIFIED, есть fallback Yandex scraper парсит **отрендеренный DOM** (state «chunked + evaporates after hydration» per docstring). `_extract_total_count` пробует 6 state-путей (`pageData.pager.totalItems`, `…pager.total`, `searchResults.pager.totalItems`, `pager.totalItems`, `pager.total`, `pageData.searchQuery.total`) + DOM-regex fallback. Поля **не подтверждены на живой prod-выдаче**. Когда total недоступен → `_walk_price_range` переходит на **paginate-until-empty** (probe-HTML page=0 переиспользуется, страницы 1,2,… до пустой / `max_pages_per_bucket`). Это основной safety-net и наиболее вероятный реальный путь. `# TODO(yandex-total)` оставлен в коде. После первого prod-прогона стоит свериться с логами `totalItems=…` vs `paginate-until-empty fallback` и при необходимости поправить пути. ## Параметры - ROOM_PATH buckets: `studio, 1, 2, 3, 4+` (slugs `studiya/odnokomnatnaya/dvuhkomnatnaya/trehkomnatnaya/4-i-bolee`). - price_cap=500 (< Yandex ~575 cards/query cap). - delay default 2.0 (консервативнее Cian — Yandex агрессивнее банит), concurrency 4. - Captcha: `_rotate_ip` на probe (`yandex_proxy_rotate_url or avito_proxy_rotate_url`, GET changeip, sleep 9), 1 retry; внутри leaf-gather captcha-страница → skip. ## Деплой (оператору) Прокси креды НЕ в коде — задать в `.env.runtime`: `YANDEX_PROXY_URL` + `YANDEX_PROXY_ROTATE_URL`. Без них — fallback на общий `scraper_proxy_url` (не падает). ## Тесты - ruff check + format: All checks passed (4 файла). - `pytest -k "yandex or full_load"`: **304 passed, 1369 deselected**. Существующие yandex-тесты не сломаны. - 5 новых тестов: adaptive split, on_bucket per-bucket, total=None fallback. Не запускалось против prod.
lekss361 added 1 commit 2026-05-31 21:00:46 +00:00
lekss361 added the
scope/backend
scrapers
labels 2026-05-31 21:01:07 +00:00
bot-reviewer approved these changes 2026-05-31 21:10:13 +00:00
bot-reviewer left a comment
Collaborator

APPROVE — зеркало одобренных #926/#929 паттернов, additive, cross-file lesson применён. Мержу.

Verified

  • Терминация: та же структура что Cian (hi всегда int, MIN_BRACKET guard, halving) — bounded depth. Fallback-путь нерекурсивен (bounded max_pages_per_bucket=100, break на empty/captcha). ✓
  • total=None fallback (новый рисковый кусок): честно задокументирован — paginate-until-empty, безопасная деградация до ~575/room (SERP cap) когда totalItems не извлекается. НЕ краш. Captcha-aware probe → _rotate_ip + 1 retry.
  • Concurrency: тот же gather+Semaphore что в одобренном #929 (нет race, asyncio single-thread); Yandex 0-indexed страницы обработаны (range(0, max_pages)).
  • Cancel: on_bucketRuntimeError("cancelled")except RuntimeError перед generic → mark_done partial. Корректный порядок. ✓
  • Cross-file (урок #919 применён автором): существующий yandex city-sweep (fetch_around_multi_room/MAX_PAGES) НЕ тронут; test_scraper_proxy.py обновлён зеркалом yandex_proxy_url → существующие proxy-тесты не ломаются; __all__ + admin import + config property (зеркало #923) — всё на месте. pytest -k yandex 304 passed.

🟡 Известное ограничение (не блокер — заведу follow-up)

_extract_total_count пути не верифицированы на живой Yandex-выдаче (state «chunked + evaporates»). Если он всегда вернёт None на проде → каждый бакет идёт fallback'ом без price-split → exhaustive-гарантия деградирует до ~575/room (5 комнатностей ≈ 2875 max), а не полное покрытие. Безопасно (не падает), но «exhaustive» обещание условно. TODO(yandex-total) в коде + operator-note. Завожу issue: сверить totalItems=… vs paginate-until-empty fallback в логах после 1-го prod-прогона.

Deploy: YANDEX_PROXY_URL/YANDEX_PROXY_ROTATE_URL в .env.runtime (fallback на scraper_proxy_url без них). Squash-merge + delete branch.

<!-- gendesign-review-bot: sha=830657c verdict=approve --> ✅ **APPROVE** — зеркало одобренных #926/#929 паттернов, additive, cross-file lesson применён. Мержу. ## Verified - **Терминация**: та же структура что Cian (hi всегда int, MIN_BRACKET guard, halving) — bounded depth. Fallback-путь нерекурсивен (bounded `max_pages_per_bucket=100`, break на empty/captcha). ✓ - **total=None fallback** (новый рисковый кусок): честно задокументирован — paginate-until-empty, безопасная деградация до ~575/room (SERP cap) когда totalItems не извлекается. НЕ краш. Captcha-aware probe → `_rotate_ip` + 1 retry. - **Concurrency**: тот же `gather`+`Semaphore` что в одобренном #929 (нет race, asyncio single-thread); Yandex 0-indexed страницы обработаны (`range(0, max_pages)`). - **Cancel**: `on_bucket` → `RuntimeError("cancelled")` → `except RuntimeError` перед generic → `mark_done` partial. Корректный порядок. ✓ - **Cross-file (урок #919 применён автором)**: существующий yandex city-sweep (`fetch_around_multi_room`/`MAX_PAGES`) НЕ тронут; `test_scraper_proxy.py` обновлён зеркалом `yandex_proxy_url` → существующие proxy-тесты не ломаются; `__all__` + admin import + config property (зеркало #923) — всё на месте. `pytest -k yandex` 304 passed. ## 🟡 Известное ограничение (не блокер — заведу follow-up) `_extract_total_count` пути **не верифицированы** на живой Yandex-выдаче (state «chunked + evaporates»). Если он всегда вернёт None на проде → каждый бакет идёт fallback'ом без price-split → exhaustive-гарантия деградирует до ~575/room (5 комнатностей ≈ 2875 max), а не полное покрытие. Безопасно (не падает), но «exhaustive» обещание условно. TODO(yandex-total) в коде + operator-note. Завожу issue: сверить `totalItems=…` vs `paginate-until-empty fallback` в логах после 1-го prod-прогона. Deploy: `YANDEX_PROXY_URL`/`YANDEX_PROXY_ROTATE_URL` в `.env.runtime` (fallback на `scraper_proxy_url` без них). Squash-merge + delete branch.
bot-reviewer merged commit 2bcccdf619 into main 2026-05-31 21:10:26 +00:00
bot-reviewer deleted branch feat/yandex-exhaustive-price-room 2026-05-31 21:10:26 +00:00
Author
Owner

Добавлен 2-й коммит — checkpoint/resume (3bcfb6b), зеркало Cian #932. Refs #930 (Yandex-половина; Cian-половина = #932).

Что добавлено поверх exhaustive-load:

  • yandex_realty.pyfetch_all_secondary/_walk_price_range принимают skip_buckets: set[str] | None. bucket_key = "{rooms}:{lo}:{hi}". Skip-проверка (probe/split-решение остаются, пропускается только пагинация + on_bucket) применена к обоим leaf-путям Yandex: нормальному параллельному и total is None paginate-until-empty fallback. Сигнатура callback on_bucket(bucket_key, lots).
  • scrape_pipeline.pyrun_yandex_full_load(resume_run_id: int | None = None). При resume читает done_buckets из counters прошлого run (SELECT counters FROM scrape_runs WHERE id = CAST(:rid AS bigint)), сидит ими skip-set. _on_bucket(bucket_key, lots) дописывает в done + heartbeat {**counters, "done_buckets": sorted(done)} (включая пустой-lots бранч). mark_done в обоих ветках (success + cancelled) включает done_buckets → resumed-run сам резюмируем. mark_failed — без (как у Cian).
  • admin.pyYandexFullLoadRequest.resume_run_id: int | None, проброшен в run_yandex_full_load. Без него — full walk с нуля.

Тесты: обновлены 2 существующих on_bucket-стаба под 2-арг сигнатуру + 2 новых (skip реально пропускает page-fetch + on_bucket; on_bucket получает (key, lots)). ruff clean, pytest -k "yandex" → 67 passed.

Ветка теперь: 830657c exhaustive + 3bcfb6b checkpoint (clean fast-forward, verified ancestor). Не запускалось против prod.

Добавлен 2-й коммит — **checkpoint/resume** (`3bcfb6b`), зеркало Cian #932. Refs #930 (Yandex-половина; Cian-половина = #932). **Что добавлено поверх exhaustive-load:** - `yandex_realty.py` — `fetch_all_secondary`/`_walk_price_range` принимают `skip_buckets: set[str] | None`. `bucket_key = "{rooms}:{lo}:{hi}"`. Skip-проверка (probe/split-решение остаются, пропускается только пагинация + on_bucket) применена к **обоим** leaf-путям Yandex: нормальному параллельному и `total is None` paginate-until-empty fallback. Сигнатура callback `on_bucket(bucket_key, lots)`. - `scrape_pipeline.py` — `run_yandex_full_load(resume_run_id: int | None = None)`. При resume читает `done_buckets` из counters прошлого run (`SELECT counters FROM scrape_runs WHERE id = CAST(:rid AS bigint)`), сидит ими skip-set. `_on_bucket(bucket_key, lots)` дописывает в `done` + heartbeat `{**counters, "done_buckets": sorted(done)}` (включая пустой-lots бранч). `mark_done` в **обоих** ветках (success + cancelled) включает `done_buckets` → resumed-run сам резюмируем. `mark_failed` — без (как у Cian). - `admin.py` — `YandexFullLoadRequest.resume_run_id: int | None`, проброшен в `run_yandex_full_load`. Без него — full walk с нуля. **Тесты:** обновлены 2 существующих on_bucket-стаба под 2-арг сигнатуру + 2 новых (skip реально пропускает page-fetch + on_bucket; on_bucket получает `(key, lots)`). ruff clean, `pytest -k "yandex"` → 67 passed. Ветка теперь: `830657c` exhaustive + `3bcfb6b` checkpoint (clean fast-forward, verified ancestor). Не запускалось против prod.
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#931
No description provided.