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
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:
parent
33444b7eab
commit
cb5714fff0
6 changed files with 286 additions and 29 deletions
|
|
@ -1944,7 +1944,10 @@ async def scrape_yandex_newbuilding(
|
||||||
"""
|
"""
|
||||||
# fetch_jk использует внутренний BrowserFetcher, httpx-клиент BaseScraper не нужен.
|
# fetch_jk использует внутренний BrowserFetcher, httpx-клиент BaseScraper не нужен.
|
||||||
# config= обязателен (#2322 fix) — иначе BrowserFetcher строится без endpoint=.
|
# 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)
|
result = await scraper.fetch_jk(jk_slug=slug, jk_id=id, city=city)
|
||||||
if result is None:
|
if result is None:
|
||||||
raise HTTPException(404, f"Could not parse Yandex JK: {slug}-{id} in {city}")
|
raise HTTPException(404, f"Could not parse Yandex JK: {slug}-{id} in {city}")
|
||||||
|
|
|
||||||
|
|
@ -367,7 +367,23 @@ async def _job_yandex_newbuilding_sweep(
|
||||||
# исход — назвать прогон неуспешным, а не дотянуть succeeded до ненуля.
|
# исход — назвать прогон неуспешным, а не дотянуть succeeded до ненуля.
|
||||||
processed = int(counters.get("processed") or 0)
|
processed = int(counters.get("processed") or 0)
|
||||||
succeeded = int(counters.get("succeeded") 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(
|
logger.warning(
|
||||||
"yandex_newbuilding_sweep run_id=%d: обработано %d, разрешено 0 — "
|
"yandex_newbuilding_sweep run_id=%d: обработано %d, разрешено 0 — "
|
||||||
"помечаю прогон неуспешным",
|
"помечаю прогон неуспешным",
|
||||||
|
|
|
||||||
|
|
@ -43,10 +43,11 @@ import random
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass, field, fields
|
from dataclasses import dataclass, field, fields
|
||||||
|
|
||||||
|
from scraper_kit.proxy_errors import caused_by_no_proxy
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
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
|
from app.services.scraper_settings import get_scraper_delay
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -78,6 +79,9 @@ class YandexNewbuildingSweepResult:
|
||||||
failed_fetch: int = 0 # fetch_jk вернул None / упал
|
failed_fetch: int = 0 # fetch_jk вернул None / упал
|
||||||
rows_inserted: int = 0 # строк в market.yandex_jk_enrichment (новых/обновлённых)
|
rows_inserted: int = 0 # строк в market.yandex_jk_enrichment (новых/обновлённых)
|
||||||
|
|
||||||
|
# Пул прокси пуст → прогон оборван, к площадке не ходили (#3197).
|
||||||
|
no_proxy_stop: bool = False
|
||||||
|
|
||||||
duration_sec: float = field(default=0.0)
|
duration_sec: float = field(default=0.0)
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, int | float]:
|
def to_dict(self) -> dict[str, int | float]:
|
||||||
|
|
@ -331,9 +335,25 @@ async def enrich_yandex_newbuilding_sweep(
|
||||||
|
|
||||||
try:
|
try:
|
||||||
resolved = await resolve_yandex_jk_slug(
|
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:
|
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(
|
logger.warning(
|
||||||
"resolve_yandex_jk_slug house_id=%s ext_id=%s raised: %s",
|
"resolve_yandex_jk_slug house_id=%s ext_id=%s raised: %s",
|
||||||
house_id,
|
house_id,
|
||||||
|
|
@ -383,9 +403,22 @@ async def enrich_yandex_newbuilding_sweep(
|
||||||
# ── Fetch через BrowserFetcher ────────────────────────────────────
|
# ── Fetch через BrowserFetcher ────────────────────────────────────
|
||||||
info = None
|
info = None
|
||||||
try:
|
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)
|
info = await scraper.fetch_jk(jk_slug=jk_slug, jk_id=ext_id, city=city)
|
||||||
except Exception as exc:
|
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(
|
logger.warning(
|
||||||
"fetch_jk failed house_id=%s jk_slug=%s ext_id=%s: %s",
|
"fetch_jk failed house_id=%s jk_slug=%s ext_id=%s: %s",
|
||||||
house_id,
|
house_id,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -97,11 +97,15 @@ async def test_cian_fetch_newbuilding_endpoint_none_without_config(monkeypatch):
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_yandex_fetch_jk_passes_endpoint_from_config(monkeypatch):
|
async def test_yandex_fetch_jk_passes_endpoint_from_config(monkeypatch):
|
||||||
"""config передан в конструктор скрапера → endpoint пробрасывается в BrowserFetcher."""
|
"""config передан в конструктор скрапера → endpoint пробрасывается в BrowserFetcher.
|
||||||
spy = _spy_browser_fetcher("<html></html>")
|
|
||||||
monkeypatch.setattr("scraper_kit.providers.yandex.newbuilding.BrowserFetcher", spy)
|
|
||||||
|
|
||||||
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)
|
scraper = YandexNewbuildingScraper(config=config)
|
||||||
await scraper.fetch_jk(jk_slug="tatlin", jk_id="1592987")
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_resolve_yandex_jk_slug_passes_endpoint_from_config(monkeypatch):
|
async def test_resolve_yandex_jk_slug_passes_endpoint_from_config(monkeypatch):
|
||||||
"""config= (новый kwarg-only параметр) должен пробрасывать endpoint в BrowserFetcher."""
|
"""config= (новый kwarg-only параметр) должен пробрасывать endpoint в BrowserFetcher.
|
||||||
spy = _spy_browser_fetcher("<html></html>")
|
|
||||||
monkeypatch.setattr("scraper_kit.providers.yandex.newbuilding.BrowserFetcher", spy)
|
|
||||||
|
|
||||||
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)
|
await resolve_yandex_jk_slug("1592987", config=config)
|
||||||
|
|
||||||
spy.assert_called_once()
|
spy.assert_called_once()
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,8 @@ from selectolax.parser import HTMLParser, Node
|
||||||
|
|
||||||
from scraper_kit.base import BaseScraper
|
from scraper_kit.base import BaseScraper
|
||||||
from scraper_kit.browser_fetcher import BrowserFetcher
|
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 (
|
from scraper_kit.yandex_helpers import (
|
||||||
RE_JK_ID,
|
RE_JK_ID,
|
||||||
parse_house_class,
|
parse_house_class,
|
||||||
|
|
@ -39,7 +41,7 @@ if TYPE_CHECKING:
|
||||||
# TYPE_CHECKING-импорт (как в avito/imv.py, cian/newbuilding.py) — избегаем
|
# TYPE_CHECKING-импорт (как в avito/imv.py, cian/newbuilding.py) — избегаем
|
||||||
# hard import cycle и лишней зависимости в офлайн-тестах парсинга, которым
|
# hard import cycle и лишней зависимости в офлайн-тестах парсинга, которым
|
||||||
# BrowserFetcher/config не нужны.
|
# BrowserFetcher/config не нужны.
|
||||||
from scraper_kit.contracts import ScraperConfig
|
from scraper_kit.contracts import ProxyProvider, ScraperConfig
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -158,6 +160,7 @@ class YandexNewbuildingScraper(BaseScraper):
|
||||||
*,
|
*,
|
||||||
config: ScraperConfig | None = None,
|
config: ScraperConfig | None = None,
|
||||||
delay_provider: Callable[[str], float] | None = None,
|
delay_provider: Callable[[str], float] | None = None,
|
||||||
|
proxy_provider: ProxyProvider | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
# Strangler-инжекция (#2133): провайдер задержки приходит снаружи вместо
|
# Strangler-инжекция (#2133): провайдер задержки приходит снаружи вместо
|
||||||
|
|
@ -169,6 +172,11 @@ class YandexNewbuildingScraper(BaseScraper):
|
||||||
# kwarg, что давало TypeError на любом вызове fetch_jk и не давало caller'у
|
# kwarg, что давало TypeError на любом вызове fetch_jk и не давало caller'у
|
||||||
# исправить это снаружи (сигнатура вообще не принимала config).
|
# исправить это снаружи (сигнатура вообще не принимала config).
|
||||||
self._config = 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]
|
async def fetch_around(self, lat: float, lon: float, radius_m: int = 1000) -> list: # type: ignore[override]
|
||||||
raise NotImplementedError(
|
raise NotImplementedError(
|
||||||
|
|
@ -184,11 +192,23 @@ class YandexNewbuildingScraper(BaseScraper):
|
||||||
через tradein-browser (camoufox) контейнер — единственный рабочий путь (#974).
|
через tradein-browser (camoufox) контейнер — единственный рабочий путь (#974).
|
||||||
"""
|
"""
|
||||||
url = f"{self.base_url}/{city}/kupit/novostrojka/{jk_slug}-{jk_id}/"
|
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:
|
try:
|
||||||
async with BrowserFetcher(source="yandex", endpoint=endpoint) as fetcher:
|
async with fetcher_cm as fetcher:
|
||||||
html = await fetcher.fetch(url)
|
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)
|
logger.exception("yandex nb browser fetch failed: %s", url)
|
||||||
return None
|
return None
|
||||||
if not html or len(html) < 500:
|
if not html or len(html) < 500:
|
||||||
|
|
@ -340,6 +360,7 @@ async def resolve_yandex_jk_slug(
|
||||||
city: str = "ekaterinburg",
|
city: str = "ekaterinburg",
|
||||||
*,
|
*,
|
||||||
config: ScraperConfig | None = None,
|
config: ScraperConfig | None = None,
|
||||||
|
proxy_provider: ProxyProvider | None = None,
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
"""Найти Yandex Realty slug для ЖК по его ext_id (jk_id).
|
"""Найти Yandex Realty slug для ЖК по его ext_id (jk_id).
|
||||||
|
|
||||||
|
|
@ -368,7 +389,10 @@ async def resolve_yandex_jk_slug(
|
||||||
Args:
|
Args:
|
||||||
jk_id: Yandex Realty ext_id ЖК.
|
jk_id: Yandex Realty ext_id ЖК.
|
||||||
city: город (по умолчанию ekaterinburg).
|
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:
|
Returns:
|
||||||
slug (str без id-суффикса), или None. Причина None всегда попадает в лог
|
slug (str без id-суффикса), или None. Причина None всегда попадает в лог
|
||||||
|
|
@ -376,15 +400,18 @@ async def resolve_yandex_jk_slug(
|
||||||
такого ЖК нет» требуют разных действий, и сливать их в одно сообщение
|
такого ЖК нет» требуют разных действий, и сливать их в одно сообщение
|
||||||
значит заставлять следующего читателя гадать.
|
значит заставлять следующего читателя гадать.
|
||||||
"""
|
"""
|
||||||
page_url = (
|
page_url = f"https://realty.yandex.ru/{city}/kupit/novostrojka/{_JK_SLUG_PLACEHOLDER}-{jk_id}/"
|
||||||
f"https://realty.yandex.ru/{city}/kupit/novostrojka/"
|
# #3197: см. fetch_jk — та же проводка пула через фабрику.
|
||||||
f"{_JK_SLUG_PLACEHOLDER}-{jk_id}/"
|
if config is not None:
|
||||||
)
|
fetcher_cm = build_browser_fetcher(config, "yandex", proxy_provider=proxy_provider)
|
||||||
endpoint = config.browser_http_endpoint if config is not None else None
|
else:
|
||||||
|
fetcher_cm = BrowserFetcher(source="yandex", endpoint=None) # type: ignore[arg-type]
|
||||||
try:
|
try:
|
||||||
async with BrowserFetcher(source="yandex", endpoint=endpoint) as fetcher:
|
async with fetcher_cm as fetcher:
|
||||||
html = await fetcher.fetch(page_url)
|
html = await fetcher.fetch(page_url)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
if caused_by_no_proxy(exc):
|
||||||
|
raise
|
||||||
logger.warning("resolve_yandex_jk_slug jk_id=%s: браузер не ответил: %s", jk_id, exc)
|
logger.warning("resolve_yandex_jk_slug jk_id=%s: браузер не ответил: %s", jk_id, exc)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
@ -395,11 +422,7 @@ async def resolve_yandex_jk_slug(
|
||||||
# Плейсхолдер отбрасываем: страница ссылается сама на себя запрошенным
|
# Плейсхолдер отбрасываем: страница ссылается сама на себя запрошенным
|
||||||
# адресом, и без этого фильтра мы бы «разрешили» slug в тот, что сами же
|
# адресом, и без этого фильтра мы бы «разрешили» slug в тот, что сами же
|
||||||
# и придумали — то есть записали бы выдумку как факт.
|
# и придумали — то есть записали бы выдумку как факт.
|
||||||
slugs = [
|
slugs = [slug for slug in _slug_link_re(jk_id).findall(html) if slug != _JK_SLUG_PLACEHOLDER]
|
||||||
slug
|
|
||||||
for slug in _slug_link_re(jk_id).findall(html)
|
|
||||||
if slug != _JK_SLUG_PLACEHOLDER
|
|
||||||
]
|
|
||||||
if not slugs:
|
if not slugs:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"resolve_yandex_jk_slug jk_id=%s: на странице нет ни одной ссылки с этим id "
|
"resolve_yandex_jk_slug jk_id=%s: на странице нет ни одной ссылки с этим id "
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue