diff --git a/tradein-mvp/backend/app/api/v1/admin.py b/tradein-mvp/backend/app/api/v1/admin.py index 2d742787..033aa394 100644 --- a/tradein-mvp/backend/app/api/v1/admin.py +++ b/tradein-mvp/backend/app/api/v1/admin.py @@ -1944,7 +1944,10 @@ async def scrape_yandex_newbuilding( """ # fetch_jk использует внутренний BrowserFetcher, httpx-клиент BaseScraper не нужен. # config= обязателен (#2322 fix) — иначе BrowserFetcher строится без endpoint=. - scraper = YandexNewbuildingScraper(config=RealScraperConfig()) + # proxy_provider (#3197): без него сайдкар уходит на свой env-узел мимо пула. + scraper = YandexNewbuildingScraper( + config=RealScraperConfig(), proxy_provider=_kit_proxy_provider() + ) result = await scraper.fetch_jk(jk_slug=slug, jk_id=id, city=city) if result is None: raise HTTPException(404, f"Could not parse Yandex JK: {slug}-{id} in {city}") diff --git a/tradein-mvp/backend/app/services/product_handlers.py b/tradein-mvp/backend/app/services/product_handlers.py index 55697a27..64d2ba74 100644 --- a/tradein-mvp/backend/app/services/product_handlers.py +++ b/tradein-mvp/backend/app/services/product_handlers.py @@ -367,7 +367,23 @@ async def _job_yandex_newbuilding_sweep( # исход — назвать прогон неуспешным, а не дотянуть succeeded до ненуля. processed = int(counters.get("processed") or 0) succeeded = int(counters.get("succeeded") or 0) - if processed > 0 and succeeded == 0: + if counters.get("no_proxy_stop"): + # #3197: прогон оборван на пустом пуле — к площадке не ходили вовсе. + # Это отказ нашей инфраструктуры, а не «ЖК не разрешились»: называть + # такой прогон успешным нельзя, и причина должна быть отличима. + logger.error( + "yandex_newbuilding_sweep run_id=%d: пул прокси пуст — прогон оборван " + "(обработано %d)", + run_id, + processed, + ) + ctx.runs.mark_failed( + db, + run_id, + "пул прокси пуст — прогон оборван, к площадке не ходили", + counters, + ) + elif processed > 0 and succeeded == 0: logger.warning( "yandex_newbuilding_sweep run_id=%d: обработано %d, разрешено 0 — " "помечаю прогон неуспешным", diff --git a/tradein-mvp/backend/app/tasks/yandex_newbuilding_sweep.py b/tradein-mvp/backend/app/tasks/yandex_newbuilding_sweep.py index f54537c8..b1e48414 100644 --- a/tradein-mvp/backend/app/tasks/yandex_newbuilding_sweep.py +++ b/tradein-mvp/backend/app/tasks/yandex_newbuilding_sweep.py @@ -43,10 +43,11 @@ import random import time from dataclasses import dataclass, field, fields +from scraper_kit.proxy_errors import caused_by_no_proxy from sqlalchemy import text from sqlalchemy.orm import Session -from app.services.scraper_adapters import RealScraperConfig +from app.services.scraper_adapters import RealProxyProvider, RealScraperConfig from app.services.scraper_settings import get_scraper_delay logger = logging.getLogger(__name__) @@ -78,6 +79,9 @@ class YandexNewbuildingSweepResult: failed_fetch: int = 0 # fetch_jk вернул None / упал rows_inserted: int = 0 # строк в market.yandex_jk_enrichment (новых/обновлённых) + # Пул прокси пуст → прогон оборван, к площадке не ходили (#3197). + no_proxy_stop: bool = False + duration_sec: float = field(default=0.0) def to_dict(self) -> dict[str, int | float]: @@ -331,9 +335,25 @@ async def enrich_yandex_newbuilding_sweep( try: resolved = await resolve_yandex_jk_slug( - ext_id, city=city, config=RealScraperConfig() + ext_id, + city=city, + config=RealScraperConfig(), + proxy_provider=RealProxyProvider(), ) except Exception as exc: + # #3197: пустой пул — не отказ площадки: запрос не уходил вовсе, и + # следующий дом упрётся ровно в то же самое. + if caused_by_no_proxy(exc): + result.no_proxy_stop = True + result.failed_resolve += 1 + logger.error( + "yandex-nb-sweep: СТОП — пул прокси пуст, к площадке не ходили. " + "house_id=%s processed=%d succeeded=%d", + house_id, + result.processed, + result.succeeded, + ) + break logger.warning( "resolve_yandex_jk_slug house_id=%s ext_id=%s raised: %s", house_id, @@ -383,9 +403,22 @@ async def enrich_yandex_newbuilding_sweep( # ── Fetch через BrowserFetcher ──────────────────────────────────── info = None try: - scraper = YandexNewbuildingScraper(config=RealScraperConfig()) + scraper = YandexNewbuildingScraper( + config=RealScraperConfig(), proxy_provider=RealProxyProvider() + ) info = await scraper.fetch_jk(jk_slug=jk_slug, jk_id=ext_id, city=city) except Exception as exc: + if caused_by_no_proxy(exc): # #3197 — см. блок resolve выше + result.no_proxy_stop = True + result.failed_fetch += 1 + logger.error( + "yandex-nb-sweep: СТОП — пул прокси пуст, к площадке не ходили. " + "house_id=%s processed=%d succeeded=%d", + house_id, + result.processed, + result.succeeded, + ) + break logger.warning( "fetch_jk failed house_id=%s jk_slug=%s ext_id=%s: %s", house_id, diff --git a/tradein-mvp/backend/tests/test_3197_yandex_newbuilding_proxy_pool_wiring.py b/tradein-mvp/backend/tests/test_3197_yandex_newbuilding_proxy_pool_wiring.py new file mode 100644 index 00000000..aa9528d1 --- /dev/null +++ b/tradein-mvp/backend/tests/test_3197_yandex_newbuilding_proxy_pool_wiring.py @@ -0,0 +1,175 @@ +"""#3197 (часть 1, Яндекс-новостройки) — sweep ходил в сайдкар мимо прокси-пула. + +Обе точки провайдера (`YandexNewbuildingScraper.fetch_jk` и `resolve_yandex_jk_slug`) +конструировали `BrowserFetcher(source="yandex", endpoint=...)` БЕЗ +`proxy_provider`/`use_pool`/`environment` — единственных трёх аргументов, которые кладут +"proxy" в тело POST /fetch. Без них сайдкар брал свой env-прокси (`SCRAPER_PROXY_URL`), +на проде это выключенный узел (407 → camoufox `InvalidIP` → `/fetch` 503, факт #3386), +а прод-отказ «пул пуст → не ходить на env/direct» (#2616) на этом пути был мёртв: он +смотрит на `environment`, который до конструктора не доезжал. Соседи уже починены: +cian (#3197, test_3197_cian_history_proxy_pool_wiring.py) и domclick/avito. + +Второй тест — про то, чем оживший отказ оборачивается в прогоне: пустой пул поднимается +ДО запроса, поэтому следующий ЖК упрётся ровно в то же самое, и прогон обязан оборваться +на первом, а не перебирать весь список. Подделка — на уровне `BrowserFetcher.fetch()`, +НАСТОЯЩИЕ `resolve_yandex_jk_slug`/`fetch_jk`: их `except Exception` глотал любое +исключение и возвращал None, поэтому стоп в задаче не сработал бы ни разу (образец +дефекта — #3382, `cian/detail.py`). + +Сеть/БД/камуфокс замоканы; в сеть тест не ходит. +""" + +from __future__ import annotations + +import os +from typing import Any, ClassVar +from unittest.mock import AsyncMock, MagicMock, patch + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import pytest +from scraper_kit.proxy_errors import NoProxyAvailableError + +from app.tasks import yandex_newbuilding_sweep as sweep + +_BASE_FETCHER = "scraper_kit.providers._base.BrowserFetcher" + + +class _CapturingFetcher: + """Собирает kwargs КАЖДОЙ конструкции — обе точки (resolve и fetch_jk) видны разом.""" + + captured: ClassVar[list[dict[str, Any]]] = [] + + def __init__(self, **kwargs: Any) -> None: + _CapturingFetcher.captured.append(kwargs) + + async def __aenter__(self) -> _CapturingFetcher: + return self + + async def __aexit__(self, *_: object) -> None: + return None + + async def fetch(self, _url: str, **_kwargs: Any) -> str: + # Короткий HTML: resolve не найдёт slug, fetch_jk отбракует как tiny — обе + # ветки идут дальше по циклу, а нам нужны только kwargs конструктора. + return "" + + +class _EmptyPoolFetcher: + """Фетчер с пустым пулом: `fetch()` поднимает `NoProxyAvailableError` ДО запроса.""" + + calls: ClassVar[list[str]] = [] + + def __init__(self, **_kwargs: Any) -> None: + pass + + async def __aenter__(self) -> _EmptyPoolFetcher: + return self + + async def __aexit__(self, *_: object) -> None: + return None + + async def fetch(self, url: str, **_kwargs: Any) -> str: + _EmptyPoolFetcher.calls.append(url) + raise NoProxyAvailableError("yandex") + + +def _mock_db(rows: list[dict[str, Any]]) -> MagicMock: + db = MagicMock() + res = MagicMock() + res.scalar_one.return_value = 0 # counts + res.mappings.return_value.all.return_value = rows + res.fetchone.return_value = None # idempotency-проба: ещё не обогащён + db.execute.return_value = res + return db + + +def _row(house_id: int, *, slug: str | None, ext_id: str | None) -> dict[str, Any]: + return {"house_id": house_id, "yandex_jk_slug": slug, "ext_id": ext_id} + + +@pytest.mark.parametrize(("use_pool", "environment"), [(True, "production"), (False, "dev")]) +async def test_browser_fetcher_gets_proxy_pool_wiring( + monkeypatch: pytest.MonkeyPatch, use_pool: bool, environment: str +) -> None: + """use_pool/proxy_provider/environment доезжают до BrowserFetcher ИЗ КОНФИГА. + + Оба значения флага одним телом: `use_pool` обязан следовать конфигу, а не быть + зашитой константой, `proxy_provider` передаётся в любом случае (при use_pool=False + фетчер его игнорирует, но call-site у dev и прода один). + + Строки подобраны так, чтобы прогон прошёл ОБЕ точки: дом без slug идёт в + `resolve_yandex_jk_slug`, дом со slug — в `fetch_jk`. + """ + from app.core.config import settings + + _CapturingFetcher.captured = [] + monkeypatch.setattr(settings, "use_proxy_pool_browser", use_pool) + monkeypatch.setattr(settings, "environment", environment) + + rows = [_row(1, slug=None, ext_id="111"), _row(2, slug="tatlin", ext_id="222")] + with patch(_BASE_FETCHER, _CapturingFetcher): + await sweep.enrich_yandex_newbuilding_sweep(_mock_db(rows), limit=2, request_delay_sec=0.0) + + captured = _CapturingFetcher.captured + assert len(captured) == 2, "обе точки (resolve и fetch_jk) обязаны строить фетчер" + for kwargs in captured: + assert kwargs["source"] == "yandex" + # .get(), а не [] — красное должно читаться как «значение не то», а не KeyError. + assert kwargs.get("proxy_provider") is not None, "без провайдера пул не подключится" + assert kwargs.get("use_pool") is use_pool, "флаг пула должен доезжать из конфига" + # #2616 шаг 1: без environment отказ «пул пуст» на этом пути мёртв. + assert kwargs.get("environment") == environment + + +@pytest.mark.parametrize( + ("rows", "url_part"), + [ + ([_row(i, slug=None, ext_id=str(i)) for i in (1, 2, 3)], "-1/"), + ([_row(i, slug=f"jk{i}", ext_id=str(i)) for i in (1, 2, 3)], "jk1-1/"), + ], + ids=["resolve", "fetch_jk"], +) +async def test_empty_pool_stops_the_sweep_on_first_house( + rows: list[dict[str, Any]], url_part: str +) -> None: + """«Пул пуст» на первом доме обрывает прогон, а не перебирает все три. + + Замокано: БД и сам `BrowserFetcher` — подделка кончается на `fetch()`. НЕ замоканы + `resolve_yandex_jk_slug`/`fetch_jk`: прогон идёт через настоящие функции провайдера, + иначе тест был бы зелёным по построению (живые функции исключение ГЛОТАЛИ, и стоп + не срабатывал — ровно дефект #3382). + """ + _EmptyPoolFetcher.calls = [] + + with patch(_BASE_FETCHER, _EmptyPoolFetcher): + result = await sweep.enrich_yandex_newbuilding_sweep( + _mock_db(rows), limit=3, request_delay_sec=0.0 + ) + + # Сначала измеримое поведение (сколько раз пошли), потом флаг: красное на откате + # должно означать «прошли 3 дома вместо 1», а не «поля нет». + assert len(_EmptyPoolFetcher.calls) == 1, "к сайдкару ходили ровно один раз" + assert _EmptyPoolFetcher.calls[0].endswith(url_part) + assert result.processed == 1, "прогон обязан оборваться, а не пройти все 3 дома" + assert getattr(result, "no_proxy_stop", False) is True + + +async def test_scheduler_marks_run_failed_on_empty_pool() -> None: + """Прогон с пустым пулом финализируется как failed, а не 'done' с честным нулём.""" + from app.services import product_handlers as ph + + ctx = MagicMock() + result = sweep.YandexNewbuildingSweepResult(processed=1, failed_fetch=1, no_proxy_stop=True) + with patch( + "app.tasks.yandex_newbuilding_sweep.enrich_yandex_newbuilding_sweep", + AsyncMock(return_value=result), + ): + await ph._job_yandex_newbuilding_sweep(MagicMock(), 3197, {"limit": 3}, ctx) + + ctx.runs.mark_done.assert_not_called() + ctx.runs.mark_failed.assert_called_once() + _db, run_id, reason, counters = ctx.runs.mark_failed.call_args.args + assert run_id == 3197 + assert "пул" in reason + assert counters["no_proxy_stop"] is True diff --git a/tradein-mvp/backend/tests/test_scraper_kit_newbuilding_endpoint.py b/tradein-mvp/backend/tests/test_scraper_kit_newbuilding_endpoint.py index ee7509ab..ae15377e 100644 --- a/tradein-mvp/backend/tests/test_scraper_kit_newbuilding_endpoint.py +++ b/tradein-mvp/backend/tests/test_scraper_kit_newbuilding_endpoint.py @@ -97,11 +97,15 @@ async def test_cian_fetch_newbuilding_endpoint_none_without_config(monkeypatch): @pytest.mark.asyncio async def test_yandex_fetch_jk_passes_endpoint_from_config(monkeypatch): - """config передан в конструктор скрапера → endpoint пробрасывается в BrowserFetcher.""" - spy = _spy_browser_fetcher("") - monkeypatch.setattr("scraper_kit.providers.yandex.newbuilding.BrowserFetcher", spy) + """config передан в конструктор скрапера → endpoint пробрасывается в BrowserFetcher. - config = SimpleNamespace(browser_http_endpoint=_TEST_ENDPOINT) + С #3197 путь идёт через `build_browser_fetcher` (подключение к пулу прокси) — как у + cian выше, подменяется BrowserFetcher В ФАБРИКЕ, сама фабрика настоящая. + """ + spy = _spy_browser_fetcher("") + monkeypatch.setattr("scraper_kit.providers._base.BrowserFetcher", spy) + + config = SimpleNamespace(browser_http_endpoint=_TEST_ENDPOINT, use_proxy_pool_browser=False) scraper = YandexNewbuildingScraper(config=config) await scraper.fetch_jk(jk_slug="tatlin", jk_id="1592987") @@ -131,11 +135,14 @@ async def test_yandex_fetch_jk_endpoint_none_without_config(monkeypatch): @pytest.mark.asyncio async def test_resolve_yandex_jk_slug_passes_endpoint_from_config(monkeypatch): - """config= (новый kwarg-only параметр) должен пробрасывать endpoint в BrowserFetcher.""" - spy = _spy_browser_fetcher("") - monkeypatch.setattr("scraper_kit.providers.yandex.newbuilding.BrowserFetcher", spy) + """config= (новый kwarg-only параметр) должен пробрасывать endpoint в BrowserFetcher. - config = SimpleNamespace(browser_http_endpoint=_TEST_ENDPOINT) + #3197: конструкция переехала в `build_browser_fetcher` — спай ставим на фабрику. + """ + spy = _spy_browser_fetcher("") + monkeypatch.setattr("scraper_kit.providers._base.BrowserFetcher", spy) + + config = SimpleNamespace(browser_http_endpoint=_TEST_ENDPOINT, use_proxy_pool_browser=False) await resolve_yandex_jk_slug("1592987", config=config) spy.assert_called_once() diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/yandex/newbuilding.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/yandex/newbuilding.py index 583e8361..0e94266c 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/yandex/newbuilding.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/yandex/newbuilding.py @@ -29,6 +29,8 @@ from selectolax.parser import HTMLParser, Node from scraper_kit.base import BaseScraper from scraper_kit.browser_fetcher import BrowserFetcher +from scraper_kit.providers._base import build_browser_fetcher +from scraper_kit.proxy_errors import caused_by_no_proxy from scraper_kit.yandex_helpers import ( RE_JK_ID, parse_house_class, @@ -39,7 +41,7 @@ if TYPE_CHECKING: # TYPE_CHECKING-импорт (как в avito/imv.py, cian/newbuilding.py) — избегаем # hard import cycle и лишней зависимости в офлайн-тестах парсинга, которым # BrowserFetcher/config не нужны. - from scraper_kit.contracts import ScraperConfig + from scraper_kit.contracts import ProxyProvider, ScraperConfig logger = logging.getLogger(__name__) @@ -158,6 +160,7 @@ class YandexNewbuildingScraper(BaseScraper): *, config: ScraperConfig | None = None, delay_provider: Callable[[str], float] | None = None, + proxy_provider: ProxyProvider | None = None, ) -> None: super().__init__() # Strangler-инжекция (#2133): провайдер задержки приходит снаружи вместо @@ -169,6 +172,11 @@ class YandexNewbuildingScraper(BaseScraper): # kwarg, что давало TypeError на любом вызове fetch_jk и не давало caller'у # исправить это снаружи (сигнатура вообще не принимала config). self._config = config + # #3197: без proxy_provider сайдкар берёт свой env-узел (SCRAPER_PROXY_URL) — + # на проде это выключенный узел 9 (407 → camoufox InvalidIP → /fetch 503), + # то есть путь шёл мимо пула целиком, а прод-отказ «пул пуст» (#2616) был мёртв + # (он смотрит на environment, который сюда не доезжал). + self._proxy_provider = proxy_provider async def fetch_around(self, lat: float, lon: float, radius_m: int = 1000) -> list: # type: ignore[override] raise NotImplementedError( @@ -184,11 +192,23 @@ class YandexNewbuildingScraper(BaseScraper): через tradein-browser (camoufox) контейнер — единственный рабочий путь (#974). """ url = f"{self.base_url}/{city}/kupit/novostrojka/{jk_slug}-{jk_id}/" - endpoint = self._config.browser_http_endpoint if self._config is not None else None + # #3197: через фабрику — endpoint/use_pool/environment из config mandatory + # (образец: yandex/serp.py::__aenter__). config=None (dev/офлайн-тесты) — + # прежний путь без пула, как в cian/newbuilding.py. + if self._config is not None: + fetcher_cm = build_browser_fetcher( + self._config, "yandex", proxy_provider=self._proxy_provider + ) + else: + fetcher_cm = BrowserFetcher(source="yandex", endpoint=None) # type: ignore[arg-type] try: - async with BrowserFetcher(source="yandex", endpoint=endpoint) as fetcher: + async with fetcher_cm as fetcher: html = await fetcher.fetch(url) - except Exception: + except Exception as exc: + # «Пул пуст» — не отказ страницы: запрос не уходил вовсе, и вызывающий обязан + # оборвать прогон, а не перебирать оставшиеся ЖК (#3197, образец #3382). + if caused_by_no_proxy(exc): + raise logger.exception("yandex nb browser fetch failed: %s", url) return None if not html or len(html) < 500: @@ -340,6 +360,7 @@ async def resolve_yandex_jk_slug( city: str = "ekaterinburg", *, config: ScraperConfig | None = None, + proxy_provider: ProxyProvider | None = None, ) -> str | None: """Найти Yandex Realty slug для ЖК по его ext_id (jk_id). @@ -368,7 +389,10 @@ async def resolve_yandex_jk_slug( Args: jk_id: Yandex Realty ext_id ЖК. city: город (по умолчанию ekaterinburg). - config: ScraperConfig — источник browser_http_endpoint для BrowserFetcher. + config: ScraperConfig — источник browser_http_endpoint/use_pool/environment + для BrowserFetcher (через build_browser_fetcher). + proxy_provider: пул браузерных прокси (#3197). None → узел берётся из env + сайдкара, как было; на проде этот env-узел выключен (407). Returns: slug (str без id-суффикса), или None. Причина None всегда попадает в лог @@ -376,15 +400,18 @@ async def resolve_yandex_jk_slug( такого ЖК нет» требуют разных действий, и сливать их в одно сообщение значит заставлять следующего читателя гадать. """ - page_url = ( - f"https://realty.yandex.ru/{city}/kupit/novostrojka/" - f"{_JK_SLUG_PLACEHOLDER}-{jk_id}/" - ) - endpoint = config.browser_http_endpoint if config is not None else None + page_url = f"https://realty.yandex.ru/{city}/kupit/novostrojka/{_JK_SLUG_PLACEHOLDER}-{jk_id}/" + # #3197: см. fetch_jk — та же проводка пула через фабрику. + if config is not None: + fetcher_cm = build_browser_fetcher(config, "yandex", proxy_provider=proxy_provider) + else: + fetcher_cm = BrowserFetcher(source="yandex", endpoint=None) # type: ignore[arg-type] try: - async with BrowserFetcher(source="yandex", endpoint=endpoint) as fetcher: + async with fetcher_cm as fetcher: html = await fetcher.fetch(page_url) except Exception as exc: + if caused_by_no_proxy(exc): + raise logger.warning("resolve_yandex_jk_slug jk_id=%s: браузер не ответил: %s", jk_id, exc) return None @@ -395,11 +422,7 @@ async def resolve_yandex_jk_slug( # Плейсхолдер отбрасываем: страница ссылается сама на себя запрошенным # адресом, и без этого фильтра мы бы «разрешили» slug в тот, что сами же # и придумали — то есть записали бы выдумку как факт. - slugs = [ - slug - for slug in _slug_link_re(jk_id).findall(html) - if slug != _JK_SLUG_PLACEHOLDER - ] + slugs = [slug for slug in _slug_link_re(jk_id).findall(html) if slug != _JK_SLUG_PLACEHOLDER] if not slugs: logger.warning( "resolve_yandex_jk_slug jk_id=%s: на странице нет ни одной ссылки с этим id "