fix(tradein/yandex): newbuilding resolve/fetch_jk через пул прокси + стоп на пустом пуле (#3197, точки 2-3) #3389
8 changed files with 322 additions and 44 deletions
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -367,7 +367,27 @@ 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 result.no_proxy_stop:
|
||||
# #3197: прогон оборван на пустом пуле — к площадке не ходили вовсе.
|
||||
# Это отказ нашей инфраструктуры, а не «ЖК не разрешились»: называть
|
||||
# такой прогон успешным нельзя, и причина должна быть отличима.
|
||||
#
|
||||
# INFO, как у соседей (scheduler.py:175, avito_detail_backfill.py:1050,
|
||||
# domclick:626): причина уже записана в mark_failed + counters.no_proxy_stop=1,
|
||||
# а ERROR ставил её в один разряд с падением задачи (logger.exception ниже).
|
||||
logger.info(
|
||||
"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 — "
|
||||
"помечаю прогон неуспешным",
|
||||
|
|
|
|||
|
|
@ -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,10 +79,21 @@ 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]:
|
||||
return {f.name: getattr(self, f.name) for f in fields(self)}
|
||||
"""Счётчики прогона; `no_proxy_stop` — 1/0, а не JSON true/false.
|
||||
|
||||
Формат один с соседями (`scheduler.py:168` cian, `avito_detail_backfill.py:1043`,
|
||||
`domclick_detail_backfill.py:610`): SQL-монитор ищет
|
||||
`counters->>'no_proxy_stop' = '1'` и мимо `true` промахнётся молча.
|
||||
"""
|
||||
out: dict[str, int | float] = {f.name: getattr(self, f.name) for f in fields(self)}
|
||||
out["no_proxy_stop"] = int(self.no_proxy_stop)
|
||||
return out
|
||||
|
||||
|
||||
# ── SQL ────────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -331,9 +343,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 +411,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,
|
||||
|
|
|
|||
|
|
@ -27,18 +27,21 @@ import pytest
|
|||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
||||
|
||||
from app.services.product_handlers import _job_yandex_newbuilding_sweep
|
||||
from app.tasks.yandex_newbuilding_sweep import YandexNewbuildingSweepResult
|
||||
|
||||
|
||||
def _ctx() -> SimpleNamespace:
|
||||
return SimpleNamespace(runs=MagicMock())
|
||||
|
||||
|
||||
def _result(processed: int, succeeded: int) -> MagicMock:
|
||||
r = MagicMock()
|
||||
r.to_dict = MagicMock(
|
||||
return_value={"processed": processed, "succeeded": succeeded, "total": 425}
|
||||
)
|
||||
return r
|
||||
def _result(processed: int, succeeded: int) -> YandexNewbuildingSweepResult:
|
||||
"""Настоящий результат задачи, а не MagicMock.
|
||||
|
||||
У MagicMock любой не заданный атрибут (`no_proxy_stop` из #3197) — truthy, то есть
|
||||
добавление хендлеру новой ветки по полю результата уводило бы сюда ВСЕ три случая,
|
||||
и тест краснел бы по причине, не имеющей отношения к #2860.
|
||||
"""
|
||||
return YandexNewbuildingSweepResult(processed=processed, succeeded=succeeded, total=425)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
|
|||
|
|
@ -0,0 +1,179 @@
|
|||
"""#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
|
||||
# Ровно 1, а не JSON true: SQL-монитор соседей ищет counters->>'no_proxy_stop' = '1'
|
||||
# (scheduler.py:168 cian, avito_detail_backfill.py:1043, domclick_detail_backfill.py:610).
|
||||
# bool — подкласс int, поэтому `== 1` один сам по себе зелёный и на True: тип проверяем
|
||||
# отдельно, иначе красное на откате не наступит.
|
||||
assert counters["no_proxy_stop"] == 1 and not isinstance(counters["no_proxy_stop"], bool)
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -206,16 +206,18 @@ def build_browser_fetcher(
|
|||
`ScraperConfig` невозможно на уровне сигнатуры (не полагаемся на
|
||||
code-review чтобы поймать пропущенный параметр).
|
||||
|
||||
`proxy_provider=None` (дефолт) — валидно: часть providers (avito legacy
|
||||
curl-only путь, domclick, cian/yandex newbuilding) пока не подключены к
|
||||
browser-proxy-пулу (#2160 P4 wiring gap, отдельный issue) — `use_pool` в
|
||||
этом случае эффективно игнорируется `BrowserFetcher` (`proxy_provider is
|
||||
None` → env-fallback, см. `browser_fetcher.py::_pool_proxy`).
|
||||
`proxy_provider=None` (дефолт) — валидно, но у providers больше не встречается:
|
||||
после #3382 (cian newbuilding) и #3389 (yandex newbuilding) провайдер передают
|
||||
ВСЕ call-site'ы providers (avito/cian/domclick/yandex, serp и newbuilding).
|
||||
Без пула остались только прямые конструкции `BrowserFetcher(...)` мимо этой
|
||||
фабрики — служебные ручки `app/api/v1/admin.py` (511 cian-login, 702
|
||||
domclick-detail-debug); прямые конструкции в `orchestration/pipeline.py` и в
|
||||
backfill-задачах пул получают. `use_pool` при `proxy_provider is None` эффективно
|
||||
игнорируется `BrowserFetcher` (env-fallback, см. `browser_fetcher.py::_pool_proxy`).
|
||||
|
||||
`fetch_timeout_s=None` (дефолт) → используется дефолт `BrowserFetcher`
|
||||
(120s). Yandex-провайдер передаёt здесь 30s явно (см. вызовы в
|
||||
`yandex/serp.py`/`yandex/newbuilding.py`) — единственный provider с
|
||||
отличным от дефолта таймаутом.
|
||||
(120s). Явный таймаут передаёт ровно один call-site — `yandex/serp.py` (30s);
|
||||
`yandex/newbuilding.py` идёт на дефолтных 120s.
|
||||
|
||||
`environment=getattr(config, "environment", "dev")` (#2616 шаг 1) — прокидывается в
|
||||
`BrowserFetcher._pool_proxy`: пул пуст/сломан + прод → отказ вместо мёртвого
|
||||
|
|
|
|||
|
|
@ -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 "
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue