fix(tradein/scrapers): убрать оставшиеся обходы пула прокси (#2830) (#2833)
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m13s
Deploy Trade-In / build-backend (push) Successful in 1m46s
Deploy Trade-In / deploy (push) Successful in 1m48s
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m13s
Deploy Trade-In / build-backend (push) Successful in 1m46s
Deploy Trade-In / deploy (push) Successful in 1m48s
This commit is contained in:
parent
f2cbd76ae0
commit
e17687aed7
6 changed files with 520 additions and 141 deletions
|
|
@ -74,6 +74,7 @@ from app.services import proxy_rotation as proxy_rotation_svc
|
||||||
from app.services import scrape_runs as runs_mod
|
from app.services import scrape_runs as runs_mod
|
||||||
from app.services.estimator import LISTINGS_FRESH_DAYS
|
from app.services.estimator import LISTINGS_FRESH_DAYS
|
||||||
from app.services.geocoder import geocode, known_city_hint
|
from app.services.geocoder import geocode, known_city_hint
|
||||||
|
from app.services.proxy_egress import ProxyPoolExhaustedError, resolve_proxy_url
|
||||||
from app.services.proxy_pool import clear_source_bans
|
from app.services.proxy_pool import clear_source_bans
|
||||||
from app.services.scheduler import has_running_run
|
from app.services.scheduler import has_running_run
|
||||||
from app.services.scraper_adapters import (
|
from app.services.scraper_adapters import (
|
||||||
|
|
@ -2370,18 +2371,33 @@ class ScraperHealthResponse(BaseModel):
|
||||||
_ROTATABLE_SOURCES = ("avito", "cian", "yandex")
|
_ROTATABLE_SOURCES = ("avito", "cian", "yandex")
|
||||||
|
|
||||||
|
|
||||||
def _provider_proxy_url(source: str) -> str | None:
|
def _provider_proxy_url(db: Session, source: str) -> str | None:
|
||||||
"""Effective proxy URL для source (учитывает property-fallback в settings).
|
"""Узел, который РЕАЛЬНО получит трафик этого источника (#2830).
|
||||||
|
|
||||||
#2616 шаг 2: avito/cian/yandex все три сходятся на settings.scraper_proxy_url
|
Раньше здесь стоял `settings.scraper_proxy_url` — одна и та же статичная
|
||||||
(per-provider AVITO_PROXY_URL/CIAN_PROXY_URL/YANDEX_PROXY_URL сняты — мёртвая
|
переменная для всех трёх источников. После #2825/#2831 egress выбирается из
|
||||||
mobileproxy-подписка, #2613).
|
`scrape_proxies` по запросу и с учётом `scrape_proxy_source_bans`, то есть
|
||||||
|
страница показывала один узел, а трафик шёл через другой — слепое пятно ровно
|
||||||
|
того класса, который спрятал инцидент 2026-08-10 (месяц сбора через узел,
|
||||||
|
забаненный и Avito, и Cian), только теперь на диагностической странице.
|
||||||
|
|
||||||
|
Тот же резолвер, что у боевых ad-hoc путей (`cian_session.verify_session`,
|
||||||
|
`*_detail_backfill`) — не «похожая логика», иначе страница снова начнёт
|
||||||
|
расходиться с трафиком.
|
||||||
|
|
||||||
|
Вердикт пулу отсюда НЕ уходит и уходить не должен (#2805): резолвер read-only,
|
||||||
|
lease не берёт, а ipify-проба ниже проверяет доступность ipify через узел, а не
|
||||||
|
его репутацию у Авито/Циана — присваивать узлу отказ по чужой пробе значит
|
||||||
|
выдавать ему чужой бан.
|
||||||
"""
|
"""
|
||||||
return {
|
try:
|
||||||
"avito": settings.scraper_proxy_url,
|
return resolve_proxy_url(db, source)
|
||||||
"cian": settings.cian_proxy_url,
|
except ProxyPoolExhaustedError:
|
||||||
"yandex": settings.yandex_proxy_url,
|
# Пул не пуст, но для source не осталось ни одного здорового небаненного узла.
|
||||||
}.get(source)
|
# resolve_proxy_url уже написал error с разбивкой; здесь отдаём None — пусть
|
||||||
|
# страница покажет «—», а не статичный env-узел (зелёная строка на месте
|
||||||
|
# отказа хуже пустой).
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _parse_proxy_host_port(proxy_url: str | None) -> tuple[str | None, int | None]:
|
def _parse_proxy_host_port(proxy_url: str | None) -> tuple[str | None, int | None]:
|
||||||
|
|
@ -2497,19 +2513,23 @@ async def _probe_current_ip(proxy_url: str | None) -> str | None:
|
||||||
|
|
||||||
|
|
||||||
@router.get("/scraper/health", response_model=ScraperHealthResponse)
|
@router.get("/scraper/health", response_model=ScraperHealthResponse)
|
||||||
async def scraper_health() -> ScraperHealthResponse:
|
async def scraper_health(
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
) -> ScraperHealthResponse:
|
||||||
"""Сводный health для единой scrapers-страницы: fetch_mode + browser + провайдеры.
|
"""Сводный health для единой scrapers-страницы: fetch_mode + browser + провайдеры.
|
||||||
|
|
||||||
- fetch_mode: settings.scraper_fetch_mode (curl_cffi / browser).
|
- fetch_mode: settings.scraper_fetch_mode (curl_cffi / browser).
|
||||||
- browser: GET tradein-browser /health (reachable + per-browser ready-флаги).
|
- browser: GET tradein-browser /health (reachable + per-browser ready-флаги).
|
||||||
- providers: для avito/cian/yandex — proxy host/port, rotate_supported
|
- providers: для avito/cian/yandex — узел, который пул отдаст ЭТОМУ источнику
|
||||||
(#2616 шаг 2: всегда False — changeip mobileproxy-ротация снята, мёртвый
|
сейчас (#2830, см. `_provider_proxy_url`; пусто = ни одного небаненного
|
||||||
аккаунт #2613; живая ASocks-ротация — POST /admin/proxies/{id}/rotate, #2611,
|
здорового узла), rotate_supported (#2616 шаг 2: всегда False — changeip
|
||||||
не per-provider-source), best-effort current_ip (пробинг через прокси на ipify).
|
mobileproxy-ротация снята, мёртвый аккаунт #2613; живая ASocks-ротация —
|
||||||
|
POST /admin/proxies/{id}/rotate, #2611, не per-provider-source), best-effort
|
||||||
|
current_ip (пробинг через этот же узел на ipify).
|
||||||
|
|
||||||
Все пробинги параллельны (asyncio.gather) и time-boxed — суммарно ≤10с.
|
Все пробинги параллельны (asyncio.gather) и time-boxed — суммарно ≤10с.
|
||||||
"""
|
"""
|
||||||
proxy_urls = {s: _provider_proxy_url(s) for s in _ROTATABLE_SOURCES}
|
proxy_urls = {s: _provider_proxy_url(db, s) for s in _ROTATABLE_SOURCES}
|
||||||
|
|
||||||
browser, *ips = await asyncio.gather(
|
browser, *ips = await asyncio.gather(
|
||||||
_probe_browser_health(),
|
_probe_browser_health(),
|
||||||
|
|
|
||||||
|
|
@ -19,19 +19,40 @@ from dataclasses import dataclass, field
|
||||||
# golden-parity была доказана против legacy cian_detail-модуля до его удаления,
|
# golden-parity была доказана против legacy cian_detail-модуля до его удаления,
|
||||||
# #2397 Part E2; extract_state/ScrapedLot parity-тесты убраны вместе с остальным
|
# #2397 Part E2; extract_state/ScrapedLot parity-тесты убраны вместе с остальным
|
||||||
# legacy scrapers-каталогом, #2397 финальный шаг E — kit единственный живой путь).
|
# legacy scrapers-каталогом, #2397 финальный шаг E — kit единственный живой путь).
|
||||||
# RealScraperConfig — тот же read-only адаптер над settings, что и остальные
|
|
||||||
# kit-инжекции (#2131) — сохраняет proxy-поведение (config.cian_proxy_url)
|
|
||||||
# идентичным прежнему прямому импорту settings.
|
|
||||||
from scraper_kit.providers.cian.detail import fetch_detail, save_detail_enrichment
|
from scraper_kit.providers.cian.detail import fetch_detail, save_detail_enrichment
|
||||||
|
from scraper_kit.proxy_errors import NoProxyAvailableError
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.services.scraper_adapters import RealMatcherAdapter, RealScraperConfig
|
from app.services.scraper_adapters import (
|
||||||
|
RealMatcherAdapter,
|
||||||
|
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__)
|
||||||
|
|
||||||
|
|
||||||
|
class _PoolCurlConfig(RealScraperConfig):
|
||||||
|
"""RealScraperConfig с принудительно включённым pool-режимом curl (#2830).
|
||||||
|
|
||||||
|
`USE_PROXY_POOL_CURL` задан только контейнеру `scraper` (docker-compose.prod.yml
|
||||||
|
services.scraper.environment), а этот бэкфилл запускается ручкой
|
||||||
|
`POST /admin/scrape/cian-price-history` в контейнере `backend`, где переменной нет
|
||||||
|
→ `settings.use_proxy_pool_curl` = False. С ней `providers/_proxy.py::curl_proxy_url`
|
||||||
|
ИГНОРИРУЕТ переданный `proxy_provider` и уходит на статичный `SCRAPER_PROXY_URL`:
|
||||||
|
один `proxy_provider=` был бы правкой без эффекта (зелёный тест, нулевой прод).
|
||||||
|
|
||||||
|
Флаг — рубильник раскатки pool-режима для планировщика, а не решение «этому пути
|
||||||
|
пул не нужен»: инцидент 2026-08-10 (#2830) — ровно про то, что нужен именно ему.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def use_proxy_pool_curl(self) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class CianPriceHistoryResult:
|
class CianPriceHistoryResult:
|
||||||
checked: int = 0
|
checked: int = 0
|
||||||
|
|
@ -60,6 +81,11 @@ async def backfill_cian_price_history(
|
||||||
result = CianPriceHistoryResult()
|
result = CianPriceHistoryResult()
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
delay = get_scraper_delay("cian") # default 5.0s
|
delay = get_scraper_delay("cian") # default 5.0s
|
||||||
|
# Egress через пул с учётом `scrape_proxy_source_bans` (#2830): узел выбирает
|
||||||
|
# `curl_proxy_url` внутри `fetch_detail`, он же на выходе возвращает вердикт
|
||||||
|
# (mark_banned на CianBlockedError / mark_health / release).
|
||||||
|
scraper_config = _PoolCurlConfig()
|
||||||
|
proxy_provider = RealProxyProvider()
|
||||||
|
|
||||||
if listing_id is not None:
|
if listing_id is not None:
|
||||||
rows = (
|
rows = (
|
||||||
|
|
@ -107,9 +133,27 @@ async def backfill_cian_price_history(
|
||||||
url: str = row["source_url"]
|
url: str = row["source_url"]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# config= обязателен — kit fetch_detail без него не читает cian_proxy_url
|
# config= обязателен — без него kit fetch_detail идёт напрямую, а без прокси
|
||||||
# (direct connection), а без прокси datacenter-IP блокируется Cian (#806).
|
# datacenter-IP блокируется Cian (#806). proxy_provider= — узел из пула
|
||||||
enrichment = await fetch_detail(url, config=RealScraperConfig())
|
# (#2830): раньше здесь был статичный SCRAPER_PROXY_URL, не знающий про
|
||||||
|
# `scrape_proxy_source_bans`, и 403 от отбитого узла никому не сообщался.
|
||||||
|
enrichment = await fetch_detail(
|
||||||
|
url, config=scraper_config, proxy_provider=proxy_provider
|
||||||
|
)
|
||||||
|
except NoProxyAvailableError as exc:
|
||||||
|
# Fail-closed (#2616): пул пуст/недоступен в проде. Остальные листинги
|
||||||
|
# упрутся в то же самое — рвём батч сразу, а не 50 раз по 5 секунд с
|
||||||
|
# логом, который читается как «Циан нас блокирует».
|
||||||
|
logger.error(
|
||||||
|
"cian_price_history: нет доступного прокси в пуле (%s) — батч прерван "
|
||||||
|
"на listing_id=%s (обработано %d из %d)",
|
||||||
|
exc,
|
||||||
|
lid,
|
||||||
|
i,
|
||||||
|
len(rows),
|
||||||
|
)
|
||||||
|
result.errors += 1
|
||||||
|
break
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"cian_price_history: fetch failed listing_id=%s url=%s: %s",
|
"cian_price_history: fetch failed listing_id=%s url=%s: %s",
|
||||||
|
|
|
||||||
|
|
@ -414,6 +414,9 @@ async def backfill_newbuilding_enrichment(
|
||||||
# уходили в тот же адрес (страница блокировки вместо карточки). Провайдер здесь ≠
|
# уходили в тот же адрес (страница блокировки вместо карточки). Провайдер здесь ≠
|
||||||
# «включить пул»: реально пул задействуется, только если включён
|
# «включить пул»: реально пул задействуется, только если включён
|
||||||
# config.use_proxy_pool_browser (build_browser_fetcher внутри fetch_newbuilding).
|
# config.use_proxy_pool_browser (build_browser_fetcher внутри fetch_newbuilding).
|
||||||
|
# #2830: тот же провайдер уходит и в resolve-ногу (curl_cffi, флаг
|
||||||
|
# use_proxy_pool_curl) — #2767 починил только fetch, а резолв ЖК-url остался на
|
||||||
|
# статичном cian_proxy_url, то есть на второй ноге той же цепочки.
|
||||||
proxy_provider = RealProxyProvider()
|
proxy_provider = RealProxyProvider()
|
||||||
|
|
||||||
result = NewbuildingEnrichBackfillResult()
|
result = NewbuildingEnrichBackfillResult()
|
||||||
|
|
@ -491,7 +494,9 @@ async def backfill_newbuilding_enrichment(
|
||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
resolved = await resolve_cian_zhk_url_via_search(nb_id, config=scraper_config)
|
resolved = await resolve_cian_zhk_url_via_search(
|
||||||
|
nb_id, config=scraper_config, proxy_provider=proxy_provider
|
||||||
|
)
|
||||||
except Exception as exc: # defensive — resolver already catches internally
|
except Exception as exc: # defensive — resolver already catches internally
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"zhk-url resolve raised house_id=%s nb_id=%s: %s", house_id, nb_id, exc
|
"zhk-url resolve raised house_id=%s nb_id=%s: %s", house_id, nb_id, exc
|
||||||
|
|
|
||||||
340
tradein-mvp/backend/tests/test_2830_pool_bypass_tails.py
Normal file
340
tradein-mvp/backend/tests/test_2830_pool_bypass_tails.py
Normal file
|
|
@ -0,0 +1,340 @@
|
||||||
|
"""#2830: хвосты инцидента 2026-08-10 — пути, которые ещё ходили мимо пула прокси.
|
||||||
|
|
||||||
|
Корень (issue #2830, разбор владельца): `SCRAPER_PROXY_URL` — один статичный узел,
|
||||||
|
общий для всех источников и НЕ знающий про `scrape_proxy_source_bans`. Три места
|
||||||
|
продолжали брать egress именно оттуда:
|
||||||
|
|
||||||
|
1. `cian_price_history` (ручка POST /admin/scrape/cian-price-history);
|
||||||
|
2. `_provider_proxy_url` / GET /admin/scraper/health — ops-панель показывала статичный
|
||||||
|
узел, пока трафик уже выбирался пулом (#2825/#2831);
|
||||||
|
3. `resolve_cian_zhk_url_via_search` — вторая нога обогащения ЖК: #2767 перевёл на пул
|
||||||
|
только `fetch_newbuilding`, резолв ЖК-url остался на `config.cian_proxy_url`.
|
||||||
|
|
||||||
|
Тесты проверяют ПОВЕДЕНИЕ пула (какой узел взят и какой вердикт вернулся), а не наличие
|
||||||
|
kwarg'а в вызове. Красные на старом коде:
|
||||||
|
|
||||||
|
* (1) `fetch_detail` вызывался без `proxy_provider` → lease не брался, 403 никому не
|
||||||
|
сообщался: `mark_banned_calls == []`. Плюс ловушка «правка без эффекта»:
|
||||||
|
`USE_PROXY_POOL_CURL` задан только контейнеру `scraper`, а ручка живёт в `backend`,
|
||||||
|
где флага нет — один `proxy_provider=` пул бы не включил (см. `_PoolCurlConfig`).
|
||||||
|
* (2) `_provider_proxy_url(source)` возвращал `settings.scraper_proxy_url` и не имел
|
||||||
|
параметра `db` — вызов из теста падал бы на сигнатуре, а исход «пул исчерпан»
|
||||||
|
выражения не имел вообще.
|
||||||
|
* (3) `resolve_cian_zhk_url_via_search` на 403 возвращал None: узел получал
|
||||||
|
`mark_health(ok=True)` и оставался в выдаче Циану (механика #2700/#2821).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
|
from scraper_kit.cian_exceptions import CianBlockedError
|
||||||
|
from scraper_kit.contracts import ProxyLease
|
||||||
|
from scraper_kit.providers.cian import detail as cian_detail
|
||||||
|
from scraper_kit.providers.cian import newbuilding as cian_newbuilding
|
||||||
|
from scraper_kit.proxy_errors import NoProxyAvailableError
|
||||||
|
|
||||||
|
# Узел 9 (asocks-mobile-1) — тот, что 2026-08-10 отдавал Циану 200, пока статичный
|
||||||
|
# SCRAPER_PROXY_URL смотрел на забаненный узел 1 (asocks-residential-1).
|
||||||
|
_LEASE = ProxyLease(id=9, url="http://user:pass@pool-node-9:10313", kind="http", rotate_url=None)
|
||||||
|
|
||||||
|
|
||||||
|
class _SpyProvider:
|
||||||
|
"""ProxyProvider-заглушка: запоминает вердикты, которые путь вернул пулу."""
|
||||||
|
|
||||||
|
def __init__(self, lease: ProxyLease | None = _LEASE) -> None:
|
||||||
|
self._lease = lease
|
||||||
|
self.acquire_calls: list[str] = []
|
||||||
|
self.mark_health_calls: list[tuple[int, bool]] = []
|
||||||
|
self.mark_banned_calls: list[tuple[int, str]] = []
|
||||||
|
self.release_calls: list[int] = []
|
||||||
|
|
||||||
|
def acquire(self, provider: str) -> ProxyLease | None:
|
||||||
|
self.acquire_calls.append(provider)
|
||||||
|
return self._lease
|
||||||
|
|
||||||
|
def release(self, lease: ProxyLease) -> None:
|
||||||
|
self.release_calls.append(lease.id)
|
||||||
|
|
||||||
|
def mark_health(
|
||||||
|
self, lease: ProxyLease, ok: bool, *, exit_ip: Any = None, latency_ms: Any = None
|
||||||
|
) -> None:
|
||||||
|
self.mark_health_calls.append((lease.id, ok))
|
||||||
|
|
||||||
|
def mark_banned(self, lease: ProxyLease, *, source: str) -> None:
|
||||||
|
self.mark_banned_calls.append((lease.id, source))
|
||||||
|
|
||||||
|
|
||||||
|
class _SpyPool:
|
||||||
|
"""Заглушка МОДУЛЯ `app.services.proxy_pool` — подставляется ПОД `RealProxyProvider`.
|
||||||
|
|
||||||
|
Так тест краснеет на поведении (пул не получил вердикт), а не на отсутствии нового
|
||||||
|
имени в модуле: старый код проходит ровно тот же путь, просто ничего пулу не говорит.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, lease: Any = _LEASE) -> None:
|
||||||
|
self._lease = lease
|
||||||
|
self.acquire_calls: list[str] = []
|
||||||
|
self.mark_health_calls: list[tuple[int, bool]] = []
|
||||||
|
self.mark_banned_calls: list[tuple[int, str]] = []
|
||||||
|
self.release_calls: list[int] = []
|
||||||
|
|
||||||
|
def acquire(self, db: Any, provider: str, **kw: Any) -> Any:
|
||||||
|
self.acquire_calls.append(provider)
|
||||||
|
return self._lease
|
||||||
|
|
||||||
|
def release(self, db: Any, lease_id: int) -> None:
|
||||||
|
self.release_calls.append(lease_id)
|
||||||
|
|
||||||
|
def mark_health(self, db: Any, lease_id: int, ok: bool, **kw: Any) -> None:
|
||||||
|
self.mark_health_calls.append((lease_id, ok))
|
||||||
|
|
||||||
|
def mark_banned(self, db: Any, lease_id: int, *, source: str) -> None:
|
||||||
|
self.mark_banned_calls.append((lease_id, source))
|
||||||
|
|
||||||
|
|
||||||
|
def _session_returning(status_code: int, text: str = "") -> MagicMock:
|
||||||
|
session = MagicMock()
|
||||||
|
session.get = AsyncMock(return_value=MagicMock(status_code=status_code, text=text))
|
||||||
|
session.close = AsyncMock()
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
# ── 1. cian_price_history: узел из пула + вердикт обратно ────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _price_history_db(n_listings: int) -> MagicMock:
|
||||||
|
"""Фейковая сессия: SELECT листингов, дальше COUNT'ы (до fetch дело не дойдёт)."""
|
||||||
|
rows = [
|
||||||
|
{"id": 100 + i, "source_url": f"https://ekb.cian.ru/sale/flat/{100 + i}/"}
|
||||||
|
for i in range(n_listings)
|
||||||
|
]
|
||||||
|
listings_result = MagicMock()
|
||||||
|
listings_result.mappings.return_value.all.return_value = rows
|
||||||
|
db = MagicMock()
|
||||||
|
db.execute.return_value = listings_result
|
||||||
|
return db
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_price_history(pool: _SpyPool, *, status_code: int, n: int = 1) -> Any:
|
||||||
|
from app.services import scraper_adapters
|
||||||
|
from app.services.cian_price_history import backfill_cian_price_history
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
cian_detail, "build_curl_cffi_session", return_value=_session_returning(status_code)
|
||||||
|
),
|
||||||
|
patch("app.services.cian_price_history.get_scraper_delay", return_value=0.0),
|
||||||
|
patch.object(scraper_adapters, "_proxy_pool", pool),
|
||||||
|
patch.object(scraper_adapters, "_SessionLocal", MagicMock()),
|
||||||
|
):
|
||||||
|
return await backfill_cian_price_history(_price_history_db(n), batch_size=n)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_price_history_takes_pool_node_despite_flag_off() -> None:
|
||||||
|
"""Узел берётся из пула даже при выключенном USE_PROXY_POOL_CURL (контейнер backend).
|
||||||
|
|
||||||
|
Красный на старом коде дважды: не было ни `proxy_provider=`, ни принудительного
|
||||||
|
pool-режима — `curl_proxy_url` уходил на статичный env-узел и `acquire` не звал.
|
||||||
|
"""
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
assert (
|
||||||
|
settings.use_proxy_pool_curl is False
|
||||||
|
), "тест обязан идти тем же путём, что прод-контейнер backend: без USE_PROXY_POOL_CURL"
|
||||||
|
pool = _SpyPool()
|
||||||
|
await _run_price_history(pool, status_code=200)
|
||||||
|
assert pool.acquire_calls == ["cian"]
|
||||||
|
assert pool.release_calls == [9] # lease не течёт
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_price_history_403_bans_the_node_for_cian() -> None:
|
||||||
|
"""403 от Циана снимает узел с выдачи ИМЕННО Циану. Красный: было `mark_banned` = []."""
|
||||||
|
pool = _SpyPool()
|
||||||
|
result = await _run_price_history(pool, status_code=403)
|
||||||
|
assert pool.mark_banned_calls == [(9, "cian")]
|
||||||
|
assert pool.mark_health_calls == [(9, False)]
|
||||||
|
assert result.errors == 1 # прогон честен: отказ посчитан
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_price_history_404_does_not_ban_the_node() -> None:
|
||||||
|
"""Снятое объявление — не бан площадки: чужой вердикт узлу не присваиваем (#2805)."""
|
||||||
|
pool = _SpyPool()
|
||||||
|
await _run_price_history(pool, status_code=404)
|
||||||
|
assert pool.mark_banned_calls == []
|
||||||
|
assert pool.mark_health_calls == [(9, True)]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_price_history_aborts_batch_when_pool_empty() -> None:
|
||||||
|
"""Пул пуст в проде → отказ ДО HTTP и разрыв батча, а не 3 попытки подряд."""
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
pool = _SpyPool(lease=None)
|
||||||
|
# Прод-признак читает `curl_proxy_url` через config.environment (#2616 шаг 1):
|
||||||
|
# только в проде пустой пул = отказ, в dev это легитимный env/direct-путь.
|
||||||
|
with patch.object(settings, "environment", "production"):
|
||||||
|
result = await _run_price_history(pool, status_code=200, n=3)
|
||||||
|
assert pool.acquire_calls == ["cian"], "после отказа пула батч продолжаться не должен"
|
||||||
|
assert result.errors == 1
|
||||||
|
assert result.checked == 3 # выбрали 3, но оборвались на первом
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_proxy_available_is_not_a_platform_ban() -> None:
|
||||||
|
"""Наш отказ инфраструктуры не должен читаться как бан площадки (#2616)."""
|
||||||
|
from scraper_kit.proxy_errors import ProxyBanError
|
||||||
|
|
||||||
|
assert not issubclass(NoProxyAvailableError, ProxyBanError)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 2. /admin/scraper/health: показывает узел, который реально получит трафик ──
|
||||||
|
|
||||||
|
|
||||||
|
def _health_db(*, candidate: dict[str, Any] | None, diag: dict[str, int]) -> MagicMock:
|
||||||
|
"""Сессия для `proxy_egress`: SELECT кандидата, при пустом — SELECT-разбивка причин."""
|
||||||
|
|
||||||
|
def _execute(stmt: Any, *args: Any, **kwargs: Any) -> MagicMock:
|
||||||
|
row = diag if "count(*)" in str(stmt).lower() else candidate
|
||||||
|
res = MagicMock()
|
||||||
|
res.mappings.return_value.fetchone.return_value = row
|
||||||
|
return res
|
||||||
|
|
||||||
|
db = MagicMock()
|
||||||
|
db.execute.side_effect = _execute
|
||||||
|
return db
|
||||||
|
|
||||||
|
|
||||||
|
def _get_health(db: MagicMock) -> dict[str, Any]:
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.api.v1 import admin as admin_module
|
||||||
|
from app.core.db import get_db
|
||||||
|
|
||||||
|
def _fake_db() -> Any:
|
||||||
|
yield db
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(admin_module.router, prefix="/api/v1/admin")
|
||||||
|
app.dependency_overrides[get_db] = _fake_db
|
||||||
|
|
||||||
|
async def _no_browser() -> Any:
|
||||||
|
return admin_module.BrowserHealth(reachable=False, browsers={})
|
||||||
|
|
||||||
|
async def _no_ip(proxy_url: str | None) -> str | None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(admin_module, "_probe_browser_health", _no_browser),
|
||||||
|
patch.object(admin_module, "_probe_current_ip", _no_ip),
|
||||||
|
# Статичный env-узел ОТЛИЧАЕТСЯ от пулового — иначе тест не различит источники.
|
||||||
|
patch.object(
|
||||||
|
admin_module.settings.__class__,
|
||||||
|
"scraper_proxy_url",
|
||||||
|
property(lambda _self: "http://u:p@static-env-node:10423"),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
r = TestClient(app).get("/api/v1/admin/scraper/health")
|
||||||
|
assert r.status_code == 200
|
||||||
|
return {p["source"]: p for p in r.json()["providers"]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_health_shows_pool_node_not_static_env() -> None:
|
||||||
|
"""Панель показывает узел, который получит трафик, а не SCRAPER_PROXY_URL.
|
||||||
|
|
||||||
|
Красный на старом коде поведенчески: возвращался `static-env-node` — ровно то
|
||||||
|
расхождение «панель показывает один узел, трафик идёт через другой», из-за
|
||||||
|
которого инцидент 2026-08-10 месяц оставался невидимым.
|
||||||
|
"""
|
||||||
|
db = _health_db(
|
||||||
|
candidate={"id": 9, "url": _LEASE.url, "label": "asocks-mobile-1"},
|
||||||
|
diag={"pool_total": 4, "banned_for_source": 0, "unhealthy_or_disabled": 0},
|
||||||
|
)
|
||||||
|
by_source = _get_health(db)
|
||||||
|
assert {p["proxy_host"] for p in by_source.values()} == {"pool-node-9"}
|
||||||
|
assert by_source["cian"]["proxy_port"] == 10313
|
||||||
|
|
||||||
|
|
||||||
|
def test_health_shows_nothing_when_pool_exhausted_for_source() -> None:
|
||||||
|
"""Все узлы забанены для источника → «—», а не статичный env-узел.
|
||||||
|
|
||||||
|
Зелёная строка на месте отказа хуже пустой: она читается как «прокси есть».
|
||||||
|
"""
|
||||||
|
db = _health_db(
|
||||||
|
candidate=None,
|
||||||
|
diag={"pool_total": 4, "banned_for_source": 4, "unhealthy_or_disabled": 0},
|
||||||
|
)
|
||||||
|
by_source = _get_health(db)
|
||||||
|
assert by_source["cian"]["proxy_host"] is None
|
||||||
|
assert by_source["cian"]["proxy_port"] is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── 3. resolve ЖК-url: вторая нога обогащения (#2767 чинил только первую) ─────
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _FakeConfig:
|
||||||
|
use_proxy_pool_curl: bool = True
|
||||||
|
cian_proxy_url: str | None = "http://static-env-node:10423"
|
||||||
|
environment: str = "production"
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve(status_code: int, spy: _SpyProvider, html: str = "") -> Any:
|
||||||
|
with patch.object(
|
||||||
|
cian_newbuilding,
|
||||||
|
"build_curl_cffi_session",
|
||||||
|
return_value=_session_returning(status_code, html),
|
||||||
|
):
|
||||||
|
return await cian_newbuilding.resolve_cian_zhk_url_via_search(
|
||||||
|
48853, config=_FakeConfig(), proxy_provider=spy
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_zhk_resolve_uses_pool_node() -> None:
|
||||||
|
"""Резолв ЖК-url берёт узел из пула, а не статичный cian_proxy_url."""
|
||||||
|
spy = _SpyProvider()
|
||||||
|
html = '<h1 data-name="Title"><a href="https://zhk-parkovyy-kvartal-ekb-i.cian.ru">ЖК</a></h1>'
|
||||||
|
assert await _resolve(200, spy, html) == "https://zhk-parkovyy-kvartal-ekb-i.cian.ru"
|
||||||
|
assert spy.acquire_calls == ["cian"]
|
||||||
|
assert spy.mark_health_calls == [(9, True)]
|
||||||
|
assert spy.release_calls == [9]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_zhk_resolve_403_reaches_the_pool() -> None:
|
||||||
|
"""Красный на старом коде: 403 гасился в `return None`, узел оставался в выдаче."""
|
||||||
|
spy = _SpyProvider()
|
||||||
|
with pytest.raises(CianBlockedError):
|
||||||
|
await _resolve(403, spy)
|
||||||
|
assert spy.mark_banned_calls == [(9, "cian")]
|
||||||
|
assert spy.mark_health_calls == [(9, False)]
|
||||||
|
assert spy.release_calls == [9]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_zhk_resolve_404_is_a_soft_miss_not_a_ban() -> None:
|
||||||
|
"""404 cat.php — не бан: здоровый узел за отсутствующий ЖК не наказываем."""
|
||||||
|
spy = _SpyProvider()
|
||||||
|
assert await _resolve(404, spy) is None
|
||||||
|
assert spy.mark_banned_calls == []
|
||||||
|
assert spy.mark_health_calls == [(9, True)]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_zhk_resolve_empty_serp_is_a_soft_miss_not_a_ban() -> None:
|
||||||
|
"""200 без слага (дрейф вёрстки / пустая выдача) — тоже не повод банить узел."""
|
||||||
|
spy = _SpyProvider()
|
||||||
|
assert await _resolve(200, spy, "<html><body>ничего</body></html>") is None
|
||||||
|
assert spy.mark_banned_calls == []
|
||||||
|
assert spy.mark_health_calls == [(9, True)]
|
||||||
|
|
@ -156,7 +156,8 @@ def test_health_assembles_browser_and_providers(client: TestClient) -> None:
|
||||||
with (
|
with (
|
||||||
patch.object(admin_module, "_probe_browser_health", fake_browser_health),
|
patch.object(admin_module, "_probe_browser_health", fake_browser_health),
|
||||||
patch.object(admin_module, "_probe_current_ip", fake_current_ip),
|
patch.object(admin_module, "_probe_current_ip", fake_current_ip),
|
||||||
patch.object(admin_module, "_provider_proxy_url", lambda s: proxy_urls[s]),
|
# #2830: узел резолвится из пула по (db, source), а не из статичного env
|
||||||
|
patch.object(admin_module, "_provider_proxy_url", lambda _db, s: proxy_urls[s]),
|
||||||
patch.object(admin_module.settings, "scraper_fetch_mode", "browser"),
|
patch.object(admin_module.settings, "scraper_fetch_mode", "browser"),
|
||||||
):
|
):
|
||||||
r = client.get("/api/v1/admin/scraper/health")
|
r = client.get("/api/v1/admin/scraper/health")
|
||||||
|
|
@ -207,7 +208,7 @@ def test_health_browser_unreachable(client: TestClient) -> None:
|
||||||
with (
|
with (
|
||||||
patch.object(admin_module.httpx, "AsyncClient", _BoomClient),
|
patch.object(admin_module.httpx, "AsyncClient", _BoomClient),
|
||||||
patch.object(admin_module, "_probe_current_ip", fake_current_ip),
|
patch.object(admin_module, "_probe_current_ip", fake_current_ip),
|
||||||
patch.object(admin_module, "_provider_proxy_url", lambda s: None),
|
patch.object(admin_module, "_provider_proxy_url", lambda _db, _s: None),
|
||||||
):
|
):
|
||||||
r = client.get("/api/v1/admin/scraper/health")
|
r = client.get("/api/v1/admin/scraper/health")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,10 @@ from dataclasses import dataclass, field
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from scraper_kit.browser_fetcher import BrowserFetcher
|
from scraper_kit.browser_fetcher import BrowserFetcher
|
||||||
|
from scraper_kit.cian_exceptions import CianBlockedError
|
||||||
from scraper_kit.cian_state_parser import extract_all_states, extract_state
|
from scraper_kit.cian_state_parser import extract_all_states, extract_state
|
||||||
from scraper_kit.providers._base import build_browser_fetcher, build_curl_cffi_session
|
from scraper_kit.providers._base import build_browser_fetcher, build_curl_cffi_session
|
||||||
|
from scraper_kit.providers._proxy import curl_proxy_url
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from curl_cffi.requests import AsyncSession # type: ignore[import-untyped]
|
from curl_cffi.requests import AsyncSession # type: ignore[import-untyped]
|
||||||
|
|
@ -801,70 +803,11 @@ def save_newbuilding_enrichment(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def resolve_cian_zhk_url(
|
# Legacy `resolve_cian_zhk_url` (nb_id → https://cian.ru/zhk/<id>/ → редирект на слаг)
|
||||||
cian_internal_house_id: int,
|
# удалён в #2830: путь редиректа не существует с #972 (Циан отдаёт 404, проверено 8/8 на
|
||||||
*,
|
# проде), вызывающих у функции не было ни одного, а собственную curl-сессию она строила
|
||||||
config: ScraperConfig | None = None,
|
# на статичном `config.cian_proxy_url` — то есть мимо пула. Чинить egress мёртвого пути
|
||||||
session: AsyncSession | None = None,
|
# незачем; удалить честнее. Рабочий резолвер — `resolve_cian_zhk_url_via_search` ниже.
|
||||||
) -> str | None:
|
|
||||||
"""Resolve canonical ZHK slug URL from a Cian internal house ID (LEGACY — BROKEN).
|
|
||||||
|
|
||||||
.. deprecated::
|
|
||||||
The redirect path this relies on — ``https://cian.ru/zhk/<id>/`` → canonical
|
|
||||||
slug — NO LONGER EXISTS. Cian now serves HTTP **404** for ``/zhk/<id>/``
|
|
||||||
(verified 8/8 on prod, #972), so this returns None for every real id. Use
|
|
||||||
:func:`resolve_cian_zhk_url_via_search` instead: it fetches the cat.php
|
|
||||||
newbuilding-SERP and extracts the canonical ``zhk-*.cian.ru`` slug from its
|
|
||||||
markup (the WORKING path). Kept only for backward-compat / reference; do not
|
|
||||||
wire new callers to it.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
cian_internal_house_id: Cian's numeric ЖК identifier.
|
|
||||||
session: optional shared curl_cffi AsyncSession (caller owns lifecycle).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Canonical ZHK URL string, or None if request failed / redirect not followed.
|
|
||||||
|
|
||||||
Note:
|
|
||||||
This function makes a real HTTP request — do NOT call it without rate limiting.
|
|
||||||
Caller must enforce per-request sleep matching scraper_settings 'cian' delay.
|
|
||||||
"""
|
|
||||||
close_session = False
|
|
||||||
if session is None:
|
|
||||||
# Mobile proxy wiring (#806 follow-up): resolve ЖК URL через мобильный прокси.
|
|
||||||
_proxy_url = config.cian_proxy_url if config is not None else None
|
|
||||||
session = build_curl_cffi_session(
|
|
||||||
proxy_url=_proxy_url,
|
|
||||||
timeout=15.0,
|
|
||||||
headers={
|
|
||||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
||||||
"Accept-Language": "ru-RU,ru;q=0.9,en;q=0.8",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
close_session = True
|
|
||||||
|
|
||||||
fallback_url = f"https://cian.ru/zhk/{cian_internal_house_id}/"
|
|
||||||
try:
|
|
||||||
resp = await session.get(fallback_url, allow_redirects=True)
|
|
||||||
final_url = str(resp.url)
|
|
||||||
if final_url and final_url != fallback_url:
|
|
||||||
logger.debug("resolve_cian_zhk_url id=%s → %s", cian_internal_house_id, final_url)
|
|
||||||
return final_url
|
|
||||||
# Redirect not followed or same URL — return fallback as canonical
|
|
||||||
if resp.status_code == 200:
|
|
||||||
return fallback_url
|
|
||||||
logger.warning(
|
|
||||||
"resolve_cian_zhk_url id=%s: HTTP %d, no usable URL",
|
|
||||||
cian_internal_house_id,
|
|
||||||
resp.status_code,
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("resolve_cian_zhk_url id=%s failed: %s", cian_internal_house_id, exc)
|
|
||||||
return None
|
|
||||||
finally:
|
|
||||||
if close_session:
|
|
||||||
await session.close()
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_zhk_url_from_serp(html: str) -> str | None:
|
def _extract_zhk_url_from_serp(html: str) -> str | None:
|
||||||
|
|
@ -897,59 +840,19 @@ def _extract_zhk_url_from_serp(html: str) -> str | None:
|
||||||
return match.group(0) if match else None
|
return match.group(0) if match else None
|
||||||
|
|
||||||
|
|
||||||
async def resolve_cian_zhk_url_via_search(
|
async def _fetch_zhk_slug(session: AsyncSession, nb_id: int) -> str | None:
|
||||||
nb_id: int,
|
"""Один GET cat.php-SERP → канонический zhk-слаг. None на любом мягком отказе.
|
||||||
*,
|
|
||||||
config: ScraperConfig | None = None,
|
|
||||||
session: AsyncSession | None = None,
|
|
||||||
) -> str | None:
|
|
||||||
"""Resolve the canonical ЖК-slug URL for a Cian newbuilding id (the WORKING path).
|
|
||||||
|
|
||||||
Fetches the cat.php newbuilding-SERP for a single ``newobject[0]=<nb_id>`` and
|
Raises:
|
||||||
extracts the canonical ``https://zhk-<slug>.cian.ru`` URL from its markup. This
|
CianBlockedError: HTTP 403 — WAF Циана отбил узел, с которого мы пришли
|
||||||
replaces the legacy :func:`resolve_cian_zhk_url`, whose ``/zhk/<id>/`` redirect path
|
(#2700). Мягкие отказы (404, пустой SERP, дрейф вёрстки) остаются None:
|
||||||
now 404s (verified on prod, #972). The returned slug URL is exactly what
|
наказывать за них здоровый узел нельзя.
|
||||||
:func:`fetch_newbuilding` parses, so the enrichment chain is
|
|
||||||
``nb_id → cat.php SERP → zhk-slug-url → fetch_newbuilding → enrich``.
|
|
||||||
|
|
||||||
Verified live (HTTP 200, slug extracted):
|
|
||||||
nb 48853 → https://zhk-parkovyy-kvartal-ekb-i.cian.ru (ЖК «Парковый квартал»)
|
|
||||||
nb 102791 → https://zhk-izumrudnyy-bor-ekb-i.cian.ru
|
|
||||||
nb 24991 → https://zhk-baltym-park-ekb-i.cian.ru
|
|
||||||
|
|
||||||
Args:
|
|
||||||
nb_id: Cian newbuilding id (``house_sources.ext_id`` for cian houses).
|
|
||||||
session: optional shared curl_cffi AsyncSession (caller owns lifecycle). When
|
|
||||||
None, an own session is created using the same mobile-proxy wiring as the
|
|
||||||
rest of the Cian scrapers (``config.cian_proxy_url`` when set, else direct).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The canonical ЖК-slug URL string, or None on non-200 / empty SERP / no match /
|
|
||||||
request failure (each logs a warning).
|
|
||||||
|
|
||||||
Note:
|
|
||||||
Makes ONE real HTTP request and does NOT sleep — the CALLER enforces the
|
|
||||||
anti-bot delay (matching scraper_settings 'cian'). At scale this needs the
|
|
||||||
mobile proxy; low-volume direct fetches work for the bounded proof.
|
|
||||||
"""
|
"""
|
||||||
close_session = False
|
|
||||||
if session is None:
|
|
||||||
# Mobile proxy wiring (#806): Cian блокирует datacenter-IP. proxy=None → прямое
|
|
||||||
# подключение (dev / proxy-down fallback — single fetches survive direct).
|
|
||||||
_proxy_url = config.cian_proxy_url if config is not None else None
|
|
||||||
session = build_curl_cffi_session(
|
|
||||||
proxy_url=_proxy_url,
|
|
||||||
timeout=30.0,
|
|
||||||
headers={
|
|
||||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
||||||
"Accept-Language": "ru-RU,ru;q=0.9,en;q=0.8",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
close_session = True
|
|
||||||
|
|
||||||
serp_url = _CATPH_NEWBUILDING_SERP.format(nb_id=nb_id)
|
serp_url = _CATPH_NEWBUILDING_SERP.format(nb_id=nb_id)
|
||||||
try:
|
try:
|
||||||
resp = await session.get(serp_url, allow_redirects=True)
|
resp = await session.get(serp_url, allow_redirects=True)
|
||||||
|
if resp.status_code == 403:
|
||||||
|
raise CianBlockedError(f"Cian cat.php nb_id={nb_id} → HTTP 403 (WAF-блок узла)")
|
||||||
if resp.status_code != 200:
|
if resp.status_code != 200:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"resolve_cian_zhk_url_via_search nb_id=%s: cat.php → HTTP %d",
|
"resolve_cian_zhk_url_via_search nb_id=%s: cat.php → HTTP %d",
|
||||||
|
|
@ -967,9 +870,75 @@ async def resolve_cian_zhk_url_via_search(
|
||||||
return None
|
return None
|
||||||
logger.info("resolve_cian_zhk_url_via_search nb_id=%s → %s", nb_id, zhk_url)
|
logger.info("resolve_cian_zhk_url_via_search nb_id=%s → %s", nb_id, zhk_url)
|
||||||
return zhk_url
|
return zhk_url
|
||||||
|
except CianBlockedError:
|
||||||
|
# НЕ гасим: снаружи это `with curl_proxy_url(...)`, и только оттуда бан доходит
|
||||||
|
# до пула (mark_banned на пару «узел × cian»). Проглоченный 403 здесь означал бы
|
||||||
|
# mark_health(ok=True) на отбитый узел — механика #2700/#2821.
|
||||||
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("resolve_cian_zhk_url_via_search nb_id=%s failed: %s", nb_id, exc)
|
logger.warning("resolve_cian_zhk_url_via_search nb_id=%s failed: %s", nb_id, exc)
|
||||||
return None
|
return None
|
||||||
finally:
|
|
||||||
if close_session:
|
|
||||||
await session.close()
|
async def resolve_cian_zhk_url_via_search(
|
||||||
|
nb_id: int,
|
||||||
|
*,
|
||||||
|
config: ScraperConfig | None = None,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
proxy_provider: ProxyProvider | None = None,
|
||||||
|
) -> str | None:
|
||||||
|
"""Resolve the canonical ЖК-slug URL for a Cian newbuilding id (the WORKING path).
|
||||||
|
|
||||||
|
Fetches the cat.php newbuilding-SERP for a single ``newobject[0]=<nb_id>`` and
|
||||||
|
extracts the canonical ``https://zhk-<slug>.cian.ru`` URL from its markup (the
|
||||||
|
legacy ``/zhk/<id>/`` redirect resolver 404s since #972 and is gone, #2830). The
|
||||||
|
returned slug URL is exactly what :func:`fetch_newbuilding` parses, so the
|
||||||
|
enrichment chain is ``nb_id → cat.php SERP → zhk-slug-url → fetch_newbuilding``.
|
||||||
|
|
||||||
|
Verified live (HTTP 200, slug extracted):
|
||||||
|
nb 48853 → https://zhk-parkovyy-kvartal-ekb-i.cian.ru (ЖК «Парковый квартал»)
|
||||||
|
nb 102791 → https://zhk-izumrudnyy-bor-ekb-i.cian.ru
|
||||||
|
nb 24991 → https://zhk-baltym-park-ekb-i.cian.ru
|
||||||
|
|
||||||
|
Args:
|
||||||
|
nb_id: Cian newbuilding id (``house_sources.ext_id`` for cian houses).
|
||||||
|
session: optional shared curl_cffi AsyncSession (caller owns lifecycle; прокси
|
||||||
|
уже применён вызывающим — пул тут не трогаем).
|
||||||
|
proxy_provider: пул прокси (#2830). Без него собственная сессия строилась на
|
||||||
|
статичном ``config.cian_proxy_url`` — мимо ``scrape_proxy_source_bans``,
|
||||||
|
то есть вторая нога обогащения ЖК ходила ровно тем путём, который #2767
|
||||||
|
починил у первой (``fetch_newbuilding``).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The canonical ЖК-slug URL string, or None on non-200 / empty SERP / no match /
|
||||||
|
request failure (each logs a warning).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
CianBlockedError: HTTP 403 на own-session-пути — узел снимается с выдачи Циану
|
||||||
|
через ``curl_proxy_url``. Вызывающий (``newbuilding_enrich_backfill``) уже
|
||||||
|
считает исключение в ``failed_resolve``.
|
||||||
|
|
||||||
|
Note:
|
||||||
|
Makes ONE real HTTP request and does NOT sleep — the CALLER enforces the
|
||||||
|
anti-bot delay (matching scraper_settings 'cian').
|
||||||
|
"""
|
||||||
|
if session is not None:
|
||||||
|
return await _fetch_zhk_slug(session, nb_id)
|
||||||
|
|
||||||
|
# Own-session path. Прокси: пул за флагом use_proxy_pool_curl (#2163), иначе env
|
||||||
|
# cian_proxy_url; пусто → прямое подключение (dev/no-op).
|
||||||
|
# curl_proxy_url: mark_banned на CianBlockedError + mark_health + release на выходе.
|
||||||
|
_env = config.cian_proxy_url if config is not None else None
|
||||||
|
with curl_proxy_url(config, proxy_provider, "cian", env_fallback_url=_env) as _proxy_url:
|
||||||
|
own_session = build_curl_cffi_session(
|
||||||
|
proxy_url=_proxy_url,
|
||||||
|
timeout=30.0,
|
||||||
|
headers={
|
||||||
|
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||||
|
"Accept-Language": "ru-RU,ru;q=0.9,en;q=0.8",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return await _fetch_zhk_slug(own_session, nb_id)
|
||||||
|
finally:
|
||||||
|
await own_session.close()
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue