fix(#3197): yandex newbuilding — обе точки в пул прокси + стоп на пустом пуле
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m56s

`YandexNewbuildingScraper.fetch_jk` и `resolve_yandex_jk_slug` строили
`BrowserFetcher(source="yandex", endpoint=...)` без proxy_provider/use_pool/
environment — сайдкар брал env-узел SCRAPER_PROXY_URL, на проде выключенный
(407 → camoufox InvalidIP → /fetch 503, факт #3386), то есть путь шёл мимо пула
целиком, а прод-отказ «пул пуст» (#2616) был мёртв: он смотрит на environment,
который до конструктора не доезжал. Обе точки собраны через
build_browser_fetcher(config, "yandex", proxy_provider=...), как yandex/serp.py.

Второе: общий `except Exception` в обеих функциях глотал NoProxyAvailableError и
возвращал None — прогон, не ходивший к площадке, перебирал все ЖК и уходил в
'done'. Теперь «пул пуст» пробрасывается наружу (caused_by_no_proxy), sweep
обрывается на первом доме с no_proxy_stop, а прогон финализируется как failed
(образец дефекта — #3382, cian/detail.py).
This commit is contained in:
bot-backend 2026-09-06 05:55:36 +05:00
parent 33444b7eab
commit cb5714fff0
6 changed files with 286 additions and 29 deletions

View file

@ -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}")

View file

@ -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 — "
"помечаю прогон неуспешным",

View file

@ -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,

View file

@ -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 "<html></html>"
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

View file

@ -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("<html></html>")
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("<html></html>")
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("<html></html>")
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("<html></html>")
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()

View file

@ -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 "