chore(tradein): удалить мёртвые mobileproxy env-переменные и rotate-ip (#2616 шаги 2-3) #2650
26 changed files with 286 additions and 844 deletions
|
|
@ -2175,7 +2175,8 @@ async def scrape_house_imv_backfill(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ── Единая scrapers-страница: unified runs + health + rotate-ip (epic) ────────
|
# ── Единая scrapers-страница: unified runs + health (epic) ───────────────────
|
||||||
|
# rotate-ip (changeip mobileproxy) удалён #2616 шаг 3 — мёртвая подписка (#2613).
|
||||||
|
|
||||||
|
|
||||||
class UnifiedScrapeRunRow(BaseModel):
|
class UnifiedScrapeRunRow(BaseModel):
|
||||||
|
|
@ -2219,33 +2220,23 @@ class ScraperHealthResponse(BaseModel):
|
||||||
providers: list[ProviderHealth]
|
providers: list[ProviderHealth]
|
||||||
|
|
||||||
|
|
||||||
class RotateIpResponse(BaseModel):
|
|
||||||
ok: bool
|
|
||||||
new_ip: str | None = None
|
|
||||||
reason: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
_ROTATABLE_SOURCES = ("avito", "cian", "yandex")
|
_ROTATABLE_SOURCES = ("avito", "cian", "yandex")
|
||||||
|
|
||||||
|
|
||||||
def _provider_proxy_url(source: str) -> str | None:
|
def _provider_proxy_url(source: str) -> str | None:
|
||||||
"""Effective proxy URL для source (учитывает property-fallback в settings)."""
|
"""Effective proxy URL для source (учитывает property-fallback в settings).
|
||||||
|
|
||||||
|
#2616 шаг 2: avito/cian/yandex все три сходятся на settings.scraper_proxy_url
|
||||||
|
(per-provider AVITO_PROXY_URL/CIAN_PROXY_URL/YANDEX_PROXY_URL сняты — мёртвая
|
||||||
|
mobileproxy-подписка, #2613).
|
||||||
|
"""
|
||||||
return {
|
return {
|
||||||
"avito": settings.avito_proxy_url,
|
"avito": settings.scraper_proxy_url,
|
||||||
"cian": settings.cian_proxy_url,
|
"cian": settings.cian_proxy_url,
|
||||||
"yandex": settings.yandex_proxy_url,
|
"yandex": settings.yandex_proxy_url,
|
||||||
}.get(source)
|
}.get(source)
|
||||||
|
|
||||||
|
|
||||||
def _provider_rotate_url(source: str) -> str | None:
|
|
||||||
"""changeip-URL для source (None → auto-rotate прокси без ручной ротации)."""
|
|
||||||
return {
|
|
||||||
"avito": settings.avito_proxy_rotate_url,
|
|
||||||
"cian": settings.cian_proxy_rotate_url,
|
|
||||||
"yandex": settings.yandex_proxy_rotate_url,
|
|
||||||
}.get(source)
|
|
||||||
|
|
||||||
|
|
||||||
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]:
|
||||||
"""Распарсить host/port из proxy URL (схема http(s)://user:pass@host:port)."""
|
"""Распарсить host/port из proxy URL (схема http(s)://user:pass@host:port)."""
|
||||||
if not proxy_url:
|
if not proxy_url:
|
||||||
|
|
@ -2344,8 +2335,10 @@ async def scraper_health() -> ScraperHealthResponse:
|
||||||
|
|
||||||
- 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 — proxy host/port, rotate_supported
|
||||||
best-effort current_ip (параллельный пробинг через прокси на ipify).
|
(#2616 шаг 2: всегда False — changeip 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с.
|
||||||
"""
|
"""
|
||||||
|
|
@ -2365,7 +2358,7 @@ async def scraper_health() -> ScraperHealthResponse:
|
||||||
source=source,
|
source=source,
|
||||||
proxy_host=host,
|
proxy_host=host,
|
||||||
proxy_port=port,
|
proxy_port=port,
|
||||||
rotate_supported=bool(_provider_rotate_url(source)),
|
rotate_supported=False,
|
||||||
current_ip=ip_by_source[source],
|
current_ip=ip_by_source[source],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -2377,46 +2370,6 @@ async def scraper_health() -> ScraperHealthResponse:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/scraper/{source}/rotate-ip", response_model=RotateIpResponse)
|
|
||||||
async def rotate_proxy_ip(
|
|
||||||
source: Literal["avito", "cian", "yandex"],
|
|
||||||
) -> RotateIpResponse:
|
|
||||||
"""Сменить мобильный exit-IP провайдера через changeip-ссылку (mobileproxy).
|
|
||||||
|
|
||||||
Зеркалит логику AvitoScraper._rotate_ip (GET rotate_url + &format=json), но БЕЗ
|
|
||||||
settle-sleep — API сразу возвращает ответ changeip. Если rotate_url не задан —
|
|
||||||
прокси с авто-ротацией (свежий IP на новое соединение), ручная ротация не нужна.
|
|
||||||
"""
|
|
||||||
rotate_url = _provider_rotate_url(source)
|
|
||||||
if not rotate_url:
|
|
||||||
return RotateIpResponse(ok=False, reason="no rotate url (auto-rotate proxy)")
|
|
||||||
|
|
||||||
sep = "&" if "?" in rotate_url else "?"
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(timeout=20.0) as client:
|
|
||||||
resp = await client.get(f"{rotate_url}{sep}format=json")
|
|
||||||
resp.raise_for_status()
|
|
||||||
try:
|
|
||||||
data = resp.json()
|
|
||||||
except Exception:
|
|
||||||
data = {}
|
|
||||||
except Exception:
|
|
||||||
# НЕ отдавать str(exc) клиенту (аудит-фикс, #security-audit): httpx-исключения
|
|
||||||
# несут полный request URL, а rotate_url — mobileproxy changeip-ссылка с API-
|
|
||||||
# ключом провайдера в query-string (?...&proxy_key=...). str(exc) с этим URL в
|
|
||||||
# HTTP-ответе — прямая утечка секрета вызывающему клиенту. Причина сбоя остаётся
|
|
||||||
# в логах (exc_info=True) для диагностики; наружу — только нейтральный reason.
|
|
||||||
logger.warning("rotate-ip: changeip failed source=%s", source, exc_info=True)
|
|
||||||
return RotateIpResponse(ok=False, reason="changeip request failed")
|
|
||||||
|
|
||||||
# changeip отдаёт новый IP в одном из полей (формат провайдер-зависимый).
|
|
||||||
new_ip = None
|
|
||||||
if isinstance(data, dict):
|
|
||||||
new_ip = data.get("new_ip") or data.get("ip") or data.get("proxy_ip")
|
|
||||||
logger.info("rotate-ip: source=%s new_ip=%s", source, new_ip)
|
|
||||||
return RotateIpResponse(ok=True, new_ip=str(new_ip) if new_ip else None)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Pacing live-регулятор (GET/PUT /scraper/pacing) ──────────────────────────
|
# ── Pacing live-регулятор (GET/PUT /scraper/pacing) ──────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -2896,10 +2849,10 @@ def patch_proxy(
|
||||||
|
|
||||||
# ── Proxy pool: ручная ротация exit-IP по proxy_id (#2600 п.5) ───────────────
|
# ── Proxy pool: ручная ротация exit-IP по proxy_id (#2600 п.5) ───────────────
|
||||||
#
|
#
|
||||||
# ОТДЕЛЬНО от /scraper/{source}/rotate-ip (выше) — тот работает по env-прокси
|
# Раньше отдельно от /scraper/{source}/rotate-ip (env-прокси mobileproxy,
|
||||||
# mobileproxy для avito/cian/yandex (changeip-ссылка, ротация "на лету" без
|
# changeip-ссылка) — тот эндпоинт удалён вместе с мёртвой подпиской (#2616 шаг 3).
|
||||||
# лимитов), не трогается. Этот эндпоинт — по proxy_id из пула scrape_proxies
|
# Этот эндпоинт — единственная живая ручная ротация, по proxy_id из пула
|
||||||
# (сейчас это ASocks-порты с суточным лимитом 3/сутки), см.
|
# scrape_proxies (сейчас это ASocks-порты с суточным лимитом 3/сутки), см.
|
||||||
# app.services.proxy_rotation.rotate_proxy.
|
# app.services.proxy_rotation.rotate_proxy.
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -651,45 +651,41 @@ class Settings(BaseSettings):
|
||||||
"www.domclick.ru",
|
"www.domclick.ru",
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── Scraper mobile proxy (#806) ──────────────────────────────────────────
|
# ── Scraper mobile proxy (#806, #2616 шаг 2) ─────────────────────────────
|
||||||
# Мобильный прокси (RU, mobileproxy.space) используется ВСЕМИ scraper-сессиями:
|
# Мобильный резидентный прокси (ASocks) используется ВСЕМИ scraper-сессиями:
|
||||||
# Avito (#623) + Cian (#806). Datacenter-IP блокируется обоими сайтами.
|
# Avito (#623) + Cian (#806) + Yandex. Datacenter-IP блокируется всеми тремя.
|
||||||
# Пусто = прямое подключение (dev/staging без прокси).
|
# Пусто = прямое подключение (dev/staging без прокси).
|
||||||
#
|
#
|
||||||
# Приоритет ENV-переменных (precedence):
|
# #2616 шаг 2: per-provider legacy-переменные (AVITO_PROXY_URL/CIAN_PROXY_URL/
|
||||||
# 1. SCRAPER_PROXY_URL — новый общий ENV; когда задан — используется первым.
|
# YANDEX_PROXY_URL и их *_ROTATE_URL, changeip mobileproxy) удалены — указывали
|
||||||
# 2. AVITO_PROXY_URL — legacy ENV; fallback, чтобы prod-серверы с уже
|
# на закрытые аккаунты (407/connection refused, проверено вживую #2613).
|
||||||
# настроенным AVITO_PROXY_URL работали без изменений .env.runtime (#806).
|
# SCRAPER_PROXY_URL — единственный живой источник, общий для всех провайдеров.
|
||||||
# property `scraper_proxy_url` реализует эту логику; используй его везде.
|
|
||||||
# validation_alias привязывает поле к env SCRAPER_PROXY_URL (без него
|
# validation_alias привязывает поле к env SCRAPER_PROXY_URL (без него
|
||||||
# pydantic-settings читал бы SCRAPER_PROXY_URL_ENV по имени поля — #806 fixup).
|
# pydantic-settings читал бы SCRAPER_PROXY_URL_ENV по имени поля — #806 fixup).
|
||||||
scraper_proxy_url_env: str | None = Field(default=None, validation_alias="SCRAPER_PROXY_URL")
|
scraper_proxy_url_env: str | None = Field(default=None, validation_alias="SCRAPER_PROXY_URL")
|
||||||
avito_proxy_url: str | None = None # ENV: AVITO_PROXY_URL (legacy fallback)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def scraper_proxy_url(self) -> str | None:
|
def scraper_proxy_url(self) -> str | None:
|
||||||
"""Единый прокси URL для всех scraper-сессий (Avito + Cian).
|
"""Единый прокси URL для всех scraper-сессий (Avito + Cian + Yandex).
|
||||||
|
|
||||||
Приоритет: SCRAPER_PROXY_URL > AVITO_PROXY_URL > None (прямое подключение).
|
Прямая проекция SCRAPER_PROXY_URL (#2616 шаг 2: legacy AVITO_PROXY_URL
|
||||||
Prod-серверы с существующим AVITO_PROXY_URL работают без изменений env.
|
fallback снят — мёртвая mobileproxy-переменная).
|
||||||
"""
|
"""
|
||||||
return self.scraper_proxy_url_env or self.avito_proxy_url
|
return self.scraper_proxy_url_env
|
||||||
|
|
||||||
# changeip-ссылка mobileproxy: GET меняет мобильный IP за ~9с. Дёргается при
|
# ── Ban-recovery budget knobs (changeip-механизм снят #2616 шаг 2) ────────
|
||||||
# детекте бана Avito перед повтором. Пусто = ротация выключена (raise сразу).
|
# Раньше эти поля тюнили retry/settle для GET-changeip mobileproxy
|
||||||
# ENV: AVITO_PROXY_ROTATE_URL.
|
# (AVITO_PROXY_ROTATE_URL и т.д., см. историю выше) — сама ссылка удалена
|
||||||
avito_proxy_rotate_url: str | None = None
|
# (закрытый аккаунт), поэтому IP-ротация сейчас всегда no-op (_rotate_ip /
|
||||||
# Сколько раз сменить IP при блоке прежде чем сдаться (на одну страницу).
|
# _rotate_proxy_ip возвращают False без сетевого похода). Поля оставлены:
|
||||||
# #1731: 2→4 — больше шансов восстановиться mid-sweep после проактивной
|
# `*_proxy_max_rotations` продолжают гейтить бюджет попыток в ban-rotation
|
||||||
# ротации на старте (Datadome ban recovery).
|
# state machine (scraper_kit.orchestration.pipeline._try_rotate_within_budget)
|
||||||
|
# — те же 0 попыток "успеха", что и раньше при мёртвом changeip, просто без
|
||||||
|
# затрат на HTTP; `avito_proxy_rotate_settle_s` — верхняя граница
|
||||||
|
# asyncio.wait_for в app.tasks.avito_detail_backfill (страховка от зависания).
|
||||||
|
# Живая ротация IP — ASOCKS_API_TOKEN / app.services.proxy_rotation (#2611).
|
||||||
avito_proxy_max_rotations: int = 4
|
avito_proxy_max_rotations: int = 4
|
||||||
# Settle-sleep после changeip-вызова: мобильный модем поднимает новый IP.
|
|
||||||
# ~9с по умолчанию (эмпирика mobileproxy.space). ENV: AVITO_PROXY_ROTATE_SETTLE_S.
|
|
||||||
avito_proxy_rotate_settle_s: float = 9.0
|
avito_proxy_rotate_settle_s: float = 9.0
|
||||||
# #1950: retry-параметры changeip-GET (_rotate_proxy_ip). Вместо одношотного 30s-timeout
|
|
||||||
# делаем proxy_rotate_attempts попыток по proxy_rotate_attempt_timeout_s каждая.
|
|
||||||
# Короткий timeout (8s) означает, что зависший changeip не блокирует весь run на 30s.
|
|
||||||
# ENV: PROXY_ROTATE_ATTEMPT_TIMEOUT_S / PROXY_ROTATE_ATTEMPTS.
|
|
||||||
proxy_rotate_attempt_timeout_s: float = 8.0
|
proxy_rotate_attempt_timeout_s: float = 8.0
|
||||||
proxy_rotate_attempts: int = 3
|
proxy_rotate_attempts: int = 3
|
||||||
|
|
||||||
|
|
@ -710,16 +706,12 @@ class Settings(BaseSettings):
|
||||||
# False = старое поведение. ENV: AVITO_SERP_OK_NOT_BANNED.
|
# False = старое поведение. ENV: AVITO_SERP_OK_NOT_BANNED.
|
||||||
avito_serp_ok_not_banned: bool = True
|
avito_serp_ok_not_banned: bool = True
|
||||||
|
|
||||||
# ── Cian dedicated mobile proxy (separate egress from Avito) ──────────────
|
# ── Cian proxy budget (#2616 шаг 2: dedicated CIAN_PROXY_URL/ROTATE_URL снят) ──
|
||||||
# Cian и Avito делят один мобильный IP при общем scraper_proxy_url → конкуренция
|
# Раньше Cian мог получить СВОЙ мобильный прокси отдельно от Avito (контеншен на
|
||||||
# за единственный egress → взаимные таймауты/баны при параллельных прогонах.
|
# общем egress); CIAN_PROXY_URL указывал на закрытый аккаунт — удалён,
|
||||||
# Отдельный прокси для Cian устраняет contention. Если не задан — fallback на
|
# cian_proxy_url ниже теперь = scraper_proxy_url. cian_proxy_max_rotations
|
||||||
# общий scraper_proxy_url (backward-compat). ENV: CIAN_PROXY_URL.
|
# остаётся: гейтит бюджет в ban-rotation state machine наравне с avito/yandex
|
||||||
cian_proxy_url_env: str | None = Field(default=None, validation_alias="CIAN_PROXY_URL")
|
# (см. комментарий у avito_proxy_max_rotations выше — сама ротация no-op).
|
||||||
# changeip-ссылка для Cian-прокси (ротация IP при бане/таймауте). Если не задан —
|
|
||||||
# fallback на avito_proxy_rotate_url. ENV: CIAN_PROXY_ROTATE_URL.
|
|
||||||
cian_proxy_rotate_url: str | None = None
|
|
||||||
# Максимум IP-ротаций для Cian на один sweep-прогон. Аналог avito_proxy_max_rotations.
|
|
||||||
# ENV: CIAN_PROXY_MAX_ROTATIONS.
|
# ENV: CIAN_PROXY_MAX_ROTATIONS.
|
||||||
cian_proxy_max_rotations: int = 4
|
cian_proxy_max_rotations: int = 4
|
||||||
|
|
||||||
|
|
@ -739,25 +731,21 @@ class Settings(BaseSettings):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def cian_proxy_url(self) -> str | None:
|
def cian_proxy_url(self) -> str | None:
|
||||||
"""Прокси для Cian-скраперов. CIAN_PROXY_URL > scraper_proxy_url (fallback)."""
|
"""Прокси для Cian-скраперов (#2616 шаг 2: = scraper_proxy_url, per-provider
|
||||||
return self.cian_proxy_url_env or self.scraper_proxy_url
|
override снят — свойство оставлено для scraper_kit.contracts.ScraperConfig
|
||||||
|
совместимости)."""
|
||||||
|
return self.scraper_proxy_url
|
||||||
|
|
||||||
# ── Yandex dedicated mobile proxy (separate egress from Avito/Cian) ────────
|
# ── Yandex proxy budget (#2616 шаг 2: dedicated YANDEX_PROXY_URL/ROTATE_URL снят) ──
|
||||||
# Отдельный прокси для Yandex устраняет contention при параллельных прогонах.
|
# Симметрично Cian выше — YANDEX_PROXY_URL указывал на закрытый аккаунт.
|
||||||
# Если не задан — fallback на общий scraper_proxy_url (backward-compat).
|
# yandex_proxy_max_rotations остаётся для ban-rotation budget-гейта.
|
||||||
# ENV: YANDEX_PROXY_URL.
|
|
||||||
yandex_proxy_url_env: str | None = Field(default=None, validation_alias="YANDEX_PROXY_URL")
|
|
||||||
# changeip-ссылка для Yandex-прокси (ротация IP при капче/таймауте). Если не задан —
|
|
||||||
# fallback на avito_proxy_rotate_url. ENV: YANDEX_PROXY_ROTATE_URL.
|
|
||||||
yandex_proxy_rotate_url: str | None = None
|
|
||||||
# Максимум IP-ротаций для Yandex на один sweep-прогон. Аналог avito_proxy_max_rotations.
|
|
||||||
# ENV: YANDEX_PROXY_MAX_ROTATIONS.
|
# ENV: YANDEX_PROXY_MAX_ROTATIONS.
|
||||||
yandex_proxy_max_rotations: int = 4
|
yandex_proxy_max_rotations: int = 4
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def yandex_proxy_url(self) -> str | None:
|
def yandex_proxy_url(self) -> str | None:
|
||||||
"""Прокси для Yandex-скраперов. YANDEX_PROXY_URL > scraper_proxy_url (fallback)."""
|
"""Прокси для Yandex-скраперов (#2616 шаг 2: = scraper_proxy_url)."""
|
||||||
return self.yandex_proxy_url_env or self.scraper_proxy_url
|
return self.scraper_proxy_url
|
||||||
|
|
||||||
# full_load повторный прогон в день пропускает листинги уже обновлённые сегодня
|
# full_load повторный прогон в день пропускает листинги уже обновлённые сегодня
|
||||||
# (last_seen_at MSK) — экономит upsert + price-trigger churn; False = всегда
|
# (last_seen_at MSK) — экономит upsert + price-trigger churn; False = всегда
|
||||||
|
|
|
||||||
|
|
@ -48,8 +48,11 @@ _TG_BOT_TOKEN_REPLACEMENT = "/bot[REDACTED]"
|
||||||
_TG_BOT_TOKEN_BARE_RE = re.compile(r"\b\d{6,12}:[A-Za-z0-9_-]{30,}\b")
|
_TG_BOT_TOKEN_BARE_RE = re.compile(r"\b\d{6,12}:[A-Za-z0-9_-]{30,}\b")
|
||||||
|
|
||||||
# Query-string секреты в исходящих URL сторонних API (аудит-фикс, #security-audit):
|
# Query-string секреты в исходящих URL сторонних API (аудит-фикс, #security-audit):
|
||||||
# mobileproxy changeip-ссылка (`AVITO_PROXY_ROTATE_URL` и др., admin.py
|
# исторически — mobileproxy changeip-ссылка (`AVITO_PROXY_ROTATE_URL` и др.,
|
||||||
# rotate_proxy_ip) несёт провайдерский API-ключ в query (`?...&proxy_key=...`).
|
# admin.rotate_proxy_ip) несла провайдерский API-ключ в query
|
||||||
|
# (`?...&proxy_key=...`). Ручка и переменные удалены (#2616 шаг 2/3, мёртвая
|
||||||
|
# подписка) — редактор оставлен как generic safety net (не ключ-based, любой
|
||||||
|
# будущий query-секрет с распространённым именем параметра тоже покрыт).
|
||||||
# Два независимых пути утечки в GlitchTip, зеркалящих TG-токен выше:
|
# Два независимых пути утечки в GlitchTip, зеркалящих TG-токен выше:
|
||||||
# 1. `HttpxIntegration.send()` парсит URL через `parse_url(str(request.url),
|
# 1. `HttpxIntegration.send()` парсит URL через `parse_url(str(request.url),
|
||||||
# sanitize=False)` (ЯВНЫЙ opt-out из sentry_sdk `sanitize_url`, который иначе
|
# sanitize=False)` (ЯВНЫЙ opt-out из sentry_sdk `sanitize_url`, который иначе
|
||||||
|
|
@ -58,13 +61,14 @@ _TG_BOT_TOKEN_BARE_RE = re.compile(r"\b\d{6,12}:[A-Za-z0-9_-]{30,}\b")
|
||||||
# span не сэмплится/не уходит), но молча перестанет спасать, если трейсинг
|
# span не сэмплится/не уходит), но молча перестанет спасать, если трейсинг
|
||||||
# когда-нибудь включат.
|
# когда-нибудь включат.
|
||||||
# 2. `include_local_variables=True` (sentry_sdk default в app/main.py — в отличие
|
# 2. `include_local_variables=True` (sentry_sdk default в app/main.py — в отличие
|
||||||
# от tgbot_main.py, где явно False) кладёт stack-frame locals (`rotate_url`,
|
# от tgbot_main.py, где явно False) кладёт stack-frame locals в traceback
|
||||||
# `exc` в rotate_proxy_ip) в traceback открытым текстом.
|
# открытым текстом (был прецедент: `rotate_url`/`exc` в удалённом
|
||||||
|
# admin.rotate_proxy_ip).
|
||||||
# Как и TG-токен — full-text regex по КАЖДОЙ строке event (не ключ-based): секрет
|
# Как и TG-токен — full-text regex по КАЖДОЙ строке event (не ключ-based): секрет
|
||||||
# может всплыть где угодно (frame locals, breadcrumb, exception message). НЕ
|
# может всплыть где угодно (frame locals, breadcrumb, exception message). НЕ
|
||||||
# завязано на конкретного провайдера — покрывает любой query-параметр из
|
# завязано на конкретного провайдера — покрывает любой query-параметр из
|
||||||
# общеупотребимого набора секретных имён (api_key/proxy_key/token/secret/password/
|
# общеупотребимого набора секретных имён (api_key/proxy_key/token/secret/password/
|
||||||
# access_token/auth), т.к. cian/yandex у нас имеют СВОИ rotate-URL (потенциально
|
# access_token/auth) — живой пример: ASOCKS_API_TOKEN (потенциально
|
||||||
# другой провайдер, другое имя параметра).
|
# другой провайдер, другое имя параметра).
|
||||||
_URL_SECRET_QUERY_RE = re.compile(
|
_URL_SECRET_QUERY_RE = re.compile(
|
||||||
r"(?i)([?&](?:api[_-]?key|proxy[_-]?key|token|secret|password|pwd|"
|
r"(?i)([?&](?:api[_-]?key|proxy[_-]?key|token|secret|password|pwd|"
|
||||||
|
|
|
||||||
|
|
@ -37,14 +37,15 @@ PR #2609, конфликт исключён: вся новая логика ту
|
||||||
|
|
||||||
⛔ Токен никогда не должен появиться в возвращаемом клиенту reason, в тексте
|
⛔ Токен никогда не должен появиться в возвращаемом клиенту reason, в тексте
|
||||||
исключения, ни в одной записи scrape_proxy_rotations. Прецедент утечки через
|
исключения, ни в одной записи scrape_proxy_rotations. Прецедент утечки через
|
||||||
str(exc) — см. комментарий в app.api.v1.admin.rotate_proxy_ip (~line 2400):
|
str(exc) — тот же паттерн, что закрывал (до удаления #2616 шаг 3) changeip-путь
|
||||||
httpx-исключения несут полный request URL/детали, поэтому наружу — только
|
admin.rotate_proxy_ip: httpx-исключения несут полный request URL/детали,
|
||||||
нейтральный reason, полные детали — в лог с exc_info=True.
|
поэтому наружу — только нейтральный reason, полные детали — в лог с exc_info=True.
|
||||||
|
|
||||||
⛔ Хост-пиннинг (security review PR #2611): scrape_proxies.rotate_url колонка
|
⛔ Хост-пиннинг (security review PR #2611): scrape_proxies.rotate_url колонка
|
||||||
НЕОДНОРОДНА — часть строк пула (id 3/4/5 на проде) несёт mobileproxy changeip-
|
НЕОДНОРОДНА — часть строк пула (id 3/4/5 на проде) несёт mobileproxy changeip-
|
||||||
ссылки (`https://changeip.mobileproxy.space/?proxy_key=<секрет mobileproxy>`,
|
ссылки (`https://changeip.mobileproxy.space/?proxy_key=<секрет mobileproxy>`,
|
||||||
см. app.api.v1.admin._provider_rotate_url / avito_proxy_rotate_url), не ASocks.
|
тот же формат, что читал удалённый #2616 шаг 2/3 admin.rotate_proxy_ip /
|
||||||
|
Settings.avito_proxy_rotate_url), не ASocks.
|
||||||
Без явной проверки хоста наш `Authorization: Bearer <ASOCKS_API_TOKEN>` ушёл бы
|
Без явной проверки хоста наш `Authorization: Bearer <ASOCKS_API_TOKEN>` ушёл бы
|
||||||
на ЧУЖОЙ провайдер (mobileproxy) — плюс сам GET/POST по их changeip, вероятно,
|
на ЧУЖОЙ провайдер (mobileproxy) — плюс сам GET/POST по их changeip, вероятно,
|
||||||
реально ротирует ИХ IP и тратит ИХ суточный лимит, а мы бы записали это как
|
реально ротирует ИХ IP и тратит ИХ суточный лимит, а мы бы записали это как
|
||||||
|
|
|
||||||
|
|
@ -136,10 +136,6 @@ class RealScraperConfig:
|
||||||
def scraper_proxy_url(self) -> str | None:
|
def scraper_proxy_url(self) -> str | None:
|
||||||
return _settings.scraper_proxy_url
|
return _settings.scraper_proxy_url
|
||||||
|
|
||||||
@property
|
|
||||||
def avito_proxy_rotate_url(self) -> str | None:
|
|
||||||
return _settings.avito_proxy_rotate_url
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def avito_proxy_max_rotations(self) -> int:
|
def avito_proxy_max_rotations(self) -> int:
|
||||||
return _settings.avito_proxy_max_rotations
|
return _settings.avito_proxy_max_rotations
|
||||||
|
|
@ -148,10 +144,6 @@ class RealScraperConfig:
|
||||||
def avito_serp_ekb_only(self) -> bool:
|
def avito_serp_ekb_only(self) -> bool:
|
||||||
return _settings.avito_serp_ekb_only
|
return _settings.avito_serp_ekb_only
|
||||||
|
|
||||||
@property
|
|
||||||
def yandex_proxy_rotate_url(self) -> str | None:
|
|
||||||
return _settings.yandex_proxy_rotate_url
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def cian_proxy_url(self) -> str | None:
|
def cian_proxy_url(self) -> str | None:
|
||||||
return _settings.cian_proxy_url
|
return _settings.cian_proxy_url
|
||||||
|
|
@ -189,10 +181,6 @@ class RealScraperConfig:
|
||||||
def proxy_rotate_attempt_timeout_s(self) -> float:
|
def proxy_rotate_attempt_timeout_s(self) -> float:
|
||||||
return _settings.proxy_rotate_attempt_timeout_s
|
return _settings.proxy_rotate_attempt_timeout_s
|
||||||
|
|
||||||
@property
|
|
||||||
def cian_proxy_rotate_url(self) -> str | None:
|
|
||||||
return _settings.cian_proxy_rotate_url
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def cian_proxy_max_rotations(self) -> int:
|
def cian_proxy_max_rotations(self) -> int:
|
||||||
return _settings.cian_proxy_max_rotations
|
return _settings.cian_proxy_max_rotations
|
||||||
|
|
|
||||||
|
|
@ -173,7 +173,7 @@ async def run_avito_detail_backfill(
|
||||||
|
|
||||||
# kit AvitoScraper требует ScraperConfig позиционно (Strangler-инжекция #2133) —
|
# kit AvitoScraper требует ScraperConfig позиционно (Strangler-инжекция #2133) —
|
||||||
# RealScraperConfig проксирует settings.* так же, как читал legacy-конструктор без
|
# RealScraperConfig проксирует settings.* так же, как читал legacy-конструктор без
|
||||||
# аргументов (avito_proxy_rotate_url и т.д. для _rotate_ip()).
|
# аргументов (scraper_proxy_url и т.д. для _build_cffi_session()/_rotate_ip()).
|
||||||
scraper = AvitoScraper(RealScraperConfig())
|
scraper = AvitoScraper(RealScraperConfig())
|
||||||
start = time.monotonic()
|
start = time.monotonic()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -141,8 +141,8 @@ async def test_backfill_processes_snapshot_to_completion() -> None:
|
||||||
runs.mark_done.assert_called_once()
|
runs.mark_done.assert_called_once()
|
||||||
runs.mark_failed.assert_not_called()
|
runs.mark_failed.assert_not_called()
|
||||||
# #2310 regression guard: kit fetch_detail silently drops the backconnect-
|
# #2310 regression guard: kit fetch_detail silently drops the backconnect-
|
||||||
# on-403 retry (and kit AvitoScraper can't read avito_proxy_rotate_url at
|
# on-403 retry (and kit AvitoScraper can't read scraper_proxy_url at all)
|
||||||
# all) unless config=RealScraperConfig() is passed/injected at the call
|
# unless config=RealScraperConfig() is passed/injected at the call
|
||||||
# site — assert_called()/call_count alone wouldn't catch someone dropping
|
# site — assert_called()/call_count alone wouldn't catch someone dropping
|
||||||
# that kwarg later (mirrors #2306's test_backfill_wave2.py:282-286 pattern).
|
# that kwarg later (mirrors #2306's test_backfill_wave2.py:282-286 pattern).
|
||||||
_, fetch_call_kwargs = mock_fetch.call_args
|
_, fetch_call_kwargs = mock_fetch.call_args
|
||||||
|
|
|
||||||
|
|
@ -212,7 +212,6 @@ def _avito_config(*, use_pool: bool) -> types.SimpleNamespace:
|
||||||
browser_http_endpoint="http://browser:3000",
|
browser_http_endpoint="http://browser:3000",
|
||||||
use_proxy_pool_browser=use_pool,
|
use_proxy_pool_browser=use_pool,
|
||||||
scraper_fetch_mode="browser",
|
scraper_fetch_mode="browser",
|
||||||
avito_proxy_rotate_url=None,
|
|
||||||
scraper_proxy_url=None,
|
scraper_proxy_url=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
"""Offline tests для unified scrapers-страницы admin-API (epic консолидации UI).
|
"""Offline tests для unified scrapers-страницы admin-API (epic консолидации UI).
|
||||||
|
|
||||||
Покрытие 3 эндпоинтов (db/httpx мокаются, NO live network/DB):
|
Покрытие эндпоинтов (db/httpx мокаются, NO live network/DB):
|
||||||
- GET /api/v1/admin/scrape/runs — unified runs + total + фильтры
|
- GET /api/v1/admin/scrape/runs — unified runs + total + фильтры
|
||||||
- GET /api/v1/admin/scraper/health — fetch_mode + browser + providers
|
- GET /api/v1/admin/scraper/health — fetch_mode + browser + providers
|
||||||
- POST /api/v1/admin/scraper/{src}/rotate-ip — changeip + no-rotate-url кейс
|
|
||||||
|
POST /scraper/{src}/rotate-ip (changeip mobileproxy) удалён #2616 шаг 3 — мёртвая
|
||||||
|
подписка (#2613); её тесты удалены вместе с ручкой.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -147,13 +149,11 @@ def test_health_assembles_browser_and_providers(client: TestClient) -> None:
|
||||||
"cian": None,
|
"cian": None,
|
||||||
"yandex": "http://u:p@host.y:10051",
|
"yandex": "http://u:p@host.y:10051",
|
||||||
}
|
}
|
||||||
rotate_urls = {"avito": "http://ch/changeip", "cian": None, "yandex": 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]),
|
patch.object(admin_module, "_provider_proxy_url", lambda s: proxy_urls[s]),
|
||||||
patch.object(admin_module, "_provider_rotate_url", lambda s: rotate_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")
|
||||||
|
|
@ -169,13 +169,14 @@ def test_health_assembles_browser_and_providers(client: TestClient) -> None:
|
||||||
assert set(by_source) == {"avito", "cian", "yandex"}
|
assert set(by_source) == {"avito", "cian", "yandex"}
|
||||||
assert by_source["avito"]["proxy_host"] == "host.a"
|
assert by_source["avito"]["proxy_host"] == "host.a"
|
||||||
assert by_source["avito"]["proxy_port"] == 10049
|
assert by_source["avito"]["proxy_port"] == 10049
|
||||||
assert by_source["avito"]["rotate_supported"] is True
|
# #2616 шаг 2: changeip mobileproxy-ротация снята — rotate_supported всегда
|
||||||
|
# False (живая ASocks-ротация — /admin/proxies/{id}/rotate, не per-source).
|
||||||
|
assert by_source["avito"]["rotate_supported"] is False
|
||||||
assert by_source["avito"]["current_ip"] == "1.2.3.4"
|
assert by_source["avito"]["current_ip"] == "1.2.3.4"
|
||||||
# cian без proxy → host/port/ip null, rotate_supported False
|
# cian без proxy → host/port/ip null
|
||||||
assert by_source["cian"]["proxy_host"] is None
|
assert by_source["cian"]["proxy_host"] is None
|
||||||
assert by_source["cian"]["current_ip"] is None
|
assert by_source["cian"]["current_ip"] is None
|
||||||
assert by_source["cian"]["rotate_supported"] is False
|
assert by_source["cian"]["rotate_supported"] is False
|
||||||
# yandex имеет proxy но нет rotate_url
|
|
||||||
assert by_source["yandex"]["proxy_host"] == "host.y"
|
assert by_source["yandex"]["proxy_host"] == "host.y"
|
||||||
assert by_source["yandex"]["rotate_supported"] is False
|
assert by_source["yandex"]["rotate_supported"] is False
|
||||||
|
|
||||||
|
|
@ -204,7 +205,6 @@ def test_health_browser_unreachable(client: TestClient) -> None:
|
||||||
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 s: None),
|
||||||
patch.object(admin_module, "_provider_rotate_url", lambda s: None),
|
|
||||||
):
|
):
|
||||||
r = client.get("/api/v1/admin/scraper/health")
|
r = client.get("/api/v1/admin/scraper/health")
|
||||||
|
|
||||||
|
|
@ -214,137 +214,6 @@ def test_health_browser_unreachable(client: TestClient) -> None:
|
||||||
assert body["browser"]["browsers"] == {}
|
assert body["browser"]["browsers"] == {}
|
||||||
|
|
||||||
|
|
||||||
# ── API 3: POST /scraper/{source}/rotate-ip ──────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def test_rotate_ip_calls_changeip(client: TestClient) -> None:
|
|
||||||
"""rotate_url задан → GET changeip + format=json, возврат new_ip из ответа."""
|
|
||||||
from app.api.v1 import admin as admin_module
|
|
||||||
|
|
||||||
captured: dict[str, str] = {}
|
|
||||||
|
|
||||||
class _FakeResp:
|
|
||||||
def raise_for_status(self) -> None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def json(self) -> dict[str, str]:
|
|
||||||
return {"new_ip": "9.9.9.9"}
|
|
||||||
|
|
||||||
class _FakeClient:
|
|
||||||
def __init__(self, *a: Any, **k: Any) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def __aenter__(self) -> _FakeClient:
|
|
||||||
return self
|
|
||||||
|
|
||||||
async def __aexit__(self, *a: Any) -> None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def get(self, url: str, *a: Any, **k: Any) -> _FakeResp:
|
|
||||||
captured["url"] = url
|
|
||||||
return _FakeResp()
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch.object(admin_module.httpx, "AsyncClient", _FakeClient),
|
|
||||||
patch.object(admin_module.settings, "avito_proxy_rotate_url", "http://ch/changeip?key=x"),
|
|
||||||
):
|
|
||||||
r = client.post("/api/v1/admin/scraper/avito/rotate-ip")
|
|
||||||
|
|
||||||
assert r.status_code == 200
|
|
||||||
body = r.json()
|
|
||||||
assert body["ok"] is True
|
|
||||||
assert body["new_ip"] == "9.9.9.9"
|
|
||||||
assert "format=json" in captured["url"]
|
|
||||||
# уже был '?' в URL → должен использовать '&'
|
|
||||||
assert captured["url"].endswith("&format=json")
|
|
||||||
|
|
||||||
|
|
||||||
def test_rotate_ip_no_rotate_url(client: TestClient) -> None:
|
|
||||||
"""rotate_url не задан → ok=False + reason про auto-rotate, changeip НЕ дёргается."""
|
|
||||||
from app.api.v1 import admin as admin_module
|
|
||||||
|
|
||||||
with patch.object(admin_module.settings, "cian_proxy_rotate_url", None):
|
|
||||||
r = client.post("/api/v1/admin/scraper/cian/rotate-ip")
|
|
||||||
|
|
||||||
assert r.status_code == 200
|
|
||||||
body = r.json()
|
|
||||||
assert body["ok"] is False
|
|
||||||
assert body["new_ip"] is None
|
|
||||||
assert "auto-rotate" in body["reason"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_rotate_ip_invalid_source_422(client: TestClient) -> None:
|
|
||||||
"""source вне {avito,cian,yandex} → 422 (Literal-валидация path)."""
|
|
||||||
r = client.post("/api/v1/admin/scraper/domclick/rotate-ip")
|
|
||||||
assert r.status_code == 422
|
|
||||||
|
|
||||||
|
|
||||||
def test_rotate_ip_changeip_error(client: TestClient) -> None:
|
|
||||||
"""httpx-ошибка changeip → ok=False + reason."""
|
|
||||||
from app.api.v1 import admin as admin_module
|
|
||||||
|
|
||||||
class _BoomClient:
|
|
||||||
def __init__(self, *a: Any, **k: Any) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def __aenter__(self) -> _BoomClient:
|
|
||||||
return self
|
|
||||||
|
|
||||||
async def __aexit__(self, *a: Any) -> None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def get(self, *a: Any, **k: Any) -> Any:
|
|
||||||
raise RuntimeError("changeip 500")
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch.object(admin_module.httpx, "AsyncClient", _BoomClient),
|
|
||||||
patch.object(admin_module.settings, "yandex_proxy_rotate_url", "http://ch/changeip"),
|
|
||||||
):
|
|
||||||
r = client.post("/api/v1/admin/scraper/yandex/rotate-ip")
|
|
||||||
|
|
||||||
assert r.status_code == 200
|
|
||||||
body = r.json()
|
|
||||||
assert body["ok"] is False
|
|
||||||
# security-audit: нейтральный reason, БЕЗ текста исходного исключения
|
|
||||||
# (str(exc) httpx мог нести rotate_url с proxy-ключом в query — см. тест ниже).
|
|
||||||
assert body["reason"] == "changeip request failed"
|
|
||||||
|
|
||||||
|
|
||||||
def test_rotate_ip_changeip_error_does_not_leak_proxy_key(client: TestClient) -> None:
|
|
||||||
"""security-audit: секретный API-ключ провайдера в rotate_url НЕ должен попасть
|
|
||||||
в HTTP-ответ клиенту через текст httpx-исключения (раньше
|
|
||||||
`reason=f"changeip error: {exc}"` отдавал str(exc) с полным URL, включая
|
|
||||||
query-параметр ключа, наружу)."""
|
|
||||||
from app.api.v1 import admin as admin_module
|
|
||||||
|
|
||||||
secret_url = "http://ch/changeip?proxy_key=TOP-SECRET-KEY-1234"
|
|
||||||
|
|
||||||
class _BoomClient:
|
|
||||||
def __init__(self, *a: Any, **k: Any) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def __aenter__(self) -> _BoomClient:
|
|
||||||
return self
|
|
||||||
|
|
||||||
async def __aexit__(self, *a: Any) -> None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def get(self, *a: Any, **k: Any) -> Any:
|
|
||||||
raise RuntimeError(f"All connection attempts failed for {secret_url}&format=json")
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch.object(admin_module.httpx, "AsyncClient", _BoomClient),
|
|
||||||
patch.object(admin_module.settings, "avito_proxy_rotate_url", secret_url),
|
|
||||||
):
|
|
||||||
r = client.post("/api/v1/admin/scraper/avito/rotate-ip")
|
|
||||||
|
|
||||||
assert r.status_code == 200
|
|
||||||
assert "TOP-SECRET-KEY-1234" not in r.text
|
|
||||||
body = r.json()
|
|
||||||
assert body["ok"] is False
|
|
||||||
assert "TOP-SECRET-KEY-1234" not in (body["reason"] or "")
|
|
||||||
|
|
||||||
|
|
||||||
# ── API 4: GET /scraper/pacing ───────────────────────────────────────────────
|
# ── API 4: GET /scraper/pacing ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -141,13 +141,19 @@ async def test_avito_fetch_detail_with_config_reconnects_on_403_like_legacy() ->
|
||||||
# ── AvitoScraper: kit constructor now requires ScraperConfig (positional) ────────
|
# ── AvitoScraper: kit constructor now requires ScraperConfig (positional) ────────
|
||||||
|
|
||||||
|
|
||||||
def test_avito_scraper_rotate_ip_reads_injected_config() -> None:
|
def test_avito_scraper_reads_injected_config() -> None:
|
||||||
"""kit AvitoScraper(config) is a required positional arg (legacy AvitoScraper()
|
"""kit AvitoScraper(config) is a required positional arg (legacy AvitoScraper()
|
||||||
took none, reading settings.avito_proxy_rotate_url directly at call time inside
|
took none, reading settings.* directly at call time). avito_detail_backfill.py
|
||||||
_rotate_ip). avito_detail_backfill.py now constructs AvitoScraper(RealScraperConfig())
|
now constructs AvitoScraper(RealScraperConfig()) — this proves the injected
|
||||||
— this proves the injected config is what _rotate_ip actually reads."""
|
config (not the settings singleton) is what the scraper actually reads.
|
||||||
|
|
||||||
|
#2616 шаг 2: previously asserted on avito_proxy_rotate_url (changeip mobileproxy,
|
||||||
|
removed — dead account #2613, _rotate_ip is now a no-op stub reading no config).
|
||||||
|
scraper_proxy_url (still config-injected, drives _build_cffi_session) proves the
|
||||||
|
same DI invariant.
|
||||||
|
"""
|
||||||
scraper = KitAvitoScraper(RealScraperConfig())
|
scraper = KitAvitoScraper(RealScraperConfig())
|
||||||
assert scraper._config.avito_proxy_rotate_url == settings.avito_proxy_rotate_url
|
assert scraper._config.scraper_proxy_url == settings.scraper_proxy_url
|
||||||
|
|
||||||
|
|
||||||
# ── BrowserFetcher(endpoint=) wiring — house_imv_backfill.py / cian_history_backfill.py ──
|
# ── BrowserFetcher(endpoint=) wiring — house_imv_backfill.py / cian_history_backfill.py ──
|
||||||
|
|
|
||||||
|
|
@ -115,15 +115,12 @@ class _Scenario:
|
||||||
scraper_fetch_mode="curl_cffi",
|
scraper_fetch_mode="curl_cffi",
|
||||||
browser_http_endpoint="http://browser.test/fetch",
|
browser_http_endpoint="http://browser.test/fetch",
|
||||||
scraper_proxy_url=None,
|
scraper_proxy_url=None,
|
||||||
avito_proxy_rotate_url=None,
|
|
||||||
avito_proxy_max_rotations=self.avito_proxy_max_rotations,
|
avito_proxy_max_rotations=self.avito_proxy_max_rotations,
|
||||||
avito_serp_ok_not_banned=self.avito_serp_ok_not_banned,
|
avito_serp_ok_not_banned=self.avito_serp_ok_not_banned,
|
||||||
avito_proxy_rotate_settle_s=0.0,
|
avito_proxy_rotate_settle_s=0.0,
|
||||||
proxy_rotate_attempts=1,
|
proxy_rotate_attempts=1,
|
||||||
proxy_rotate_attempt_timeout_s=1.0,
|
proxy_rotate_attempt_timeout_s=1.0,
|
||||||
cian_proxy_rotate_url=None,
|
|
||||||
cian_proxy_max_rotations=0,
|
cian_proxy_max_rotations=0,
|
||||||
yandex_proxy_rotate_url=None,
|
|
||||||
yandex_proxy_max_rotations=0,
|
yandex_proxy_max_rotations=0,
|
||||||
scraper_skip_seen_today=False,
|
scraper_skip_seen_today=False,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -83,15 +83,12 @@ def _config() -> SimpleNamespace:
|
||||||
scraper_fetch_mode="curl_cffi",
|
scraper_fetch_mode="curl_cffi",
|
||||||
browser_http_endpoint="http://browser.test/fetch",
|
browser_http_endpoint="http://browser.test/fetch",
|
||||||
scraper_proxy_url=None,
|
scraper_proxy_url=None,
|
||||||
avito_proxy_rotate_url=None,
|
|
||||||
avito_proxy_max_rotations=0,
|
avito_proxy_max_rotations=0,
|
||||||
avito_serp_ok_not_banned=True,
|
avito_serp_ok_not_banned=True,
|
||||||
avito_proxy_rotate_settle_s=0.0,
|
avito_proxy_rotate_settle_s=0.0,
|
||||||
proxy_rotate_attempts=1,
|
proxy_rotate_attempts=1,
|
||||||
proxy_rotate_attempt_timeout_s=1.0,
|
proxy_rotate_attempt_timeout_s=1.0,
|
||||||
cian_proxy_rotate_url=None,
|
|
||||||
cian_proxy_max_rotations=0,
|
cian_proxy_max_rotations=0,
|
||||||
yandex_proxy_rotate_url=None,
|
|
||||||
yandex_proxy_max_rotations=0,
|
yandex_proxy_max_rotations=0,
|
||||||
scraper_skip_seen_today=False,
|
scraper_skip_seen_today=False,
|
||||||
cian_full_load_per_fetch_timeout_s=0.0,
|
cian_full_load_per_fetch_timeout_s=0.0,
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
"""Tests for scraper proxy wiring (#806 + follow-up).
|
"""Tests for scraper proxy wiring (#806 + #2616 шаг 2 cleanup).
|
||||||
|
|
||||||
Covers:
|
Covers:
|
||||||
- scraper_proxy_url property: SCRAPER_PROXY_URL takes precedence over AVITO_PROXY_URL
|
- scraper_proxy_url property: reads SCRAPER_PROXY_URL only. Legacy per-provider
|
||||||
- AVITO_PROXY_URL fallback works when SCRAPER_PROXY_URL is absent
|
AVITO_PROXY_URL/CIAN_PROXY_URL/YANDEX_PROXY_URL fallback removed (#2616 шаг 2 —
|
||||||
|
dead mobileproxy accounts, 407/connection refused, #2613).
|
||||||
- None/empty → direct connection (no proxies dict)
|
- None/empty → direct connection (no proxies dict)
|
||||||
- _avito_proxies() helper returns correct dict shape
|
- _avito_proxies() helper returns correct dict shape
|
||||||
- per-scraper proxy wiring: YandexRealty, YandexValuation (kit), CianValuation,
|
- per-scraper proxy wiring: YandexRealty, YandexValuation (kit), CianValuation,
|
||||||
|
|
@ -46,12 +47,10 @@ def _mock_settings(
|
||||||
"""Minimal settings stand-in with fields read by proxy helpers and scraper __aenter__."""
|
"""Minimal settings stand-in with fields read by proxy helpers and scraper __aenter__."""
|
||||||
return SimpleNamespace(
|
return SimpleNamespace(
|
||||||
scraper_proxy_url=scraper_proxy_url,
|
scraper_proxy_url=scraper_proxy_url,
|
||||||
# Cian scrapers read settings.cian_proxy_url (property: CIAN_PROXY_URL or
|
# cian_proxy_url / yandex_proxy_url properties now mirror scraper_proxy_url
|
||||||
# scraper_proxy_url fallback). Mirror scraper_proxy_url here so the existing
|
# 1:1 (#2616 шаг 2: per-provider CIAN_PROXY_URL/YANDEX_PROXY_URL override
|
||||||
# cian proxy-wiring assertions keep passing (#923 follow-up).
|
# removed — dead mobileproxy accounts). Mirrored here for test compat.
|
||||||
cian_proxy_url=scraper_proxy_url,
|
cian_proxy_url=scraper_proxy_url,
|
||||||
# Yandex scrapers read settings.yandex_proxy_url (property: YANDEX_PROXY_URL or
|
|
||||||
# scraper_proxy_url fallback). Mirror scraper_proxy_url here for test compat.
|
|
||||||
yandex_proxy_url=scraper_proxy_url,
|
yandex_proxy_url=scraper_proxy_url,
|
||||||
yandex_cookies_file=yandex_cookies_file,
|
yandex_cookies_file=yandex_cookies_file,
|
||||||
)
|
)
|
||||||
|
|
@ -62,44 +61,23 @@ def _mock_settings(
|
||||||
# pydantic-settings (requires DATABASE_URL) for pure unit tests of the rule.
|
# pydantic-settings (requires DATABASE_URL) for pure unit tests of the rule.
|
||||||
|
|
||||||
|
|
||||||
def _resolve_scraper_proxy_url(
|
def _resolve_scraper_proxy_url(scraper_proxy_url_env: str | None) -> str | None:
|
||||||
scraper_proxy_url_env: str | None, avito_proxy_url: str | None
|
"""Inline replica of Settings.scraper_proxy_url property logic (#2616 шаг 2:
|
||||||
) -> str | None:
|
legacy AVITO_PROXY_URL fallback removed — SCRAPER_PROXY_URL is now the sole
|
||||||
"""Inline replica of Settings.scraper_proxy_url property logic."""
|
source, projected as-is)."""
|
||||||
return scraper_proxy_url_env or avito_proxy_url
|
return scraper_proxy_url_env
|
||||||
|
|
||||||
|
|
||||||
def test_scraper_proxy_url_uses_scraper_env_when_set():
|
def test_scraper_proxy_url_uses_scraper_env_when_set():
|
||||||
result = _resolve_scraper_proxy_url(
|
result = _resolve_scraper_proxy_url(scraper_proxy_url_env="http://scraper-proxy:1234")
|
||||||
scraper_proxy_url_env="http://scraper-proxy:1234",
|
|
||||||
avito_proxy_url="http://avito-proxy:5678",
|
|
||||||
)
|
|
||||||
assert result == "http://scraper-proxy:1234"
|
assert result == "http://scraper-proxy:1234"
|
||||||
|
|
||||||
|
|
||||||
def test_scraper_proxy_url_falls_back_to_avito_proxy_url():
|
def test_scraper_proxy_url_none_when_absent():
|
||||||
"""AVITO_PROXY_URL used when SCRAPER_PROXY_URL absent — zero env change on prod."""
|
result = _resolve_scraper_proxy_url(scraper_proxy_url_env=None)
|
||||||
result = _resolve_scraper_proxy_url(
|
|
||||||
scraper_proxy_url_env=None,
|
|
||||||
avito_proxy_url="http://avito-proxy:5678",
|
|
||||||
)
|
|
||||||
assert result == "http://avito-proxy:5678"
|
|
||||||
|
|
||||||
|
|
||||||
def test_scraper_proxy_url_none_when_both_absent():
|
|
||||||
result = _resolve_scraper_proxy_url(scraper_proxy_url_env=None, avito_proxy_url=None)
|
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
def test_scraper_proxy_url_empty_string_falls_through_to_avito():
|
|
||||||
"""Empty string is falsy — falls through to avito_proxy_url."""
|
|
||||||
result = _resolve_scraper_proxy_url(
|
|
||||||
scraper_proxy_url_env="",
|
|
||||||
avito_proxy_url="http://avito-proxy:5678",
|
|
||||||
)
|
|
||||||
assert result == "http://avito-proxy:5678"
|
|
||||||
|
|
||||||
|
|
||||||
# ── _avito_proxies() dict shape ───────────────────────────────────────────────
|
# ── _avito_proxies() dict shape ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -121,22 +99,8 @@ def test_avito_proxies_returns_none_when_no_proxy():
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
def test_avito_proxies_fallback_via_avito_proxy_url():
|
|
||||||
"""_avito_proxies(config) uses AVITO_PROXY_URL via scraper_proxy_url fallback.
|
|
||||||
|
|
||||||
When config.scraper_proxy_url resolves to the avito URL (fallback path),
|
|
||||||
the dict shape must be {"http": url, "https": url}.
|
|
||||||
"""
|
|
||||||
from scraper_kit.orchestration.pipeline import _avito_proxies
|
|
||||||
|
|
||||||
avito_url = "http://mobile-proxy.space:9090"
|
|
||||||
result = _avito_proxies(_mock_settings(avito_url))
|
|
||||||
|
|
||||||
assert result == {"http": avito_url, "https": avito_url}
|
|
||||||
|
|
||||||
|
|
||||||
# ── Real pydantic env-binding (regression for #806 fixup) ─────────────────────
|
# ── Real pydantic env-binding (regression for #806 fixup) ─────────────────────
|
||||||
# The inline-replica tests above prove the OR-logic but NOT that the field
|
# The inline-replica tests above prove the property logic but NOT that the field
|
||||||
# actually binds to env SCRAPER_PROXY_URL. `validation_alias` is what makes that
|
# actually binds to env SCRAPER_PROXY_URL. `validation_alias` is what makes that
|
||||||
# work — without it pydantic-settings reads SCRAPER_PROXY_URL_ENV (the field
|
# work — without it pydantic-settings reads SCRAPER_PROXY_URL_ENV (the field
|
||||||
# name), so setting SCRAPER_PROXY_URL would be a silent no-op. These tests
|
# name), so setting SCRAPER_PROXY_URL would be a silent no-op. These tests
|
||||||
|
|
@ -151,26 +115,17 @@ def _fresh_settings():
|
||||||
|
|
||||||
def test_settings_binds_scraper_proxy_url_env(monkeypatch):
|
def test_settings_binds_scraper_proxy_url_env(monkeypatch):
|
||||||
monkeypatch.setenv("SCRAPER_PROXY_URL", "http://scraper-proxy:1234")
|
monkeypatch.setenv("SCRAPER_PROXY_URL", "http://scraper-proxy:1234")
|
||||||
monkeypatch.delenv("AVITO_PROXY_URL", raising=False)
|
|
||||||
assert _fresh_settings().scraper_proxy_url == "http://scraper-proxy:1234"
|
assert _fresh_settings().scraper_proxy_url == "http://scraper-proxy:1234"
|
||||||
|
|
||||||
|
|
||||||
def test_settings_scraper_env_overrides_avito(monkeypatch):
|
def test_settings_scraper_proxy_url_none_when_unset(monkeypatch):
|
||||||
monkeypatch.setenv("SCRAPER_PROXY_URL", "http://scraper-proxy:1234")
|
|
||||||
monkeypatch.setenv("AVITO_PROXY_URL", "http://avito-proxy:5678")
|
|
||||||
assert _fresh_settings().scraper_proxy_url == "http://scraper-proxy:1234"
|
|
||||||
|
|
||||||
|
|
||||||
def test_settings_falls_back_to_avito_env(monkeypatch):
|
|
||||||
monkeypatch.delenv("SCRAPER_PROXY_URL", raising=False)
|
monkeypatch.delenv("SCRAPER_PROXY_URL", raising=False)
|
||||||
monkeypatch.setenv("AVITO_PROXY_URL", "http://avito-proxy:5678")
|
assert _fresh_settings().scraper_proxy_url is None
|
||||||
assert _fresh_settings().scraper_proxy_url == "http://avito-proxy:5678"
|
|
||||||
|
|
||||||
|
|
||||||
def test_settings_ignores_misnamed_scraper_proxy_url_env(monkeypatch):
|
def test_settings_ignores_misnamed_scraper_proxy_url_env(monkeypatch):
|
||||||
"""Regression: the old wrong env name SCRAPER_PROXY_URL_ENV must NOT bind."""
|
"""Regression: the old wrong env name SCRAPER_PROXY_URL_ENV must NOT bind."""
|
||||||
monkeypatch.delenv("SCRAPER_PROXY_URL", raising=False)
|
monkeypatch.delenv("SCRAPER_PROXY_URL", raising=False)
|
||||||
monkeypatch.delenv("AVITO_PROXY_URL", raising=False)
|
|
||||||
monkeypatch.setenv("SCRAPER_PROXY_URL_ENV", "http://wrong-name:1111")
|
monkeypatch.setenv("SCRAPER_PROXY_URL_ENV", "http://wrong-name:1111")
|
||||||
assert _fresh_settings().scraper_proxy_url is None
|
assert _fresh_settings().scraper_proxy_url is None
|
||||||
|
|
||||||
|
|
@ -378,60 +333,27 @@ async def test_avito_detail_shared_session_not_patched():
|
||||||
|
|
||||||
|
|
||||||
# ── Settings.cian_proxy_url property ─────────────────────────────────────────
|
# ── Settings.cian_proxy_url property ─────────────────────────────────────────
|
||||||
# cian_proxy_url: CIAN_PROXY_URL > scraper_proxy_url (fallback) > None.
|
# #2616 шаг 2: dedicated CIAN_PROXY_URL removed (dead mobileproxy account, #2613) —
|
||||||
|
# cian_proxy_url now mirrors scraper_proxy_url 1:1. Property kept (not inlined at
|
||||||
|
# call sites) for scraper_kit.contracts.ScraperConfig Protocol compatibility.
|
||||||
|
|
||||||
|
|
||||||
def _resolve_cian_proxy_url(
|
def _resolve_cian_proxy_url(scraper_proxy_url: str | None) -> str | None:
|
||||||
cian_proxy_url_env: str | None, scraper_proxy_url: str | None
|
"""Inline replica of Settings.cian_proxy_url property logic (post #2616 шаг 2)."""
|
||||||
) -> str | None:
|
return scraper_proxy_url
|
||||||
"""Inline replica of Settings.cian_proxy_url property logic."""
|
|
||||||
return cian_proxy_url_env or scraper_proxy_url
|
|
||||||
|
|
||||||
|
|
||||||
def test_cian_proxy_url_uses_cian_env_when_set():
|
def test_cian_proxy_url_mirrors_scraper_proxy_url():
|
||||||
"""CIAN_PROXY_URL takes precedence over shared scraper_proxy_url."""
|
assert _resolve_cian_proxy_url("http://shared-proxy:1111") == "http://shared-proxy:1111"
|
||||||
result = _resolve_cian_proxy_url(
|
|
||||||
cian_proxy_url_env="http://cian-proxy:2222",
|
|
||||||
scraper_proxy_url="http://shared-proxy:1111",
|
|
||||||
)
|
|
||||||
assert result == "http://cian-proxy:2222"
|
|
||||||
|
|
||||||
|
|
||||||
def test_cian_proxy_url_falls_back_to_scraper_proxy_url():
|
def test_cian_proxy_url_none_when_absent():
|
||||||
"""Falls back to shared scraper_proxy_url when CIAN_PROXY_URL is absent."""
|
assert _resolve_cian_proxy_url(None) is None
|
||||||
result = _resolve_cian_proxy_url(
|
|
||||||
cian_proxy_url_env=None,
|
|
||||||
scraper_proxy_url="http://shared-proxy:1111",
|
|
||||||
)
|
|
||||||
assert result == "http://shared-proxy:1111"
|
|
||||||
|
|
||||||
|
|
||||||
def test_cian_proxy_url_none_when_both_absent():
|
def test_settings_cian_proxy_url_mirrors_scraper_proxy_url(monkeypatch):
|
||||||
result = _resolve_cian_proxy_url(cian_proxy_url_env=None, scraper_proxy_url=None)
|
"""cian_proxy_url tracks scraper_proxy_url 1:1 — no more independent CIAN_PROXY_URL."""
|
||||||
assert result is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_settings_binds_cian_proxy_url_env(monkeypatch):
|
|
||||||
"""CIAN_PROXY_URL env binds to cian_proxy_url_env via validation_alias."""
|
|
||||||
monkeypatch.setenv("CIAN_PROXY_URL", "http://cian-proxy:2222")
|
|
||||||
monkeypatch.delenv("SCRAPER_PROXY_URL", raising=False)
|
|
||||||
monkeypatch.delenv("AVITO_PROXY_URL", raising=False)
|
|
||||||
assert _fresh_settings().cian_proxy_url == "http://cian-proxy:2222"
|
|
||||||
|
|
||||||
|
|
||||||
def test_settings_cian_env_overrides_scraper_proxy(monkeypatch):
|
|
||||||
"""CIAN_PROXY_URL overrides shared SCRAPER_PROXY_URL for Cian scrapers."""
|
|
||||||
monkeypatch.setenv("CIAN_PROXY_URL", "http://cian-proxy:2222")
|
|
||||||
monkeypatch.setenv("SCRAPER_PROXY_URL", "http://shared-proxy:1111")
|
monkeypatch.setenv("SCRAPER_PROXY_URL", "http://shared-proxy:1111")
|
||||||
monkeypatch.delenv("AVITO_PROXY_URL", raising=False)
|
|
||||||
s = _fresh_settings()
|
s = _fresh_settings()
|
||||||
assert s.cian_proxy_url == "http://cian-proxy:2222"
|
assert s.cian_proxy_url == "http://shared-proxy:1111"
|
||||||
assert s.scraper_proxy_url == "http://shared-proxy:1111"
|
assert s.cian_proxy_url == s.scraper_proxy_url
|
||||||
|
|
||||||
|
|
||||||
def test_settings_cian_proxy_falls_back_to_scraper(monkeypatch):
|
|
||||||
"""Without CIAN_PROXY_URL, cian_proxy_url falls back to scraper_proxy_url."""
|
|
||||||
monkeypatch.delenv("CIAN_PROXY_URL", raising=False)
|
|
||||||
monkeypatch.setenv("SCRAPER_PROXY_URL", "http://shared-proxy:1111")
|
|
||||||
monkeypatch.delenv("AVITO_PROXY_URL", raising=False)
|
|
||||||
assert _fresh_settings().cian_proxy_url == "http://shared-proxy:1111"
|
|
||||||
|
|
|
||||||
|
|
@ -54,15 +54,12 @@ def _config() -> SimpleNamespace:
|
||||||
scraper_fetch_mode="curl_cffi",
|
scraper_fetch_mode="curl_cffi",
|
||||||
browser_http_endpoint="http://browser.test/fetch",
|
browser_http_endpoint="http://browser.test/fetch",
|
||||||
scraper_proxy_url=None,
|
scraper_proxy_url=None,
|
||||||
avito_proxy_rotate_url=None,
|
|
||||||
avito_proxy_max_rotations=0,
|
avito_proxy_max_rotations=0,
|
||||||
avito_serp_ok_not_banned=True,
|
avito_serp_ok_not_banned=True,
|
||||||
avito_proxy_rotate_settle_s=0.0,
|
avito_proxy_rotate_settle_s=0.0,
|
||||||
proxy_rotate_attempts=1,
|
proxy_rotate_attempts=1,
|
||||||
proxy_rotate_attempt_timeout_s=1.0,
|
proxy_rotate_attempt_timeout_s=1.0,
|
||||||
cian_proxy_rotate_url=None,
|
|
||||||
cian_proxy_max_rotations=0,
|
cian_proxy_max_rotations=0,
|
||||||
yandex_proxy_rotate_url=None,
|
|
||||||
yandex_proxy_max_rotations=0,
|
yandex_proxy_max_rotations=0,
|
||||||
scraper_skip_seen_today=False,
|
scraper_skip_seen_today=False,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,6 @@ from scraper_kit.providers.yandex.valuation import YandexValuationScraper
|
||||||
|
|
||||||
_KIT_CONFIG = SimpleNamespace(
|
_KIT_CONFIG = SimpleNamespace(
|
||||||
scraper_proxy_url=None,
|
scraper_proxy_url=None,
|
||||||
yandex_proxy_rotate_url=None,
|
|
||||||
avito_proxy_rotate_url=None,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,6 @@ from scraper_kit.providers.yandex.valuation import (
|
||||||
|
|
||||||
_KIT_CONFIG = SimpleNamespace(
|
_KIT_CONFIG = SimpleNamespace(
|
||||||
scraper_proxy_url=None,
|
scraper_proxy_url=None,
|
||||||
yandex_proxy_rotate_url=None,
|
|
||||||
avito_proxy_rotate_url=None,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -19,9 +19,10 @@ Per-provider модель (#1793):
|
||||||
Вместо одного глобального браузера + одного egress-прокси держим ОТДЕЛЬНЫЙ
|
Вместо одного глобального браузера + одного egress-прокси держим ОТДЕЛЬНЫЙ
|
||||||
инстанс camoufox на каждого поставщика (avito/cian/yandex/generic). Поставщик
|
инстанс camoufox на каждого поставщика (avito/cian/yandex/generic). Поставщик
|
||||||
определяется по host URL запроса (или явному полю body["provider"]/["source"]). Каждый
|
определяется по host URL запроса (или явному полю body["provider"]/["source"]). Каждый
|
||||||
инстанс ходит через свой http-прокси (BROWSER_PROXY_AVITO/CIAN/YANDEX) и имеет
|
инстанс имеет geoip=True — фингерпринт привязан к гео прокси, поэтому смешивать
|
||||||
geoip=True — фингерпринт привязан к гео конкретного прокси, поэтому смешивать
|
источники в одном браузере нельзя. Egress-прокси сейчас общий для всех
|
||||||
источники в одном браузере нельзя.
|
поставщиков (SCRAPER_PROXY_URL, #2616 шаг 2) — раздельные instance'ы остаются
|
||||||
|
ради geoip-изоляции fingerprint'а и per-provider concurrency (ниже), не прокси.
|
||||||
|
|
||||||
Конкурентность:
|
Конкурентность:
|
||||||
* МЕЖДУ поставщиками — параллельно (avito/cian/yandex гоняются одновременно);
|
* МЕЖДУ поставщиками — параллельно (avito/cian/yandex гоняются одновременно);
|
||||||
|
|
@ -59,23 +60,19 @@ Per-provider модель (#1793):
|
||||||
глобальный BROWSER_MIN_PAGE_INTERVAL_S. Backward-compat:
|
глобальный BROWSER_MIN_PAGE_INTERVAL_S. Backward-compat:
|
||||||
при незаданных per-provider env поведение идентично
|
при незаданных per-provider env поведение идентично
|
||||||
предыдущему (чисто аддитивный override).
|
предыдущему (чисто аддитивный override).
|
||||||
BROWSER_PROXY_AVITO — http-прокси для avito (fallback AVITO_PROXY_URL).
|
SCRAPER_PROXY_URL — http-прокси, ОБЩИЙ для всех поставщиков (avito/cian/
|
||||||
BROWSER_PROXY_CIAN — http-прокси для cian (fallback CIAN_PROXY_URL).
|
yandex/generic). #2616 шаг 2: per-provider
|
||||||
BROWSER_PROXY_YANDEX — http-прокси для yandex (fallback YANDEX_PROXY_URL).
|
BROWSER_PROXY_AVITO/CIAN/YANDEX и legacy
|
||||||
SCRAPER_PROXY_URL — http-прокси для generic (fallback AVITO_PROXY_URL).
|
AVITO_PROXY_URL/CIAN_PROXY_URL/YANDEX_PROXY_URL сняты —
|
||||||
|
все указывали на закрытые mobileproxy-аккаунты
|
||||||
ВАЖНО: для браузера используем именно BROWSER_PROXY_* (http-прокси). Legacy
|
(407/connection refused, проверено вживую #2613).
|
||||||
CIAN_PROXY_URL/YANDEX_PROXY_URL могут быть socks5 с авторизацией — playwright
|
|
||||||
их не умеет, поэтому они лишь fallback (если кто-то положил http туда же).
|
|
||||||
|
|
||||||
ENVIRONMENT — "production" в прод-контейнерах, иначе "dev" (дефолт).
|
ENVIRONMENT — "production" в прод-контейнерах, иначе "dev" (дефолт).
|
||||||
#2616 шаг 1: прод + нет НИ override в теле, НИ ЛЮБОГО
|
#2616 шаг 1: прод + нет НИ override в теле, НИ
|
||||||
env-прокси для provider'а → отказ (503, явная причина),
|
SCRAPER_PROXY_URL → отказ (503, явная причина), а НЕ
|
||||||
а НЕ launch camoufox без proxy (= прямое подключение с IP
|
launch camoufox без proxy (= прямое подключение с IP
|
||||||
сервера). В dev отсутствие прокси легитимно (см.
|
сервера). В dev отсутствие прокси легитимно (см.
|
||||||
_no_live_proxy). Env-переменная, которая ЗАДАНА, но мертва
|
_no_live_proxy).
|
||||||
(407/connection refused — #2613) — этот guard НЕ триггерит,
|
|
||||||
удаление мёртвых переменных отдельным шагом ПОСЛЕ #2616.
|
|
||||||
|
|
||||||
Контракт /login (провалидировано вживую 2026-05-31, Cian email+пароль без SMS):
|
Контракт /login (провалидировано вживую 2026-05-31, Cian email+пароль без SMS):
|
||||||
pre_click_selectors — список селекторов для последовательного клика до формы;
|
pre_click_selectors — список селекторов для последовательного клика до формы;
|
||||||
|
|
@ -226,32 +223,28 @@ def _provider_for_url(url: str) -> str:
|
||||||
|
|
||||||
|
|
||||||
def _provider_proxy(provider: str) -> str | None:
|
def _provider_proxy(provider: str) -> str | None:
|
||||||
"""Возвращает http-прокси для поставщика из env (с фолбэком), иначе None.
|
"""Возвращает http-прокси для поставщика из env, иначе None.
|
||||||
|
|
||||||
Используем BROWSER_PROXY_* как primary — это http-прокси для браузера.
|
#2616 шаг 2: per-provider BROWSER_PROXY_AVITO/CIAN/YANDEX и legacy
|
||||||
Legacy *_PROXY_URL — лишь fallback: playwright НЕ умеет socks5 с авторизацией,
|
AVITO_PROXY_URL/CIAN_PROXY_URL/YANDEX_PROXY_URL сняты — все указывали на
|
||||||
поэтому socks5-варианты CIAN/YANDEX как primary не годятся.
|
закрытые mobileproxy-аккаунты (#2613). SCRAPER_PROXY_URL (ASocks, http-прокси,
|
||||||
|
playwright-совместимый) — единственный источник, общий для ВСЕХ поставщиков
|
||||||
|
(включая generic). `provider` оставлен параметром сигнатуры (вызывающие
|
||||||
|
передают его) — на случай возврата к per-provider egress в будущем.
|
||||||
"""
|
"""
|
||||||
env_map: dict[str, tuple[str, str]] = {
|
return os.environ.get("SCRAPER_PROXY_URL")
|
||||||
"avito": ("BROWSER_PROXY_AVITO", "AVITO_PROXY_URL"),
|
|
||||||
"cian": ("BROWSER_PROXY_CIAN", "CIAN_PROXY_URL"),
|
|
||||||
"yandex": ("BROWSER_PROXY_YANDEX", "YANDEX_PROXY_URL"),
|
|
||||||
"generic": ("SCRAPER_PROXY_URL", "AVITO_PROXY_URL"),
|
|
||||||
}
|
|
||||||
primary, fallback = env_map.get(provider, env_map["generic"])
|
|
||||||
return os.environ.get(primary) or os.environ.get(fallback)
|
|
||||||
|
|
||||||
|
|
||||||
def _no_live_proxy(provider: str, proxy_override: str | None) -> bool:
|
def _no_live_proxy(provider: str, proxy_override: str | None) -> bool:
|
||||||
"""True если нет НИ explicit proxy в теле запроса, НИ env-прокси для поставщика.
|
"""True если нет НИ explicit proxy в теле запроса, НИ SCRAPER_PROXY_URL.
|
||||||
|
|
||||||
#2616 шаг 1: используется ТОЛЬКО в prod (IS_PROD) — в dev отсутствие прокси
|
#2616 шаг 1: используется ТОЛЬКО в prod (IS_PROD) — в dev отсутствие прокси
|
||||||
легитимно (прямое подключение для локальной разработки/тестов, см. #2616 issue
|
легитимно (прямое подключение для локальной разработки/тестов, см. #2616 issue
|
||||||
пункт 2). Отдельная функция от `_provider_proxy`, потому что "нет живого прокси"
|
пункт 2). Отдельная функция от `_provider_proxy`, потому что "нет живого прокси"
|
||||||
здесь означает буквально "нет НИКАКОГО прокси" (ни override, ни env) — единственный
|
здесь означает буквально "нет НИКАКОГО прокси" (ни override, ни env) — единственный
|
||||||
случай, который код МОЖЕТ проверить без сетевой пробы. Env-переменная, что задана
|
случай, который код МОЖЕТ проверить без сетевой пробы. Env-переменная, что задана
|
||||||
но мертва (407/connection refused — проверено вживую #2613), эту функцию не
|
но мертва (407/connection refused), этот guard не триггерит — за живость значения
|
||||||
триггерит: удаление мёртвых переменных — отдельный шаг #2616 ПОСЛЕ этой правки.
|
отвечает мониторинг прокси-пула, не этот код.
|
||||||
"""
|
"""
|
||||||
return proxy_override is None and _provider_proxy(provider) is None
|
return proxy_override is None and _provider_proxy(provider) is None
|
||||||
|
|
||||||
|
|
@ -577,9 +570,9 @@ def _resolve_provider(body: dict, url: str) -> str:
|
||||||
берём из "provider" ИЛИ "source", но применяем ТОЛЬКО если оно в PROVIDERS;
|
берём из "provider" ИЛИ "source", но применяем ТОЛЬКО если оно в PROVIDERS;
|
||||||
иначе host-detect; иначе generic.
|
иначе host-detect; иначе generic.
|
||||||
|
|
||||||
# domclick (source='domklik') не имеет выделенного BROWSER_PROXY_DOMCLICK в prod
|
# domclick (source='domklik') нет в PROVIDERS → fallthrough на host-detect
|
||||||
# → generic. Намеренно. ('domklik' нет в PROVIDERS → fallthrough на host-detect
|
# domclick.ru → generic. Намеренно (все поставщики делят один SCRAPER_PROXY_URL
|
||||||
# domclick.ru → generic.)
|
# с #2616 шаг 2, generic-путь эквивалентен).
|
||||||
"""
|
"""
|
||||||
explicit = body.get("provider") or body.get("source")
|
explicit = body.get("provider") or body.get("source")
|
||||||
if isinstance(explicit, str) and explicit in PROVIDERS:
|
if isinstance(explicit, str) and explicit in PROVIDERS:
|
||||||
|
|
@ -593,7 +586,7 @@ def _resolve_proxy_override(body: dict, provider: str) -> str | None:
|
||||||
Scraper-сторона (BrowserFetcher за флагом use_proxy_pool_browser) кладёт в тело
|
Scraper-сторона (BrowserFetcher за флагом use_proxy_pool_browser) кладёт в тело
|
||||||
``{"proxy": "http://user:pass@host:port", "proxy_kind": "http"}`` — прокси, взятый из
|
``{"proxy": "http://user:pass@host:port", "proxy_kind": "http"}`` — прокси, взятый из
|
||||||
пула через acquire(source). Если поля нет / пусто → None → инстанс поставщика берёт
|
пула через acquire(source). Если поля нет / пусто → None → инстанс поставщика берёт
|
||||||
свой env-прокси (BROWSER_PROXY_*), поведение не меняется (ship-dark parity).
|
общий SCRAPER_PROXY_URL (#2616 шаг 2), поведение не меняется (ship-dark parity).
|
||||||
|
|
||||||
camoufox НЕ умеет socks5 с авторизацией → принимаем ТОЛЬКО http/https-прокси; иной
|
camoufox НЕ умеет socks5 с авторизацией → принимаем ТОЛЬКО http/https-прокси; иной
|
||||||
proxy_kind игнорируем (→ None, env-fallback) с предупреждением. Отсутствие proxy_kind
|
proxy_kind игнорируем (→ None, env-fallback) с предупреждением. Отсутствие proxy_kind
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,8 @@
|
||||||
|
|
||||||
Проверяет ядро per-provider модели:
|
Проверяет ядро per-provider модели:
|
||||||
1. _provider_for_url — корректный маппинг host → provider (avito/cian/yandex/generic);
|
1. _provider_for_url — корректный маппинг host → provider (avito/cian/yandex/generic);
|
||||||
2. _provider_proxy — BROWSER_PROXY_* primary с фолбэком на legacy *_PROXY_URL;
|
2. _provider_proxy — SCRAPER_PROXY_URL, общий для всех поставщиков (#2616 шаг 2:
|
||||||
|
per-provider BROWSER_PROXY_*/legacy *_PROXY_URL сняты);
|
||||||
3. _resolve_provider — явный body["provider"] перебивает host, иначе host;
|
3. _resolve_provider — явный body["provider"] перебивает host, иначе host;
|
||||||
4. конкурентность: два /fetch к РАЗНЫМ поставщикам идут параллельно (разные локи),
|
4. конкурентность: два /fetch к РАЗНЫМ поставщикам идут параллельно (разные локи),
|
||||||
два к ОДНОМУ — сериализованы (один лок). Через _do_fetch с asyncio.Event и
|
два к ОДНОМУ — сериализованы (один лок). Через _do_fetch с asyncio.Event и
|
||||||
|
|
@ -79,57 +80,21 @@ def test_provider_for_url_generic_fallback() -> None:
|
||||||
# ── _provider_proxy ──────────────────────────────────────────────────────────────
|
# ── _provider_proxy ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def test_provider_proxy_uses_browser_proxy_primary(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_provider_proxy_shared_across_providers(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
"""BROWSER_PROXY_* — primary источник для каждого поставщика."""
|
"""#2616 шаг 2: SCRAPER_PROXY_URL — единственный источник, общий для всех."""
|
||||||
monkeypatch.setenv("BROWSER_PROXY_AVITO", "http://u:p@avito-proxy:8080")
|
monkeypatch.setenv("SCRAPER_PROXY_URL", "http://u:p@shared-proxy:8080")
|
||||||
monkeypatch.setenv("BROWSER_PROXY_CIAN", "http://u:p@cian-proxy:8080")
|
assert server._provider_proxy("avito") == "http://u:p@shared-proxy:8080"
|
||||||
monkeypatch.setenv("BROWSER_PROXY_YANDEX", "http://u:p@yandex-proxy:8080")
|
assert server._provider_proxy("cian") == "http://u:p@shared-proxy:8080"
|
||||||
assert server._provider_proxy("avito") == "http://u:p@avito-proxy:8080"
|
assert server._provider_proxy("yandex") == "http://u:p@shared-proxy:8080"
|
||||||
assert server._provider_proxy("cian") == "http://u:p@cian-proxy:8080"
|
assert server._provider_proxy("generic") == "http://u:p@shared-proxy:8080"
|
||||||
assert server._provider_proxy("yandex") == "http://u:p@yandex-proxy:8080"
|
|
||||||
|
|
||||||
|
|
||||||
def test_provider_proxy_falls_back_to_legacy(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
"""Без BROWSER_PROXY_* берётся legacy *_PROXY_URL фолбэк."""
|
|
||||||
monkeypatch.delenv("BROWSER_PROXY_AVITO", raising=False)
|
|
||||||
monkeypatch.delenv("BROWSER_PROXY_CIAN", raising=False)
|
|
||||||
monkeypatch.delenv("BROWSER_PROXY_YANDEX", raising=False)
|
|
||||||
monkeypatch.setenv("AVITO_PROXY_URL", "http://u:p@legacy-avito:8080")
|
|
||||||
monkeypatch.setenv("CIAN_PROXY_URL", "http://u:p@legacy-cian:8080")
|
|
||||||
monkeypatch.setenv("YANDEX_PROXY_URL", "http://u:p@legacy-yandex:8080")
|
|
||||||
assert server._provider_proxy("avito") == "http://u:p@legacy-avito:8080"
|
|
||||||
assert server._provider_proxy("cian") == "http://u:p@legacy-cian:8080"
|
|
||||||
assert server._provider_proxy("yandex") == "http://u:p@legacy-yandex:8080"
|
|
||||||
|
|
||||||
|
|
||||||
def test_provider_proxy_generic(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
"""generic: SCRAPER_PROXY_URL primary, AVITO_PROXY_URL фолбэк."""
|
|
||||||
monkeypatch.delenv("SCRAPER_PROXY_URL", raising=False)
|
|
||||||
monkeypatch.setenv("AVITO_PROXY_URL", "http://u:p@avito:8080")
|
|
||||||
assert server._provider_proxy("generic") == "http://u:p@avito:8080"
|
|
||||||
monkeypatch.setenv("SCRAPER_PROXY_URL", "http://u:p@scraper:8080")
|
|
||||||
assert server._provider_proxy("generic") == "http://u:p@scraper:8080"
|
|
||||||
|
|
||||||
|
|
||||||
def test_provider_proxy_none_when_unset(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_provider_proxy_none_when_unset(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
"""Ни одной env → None (direct, как одиночный браузер без прокси)."""
|
"""SCRAPER_PROXY_URL не задан → None (direct, как одиночный браузер без прокси)."""
|
||||||
for var in (
|
monkeypatch.delenv("SCRAPER_PROXY_URL", raising=False)
|
||||||
"BROWSER_PROXY_CIAN",
|
|
||||||
"CIAN_PROXY_URL",
|
|
||||||
):
|
|
||||||
monkeypatch.delenv(var, raising=False)
|
|
||||||
assert server._provider_proxy("cian") is None
|
assert server._provider_proxy("cian") is None
|
||||||
|
|
||||||
|
|
||||||
def test_provider_proxy_browser_proxy_wins_over_legacy(
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
"""BROWSER_PROXY_* перебивает legacy *_PROXY_URL (http vs возможный socks5)."""
|
|
||||||
monkeypatch.setenv("BROWSER_PROXY_CIAN", "http://u:p@http-cian:8080")
|
|
||||||
monkeypatch.setenv("CIAN_PROXY_URL", "socks5://u:p@socks-cian:1080")
|
|
||||||
assert server._provider_proxy("cian") == "http://u:p@http-cian:8080"
|
|
||||||
|
|
||||||
|
|
||||||
# ── _resolve_provider ────────────────────────────────────────────────────────────
|
# ── _resolve_provider ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -158,8 +123,8 @@ def test_resolve_provider_uses_source_field() -> None:
|
||||||
def test_resolve_provider_domclick_source_to_generic() -> None:
|
def test_resolve_provider_domclick_source_to_generic() -> None:
|
||||||
"""domclick scraper шлёт source='domklik' (нет в PROVIDERS) + хост domclick.ru.
|
"""domclick scraper шлёт source='domklik' (нет в PROVIDERS) + хост domclick.ru.
|
||||||
|
|
||||||
'domklik' не валиден → host-detect domclick.ru → generic. Намеренно: в prod
|
'domklik' не валиден → host-detect domclick.ru → generic. Намеренно: 'domklik'
|
||||||
нет выделенного BROWSER_PROXY_DOMCLICK.
|
не входит в PROVIDERS (нет выделенного instance).
|
||||||
"""
|
"""
|
||||||
assert (
|
assert (
|
||||||
server._resolve_provider({"source": "domklik"}, "https://domclick.ru/x") == "generic"
|
server._resolve_provider({"source": "domklik"}, "https://domclick.ru/x") == "generic"
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,12 @@
|
||||||
"""test_server_no_proxy_refusal.py — #2616 шаг 1: не идти напрямую с IP сервера.
|
"""test_server_no_proxy_refusal.py — #2616 шаг 1: не идти напрямую с IP сервера.
|
||||||
|
|
||||||
tradein-browser (per-provider camoufox) раньше в прод-контейнере, если ни тело
|
tradein-browser (per-provider camoufox) раньше в прод-контейнере, если ни тело
|
||||||
запроса, ни ЛЮБАЯ env-переменная не несли proxy, лениво запускал camoufox БЕЗ
|
запроса, ни SCRAPER_PROXY_URL не несли proxy, лениво запускал camoufox БЕЗ
|
||||||
proxy — прямое подключение с IP сервера. Мертвые env-переменные (407/connection
|
proxy — прямое подключение с IP сервера. #2616 шаг 2 удалил мёртвые per-provider
|
||||||
refused, #2613) сегодня этого не триггерят (запуск падает раньше, на invalid-proxy
|
переменные (AVITO_PROXY_URL/BROWSER_PROXY_* и т.д., 407/connection refused,
|
||||||
проверке camoufox) — но как только шаг 2 issue #2616 их удалит, сценарий станет
|
#2613) — SCRAPER_PROXY_URL остался единственным источником. Этот файл фиксирует
|
||||||
реальным. Этот файл фиксирует контракт ЗАРАНЕЕ (`_no_live_proxy` + `IS_PROD` guard
|
контракт (`_no_live_proxy` + `IS_PROD` guard в
|
||||||
в fetch_handler/fetch_json_handler/login_handler), до того как #2616 шаг 2 сделает
|
fetch_handler/fetch_json_handler/login_handler).
|
||||||
его наблюдаемым в проде.
|
|
||||||
|
|
||||||
Проверяет:
|
Проверяет:
|
||||||
1. `_no_live_proxy` — pure-function поведение (override / env приоритеты).
|
1. `_no_live_proxy` — pure-function поведение (override / env приоритеты).
|
||||||
|
|
@ -65,8 +64,9 @@ def _make_request(path: str, body: dict[str, Any]) -> Any:
|
||||||
def _reset_state(monkeypatch: pytest.MonkeyPatch) -> None:
|
def _reset_state(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
"""Чистое per-provider состояние + НЕТ live env-прокси ни для одного провайдера.
|
"""Чистое per-provider состояние + НЕТ live env-прокси ни для одного провайдера.
|
||||||
|
|
||||||
Явно чистим все env-переменные, которые `_provider_proxy` умеет читать — тест
|
Явно чистим SCRAPER_PROXY_URL (единственная переменная, которую `_provider_proxy`
|
||||||
не должен зависеть от того, что реально задано в shell/CI-раннере.
|
читает, #2616 шаг 2) — тест не должен зависеть от того, что реально задано в
|
||||||
|
shell/CI-раннере.
|
||||||
"""
|
"""
|
||||||
monkeypatch.setattr(server, "_browsers", {})
|
monkeypatch.setattr(server, "_browsers", {})
|
||||||
monkeypatch.setattr(server, "_browser_cms", {})
|
monkeypatch.setattr(server, "_browser_cms", {})
|
||||||
|
|
@ -76,16 +76,7 @@ def _reset_state(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
monkeypatch.setattr(server, "_last_goto_at", {})
|
monkeypatch.setattr(server, "_last_goto_at", {})
|
||||||
monkeypatch.setattr(server, "_launched_proxy", {})
|
monkeypatch.setattr(server, "_launched_proxy", {})
|
||||||
monkeypatch.setattr(server, "_locks_guard", asyncio.Lock())
|
monkeypatch.setattr(server, "_locks_guard", asyncio.Lock())
|
||||||
for var in (
|
monkeypatch.delenv("SCRAPER_PROXY_URL", raising=False)
|
||||||
"BROWSER_PROXY_AVITO",
|
|
||||||
"AVITO_PROXY_URL",
|
|
||||||
"BROWSER_PROXY_CIAN",
|
|
||||||
"CIAN_PROXY_URL",
|
|
||||||
"BROWSER_PROXY_YANDEX",
|
|
||||||
"YANDEX_PROXY_URL",
|
|
||||||
"SCRAPER_PROXY_URL",
|
|
||||||
):
|
|
||||||
monkeypatch.delenv(var, raising=False)
|
|
||||||
|
|
||||||
|
|
||||||
def _forbid_ensure_browser(monkeypatch: pytest.MonkeyPatch) -> list[str]:
|
def _forbid_ensure_browser(monkeypatch: pytest.MonkeyPatch) -> list[str]:
|
||||||
|
|
@ -115,7 +106,7 @@ def test_no_live_proxy_false_with_override() -> None:
|
||||||
|
|
||||||
|
|
||||||
def test_no_live_proxy_false_with_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_no_live_proxy_false_with_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
monkeypatch.setenv("BROWSER_PROXY_AVITO", "http://env:8080")
|
monkeypatch.setenv("SCRAPER_PROXY_URL", "http://env:8080")
|
||||||
assert server._no_live_proxy("avito", None) is False
|
assert server._no_live_proxy("avito", None) is False
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -220,7 +211,7 @@ def test_fetch_handler_dev_still_attempts_launch(monkeypatch: pytest.MonkeyPatch
|
||||||
|
|
||||||
def test_fetch_handler_prod_with_env_proxy_unaffected(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_fetch_handler_prod_with_env_proxy_unaffected(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
monkeypatch.setattr(server, "IS_PROD", True)
|
monkeypatch.setattr(server, "IS_PROD", True)
|
||||||
monkeypatch.setenv("BROWSER_PROXY_AVITO", "http://env:8080") # "живой" с т.з. guard'а
|
monkeypatch.setenv("SCRAPER_PROXY_URL", "http://env:8080") # "живой" с т.з. guard'а
|
||||||
calls: list[tuple[str, str | None]] = []
|
calls: list[tuple[str, str | None]] = []
|
||||||
|
|
||||||
async def _ensure(provider: str, proxy_override: str | None = None) -> bool:
|
async def _ensure(provider: str, proxy_override: str | None = None) -> bool:
|
||||||
|
|
@ -294,7 +285,7 @@ def test_refusal_message_distinguishable_from_launch_failure(
|
||||||
no_proxy_text = _json_body(no_proxy_resp)["error"]
|
no_proxy_text = _json_body(no_proxy_resp)["error"]
|
||||||
|
|
||||||
# launch failure (дохлый-но-заданный env) — существующий, другой путь.
|
# launch failure (дохлый-но-заданный env) — существующий, другой путь.
|
||||||
monkeypatch.setenv("BROWSER_PROXY_AVITO", "http://dead:8080")
|
monkeypatch.setenv("SCRAPER_PROXY_URL", "http://dead:8080")
|
||||||
|
|
||||||
async def _fails_launch(provider: str, proxy_override: str | None = None) -> bool:
|
async def _fails_launch(provider: str, proxy_override: str | None = None) -> bool:
|
||||||
return False # camoufox launch упал (InvalidProxy) — существующая семантика
|
return False # camoufox launch упал (InvalidProxy) — существующая семантика
|
||||||
|
|
|
||||||
|
|
@ -126,8 +126,7 @@ def test_ensure_browser_changed_proxy_relaunches(monkeypatch: pytest.MonkeyPatch
|
||||||
|
|
||||||
def test_ensure_browser_none_override_env_no_relaunch(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_ensure_browser_none_override_env_no_relaunch(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
"""proxy_override=None (флаг off) + инстанс на env → НЕ релончим (golden-parity)."""
|
"""proxy_override=None (флаг off) + инстанс на env → НЕ релончим (golden-parity)."""
|
||||||
for var in ("BROWSER_PROXY_AVITO", "AVITO_PROXY_URL"):
|
monkeypatch.setenv("SCRAPER_PROXY_URL", "http://env:8080")
|
||||||
monkeypatch.setenv(var, "http://env:8080")
|
|
||||||
calls = _install_fake_launch(monkeypatch)
|
calls = _install_fake_launch(monkeypatch)
|
||||||
|
|
||||||
async def _run() -> None:
|
async def _run() -> None:
|
||||||
|
|
@ -141,8 +140,7 @@ def test_ensure_browser_none_override_env_no_relaunch(monkeypatch: pytest.Monkey
|
||||||
|
|
||||||
def test_ensure_browser_env_then_pool_relaunches(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_ensure_browser_env_then_pool_relaunches(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
"""env-launched инстанс + пришёл pool-proxy → relaunch (реальная смена)."""
|
"""env-launched инстанс + пришёл pool-proxy → relaunch (реальная смена)."""
|
||||||
for var in ("BROWSER_PROXY_AVITO", "AVITO_PROXY_URL"):
|
monkeypatch.setenv("SCRAPER_PROXY_URL", "http://env:8080")
|
||||||
monkeypatch.setenv(var, "http://env:8080")
|
|
||||||
calls = _install_fake_launch(monkeypatch)
|
calls = _install_fake_launch(monkeypatch)
|
||||||
|
|
||||||
async def _run() -> None:
|
async def _run() -> None:
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import { useScraperHealth } from "@/components/scrapers/ProxyHealthCard";
|
||||||
useScraperHealth,
|
|
||||||
useRotateIp,
|
|
||||||
} from "@/components/scrapers/ProxyHealthCard";
|
|
||||||
|
|
||||||
// ── Per-provider proxy section (inside provider tab) ───────────────────────
|
// ── Per-provider proxy section (inside provider tab) ───────────────────────
|
||||||
|
// #2616 шаг 3: ручная ротация IP (changeip mobileproxy) удалена вместе с
|
||||||
|
// мёртвой подпиской (#2613) — секция теперь read-only (прокси + текущий IP).
|
||||||
|
// Живая ротация — ASOCKS proxy /admin/proxies/{id}/rotate (#2611), отдельная
|
||||||
|
// от per-provider UI здесь.
|
||||||
|
|
||||||
interface ProviderProxySectionProps {
|
interface ProviderProxySectionProps {
|
||||||
source: string;
|
source: string;
|
||||||
|
|
@ -13,7 +14,6 @@ interface ProviderProxySectionProps {
|
||||||
|
|
||||||
export function ProviderProxySection({ source }: ProviderProxySectionProps) {
|
export function ProviderProxySection({ source }: ProviderProxySectionProps) {
|
||||||
const healthQ = useScraperHealth();
|
const healthQ = useScraperHealth();
|
||||||
const rotateMut = useRotateIp(source);
|
|
||||||
|
|
||||||
const provider = healthQ.data?.providers.find((p) => p.source === source);
|
const provider = healthQ.data?.providers.find((p) => p.source === source);
|
||||||
|
|
||||||
|
|
@ -68,70 +68,18 @@ export function ProviderProxySection({ source }: ProviderProxySectionProps) {
|
||||||
<span className="schedule-status__label">Текущий IP:</span>
|
<span className="schedule-status__label">Текущий IP:</span>
|
||||||
<span>
|
<span>
|
||||||
{provider.current_ip ? (
|
{provider.current_ip ? (
|
||||||
<code style={{ fontSize: "0.85rem" }}>
|
<code style={{ fontSize: "0.85rem" }}>{provider.current_ip}</code>
|
||||||
{provider.current_ip}
|
|
||||||
</code>
|
|
||||||
) : (
|
) : (
|
||||||
<span style={{ color: "var(--fg-tertiary, #73767e)" }}>—</span>
|
<span style={{ color: "var(--fg-tertiary, #73767e)" }}>—</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="schedule-status__row">
|
|
||||||
<span className="schedule-status__label">Режим:</span>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontSize: "0.85rem",
|
|
||||||
color: "var(--fg-secondary, #5b6066)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{provider.rotate_supported ? "rotate-поддержка" : "статический"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{provider.rotate_supported && (
|
<p className="scraper-hint" style={{ margin: 0 }}>
|
||||||
<div>
|
Ручная ротация IP для этого провайдера недоступна — резидентный прокси
|
||||||
<button
|
ротируется через ASOCKS-пул (см. страницу «Прокси»).
|
||||||
type="button"
|
</p>
|
||||||
disabled={rotateMut.isPending}
|
|
||||||
onClick={() => rotateMut.mutate()}
|
|
||||||
>
|
|
||||||
{rotateMut.isPending ? "Ротируем…" : "Сменить IP"}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{rotateMut.isSuccess && (
|
|
||||||
<div
|
|
||||||
className="scraper-result"
|
|
||||||
style={{ marginTop: 8 }}
|
|
||||||
>
|
|
||||||
{rotateMut.data.ok ? (
|
|
||||||
<>
|
|
||||||
Новый IP: <strong>{rotateMut.data.new_ip ?? "обновлён"}</strong>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<span style={{ color: "var(--danger, #b3261e)" }}>
|
|
||||||
{rotateMut.data.reason ?? "ошибка ротации"}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{rotateMut.isError && (
|
|
||||||
<div className="scraper-result scraper-result--error" style={{ marginTop: 8 }}>
|
|
||||||
Ошибка: {rotateMut.error.message}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!provider.rotate_supported && (
|
|
||||||
<p
|
|
||||||
className="scraper-hint"
|
|
||||||
style={{ margin: 0 }}
|
|
||||||
>
|
|
||||||
Ротация IP не поддерживается для этого провайдера.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { apiFetch } from "@/lib/api";
|
import { apiFetch } from "@/lib/api";
|
||||||
|
|
||||||
// ── Types ──────────────────────────────────────────────────────────────────
|
// ── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -9,6 +9,10 @@ export interface ProviderHealth {
|
||||||
source: string;
|
source: string;
|
||||||
proxy_host: string | null;
|
proxy_host: string | null;
|
||||||
proxy_port: number | null;
|
proxy_port: number | null;
|
||||||
|
// #2616 шаг 3: всегда false — changeip mobileproxy-ротация (POST
|
||||||
|
// /scraper/{source}/rotate-ip) снята вместе с мёртвой подпиской. Живая
|
||||||
|
// ротация — ASOCKS proxy /admin/proxies/{id}/rotate (#2611), без per-provider
|
||||||
|
// UI-кнопки здесь.
|
||||||
rotate_supported: boolean;
|
rotate_supported: boolean;
|
||||||
current_ip: string | null;
|
current_ip: string | null;
|
||||||
}
|
}
|
||||||
|
|
@ -24,12 +28,6 @@ export interface ScraperHealthResp {
|
||||||
providers: ProviderHealth[];
|
providers: ProviderHealth[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RotateIpResp {
|
|
||||||
ok: boolean;
|
|
||||||
new_ip: string | null;
|
|
||||||
reason: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Hooks ──────────────────────────────────────────────────────────────────
|
// ── Hooks ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function useScraperHealth() {
|
export function useScraperHealth() {
|
||||||
|
|
@ -42,20 +40,6 @@ export function useScraperHealth() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useRotateIp(source: string) {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation<RotateIpResp, Error>({
|
|
||||||
mutationFn: () =>
|
|
||||||
apiFetch<RotateIpResp>(
|
|
||||||
`/api/v1/admin/scraper/${encodeURIComponent(source)}/rotate-ip`,
|
|
||||||
{ method: "POST" },
|
|
||||||
),
|
|
||||||
onSuccess: () => {
|
|
||||||
qc.invalidateQueries({ queryKey: ["scraper-health"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── SystemSection ──────────────────────────────────────────────────────────
|
// ── SystemSection ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function FetchModeBadge({ mode }: { mode: string }) {
|
function FetchModeBadge({ mode }: { mode: string }) {
|
||||||
|
|
@ -71,9 +55,7 @@ function FetchModeBadge({ mode }: { mode: string }) {
|
||||||
background: isBrowser
|
background: isBrowser
|
||||||
? "var(--accent-soft, #dbeafe)"
|
? "var(--accent-soft, #dbeafe)"
|
||||||
: "var(--warn-soft, #fef3c7)",
|
: "var(--warn-soft, #fef3c7)",
|
||||||
color: isBrowser
|
color: isBrowser ? "var(--accent, #1d4ed8)" : "var(--warn, #9a6700)",
|
||||||
? "var(--accent, #1d4ed8)"
|
|
||||||
: "var(--warn, #9a6700)",
|
|
||||||
fontFamily: "ui-monospace, monospace",
|
fontFamily: "ui-monospace, monospace",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -94,9 +76,7 @@ function BrowserBadge({ reachable }: { reachable: boolean }) {
|
||||||
background: reachable
|
background: reachable
|
||||||
? "var(--success-soft, #dcfce7)"
|
? "var(--success-soft, #dcfce7)"
|
||||||
: "var(--danger-soft, #fee2e2)",
|
: "var(--danger-soft, #fee2e2)",
|
||||||
color: reachable
|
color: reachable ? "var(--success, #0a7a3a)" : "var(--danger, #b3261e)",
|
||||||
? "var(--success, #0a7a3a)"
|
|
||||||
: "var(--danger, #b3261e)",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{reachable ? "доступен" : "недоступен"}
|
{reachable ? "доступен" : "недоступен"}
|
||||||
|
|
@ -109,7 +89,6 @@ interface ProviderRowProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProviderProxyRow({ provider }: ProviderRowProps) {
|
function ProviderProxyRow({ provider }: ProviderRowProps) {
|
||||||
const rotateMut = useRotateIp(provider.source);
|
|
||||||
const proxyStr =
|
const proxyStr =
|
||||||
provider.proxy_host && provider.proxy_port
|
provider.proxy_host && provider.proxy_port
|
||||||
? `${provider.proxy_host}:${provider.proxy_port}`
|
? `${provider.proxy_host}:${provider.proxy_port}`
|
||||||
|
|
@ -124,74 +103,7 @@ function ProviderProxyRow({ provider }: ProviderRowProps) {
|
||||||
<code style={{ fontSize: "0.8rem" }}>{proxyStr}</code>
|
<code style={{ fontSize: "0.8rem" }}>{proxyStr}</code>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<code style={{ fontSize: "0.8rem" }}>
|
<code style={{ fontSize: "0.8rem" }}>{provider.current_ip ?? "—"}</code>
|
||||||
{provider.current_ip ?? "—"}
|
|
||||||
</code>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontSize: "0.8rem",
|
|
||||||
color: "var(--fg-secondary, #5b6066)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{provider.rotate_supported ? "rotate" : "статический"}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{provider.rotate_supported ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="cancel-btn"
|
|
||||||
disabled={rotateMut.isPending}
|
|
||||||
onClick={() => rotateMut.mutate()}
|
|
||||||
style={{ fontSize: "0.8rem", padding: "3px 10px" }}
|
|
||||||
>
|
|
||||||
{rotateMut.isPending ? "…" : "Сменить IP"}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontSize: "0.8rem",
|
|
||||||
color: "var(--fg-tertiary, #73767e)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
—
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{rotateMut.isSuccess && rotateMut.data.ok && (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
marginLeft: 8,
|
|
||||||
fontSize: "0.75rem",
|
|
||||||
color: "var(--success, #0a7a3a)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{rotateMut.data.new_ip ?? "обновлён"}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{rotateMut.isSuccess && !rotateMut.data.ok && (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
marginLeft: 8,
|
|
||||||
fontSize: "0.75rem",
|
|
||||||
color: "var(--danger, #b3261e)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{rotateMut.data.reason ?? "ошибка"}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{rotateMut.isError && (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
marginLeft: 8,
|
|
||||||
fontSize: "0.75rem",
|
|
||||||
color: "var(--danger, #b3261e)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{rotateMut.error.message}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
|
|
@ -236,9 +148,7 @@ export function SystemHealthSection() {
|
||||||
flexWrap: "wrap",
|
flexWrap: "wrap",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
style={{ display: "flex", alignItems: "center", gap: 8 }}
|
|
||||||
>
|
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
fontSize: "0.85rem",
|
fontSize: "0.85rem",
|
||||||
|
|
@ -249,9 +159,7 @@ export function SystemHealthSection() {
|
||||||
</span>
|
</span>
|
||||||
<FetchModeBadge mode={healthQ.data.fetch_mode} />
|
<FetchModeBadge mode={healthQ.data.fetch_mode} />
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
style={{ display: "flex", alignItems: "center", gap: 8 }}
|
|
||||||
>
|
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
fontSize: "0.85rem",
|
fontSize: "0.85rem",
|
||||||
|
|
@ -260,40 +168,36 @@ export function SystemHealthSection() {
|
||||||
>
|
>
|
||||||
Browser-сервис:
|
Browser-сервис:
|
||||||
</span>
|
</span>
|
||||||
<BrowserBadge
|
<BrowserBadge reachable={healthQ.data.browser.reachable} />
|
||||||
reachable={healthQ.data.browser.reachable}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
{Object.entries(healthQ.data.browser.browsers).map(
|
{Object.entries(healthQ.data.browser.browsers).map(([name, ok]) => (
|
||||||
([name, ok]) => (
|
<div
|
||||||
<div
|
key={name}
|
||||||
key={name}
|
style={{ display: "flex", alignItems: "center", gap: 4 }}
|
||||||
style={{ display: "flex", alignItems: "center", gap: 4 }}
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
color: "var(--fg-secondary, #5b6066)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span
|
{name}:
|
||||||
style={{
|
</span>
|
||||||
fontSize: "0.8rem",
|
<span
|
||||||
color: "var(--fg-secondary, #5b6066)",
|
role="img"
|
||||||
}}
|
aria-label={ok ? "статус: доступен" : "статус: недоступен"}
|
||||||
>
|
style={{
|
||||||
{name}:
|
width: 8,
|
||||||
</span>
|
height: 8,
|
||||||
<span
|
borderRadius: "50%",
|
||||||
role="img"
|
display: "inline-block",
|
||||||
aria-label={ok ? "статус: доступен" : "статус: недоступен"}
|
background: ok
|
||||||
style={{
|
? "var(--success, #0a7a3a)"
|
||||||
width: 8,
|
: "var(--danger, #b3261e)",
|
||||||
height: 8,
|
}}
|
||||||
borderRadius: "50%",
|
/>
|
||||||
display: "inline-block",
|
</div>
|
||||||
background: ok
|
))}
|
||||||
? "var(--success, #0a7a3a)"
|
|
||||||
: "var(--danger, #b3261e)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Per-provider proxy table */}
|
{/* Per-provider proxy table */}
|
||||||
|
|
@ -304,8 +208,6 @@ export function SystemHealthSection() {
|
||||||
<th scope="col">Провайдер</th>
|
<th scope="col">Провайдер</th>
|
||||||
<th scope="col">Прокси (host:port)</th>
|
<th scope="col">Прокси (host:port)</th>
|
||||||
<th scope="col">Текущий IP</th>
|
<th scope="col">Текущий IP</th>
|
||||||
<th scope="col">Режим</th>
|
|
||||||
<th scope="col">Действие</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|
|
||||||
|
|
@ -115,16 +115,14 @@ class ScraperConfig(Protocol):
|
||||||
scraper_fetch_mode: str
|
scraper_fetch_mode: str
|
||||||
# HTTP-эндпоинт headless-браузера (tradein-browser).
|
# HTTP-эндпоинт headless-браузера (tradein-browser).
|
||||||
browser_http_endpoint: str
|
browser_http_endpoint: str
|
||||||
# Backconnect-прокси общий (Avito/Cian fallback).
|
# Backconnect-прокси общий (Avito/Cian/Yandex, #2616 шаг 2: единственный источник).
|
||||||
scraper_proxy_url: str | None
|
scraper_proxy_url: str | None
|
||||||
# Avito rotate-прокси + лимит ротаций на прогон.
|
# Лимит IP-ротаций на прогон (changeip-ссылка снята #2616 шаг 2 — сама ротация
|
||||||
avito_proxy_rotate_url: str | None
|
# no-op, поле гейтит budget accounting в ban-rotation state machine).
|
||||||
avito_proxy_max_rotations: int
|
avito_proxy_max_rotations: int
|
||||||
# Ограничивать Avito SERP только ЕКБ.
|
# Ограничивать Avito SERP только ЕКБ.
|
||||||
avito_serp_ekb_only: bool
|
avito_serp_ekb_only: bool
|
||||||
# Yandex rotate-прокси.
|
# Cian-прокси (#2616 шаг 2: = scraper_proxy_url, per-provider override снят).
|
||||||
yandex_proxy_rotate_url: str | None
|
|
||||||
# Cian-прокси (CIAN_PROXY_URL > scraper_proxy_url fallback).
|
|
||||||
cian_proxy_url: str | None
|
cian_proxy_url: str | None
|
||||||
# Границы валидности cian-оценки, руб.
|
# Границы валидности cian-оценки, руб.
|
||||||
cian_valuation_min_rub: float
|
cian_valuation_min_rub: float
|
||||||
|
|
@ -144,12 +142,14 @@ class ScraperConfig(Protocol):
|
||||||
# changeip settle-пауза (секунды) после ротации IP.
|
# changeip settle-пауза (секунды) после ротации IP.
|
||||||
avito_proxy_rotate_settle_s: float
|
avito_proxy_rotate_settle_s: float
|
||||||
# Ретраи changeip-GET: N попыток по proxy_rotate_attempt_timeout_s каждая (#1950).
|
# Ретраи changeip-GET: N попыток по proxy_rotate_attempt_timeout_s каждая (#1950).
|
||||||
|
# changeip-ссылка снята (#2616 шаг 2) — эти два поля сейчас не читаются
|
||||||
|
# ban-rotation кодом (см. avito_proxy_max_rotations выше), оставлены как
|
||||||
|
# budget-верхняя-граница для app.tasks.avito_detail_backfill wait_for.
|
||||||
proxy_rotate_attempts: int
|
proxy_rotate_attempts: int
|
||||||
proxy_rotate_attempt_timeout_s: float
|
proxy_rotate_attempt_timeout_s: float
|
||||||
# Cian rotate-прокси + лимит ротаций.
|
# Cian лимит ротаций (rotate_url снят #2616 шаг 2, см. avito_proxy_max_rotations).
|
||||||
cian_proxy_rotate_url: str | None
|
|
||||||
cian_proxy_max_rotations: int
|
cian_proxy_max_rotations: int
|
||||||
# Yandex лимит ротаций (rotate_url уже выше).
|
# Yandex лимит ротаций (аналогично).
|
||||||
yandex_proxy_max_rotations: int
|
yandex_proxy_max_rotations: int
|
||||||
# Пропускать листинги, уже обновлённые сегодня по МСК (full-load дедуп).
|
# Пропускать листинги, уже обновлённые сегодня по МСК (full-load дедуп).
|
||||||
scraper_skip_seen_today: bool
|
scraper_skip_seen_today: bool
|
||||||
|
|
|
||||||
|
|
@ -103,77 +103,25 @@ async def _rotate_proxy_ip(
|
||||||
rotations_done: int,
|
rotations_done: int,
|
||||||
source: str = "avito",
|
source: str = "avito",
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Вызвать changeip-ссылку mobileproxy и подождать settle (#1790/#1848).
|
"""changeip-ротация mobileproxy снята (#2616 шаг 2) — все три changeip-ссылки
|
||||||
|
(avito/cian/yandex `*_PROXY_ROTATE_URL`) указывали на закрытые аккаунты
|
||||||
|
(407/connection refused, проверено вживую #2613) и удалены из ScraperConfig.
|
||||||
|
|
||||||
Provider-aware: выбирает rotate_url и max_rotations по параметру `source`
|
IP-ротация здесь физически невозможна без changeip-ссылки → всегда False.
|
||||||
(avito / cian / yandex). Fallback-цепочка для rotate_url:
|
Аналог AvitoScraper._rotate_ip (см. providers/avito/serp.py) — используется в
|
||||||
avito: avito_proxy_rotate_url
|
enrichment-фазах где нет доступа к экземпляру scraper'а. Единственный живой
|
||||||
cian: cian_proxy_rotate_url → avito_proxy_rotate_url
|
прокси (SCRAPER_PROXY_URL, ASocks) не имеет per-request changeip — его ротация
|
||||||
yandex: yandex_proxy_rotate_url → avito_proxy_rotate_url
|
— POST /api/v1/admin/proxies/{id}/rotate (app.services.proxy_rotation, #2611),
|
||||||
|
отдельный от sweep-оркестрации путь.
|
||||||
|
|
||||||
Аналог AvitoScraper._rotate_ip, но на уровне pipeline — используется в
|
Оставлена как stub (не удалена целиком): `_try_rotate_within_budget` продолжает
|
||||||
enrichment-фазах где нет доступа к экземпляру scraper'а.
|
считать бюджет ротаций точно так же, как раньше при мёртвом changeip (та же
|
||||||
|
False-ветка) — вызывающие сохраняют abort-поведение без изменений, без
|
||||||
Returns True при успешной смене IP, False если rotate_url не задан или ошибка.
|
сетевого похода в никуда.
|
||||||
Логирует каждую ротацию (причина, порядковый номер, source, остаток лимита).
|
|
||||||
"""
|
"""
|
||||||
if source == "cian":
|
logger.debug(
|
||||||
rotate_url = config.cian_proxy_rotate_url or config.avito_proxy_rotate_url
|
"pipeline: IP rotation unavailable (changeip retired #2616) — "
|
||||||
max_rot = config.cian_proxy_max_rotations
|
"source=%s reason=%s rotation=#%d",
|
||||||
elif source == "yandex":
|
|
||||||
rotate_url = config.yandex_proxy_rotate_url or config.avito_proxy_rotate_url
|
|
||||||
max_rot = config.yandex_proxy_max_rotations
|
|
||||||
else:
|
|
||||||
# avito (default) — backward compat
|
|
||||||
rotate_url = config.avito_proxy_rotate_url
|
|
||||||
max_rot = config.avito_proxy_max_rotations
|
|
||||||
if not rotate_url:
|
|
||||||
return False
|
|
||||||
sep = "&" if "?" in rotate_url else "?"
|
|
||||||
# #1950: retry changeip-GET короткими попытками вместо одношотного 30s-timeout.
|
|
||||||
# Если changeip-сервер завис на 30s → весь прогон ждёт зря + ротация считается
|
|
||||||
# успешной (False возвращался). Теперь: proxy_rotate_attempts попыток по
|
|
||||||
# proxy_rotate_attempt_timeout_s каждая; возвращаем True при первом успехе.
|
|
||||||
_attempts = config.proxy_rotate_attempts
|
|
||||||
_attempt_timeout = config.proxy_rotate_attempt_timeout_s
|
|
||||||
for attempt in range(_attempts):
|
|
||||||
try:
|
|
||||||
async with AsyncSession(timeout=_attempt_timeout) as rot:
|
|
||||||
resp = await rot.get(f"{rotate_url}{sep}format=json")
|
|
||||||
new_ip: str = ""
|
|
||||||
try:
|
|
||||||
data = resp.json()
|
|
||||||
new_ip = str(data.get("new_ip", ""))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
await asyncio.sleep(config.avito_proxy_rotate_settle_s)
|
|
||||||
logger.info(
|
|
||||||
"pipeline: IP rotated via changeip — source=%s reason=%s "
|
|
||||||
"rotation=#%d attempt=%d/%d remaining=%d new_ip=%s",
|
|
||||||
source,
|
|
||||||
reason,
|
|
||||||
rotations_done + 1,
|
|
||||||
attempt + 1,
|
|
||||||
_attempts,
|
|
||||||
max_rot - rotations_done - 1,
|
|
||||||
new_ip or "unknown",
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
except Exception:
|
|
||||||
logger.warning(
|
|
||||||
"pipeline: IP rotation attempt %d/%d failed " "(source=%s reason=%s rotation=#%d)",
|
|
||||||
attempt + 1,
|
|
||||||
_attempts,
|
|
||||||
source,
|
|
||||||
reason,
|
|
||||||
rotations_done + 1,
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
if attempt < _attempts - 1:
|
|
||||||
await asyncio.sleep(1.0)
|
|
||||||
logger.error(
|
|
||||||
"pipeline: all %d changeip attempts failed " "(source=%s reason=%s rotation=#%d)",
|
|
||||||
_attempts,
|
|
||||||
source,
|
source,
|
||||||
reason,
|
reason,
|
||||||
rotations_done + 1,
|
rotations_done + 1,
|
||||||
|
|
@ -464,7 +412,8 @@ def _avito_proxies(config: ScraperConfig) -> dict[str, str] | None:
|
||||||
detail-страницы летят с datacenter-IP → HTTP 429 → весь run mark_banned.
|
detail-страницы летят с datacenter-IP → HTTP 429 → весь run mark_banned.
|
||||||
Пусто (env не задан) → прямое подключение (dev/no-op).
|
Пусто (env не задан) → прямое подключение (dev/no-op).
|
||||||
|
|
||||||
Читает config.scraper_proxy_url (SCRAPER_PROXY_URL > AVITO_PROXY_URL fallback).
|
Читает config.scraper_proxy_url (= SCRAPER_PROXY_URL, #2616 шаг 2: legacy
|
||||||
|
AVITO_PROXY_URL fallback снят).
|
||||||
"""
|
"""
|
||||||
url = config.scraper_proxy_url
|
url = config.scraper_proxy_url
|
||||||
return {"http": url, "https": url} if url else None
|
return {"http": url, "https": url} if url else None
|
||||||
|
|
|
||||||
|
|
@ -376,22 +376,9 @@ class AvitoScraper(BaseScraper):
|
||||||
self._target_city_slug = target_city_slug
|
self._target_city_slug = target_city_slug
|
||||||
|
|
||||||
async def __aenter__(self) -> AvitoScraper:
|
async def __aenter__(self) -> AvitoScraper:
|
||||||
# Проактивная ротация IP в начале sweep (#1731): apw-IP «протухает» по
|
# Проактивная changeip-ротация в начале sweep (#1731) снята #2616 шаг 2 —
|
||||||
# Datadome между суточными прогонами, поэтому первые якоря блокируются и
|
# changeip-ссылка (мёртвый mobileproxy-аккаунт) удалена из ScraperConfig,
|
||||||
# реактивного бюджета max_rotations не хватает (3 подряд блока →
|
# _rotate_ip() ниже теперь всегда no-op (см. её docstring).
|
||||||
# mark_banned). Один changeip-вызов до первого SERP-fetch даёт свежий IP.
|
|
||||||
# _rotate_ip() сам ждёт ~9с (settle) и логирует/глотает ошибку, поэтому
|
|
||||||
# тут только дополнительный guard: сбой ротации НЕ должен ронять sweep.
|
|
||||||
if self._config.avito_proxy_rotate_url:
|
|
||||||
try:
|
|
||||||
if await self._rotate_ip():
|
|
||||||
logger.info("avito: proactive IP rotate at sweep start (#1731)")
|
|
||||||
else:
|
|
||||||
logger.warning("avito: proactive IP rotate at sweep start failed — proceeding")
|
|
||||||
except Exception:
|
|
||||||
logger.warning(
|
|
||||||
"avito: proactive IP rotate at sweep start raised — proceeding", exc_info=True
|
|
||||||
)
|
|
||||||
if self._config.scraper_fetch_mode == "browser":
|
if self._config.scraper_fetch_mode == "browser":
|
||||||
# Default (#1805): SERP-фетч через camoufox (tradein-browser per-provider
|
# Default (#1805): SERP-фетч через camoufox (tradein-browser per-provider
|
||||||
# + ротирующий backconnect-прокси). curl_cffi поднимаем тут же как
|
# + ротирующий backconnect-прокси). curl_cffi поднимаем тут же как
|
||||||
|
|
@ -442,24 +429,14 @@ class AvitoScraper(BaseScraper):
|
||||||
|
|
||||||
# ── Anti-block (#623) ─────────────────────────────────────────────────────
|
# ── Anti-block (#623) ─────────────────────────────────────────────────────
|
||||||
async def _rotate_ip(self) -> bool:
|
async def _rotate_ip(self) -> bool:
|
||||||
"""Сменить мобильный IP через changeip-ссылку mobileproxy (#623).
|
"""changeip mobileproxy-ротация снята (#2616 шаг 2) — аккаунт закрыт
|
||||||
|
(407/connection refused, проверено вживую #2613), changeip-ссылка удалена
|
||||||
Дёргается напрямую (без прокси) — это API провайдера, не Avito. Ждём ~9с:
|
из ScraperConfig. Метод оставлен (returns False) — вызывающие (proактивная
|
||||||
мобильному модему нужно время поднять новый IP. Returns True при успехе.
|
ротация at sweep start, soft-ban retry в _fetch_serp_html_*) продолжают
|
||||||
|
работать через тот же no-op путь, что и раньше при незаданном rotate_url.
|
||||||
|
Живая ротация — ASOCKS proxy_rotation (#2611), отдельный от SERP-цикла путь.
|
||||||
"""
|
"""
|
||||||
rotate_url = self._config.avito_proxy_rotate_url
|
return False
|
||||||
if not rotate_url:
|
|
||||||
return False
|
|
||||||
sep = "&" if "?" in rotate_url else "?"
|
|
||||||
try:
|
|
||||||
async with AsyncSession(timeout=30) as rot:
|
|
||||||
await rot.get(f"{rotate_url}{sep}format=json")
|
|
||||||
await asyncio.sleep(9)
|
|
||||||
logger.info("avito proxy: IP rotated via changeip")
|
|
||||||
return True
|
|
||||||
except Exception:
|
|
||||||
logger.warning("avito proxy: IP rotation failed", exc_info=True)
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def _fetch_serp_html(self, url: str, page: int) -> str | None:
|
async def _fetch_serp_html(self, url: str, page: int) -> str | None:
|
||||||
"""GET SERP HTML — dispatcher между browser-default и curl_cffi (#1805).
|
"""GET SERP HTML — dispatcher между browser-default и curl_cffi (#1805).
|
||||||
|
|
@ -508,7 +485,9 @@ class AvitoScraper(BaseScraper):
|
||||||
исчерпаны → AvitoRateLimitedError (graceful mark_banned, partial preserved).
|
исчерпаны → AvitoRateLimitedError (graceful mark_banned, partial preserved).
|
||||||
"""
|
"""
|
||||||
assert self._browser is not None
|
assert self._browser is not None
|
||||||
max_rot = self._config.avito_proxy_max_rotations if self._config.avito_proxy_rotate_url else 0
|
# changeip снят (#2616 шаг 2) — _rotate_ip() всегда False, бюджет 0 (было
|
||||||
|
# условным на avito_proxy_rotate_url, который больше не существует).
|
||||||
|
max_rot = 0
|
||||||
rot_done = 0
|
rot_done = 0
|
||||||
# Независимый бюджет коротких retry на transient sidecar-ошибки (timeout/
|
# Независимый бюджет коротких retry на transient sidecar-ошибки (timeout/
|
||||||
# crash) — НЕ тратит бюджет ротации. Каждая ветка ниже (ротация / transient)
|
# crash) — НЕ тратит бюджет ротации. Каждая ветка ниже (ротация / transient)
|
||||||
|
|
@ -641,7 +620,9 @@ class AvitoScraper(BaseScraper):
|
||||||
AvitoBlockedError / AvitoRateLimitedError — если бан не снят.
|
AvitoBlockedError / AvitoRateLimitedError — если бан не снят.
|
||||||
"""
|
"""
|
||||||
assert self._cffi is not None
|
assert self._cffi is not None
|
||||||
max_rot = self._config.avito_proxy_max_rotations if self._config.avito_proxy_rotate_url else 0
|
# changeip снят (#2616 шаг 2) — _rotate_ip() всегда False, бюджет 0 (было
|
||||||
|
# условным на avito_proxy_rotate_url, который больше не существует).
|
||||||
|
max_rot = 0
|
||||||
# curl_cffi-сессия всегда egress через self._config.scraper_proxy_url (backconnect
|
# curl_cffi-сессия всегда egress через self._config.scraper_proxy_url (backconnect
|
||||||
# mproxy). Пересоздание сессии (_reset_cffi) даёт свежий exit-IP вне зависимости
|
# mproxy). Пересоздание сессии (_reset_cffi) даёт свежий exit-IP вне зависимости
|
||||||
# от наличия changeip-URL (тот относится к ДРУГОМУ, browser/auv-прокси). Поэтому
|
# от наличия changeip-URL (тот относится к ДРУГОМУ, browser/auv-прокси). Поэтому
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,9 @@ Studio roomsTotal:
|
||||||
Response entities: roomsTotal=None -> mapped to rooms=0 (existing convention).
|
Response entities: roomsTotal=None -> mapped to rooms=0 (existing convention).
|
||||||
|
|
||||||
Strangler-копия `app.services.scrapers.yandex_realty` (#2133). Развязка от `app.*`:
|
Strangler-копия `app.services.scrapers.yandex_realty` (#2133). Развязка от `app.*`:
|
||||||
- `app.core.config.settings` → инжектируемый `ScraperConfig` (yandex_proxy_rotate_url,
|
- `app.core.config.settings` → инжектируемый `ScraperConfig` (scraper_proxy_url).
|
||||||
avito_proxy_rotate_url)
|
`_rotate_ip()` ниже — changeip-stub (#2616 шаг 2, ссылка снята вместе с мёртвой
|
||||||
|
mobileproxy-подпиской)
|
||||||
- `app.services.scraper_settings.get_scraper_delay` → инжектируемый `delay_provider`
|
- `app.services.scraper_settings.get_scraper_delay` → инжектируемый `delay_provider`
|
||||||
- `app.services.scrapers.{base,browser_fetcher,price_brackets}` → `scraper_kit.*`
|
- `app.services.scrapers.{base,browser_fetcher,price_brackets}` → `scraper_kit.*`
|
||||||
- `app.services.scrapers.house_type_normalizer` → `scraper_kit.providers.yandex.shared`
|
- `app.services.scrapers.house_type_normalizer` → `scraper_kit.providers.yandex.shared`
|
||||||
|
|
@ -498,7 +499,10 @@ class YandexRealtyScraper(BaseScraper):
|
||||||
# #12 (oblast rollout): rgid города-цели SERP-запроса (gate-API). None →
|
# #12 (oblast rollout): rgid города-цели SERP-запроса (gate-API). None →
|
||||||
# ЕКБ-дефолт (_EKB_RGID).
|
# ЕКБ-дефолт (_EKB_RGID).
|
||||||
self._rgid = city_rgid or _EKB_RGID
|
self._rgid = city_rgid or _EKB_RGID
|
||||||
# _cffi_session retained only for _rotate_ip (changeip call).
|
# _cffi_session: was created lazily inside _rotate_ip() for the changeip
|
||||||
|
# call — that call is a no-op stub since #2616 шаг 2, so this now stays
|
||||||
|
# None for the lifetime of the scraper. Attribute + __aexit__ cleanup kept
|
||||||
|
# (harmless, avoids touching the close-lifecycle contract for a doc-only fix).
|
||||||
self._cffi_session: _CurlCffiSession | None = None
|
self._cffi_session: _CurlCffiSession | None = None
|
||||||
self._cookies: dict[str, str] = {}
|
self._cookies: dict[str, str] = {}
|
||||||
# #2625: счётчики gate-API "структура извлечена" за время жизни этого
|
# #2625: счётчики gate-API "структура извлечена" за время жизни этого
|
||||||
|
|
@ -586,20 +590,14 @@ class YandexRealtyScraper(BaseScraper):
|
||||||
return _CurlResponse(status_code=200, text=json_text)
|
return _CurlResponse(status_code=200, text=json_text)
|
||||||
|
|
||||||
async def _rotate_ip(self) -> bool:
|
async def _rotate_ip(self) -> bool:
|
||||||
"""Rotate mobile proxy IP via changeip URL. Returns True on success."""
|
"""changeip mobileproxy-ротация снята (#2616 шаг 2) — YANDEX_PROXY_ROTATE_URL /
|
||||||
rotate_url = self._config.yandex_proxy_rotate_url or self._config.avito_proxy_rotate_url
|
AVITO_PROXY_ROTATE_URL указывали на закрытые аккаунты (407/connection refused,
|
||||||
if not rotate_url:
|
#2613), удалены из ScraperConfig. Метод оставлен (returns False) — вызывающие
|
||||||
return False
|
(soft-ban retry в SERP-цикле) продолжают работать через тот же no-op путь, что
|
||||||
sep = "&" if "?" in rotate_url else "?"
|
и раньше при незаданном rotate_url. Живая ротация — ASOCKS proxy_rotation
|
||||||
try:
|
(#2611), отдельный от SERP-цикла путь.
|
||||||
async with _CurlCffiSession(timeout=30) as rot:
|
"""
|
||||||
await rot.get(f"{rotate_url}{sep}format=json")
|
return False
|
||||||
await asyncio.sleep(9)
|
|
||||||
logger.info("yandex proxy: IP rotated via changeip")
|
|
||||||
return True
|
|
||||||
except Exception:
|
|
||||||
logger.warning("yandex proxy: IP rotation failed", exc_info=True)
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _build_url(
|
def _build_url(
|
||||||
self,
|
self,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue