chore(tradein): удалить мёртвые mobileproxy env-переменные и rotate-ip (#2616 шаги 2-3) (#2650)
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-frontend (push) Successful in 2m13s
Deploy Trade-In / test (push) Successful in 2m45s
Deploy Trade-In / build-browser (push) Successful in 3m2s
Deploy Trade-In / build-backend (push) Successful in 1m38s
Deploy Trade-In / deploy (push) Successful in 2m29s
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-frontend (push) Successful in 2m13s
Deploy Trade-In / test (push) Successful in 2m45s
Deploy Trade-In / build-browser (push) Successful in 3m2s
Deploy Trade-In / build-backend (push) Successful in 1m38s
Deploy Trade-In / deploy (push) Successful in 2m29s
This commit is contained in:
parent
ac0c472faf
commit
59c072fc4c
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):
|
||||
|
|
@ -2219,33 +2220,23 @@ class ScraperHealthResponse(BaseModel):
|
|||
providers: list[ProviderHealth]
|
||||
|
||||
|
||||
class RotateIpResponse(BaseModel):
|
||||
ok: bool
|
||||
new_ip: str | None = None
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
_ROTATABLE_SOURCES = ("avito", "cian", "yandex")
|
||||
|
||||
|
||||
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 {
|
||||
"avito": settings.avito_proxy_url,
|
||||
"avito": settings.scraper_proxy_url,
|
||||
"cian": settings.cian_proxy_url,
|
||||
"yandex": settings.yandex_proxy_url,
|
||||
}.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]:
|
||||
"""Распарсить host/port из proxy URL (схема http(s)://user:pass@host:port)."""
|
||||
if not proxy_url:
|
||||
|
|
@ -2344,8 +2335,10 @@ async def scraper_health() -> ScraperHealthResponse:
|
|||
|
||||
- fetch_mode: settings.scraper_fetch_mode (curl_cffi / browser).
|
||||
- browser: GET tradein-browser /health (reachable + per-browser ready-флаги).
|
||||
- providers: для avito/cian/yandex — proxy host/port, rotate_supported,
|
||||
best-effort current_ip (параллельный пробинг через прокси на ipify).
|
||||
- providers: для avito/cian/yandex — proxy host/port, rotate_supported
|
||||
(#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с.
|
||||
"""
|
||||
|
|
@ -2365,7 +2358,7 @@ async def scraper_health() -> ScraperHealthResponse:
|
|||
source=source,
|
||||
proxy_host=host,
|
||||
proxy_port=port,
|
||||
rotate_supported=bool(_provider_rotate_url(source)),
|
||||
rotate_supported=False,
|
||||
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) ──────────────────────────
|
||||
|
||||
|
||||
|
|
@ -2896,10 +2849,10 @@ def patch_proxy(
|
|||
|
||||
# ── Proxy pool: ручная ротация exit-IP по proxy_id (#2600 п.5) ───────────────
|
||||
#
|
||||
# ОТДЕЛЬНО от /scraper/{source}/rotate-ip (выше) — тот работает по env-прокси
|
||||
# mobileproxy для avito/cian/yandex (changeip-ссылка, ротация "на лету" без
|
||||
# лимитов), не трогается. Этот эндпоинт — по proxy_id из пула scrape_proxies
|
||||
# (сейчас это ASocks-порты с суточным лимитом 3/сутки), см.
|
||||
# Раньше отдельно от /scraper/{source}/rotate-ip (env-прокси mobileproxy,
|
||||
# changeip-ссылка) — тот эндпоинт удалён вместе с мёртвой подпиской (#2616 шаг 3).
|
||||
# Этот эндпоинт — единственная живая ручная ротация, по proxy_id из пула
|
||||
# scrape_proxies (сейчас это ASocks-порты с суточным лимитом 3/сутки), см.
|
||||
# app.services.proxy_rotation.rotate_proxy.
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -651,45 +651,41 @@ class Settings(BaseSettings):
|
|||
"www.domclick.ru",
|
||||
}
|
||||
|
||||
# ── Scraper mobile proxy (#806) ──────────────────────────────────────────
|
||||
# Мобильный прокси (RU, mobileproxy.space) используется ВСЕМИ scraper-сессиями:
|
||||
# Avito (#623) + Cian (#806). Datacenter-IP блокируется обоими сайтами.
|
||||
# ── Scraper mobile proxy (#806, #2616 шаг 2) ─────────────────────────────
|
||||
# Мобильный резидентный прокси (ASocks) используется ВСЕМИ scraper-сессиями:
|
||||
# Avito (#623) + Cian (#806) + Yandex. Datacenter-IP блокируется всеми тремя.
|
||||
# Пусто = прямое подключение (dev/staging без прокси).
|
||||
#
|
||||
# Приоритет ENV-переменных (precedence):
|
||||
# 1. SCRAPER_PROXY_URL — новый общий ENV; когда задан — используется первым.
|
||||
# 2. AVITO_PROXY_URL — legacy ENV; fallback, чтобы prod-серверы с уже
|
||||
# настроенным AVITO_PROXY_URL работали без изменений .env.runtime (#806).
|
||||
# property `scraper_proxy_url` реализует эту логику; используй его везде.
|
||||
# #2616 шаг 2: per-provider legacy-переменные (AVITO_PROXY_URL/CIAN_PROXY_URL/
|
||||
# YANDEX_PROXY_URL и их *_ROTATE_URL, changeip mobileproxy) удалены — указывали
|
||||
# на закрытые аккаунты (407/connection refused, проверено вживую #2613).
|
||||
# SCRAPER_PROXY_URL — единственный живой источник, общий для всех провайдеров.
|
||||
# validation_alias привязывает поле к env SCRAPER_PROXY_URL (без него
|
||||
# pydantic-settings читал бы SCRAPER_PROXY_URL_ENV по имени поля — #806 fixup).
|
||||
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
|
||||
def scraper_proxy_url(self) -> str | None:
|
||||
"""Единый прокси URL для всех scraper-сессий (Avito + Cian).
|
||||
"""Единый прокси URL для всех scraper-сессий (Avito + Cian + Yandex).
|
||||
|
||||
Приоритет: SCRAPER_PROXY_URL > AVITO_PROXY_URL > None (прямое подключение).
|
||||
Prod-серверы с существующим AVITO_PROXY_URL работают без изменений env.
|
||||
Прямая проекция SCRAPER_PROXY_URL (#2616 шаг 2: legacy AVITO_PROXY_URL
|
||||
fallback снят — мёртвая mobileproxy-переменная).
|
||||
"""
|
||||
return self.scraper_proxy_url_env or self.avito_proxy_url
|
||||
return self.scraper_proxy_url_env
|
||||
|
||||
# changeip-ссылка mobileproxy: GET меняет мобильный IP за ~9с. Дёргается при
|
||||
# детекте бана Avito перед повтором. Пусто = ротация выключена (raise сразу).
|
||||
# ENV: AVITO_PROXY_ROTATE_URL.
|
||||
avito_proxy_rotate_url: str | None = None
|
||||
# Сколько раз сменить IP при блоке прежде чем сдаться (на одну страницу).
|
||||
# #1731: 2→4 — больше шансов восстановиться mid-sweep после проактивной
|
||||
# ротации на старте (Datadome ban recovery).
|
||||
# ── Ban-recovery budget knobs (changeip-механизм снят #2616 шаг 2) ────────
|
||||
# Раньше эти поля тюнили retry/settle для GET-changeip mobileproxy
|
||||
# (AVITO_PROXY_ROTATE_URL и т.д., см. историю выше) — сама ссылка удалена
|
||||
# (закрытый аккаунт), поэтому IP-ротация сейчас всегда no-op (_rotate_ip /
|
||||
# _rotate_proxy_ip возвращают False без сетевого похода). Поля оставлены:
|
||||
# `*_proxy_max_rotations` продолжают гейтить бюджет попыток в ban-rotation
|
||||
# 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
|
||||
# Settle-sleep после changeip-вызова: мобильный модем поднимает новый IP.
|
||||
# ~9с по умолчанию (эмпирика mobileproxy.space). ENV: AVITO_PROXY_ROTATE_SETTLE_S.
|
||||
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_attempts: int = 3
|
||||
|
||||
|
|
@ -710,16 +706,12 @@ class Settings(BaseSettings):
|
|||
# False = старое поведение. ENV: AVITO_SERP_OK_NOT_BANNED.
|
||||
avito_serp_ok_not_banned: bool = True
|
||||
|
||||
# ── Cian dedicated mobile proxy (separate egress from Avito) ──────────────
|
||||
# Cian и Avito делят один мобильный IP при общем scraper_proxy_url → конкуренция
|
||||
# за единственный egress → взаимные таймауты/баны при параллельных прогонах.
|
||||
# Отдельный прокси для Cian устраняет contention. Если не задан — fallback на
|
||||
# общий scraper_proxy_url (backward-compat). ENV: CIAN_PROXY_URL.
|
||||
cian_proxy_url_env: str | None = Field(default=None, validation_alias="CIAN_PROXY_URL")
|
||||
# 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.
|
||||
# ── Cian proxy budget (#2616 шаг 2: dedicated CIAN_PROXY_URL/ROTATE_URL снят) ──
|
||||
# Раньше Cian мог получить СВОЙ мобильный прокси отдельно от Avito (контеншен на
|
||||
# общем egress); CIAN_PROXY_URL указывал на закрытый аккаунт — удалён,
|
||||
# cian_proxy_url ниже теперь = scraper_proxy_url. cian_proxy_max_rotations
|
||||
# остаётся: гейтит бюджет в ban-rotation state machine наравне с avito/yandex
|
||||
# (см. комментарий у avito_proxy_max_rotations выше — сама ротация no-op).
|
||||
# ENV: CIAN_PROXY_MAX_ROTATIONS.
|
||||
cian_proxy_max_rotations: int = 4
|
||||
|
||||
|
|
@ -739,25 +731,21 @@ class Settings(BaseSettings):
|
|||
|
||||
@property
|
||||
def cian_proxy_url(self) -> str | None:
|
||||
"""Прокси для Cian-скраперов. CIAN_PROXY_URL > scraper_proxy_url (fallback)."""
|
||||
return self.cian_proxy_url_env or self.scraper_proxy_url
|
||||
"""Прокси для Cian-скраперов (#2616 шаг 2: = scraper_proxy_url, per-provider
|
||||
override снят — свойство оставлено для scraper_kit.contracts.ScraperConfig
|
||||
совместимости)."""
|
||||
return self.scraper_proxy_url
|
||||
|
||||
# ── Yandex dedicated mobile proxy (separate egress from Avito/Cian) ────────
|
||||
# Отдельный прокси для Yandex устраняет contention при параллельных прогонах.
|
||||
# Если не задан — fallback на общий scraper_proxy_url (backward-compat).
|
||||
# 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.
|
||||
# ── Yandex proxy budget (#2616 шаг 2: dedicated YANDEX_PROXY_URL/ROTATE_URL снят) ──
|
||||
# Симметрично Cian выше — YANDEX_PROXY_URL указывал на закрытый аккаунт.
|
||||
# yandex_proxy_max_rotations остаётся для ban-rotation budget-гейта.
|
||||
# ENV: YANDEX_PROXY_MAX_ROTATIONS.
|
||||
yandex_proxy_max_rotations: int = 4
|
||||
|
||||
@property
|
||||
def yandex_proxy_url(self) -> str | None:
|
||||
"""Прокси для Yandex-скраперов. YANDEX_PROXY_URL > scraper_proxy_url (fallback)."""
|
||||
return self.yandex_proxy_url_env or self.scraper_proxy_url
|
||||
"""Прокси для Yandex-скраперов (#2616 шаг 2: = scraper_proxy_url)."""
|
||||
return self.scraper_proxy_url
|
||||
|
||||
# full_load повторный прогон в день пропускает листинги уже обновлённые сегодня
|
||||
# (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")
|
||||
|
||||
# Query-string секреты в исходящих URL сторонних API (аудит-фикс, #security-audit):
|
||||
# mobileproxy changeip-ссылка (`AVITO_PROXY_ROTATE_URL` и др., admin.py
|
||||
# rotate_proxy_ip) несёт провайдерский API-ключ в query (`?...&proxy_key=...`).
|
||||
# исторически — mobileproxy changeip-ссылка (`AVITO_PROXY_ROTATE_URL` и др.,
|
||||
# admin.rotate_proxy_ip) несла провайдерский API-ключ в query
|
||||
# (`?...&proxy_key=...`). Ручка и переменные удалены (#2616 шаг 2/3, мёртвая
|
||||
# подписка) — редактор оставлен как generic safety net (не ключ-based, любой
|
||||
# будущий query-секрет с распространённым именем параметра тоже покрыт).
|
||||
# Два независимых пути утечки в GlitchTip, зеркалящих TG-токен выше:
|
||||
# 1. `HttpxIntegration.send()` парсит URL через `parse_url(str(request.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 не сэмплится/не уходит), но молча перестанет спасать, если трейсинг
|
||||
# когда-нибудь включат.
|
||||
# 2. `include_local_variables=True` (sentry_sdk default в app/main.py — в отличие
|
||||
# от tgbot_main.py, где явно False) кладёт stack-frame locals (`rotate_url`,
|
||||
# `exc` в rotate_proxy_ip) в traceback открытым текстом.
|
||||
# от tgbot_main.py, где явно False) кладёт stack-frame locals в traceback
|
||||
# открытым текстом (был прецедент: `rotate_url`/`exc` в удалённом
|
||||
# admin.rotate_proxy_ip).
|
||||
# Как и TG-токен — full-text regex по КАЖДОЙ строке event (не ключ-based): секрет
|
||||
# может всплыть где угодно (frame locals, breadcrumb, exception message). НЕ
|
||||
# завязано на конкретного провайдера — покрывает любой query-параметр из
|
||||
# общеупотребимого набора секретных имён (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(
|
||||
r"(?i)([?&](?:api[_-]?key|proxy[_-]?key|token|secret|password|pwd|"
|
||||
|
|
|
|||
|
|
@ -37,14 +37,15 @@ PR #2609, конфликт исключён: вся новая логика ту
|
|||
|
||||
⛔ Токен никогда не должен появиться в возвращаемом клиенту reason, в тексте
|
||||
исключения, ни в одной записи scrape_proxy_rotations. Прецедент утечки через
|
||||
str(exc) — см. комментарий в app.api.v1.admin.rotate_proxy_ip (~line 2400):
|
||||
httpx-исключения несут полный request URL/детали, поэтому наружу — только
|
||||
нейтральный reason, полные детали — в лог с exc_info=True.
|
||||
str(exc) — тот же паттерн, что закрывал (до удаления #2616 шаг 3) changeip-путь
|
||||
admin.rotate_proxy_ip: httpx-исключения несут полный request URL/детали,
|
||||
поэтому наружу — только нейтральный reason, полные детали — в лог с exc_info=True.
|
||||
|
||||
⛔ Хост-пиннинг (security review PR #2611): scrape_proxies.rotate_url колонка
|
||||
НЕОДНОРОДНА — часть строк пула (id 3/4/5 на проде) несёт mobileproxy changeip-
|
||||
ссылки (`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>` ушёл бы
|
||||
на ЧУЖОЙ провайдер (mobileproxy) — плюс сам GET/POST по их changeip, вероятно,
|
||||
реально ротирует ИХ IP и тратит ИХ суточный лимит, а мы бы записали это как
|
||||
|
|
|
|||
|
|
@ -136,10 +136,6 @@ class RealScraperConfig:
|
|||
def scraper_proxy_url(self) -> str | None:
|
||||
return _settings.scraper_proxy_url
|
||||
|
||||
@property
|
||||
def avito_proxy_rotate_url(self) -> str | None:
|
||||
return _settings.avito_proxy_rotate_url
|
||||
|
||||
@property
|
||||
def avito_proxy_max_rotations(self) -> int:
|
||||
return _settings.avito_proxy_max_rotations
|
||||
|
|
@ -148,10 +144,6 @@ class RealScraperConfig:
|
|||
def avito_serp_ekb_only(self) -> bool:
|
||||
return _settings.avito_serp_ekb_only
|
||||
|
||||
@property
|
||||
def yandex_proxy_rotate_url(self) -> str | None:
|
||||
return _settings.yandex_proxy_rotate_url
|
||||
|
||||
@property
|
||||
def cian_proxy_url(self) -> str | None:
|
||||
return _settings.cian_proxy_url
|
||||
|
|
@ -189,10 +181,6 @@ class RealScraperConfig:
|
|||
def proxy_rotate_attempt_timeout_s(self) -> float:
|
||||
return _settings.proxy_rotate_attempt_timeout_s
|
||||
|
||||
@property
|
||||
def cian_proxy_rotate_url(self) -> str | None:
|
||||
return _settings.cian_proxy_rotate_url
|
||||
|
||||
@property
|
||||
def cian_proxy_max_rotations(self) -> int:
|
||||
return _settings.cian_proxy_max_rotations
|
||||
|
|
|
|||
|
|
@ -173,7 +173,7 @@ async def run_avito_detail_backfill(
|
|||
|
||||
# kit AvitoScraper требует ScraperConfig позиционно (Strangler-инжекция #2133) —
|
||||
# RealScraperConfig проксирует settings.* так же, как читал legacy-конструктор без
|
||||
# аргументов (avito_proxy_rotate_url и т.д. для _rotate_ip()).
|
||||
# аргументов (scraper_proxy_url и т.д. для _build_cffi_session()/_rotate_ip()).
|
||||
scraper = AvitoScraper(RealScraperConfig())
|
||||
start = time.monotonic()
|
||||
|
||||
|
|
|
|||
|
|
@ -141,8 +141,8 @@ async def test_backfill_processes_snapshot_to_completion() -> None:
|
|||
runs.mark_done.assert_called_once()
|
||||
runs.mark_failed.assert_not_called()
|
||||
# #2310 regression guard: kit fetch_detail silently drops the backconnect-
|
||||
# on-403 retry (and kit AvitoScraper can't read avito_proxy_rotate_url at
|
||||
# all) unless config=RealScraperConfig() is passed/injected at the call
|
||||
# on-403 retry (and kit AvitoScraper can't read scraper_proxy_url at all)
|
||||
# unless config=RealScraperConfig() is passed/injected at the call
|
||||
# site — assert_called()/call_count alone wouldn't catch someone dropping
|
||||
# that kwarg later (mirrors #2306's test_backfill_wave2.py:282-286 pattern).
|
||||
_, 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",
|
||||
use_proxy_pool_browser=use_pool,
|
||||
scraper_fetch_mode="browser",
|
||||
avito_proxy_rotate_url=None,
|
||||
scraper_proxy_url=None,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
"""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/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
|
||||
|
|
@ -147,13 +149,11 @@ def test_health_assembles_browser_and_providers(client: TestClient) -> None:
|
|||
"cian": None,
|
||||
"yandex": "http://u:p@host.y:10051",
|
||||
}
|
||||
rotate_urls = {"avito": "http://ch/changeip", "cian": None, "yandex": None}
|
||||
|
||||
with (
|
||||
patch.object(admin_module, "_probe_browser_health", fake_browser_health),
|
||||
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_rotate_url", lambda s: rotate_urls[s]),
|
||||
patch.object(admin_module.settings, "scraper_fetch_mode", "browser"),
|
||||
):
|
||||
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 by_source["avito"]["proxy_host"] == "host.a"
|
||||
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"
|
||||
# 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"]["current_ip"] is None
|
||||
assert by_source["cian"]["rotate_supported"] is False
|
||||
# yandex имеет proxy но нет rotate_url
|
||||
assert by_source["yandex"]["proxy_host"] == "host.y"
|
||||
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, "_probe_current_ip", fake_current_ip),
|
||||
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")
|
||||
|
||||
|
|
@ -214,137 +214,6 @@ def test_health_browser_unreachable(client: TestClient) -> None:
|
|||
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 ───────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -141,13 +141,19 @@ async def test_avito_fetch_detail_with_config_reconnects_on_403_like_legacy() ->
|
|||
# ── 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()
|
||||
took none, reading settings.avito_proxy_rotate_url directly at call time inside
|
||||
_rotate_ip). avito_detail_backfill.py now constructs AvitoScraper(RealScraperConfig())
|
||||
— this proves the injected config is what _rotate_ip actually reads."""
|
||||
took none, reading settings.* directly at call time). avito_detail_backfill.py
|
||||
now constructs AvitoScraper(RealScraperConfig()) — this proves the injected
|
||||
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())
|
||||
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 ──
|
||||
|
|
|
|||
|
|
@ -115,15 +115,12 @@ class _Scenario:
|
|||
scraper_fetch_mode="curl_cffi",
|
||||
browser_http_endpoint="http://browser.test/fetch",
|
||||
scraper_proxy_url=None,
|
||||
avito_proxy_rotate_url=None,
|
||||
avito_proxy_max_rotations=self.avito_proxy_max_rotations,
|
||||
avito_serp_ok_not_banned=self.avito_serp_ok_not_banned,
|
||||
avito_proxy_rotate_settle_s=0.0,
|
||||
proxy_rotate_attempts=1,
|
||||
proxy_rotate_attempt_timeout_s=1.0,
|
||||
cian_proxy_rotate_url=None,
|
||||
cian_proxy_max_rotations=0,
|
||||
yandex_proxy_rotate_url=None,
|
||||
yandex_proxy_max_rotations=0,
|
||||
scraper_skip_seen_today=False,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -83,15 +83,12 @@ def _config() -> SimpleNamespace:
|
|||
scraper_fetch_mode="curl_cffi",
|
||||
browser_http_endpoint="http://browser.test/fetch",
|
||||
scraper_proxy_url=None,
|
||||
avito_proxy_rotate_url=None,
|
||||
avito_proxy_max_rotations=0,
|
||||
avito_serp_ok_not_banned=True,
|
||||
avito_proxy_rotate_settle_s=0.0,
|
||||
proxy_rotate_attempts=1,
|
||||
proxy_rotate_attempt_timeout_s=1.0,
|
||||
cian_proxy_rotate_url=None,
|
||||
cian_proxy_max_rotations=0,
|
||||
yandex_proxy_rotate_url=None,
|
||||
yandex_proxy_max_rotations=0,
|
||||
scraper_skip_seen_today=False,
|
||||
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:
|
||||
- scraper_proxy_url property: SCRAPER_PROXY_URL takes precedence over AVITO_PROXY_URL
|
||||
- AVITO_PROXY_URL fallback works when SCRAPER_PROXY_URL is absent
|
||||
- scraper_proxy_url property: reads SCRAPER_PROXY_URL only. Legacy per-provider
|
||||
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)
|
||||
- _avito_proxies() helper returns correct dict shape
|
||||
- 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__."""
|
||||
return SimpleNamespace(
|
||||
scraper_proxy_url=scraper_proxy_url,
|
||||
# Cian scrapers read settings.cian_proxy_url (property: CIAN_PROXY_URL or
|
||||
# scraper_proxy_url fallback). Mirror scraper_proxy_url here so the existing
|
||||
# cian proxy-wiring assertions keep passing (#923 follow-up).
|
||||
# cian_proxy_url / yandex_proxy_url properties now mirror scraper_proxy_url
|
||||
# 1:1 (#2616 шаг 2: per-provider CIAN_PROXY_URL/YANDEX_PROXY_URL override
|
||||
# removed — dead mobileproxy accounts). Mirrored here for test compat.
|
||||
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_cookies_file=yandex_cookies_file,
|
||||
)
|
||||
|
|
@ -62,44 +61,23 @@ def _mock_settings(
|
|||
# pydantic-settings (requires DATABASE_URL) for pure unit tests of the rule.
|
||||
|
||||
|
||||
def _resolve_scraper_proxy_url(
|
||||
scraper_proxy_url_env: str | None, avito_proxy_url: str | None
|
||||
) -> str | None:
|
||||
"""Inline replica of Settings.scraper_proxy_url property logic."""
|
||||
return scraper_proxy_url_env or avito_proxy_url
|
||||
def _resolve_scraper_proxy_url(scraper_proxy_url_env: str | None) -> str | None:
|
||||
"""Inline replica of Settings.scraper_proxy_url property logic (#2616 шаг 2:
|
||||
legacy AVITO_PROXY_URL fallback removed — SCRAPER_PROXY_URL is now the sole
|
||||
source, projected as-is)."""
|
||||
return scraper_proxy_url_env
|
||||
|
||||
|
||||
def test_scraper_proxy_url_uses_scraper_env_when_set():
|
||||
result = _resolve_scraper_proxy_url(
|
||||
scraper_proxy_url_env="http://scraper-proxy:1234",
|
||||
avito_proxy_url="http://avito-proxy:5678",
|
||||
)
|
||||
result = _resolve_scraper_proxy_url(scraper_proxy_url_env="http://scraper-proxy:1234")
|
||||
assert result == "http://scraper-proxy:1234"
|
||||
|
||||
|
||||
def test_scraper_proxy_url_falls_back_to_avito_proxy_url():
|
||||
"""AVITO_PROXY_URL used when SCRAPER_PROXY_URL absent — zero env change on prod."""
|
||||
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)
|
||||
def test_scraper_proxy_url_none_when_absent():
|
||||
result = _resolve_scraper_proxy_url(scraper_proxy_url_env=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 ───────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
@ -121,22 +99,8 @@ def test_avito_proxies_returns_none_when_no_proxy():
|
|||
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) ─────────────────────
|
||||
# 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
|
||||
# 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
|
||||
|
|
@ -151,26 +115,17 @@ def _fresh_settings():
|
|||
|
||||
def test_settings_binds_scraper_proxy_url_env(monkeypatch):
|
||||
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"
|
||||
|
||||
|
||||
def test_settings_scraper_env_overrides_avito(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):
|
||||
def test_settings_scraper_proxy_url_none_when_unset(monkeypatch):
|
||||
monkeypatch.delenv("SCRAPER_PROXY_URL", raising=False)
|
||||
monkeypatch.setenv("AVITO_PROXY_URL", "http://avito-proxy:5678")
|
||||
assert _fresh_settings().scraper_proxy_url == "http://avito-proxy:5678"
|
||||
assert _fresh_settings().scraper_proxy_url is None
|
||||
|
||||
|
||||
def test_settings_ignores_misnamed_scraper_proxy_url_env(monkeypatch):
|
||||
"""Regression: the old wrong env name SCRAPER_PROXY_URL_ENV must NOT bind."""
|
||||
monkeypatch.delenv("SCRAPER_PROXY_URL", raising=False)
|
||||
monkeypatch.delenv("AVITO_PROXY_URL", raising=False)
|
||||
monkeypatch.setenv("SCRAPER_PROXY_URL_ENV", "http://wrong-name:1111")
|
||||
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 ─────────────────────────────────────────
|
||||
# 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(
|
||||
cian_proxy_url_env: str | None, scraper_proxy_url: str | None
|
||||
) -> str | None:
|
||||
"""Inline replica of Settings.cian_proxy_url property logic."""
|
||||
return cian_proxy_url_env or scraper_proxy_url
|
||||
def _resolve_cian_proxy_url(scraper_proxy_url: str | None) -> str | None:
|
||||
"""Inline replica of Settings.cian_proxy_url property logic (post #2616 шаг 2)."""
|
||||
return scraper_proxy_url
|
||||
|
||||
|
||||
def test_cian_proxy_url_uses_cian_env_when_set():
|
||||
"""CIAN_PROXY_URL takes precedence over shared scraper_proxy_url."""
|
||||
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_mirrors_scraper_proxy_url():
|
||||
assert _resolve_cian_proxy_url("http://shared-proxy:1111") == "http://shared-proxy:1111"
|
||||
|
||||
|
||||
def test_cian_proxy_url_falls_back_to_scraper_proxy_url():
|
||||
"""Falls back to shared scraper_proxy_url when CIAN_PROXY_URL is absent."""
|
||||
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_absent():
|
||||
assert _resolve_cian_proxy_url(None) is None
|
||||
|
||||
|
||||
def test_cian_proxy_url_none_when_both_absent():
|
||||
result = _resolve_cian_proxy_url(cian_proxy_url_env=None, scraper_proxy_url=None)
|
||||
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")
|
||||
def test_settings_cian_proxy_url_mirrors_scraper_proxy_url(monkeypatch):
|
||||
"""cian_proxy_url tracks scraper_proxy_url 1:1 — no more independent CIAN_PROXY_URL."""
|
||||
monkeypatch.setenv("SCRAPER_PROXY_URL", "http://shared-proxy:1111")
|
||||
monkeypatch.delenv("AVITO_PROXY_URL", raising=False)
|
||||
s = _fresh_settings()
|
||||
assert s.cian_proxy_url == "http://cian-proxy:2222"
|
||||
assert s.scraper_proxy_url == "http://shared-proxy:1111"
|
||||
|
||||
|
||||
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"
|
||||
assert s.cian_proxy_url == "http://shared-proxy:1111"
|
||||
assert s.cian_proxy_url == s.scraper_proxy_url
|
||||
|
|
|
|||
|
|
@ -54,15 +54,12 @@ def _config() -> SimpleNamespace:
|
|||
scraper_fetch_mode="curl_cffi",
|
||||
browser_http_endpoint="http://browser.test/fetch",
|
||||
scraper_proxy_url=None,
|
||||
avito_proxy_rotate_url=None,
|
||||
avito_proxy_max_rotations=0,
|
||||
avito_serp_ok_not_banned=True,
|
||||
avito_proxy_rotate_settle_s=0.0,
|
||||
proxy_rotate_attempts=1,
|
||||
proxy_rotate_attempt_timeout_s=1.0,
|
||||
cian_proxy_rotate_url=None,
|
||||
cian_proxy_max_rotations=0,
|
||||
yandex_proxy_rotate_url=None,
|
||||
yandex_proxy_max_rotations=0,
|
||||
scraper_skip_seen_today=False,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -20,8 +20,6 @@ from scraper_kit.providers.yandex.valuation import YandexValuationScraper
|
|||
|
||||
_KIT_CONFIG = SimpleNamespace(
|
||||
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(
|
||||
scraper_proxy_url=None,
|
||||
yandex_proxy_rotate_url=None,
|
||||
avito_proxy_rotate_url=None,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -19,9 +19,10 @@ Per-provider модель (#1793):
|
|||
Вместо одного глобального браузера + одного egress-прокси держим ОТДЕЛЬНЫЙ
|
||||
инстанс camoufox на каждого поставщика (avito/cian/yandex/generic). Поставщик
|
||||
определяется по 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 гоняются одновременно);
|
||||
|
|
@ -59,23 +60,19 @@ Per-provider модель (#1793):
|
|||
глобальный BROWSER_MIN_PAGE_INTERVAL_S. Backward-compat:
|
||||
при незаданных per-provider env поведение идентично
|
||||
предыдущему (чисто аддитивный override).
|
||||
BROWSER_PROXY_AVITO — http-прокси для avito (fallback AVITO_PROXY_URL).
|
||||
BROWSER_PROXY_CIAN — http-прокси для cian (fallback CIAN_PROXY_URL).
|
||||
BROWSER_PROXY_YANDEX — http-прокси для yandex (fallback YANDEX_PROXY_URL).
|
||||
SCRAPER_PROXY_URL — http-прокси для generic (fallback AVITO_PROXY_URL).
|
||||
|
||||
ВАЖНО: для браузера используем именно BROWSER_PROXY_* (http-прокси). Legacy
|
||||
CIAN_PROXY_URL/YANDEX_PROXY_URL могут быть socks5 с авторизацией — playwright
|
||||
их не умеет, поэтому они лишь fallback (если кто-то положил http туда же).
|
||||
SCRAPER_PROXY_URL — http-прокси, ОБЩИЙ для всех поставщиков (avito/cian/
|
||||
yandex/generic). #2616 шаг 2: per-provider
|
||||
BROWSER_PROXY_AVITO/CIAN/YANDEX и legacy
|
||||
AVITO_PROXY_URL/CIAN_PROXY_URL/YANDEX_PROXY_URL сняты —
|
||||
все указывали на закрытые mobileproxy-аккаунты
|
||||
(407/connection refused, проверено вживую #2613).
|
||||
|
||||
ENVIRONMENT — "production" в прод-контейнерах, иначе "dev" (дефолт).
|
||||
#2616 шаг 1: прод + нет НИ override в теле, НИ ЛЮБОГО
|
||||
env-прокси для provider'а → отказ (503, явная причина),
|
||||
а НЕ launch camoufox без proxy (= прямое подключение с IP
|
||||
#2616 шаг 1: прод + нет НИ override в теле, НИ
|
||||
SCRAPER_PROXY_URL → отказ (503, явная причина), а НЕ
|
||||
launch camoufox без proxy (= прямое подключение с IP
|
||||
сервера). В dev отсутствие прокси легитимно (см.
|
||||
_no_live_proxy). Env-переменная, которая ЗАДАНА, но мертва
|
||||
(407/connection refused — #2613) — этот guard НЕ триггерит,
|
||||
удаление мёртвых переменных отдельным шагом ПОСЛЕ #2616.
|
||||
_no_live_proxy).
|
||||
|
||||
Контракт /login (провалидировано вживую 2026-05-31, Cian email+пароль без SMS):
|
||||
pre_click_selectors — список селекторов для последовательного клика до формы;
|
||||
|
|
@ -226,32 +223,28 @@ def _provider_for_url(url: str) -> str:
|
|||
|
||||
|
||||
def _provider_proxy(provider: str) -> str | None:
|
||||
"""Возвращает http-прокси для поставщика из env (с фолбэком), иначе None.
|
||||
"""Возвращает http-прокси для поставщика из env, иначе None.
|
||||
|
||||
Используем BROWSER_PROXY_* как primary — это http-прокси для браузера.
|
||||
Legacy *_PROXY_URL — лишь fallback: playwright НЕ умеет socks5 с авторизацией,
|
||||
поэтому socks5-варианты CIAN/YANDEX как primary не годятся.
|
||||
#2616 шаг 2: per-provider BROWSER_PROXY_AVITO/CIAN/YANDEX и legacy
|
||||
AVITO_PROXY_URL/CIAN_PROXY_URL/YANDEX_PROXY_URL сняты — все указывали на
|
||||
закрытые mobileproxy-аккаунты (#2613). SCRAPER_PROXY_URL (ASocks, http-прокси,
|
||||
playwright-совместимый) — единственный источник, общий для ВСЕХ поставщиков
|
||||
(включая generic). `provider` оставлен параметром сигнатуры (вызывающие
|
||||
передают его) — на случай возврата к per-provider egress в будущем.
|
||||
"""
|
||||
env_map: dict[str, tuple[str, str]] = {
|
||||
"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)
|
||||
return os.environ.get("SCRAPER_PROXY_URL")
|
||||
|
||||
|
||||
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 issue
|
||||
пункт 2). Отдельная функция от `_provider_proxy`, потому что "нет живого прокси"
|
||||
здесь означает буквально "нет НИКАКОГО прокси" (ни override, ни env) — единственный
|
||||
случай, который код МОЖЕТ проверить без сетевой пробы. Env-переменная, что задана
|
||||
но мертва (407/connection refused — проверено вживую #2613), эту функцию не
|
||||
триггерит: удаление мёртвых переменных — отдельный шаг #2616 ПОСЛЕ этой правки.
|
||||
но мертва (407/connection refused), этот guard не триггерит — за живость значения
|
||||
отвечает мониторинг прокси-пула, не этот код.
|
||||
"""
|
||||
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;
|
||||
иначе host-detect; иначе generic.
|
||||
|
||||
# domclick (source='domklik') не имеет выделенного BROWSER_PROXY_DOMCLICK в prod
|
||||
# → generic. Намеренно. ('domklik' нет в PROVIDERS → fallthrough на host-detect
|
||||
# domclick.ru → generic.)
|
||||
# domclick (source='domklik') нет в PROVIDERS → fallthrough на host-detect
|
||||
# domclick.ru → generic. Намеренно (все поставщики делят один SCRAPER_PROXY_URL
|
||||
# с #2616 шаг 2, generic-путь эквивалентен).
|
||||
"""
|
||||
explicit = body.get("provider") or body.get("source")
|
||||
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) кладёт в тело
|
||||
``{"proxy": "http://user:pass@host:port", "proxy_kind": "http"}`` — прокси, взятый из
|
||||
пула через acquire(source). Если поля нет / пусто → None → инстанс поставщика берёт
|
||||
свой env-прокси (BROWSER_PROXY_*), поведение не меняется (ship-dark parity).
|
||||
общий SCRAPER_PROXY_URL (#2616 шаг 2), поведение не меняется (ship-dark parity).
|
||||
|
||||
camoufox НЕ умеет socks5 с авторизацией → принимаем ТОЛЬКО http/https-прокси; иной
|
||||
proxy_kind игнорируем (→ None, env-fallback) с предупреждением. Отсутствие proxy_kind
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@
|
|||
|
||||
Проверяет ядро per-provider модели:
|
||||
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;
|
||||
4. конкурентность: два /fetch к РАЗНЫМ поставщикам идут параллельно (разные локи),
|
||||
два к ОДНОМУ — сериализованы (один лок). Через _do_fetch с asyncio.Event и
|
||||
|
|
@ -79,57 +80,21 @@ def test_provider_for_url_generic_fallback() -> None:
|
|||
# ── _provider_proxy ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_provider_proxy_uses_browser_proxy_primary(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""BROWSER_PROXY_* — primary источник для каждого поставщика."""
|
||||
monkeypatch.setenv("BROWSER_PROXY_AVITO", "http://u:p@avito-proxy:8080")
|
||||
monkeypatch.setenv("BROWSER_PROXY_CIAN", "http://u:p@cian-proxy:8080")
|
||||
monkeypatch.setenv("BROWSER_PROXY_YANDEX", "http://u:p@yandex-proxy:8080")
|
||||
assert server._provider_proxy("avito") == "http://u:p@avito-proxy:8080"
|
||||
assert server._provider_proxy("cian") == "http://u:p@cian-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_shared_across_providers(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""#2616 шаг 2: SCRAPER_PROXY_URL — единственный источник, общий для всех."""
|
||||
monkeypatch.setenv("SCRAPER_PROXY_URL", "http://u:p@shared-proxy:8080")
|
||||
assert server._provider_proxy("avito") == "http://u:p@shared-proxy:8080"
|
||||
assert server._provider_proxy("cian") == "http://u:p@shared-proxy:8080"
|
||||
assert server._provider_proxy("yandex") == "http://u:p@shared-proxy:8080"
|
||||
assert server._provider_proxy("generic") == "http://u:p@shared-proxy:8080"
|
||||
|
||||
|
||||
def test_provider_proxy_none_when_unset(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Ни одной env → None (direct, как одиночный браузер без прокси)."""
|
||||
for var in (
|
||||
"BROWSER_PROXY_CIAN",
|
||||
"CIAN_PROXY_URL",
|
||||
):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
"""SCRAPER_PROXY_URL не задан → None (direct, как одиночный браузер без прокси)."""
|
||||
monkeypatch.delenv("SCRAPER_PROXY_URL", raising=False)
|
||||
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 ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
@ -158,8 +123,8 @@ def test_resolve_provider_uses_source_field() -> None:
|
|||
def test_resolve_provider_domclick_source_to_generic() -> None:
|
||||
"""domclick scraper шлёт source='domklik' (нет в PROVIDERS) + хост domclick.ru.
|
||||
|
||||
'domklik' не валиден → host-detect domclick.ru → generic. Намеренно: в prod
|
||||
нет выделенного BROWSER_PROXY_DOMCLICK.
|
||||
'domklik' не валиден → host-detect domclick.ru → generic. Намеренно: 'domklik'
|
||||
не входит в PROVIDERS (нет выделенного instance).
|
||||
"""
|
||||
assert (
|
||||
server._resolve_provider({"source": "domklik"}, "https://domclick.ru/x") == "generic"
|
||||
|
|
|
|||
|
|
@ -1,13 +1,12 @@
|
|||
"""test_server_no_proxy_refusal.py — #2616 шаг 1: не идти напрямую с IP сервера.
|
||||
|
||||
tradein-browser (per-provider camoufox) раньше в прод-контейнере, если ни тело
|
||||
запроса, ни ЛЮБАЯ env-переменная не несли proxy, лениво запускал camoufox БЕЗ
|
||||
proxy — прямое подключение с IP сервера. Мертвые env-переменные (407/connection
|
||||
refused, #2613) сегодня этого не триггерят (запуск падает раньше, на invalid-proxy
|
||||
проверке camoufox) — но как только шаг 2 issue #2616 их удалит, сценарий станет
|
||||
реальным. Этот файл фиксирует контракт ЗАРАНЕЕ (`_no_live_proxy` + `IS_PROD` guard
|
||||
в fetch_handler/fetch_json_handler/login_handler), до того как #2616 шаг 2 сделает
|
||||
его наблюдаемым в проде.
|
||||
запроса, ни SCRAPER_PROXY_URL не несли proxy, лениво запускал camoufox БЕЗ
|
||||
proxy — прямое подключение с IP сервера. #2616 шаг 2 удалил мёртвые per-provider
|
||||
переменные (AVITO_PROXY_URL/BROWSER_PROXY_* и т.д., 407/connection refused,
|
||||
#2613) — SCRAPER_PROXY_URL остался единственным источником. Этот файл фиксирует
|
||||
контракт (`_no_live_proxy` + `IS_PROD` guard в
|
||||
fetch_handler/fetch_json_handler/login_handler).
|
||||
|
||||
Проверяет:
|
||||
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:
|
||||
"""Чистое per-provider состояние + НЕТ live env-прокси ни для одного провайдера.
|
||||
|
||||
Явно чистим все env-переменные, которые `_provider_proxy` умеет читать — тест
|
||||
не должен зависеть от того, что реально задано в shell/CI-раннере.
|
||||
Явно чистим SCRAPER_PROXY_URL (единственная переменная, которую `_provider_proxy`
|
||||
читает, #2616 шаг 2) — тест не должен зависеть от того, что реально задано в
|
||||
shell/CI-раннере.
|
||||
"""
|
||||
monkeypatch.setattr(server, "_browsers", {})
|
||||
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, "_launched_proxy", {})
|
||||
monkeypatch.setattr(server, "_locks_guard", asyncio.Lock())
|
||||
for var in (
|
||||
"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)
|
||||
monkeypatch.delenv("SCRAPER_PROXY_URL", raising=False)
|
||||
|
||||
|
||||
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:
|
||||
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
|
||||
|
||||
|
||||
|
|
@ -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:
|
||||
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]] = []
|
||||
|
||||
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"]
|
||||
|
||||
# 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:
|
||||
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:
|
||||
"""proxy_override=None (флаг off) + инстанс на env → НЕ релончим (golden-parity)."""
|
||||
for var in ("BROWSER_PROXY_AVITO", "AVITO_PROXY_URL"):
|
||||
monkeypatch.setenv(var, "http://env:8080")
|
||||
monkeypatch.setenv("SCRAPER_PROXY_URL", "http://env:8080")
|
||||
calls = _install_fake_launch(monkeypatch)
|
||||
|
||||
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:
|
||||
"""env-launched инстанс + пришёл pool-proxy → relaunch (реальная смена)."""
|
||||
for var in ("BROWSER_PROXY_AVITO", "AVITO_PROXY_URL"):
|
||||
monkeypatch.setenv(var, "http://env:8080")
|
||||
monkeypatch.setenv("SCRAPER_PROXY_URL", "http://env:8080")
|
||||
calls = _install_fake_launch(monkeypatch)
|
||||
|
||||
async def _run() -> None:
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
useScraperHealth,
|
||||
useRotateIp,
|
||||
} from "@/components/scrapers/ProxyHealthCard";
|
||||
import { useScraperHealth } from "@/components/scrapers/ProxyHealthCard";
|
||||
|
||||
// ── 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 {
|
||||
source: string;
|
||||
|
|
@ -13,7 +14,6 @@ interface ProviderProxySectionProps {
|
|||
|
||||
export function ProviderProxySection({ source }: ProviderProxySectionProps) {
|
||||
const healthQ = useScraperHealth();
|
||||
const rotateMut = useRotateIp(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>
|
||||
{provider.current_ip ? (
|
||||
<code style={{ fontSize: "0.85rem" }}>
|
||||
{provider.current_ip}
|
||||
</code>
|
||||
<code style={{ fontSize: "0.85rem" }}>{provider.current_ip}</code>
|
||||
) : (
|
||||
<span style={{ color: "var(--fg-tertiary, #73767e)" }}>—</span>
|
||||
)}
|
||||
</span>
|
||||
</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>
|
||||
|
||||
{provider.rotate_supported && (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
)}
|
||||
<p className="scraper-hint" style={{ margin: 0 }}>
|
||||
Ручная ротация IP для этого провайдера недоступна — резидентный прокси
|
||||
ротируется через ASOCKS-пул (см. страницу «Прокси»).
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────
|
||||
|
|
@ -9,6 +9,10 @@ export interface ProviderHealth {
|
|||
source: string;
|
||||
proxy_host: string | 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;
|
||||
current_ip: string | null;
|
||||
}
|
||||
|
|
@ -24,12 +28,6 @@ export interface ScraperHealthResp {
|
|||
providers: ProviderHealth[];
|
||||
}
|
||||
|
||||
interface RotateIpResp {
|
||||
ok: boolean;
|
||||
new_ip: string | null;
|
||||
reason: string | null;
|
||||
}
|
||||
|
||||
// ── Hooks ──────────────────────────────────────────────────────────────────
|
||||
|
||||
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 ──────────────────────────────────────────────────────────
|
||||
|
||||
function FetchModeBadge({ mode }: { mode: string }) {
|
||||
|
|
@ -71,9 +55,7 @@ function FetchModeBadge({ mode }: { mode: string }) {
|
|||
background: isBrowser
|
||||
? "var(--accent-soft, #dbeafe)"
|
||||
: "var(--warn-soft, #fef3c7)",
|
||||
color: isBrowser
|
||||
? "var(--accent, #1d4ed8)"
|
||||
: "var(--warn, #9a6700)",
|
||||
color: isBrowser ? "var(--accent, #1d4ed8)" : "var(--warn, #9a6700)",
|
||||
fontFamily: "ui-monospace, monospace",
|
||||
}}
|
||||
>
|
||||
|
|
@ -94,9 +76,7 @@ function BrowserBadge({ reachable }: { reachable: boolean }) {
|
|||
background: reachable
|
||||
? "var(--success-soft, #dcfce7)"
|
||||
: "var(--danger-soft, #fee2e2)",
|
||||
color: reachable
|
||||
? "var(--success, #0a7a3a)"
|
||||
: "var(--danger, #b3261e)",
|
||||
color: reachable ? "var(--success, #0a7a3a)" : "var(--danger, #b3261e)",
|
||||
}}
|
||||
>
|
||||
{reachable ? "доступен" : "недоступен"}
|
||||
|
|
@ -109,7 +89,6 @@ interface ProviderRowProps {
|
|||
}
|
||||
|
||||
function ProviderProxyRow({ provider }: ProviderRowProps) {
|
||||
const rotateMut = useRotateIp(provider.source);
|
||||
const proxyStr =
|
||||
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>
|
||||
</td>
|
||||
<td>
|
||||
<code style={{ fontSize: "0.8rem" }}>
|
||||
{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>
|
||||
)}
|
||||
<code style={{ fontSize: "0.8rem" }}>{provider.current_ip ?? "—"}</code>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
|
|
@ -236,9 +148,7 @@ export function SystemHealthSection() {
|
|||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{ display: "flex", alignItems: "center", gap: 8 }}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.85rem",
|
||||
|
|
@ -249,9 +159,7 @@ export function SystemHealthSection() {
|
|||
</span>
|
||||
<FetchModeBadge mode={healthQ.data.fetch_mode} />
|
||||
</div>
|
||||
<div
|
||||
style={{ display: "flex", alignItems: "center", gap: 8 }}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.85rem",
|
||||
|
|
@ -260,40 +168,36 @@ export function SystemHealthSection() {
|
|||
>
|
||||
Browser-сервис:
|
||||
</span>
|
||||
<BrowserBadge
|
||||
reachable={healthQ.data.browser.reachable}
|
||||
/>
|
||||
<BrowserBadge reachable={healthQ.data.browser.reachable} />
|
||||
</div>
|
||||
{Object.entries(healthQ.data.browser.browsers).map(
|
||||
([name, ok]) => (
|
||||
<div
|
||||
key={name}
|
||||
style={{ display: "flex", alignItems: "center", gap: 4 }}
|
||||
{Object.entries(healthQ.data.browser.browsers).map(([name, ok]) => (
|
||||
<div
|
||||
key={name}
|
||||
style={{ display: "flex", alignItems: "center", gap: 4 }}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.8rem",
|
||||
color: "var(--fg-secondary, #5b6066)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.8rem",
|
||||
color: "var(--fg-secondary, #5b6066)",
|
||||
}}
|
||||
>
|
||||
{name}:
|
||||
</span>
|
||||
<span
|
||||
role="img"
|
||||
aria-label={ok ? "статус: доступен" : "статус: недоступен"}
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: "50%",
|
||||
display: "inline-block",
|
||||
background: ok
|
||||
? "var(--success, #0a7a3a)"
|
||||
: "var(--danger, #b3261e)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
{name}:
|
||||
</span>
|
||||
<span
|
||||
role="img"
|
||||
aria-label={ok ? "статус: доступен" : "статус: недоступен"}
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: "50%",
|
||||
display: "inline-block",
|
||||
background: ok
|
||||
? "var(--success, #0a7a3a)"
|
||||
: "var(--danger, #b3261e)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Per-provider proxy table */}
|
||||
|
|
@ -304,8 +208,6 @@ export function SystemHealthSection() {
|
|||
<th scope="col">Провайдер</th>
|
||||
<th scope="col">Прокси (host:port)</th>
|
||||
<th scope="col">Текущий IP</th>
|
||||
<th scope="col">Режим</th>
|
||||
<th scope="col">Действие</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
|
|||
|
|
@ -115,16 +115,14 @@ class ScraperConfig(Protocol):
|
|||
scraper_fetch_mode: str
|
||||
# HTTP-эндпоинт headless-браузера (tradein-browser).
|
||||
browser_http_endpoint: str
|
||||
# Backconnect-прокси общий (Avito/Cian fallback).
|
||||
# Backconnect-прокси общий (Avito/Cian/Yandex, #2616 шаг 2: единственный источник).
|
||||
scraper_proxy_url: str | None
|
||||
# Avito rotate-прокси + лимит ротаций на прогон.
|
||||
avito_proxy_rotate_url: str | None
|
||||
# Лимит IP-ротаций на прогон (changeip-ссылка снята #2616 шаг 2 — сама ротация
|
||||
# no-op, поле гейтит budget accounting в ban-rotation state machine).
|
||||
avito_proxy_max_rotations: int
|
||||
# Ограничивать Avito SERP только ЕКБ.
|
||||
avito_serp_ekb_only: bool
|
||||
# Yandex rotate-прокси.
|
||||
yandex_proxy_rotate_url: str | None
|
||||
# Cian-прокси (CIAN_PROXY_URL > scraper_proxy_url fallback).
|
||||
# Cian-прокси (#2616 шаг 2: = scraper_proxy_url, per-provider override снят).
|
||||
cian_proxy_url: str | None
|
||||
# Границы валидности cian-оценки, руб.
|
||||
cian_valuation_min_rub: float
|
||||
|
|
@ -144,12 +142,14 @@ class ScraperConfig(Protocol):
|
|||
# changeip settle-пауза (секунды) после ротации IP.
|
||||
avito_proxy_rotate_settle_s: float
|
||||
# Ретраи 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_attempt_timeout_s: float
|
||||
# Cian rotate-прокси + лимит ротаций.
|
||||
cian_proxy_rotate_url: str | None
|
||||
# Cian лимит ротаций (rotate_url снят #2616 шаг 2, см. avito_proxy_max_rotations).
|
||||
cian_proxy_max_rotations: int
|
||||
# Yandex лимит ротаций (rotate_url уже выше).
|
||||
# Yandex лимит ротаций (аналогично).
|
||||
yandex_proxy_max_rotations: int
|
||||
# Пропускать листинги, уже обновлённые сегодня по МСК (full-load дедуп).
|
||||
scraper_skip_seen_today: bool
|
||||
|
|
|
|||
|
|
@ -103,77 +103,25 @@ async def _rotate_proxy_ip(
|
|||
rotations_done: int,
|
||||
source: str = "avito",
|
||||
) -> 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`
|
||||
(avito / cian / yandex). Fallback-цепочка для rotate_url:
|
||||
avito: avito_proxy_rotate_url
|
||||
cian: cian_proxy_rotate_url → avito_proxy_rotate_url
|
||||
yandex: yandex_proxy_rotate_url → avito_proxy_rotate_url
|
||||
IP-ротация здесь физически невозможна без changeip-ссылки → всегда False.
|
||||
Аналог AvitoScraper._rotate_ip (см. providers/avito/serp.py) — используется в
|
||||
enrichment-фазах где нет доступа к экземпляру scraper'а. Единственный живой
|
||||
прокси (SCRAPER_PROXY_URL, ASocks) не имеет per-request changeip — его ротация
|
||||
— POST /api/v1/admin/proxies/{id}/rotate (app.services.proxy_rotation, #2611),
|
||||
отдельный от sweep-оркестрации путь.
|
||||
|
||||
Аналог AvitoScraper._rotate_ip, но на уровне pipeline — используется в
|
||||
enrichment-фазах где нет доступа к экземпляру scraper'а.
|
||||
|
||||
Returns True при успешной смене IP, False если rotate_url не задан или ошибка.
|
||||
Логирует каждую ротацию (причина, порядковый номер, source, остаток лимита).
|
||||
Оставлена как stub (не удалена целиком): `_try_rotate_within_budget` продолжает
|
||||
считать бюджет ротаций точно так же, как раньше при мёртвом changeip (та же
|
||||
False-ветка) — вызывающие сохраняют abort-поведение без изменений, без
|
||||
сетевого похода в никуда.
|
||||
"""
|
||||
if source == "cian":
|
||||
rotate_url = config.cian_proxy_rotate_url or config.avito_proxy_rotate_url
|
||||
max_rot = config.cian_proxy_max_rotations
|
||||
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,
|
||||
logger.debug(
|
||||
"pipeline: IP rotation unavailable (changeip retired #2616) — "
|
||||
"source=%s reason=%s rotation=#%d",
|
||||
source,
|
||||
reason,
|
||||
rotations_done + 1,
|
||||
|
|
@ -464,7 +412,8 @@ def _avito_proxies(config: ScraperConfig) -> dict[str, str] | None:
|
|||
detail-страницы летят с datacenter-IP → HTTP 429 → весь run mark_banned.
|
||||
Пусто (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
|
||||
return {"http": url, "https": url} if url else None
|
||||
|
|
|
|||
|
|
@ -376,22 +376,9 @@ class AvitoScraper(BaseScraper):
|
|||
self._target_city_slug = target_city_slug
|
||||
|
||||
async def __aenter__(self) -> AvitoScraper:
|
||||
# Проактивная ротация IP в начале sweep (#1731): apw-IP «протухает» по
|
||||
# Datadome между суточными прогонами, поэтому первые якоря блокируются и
|
||||
# реактивного бюджета max_rotations не хватает (3 подряд блока →
|
||||
# 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
|
||||
)
|
||||
# Проактивная changeip-ротация в начале sweep (#1731) снята #2616 шаг 2 —
|
||||
# changeip-ссылка (мёртвый mobileproxy-аккаунт) удалена из ScraperConfig,
|
||||
# _rotate_ip() ниже теперь всегда no-op (см. её docstring).
|
||||
if self._config.scraper_fetch_mode == "browser":
|
||||
# Default (#1805): SERP-фетч через camoufox (tradein-browser per-provider
|
||||
# + ротирующий backconnect-прокси). curl_cffi поднимаем тут же как
|
||||
|
|
@ -442,24 +429,14 @@ class AvitoScraper(BaseScraper):
|
|||
|
||||
# ── Anti-block (#623) ─────────────────────────────────────────────────────
|
||||
async def _rotate_ip(self) -> bool:
|
||||
"""Сменить мобильный IP через changeip-ссылку mobileproxy (#623).
|
||||
|
||||
Дёргается напрямую (без прокси) — это API провайдера, не Avito. Ждём ~9с:
|
||||
мобильному модему нужно время поднять новый IP. Returns True при успехе.
|
||||
"""changeip mobileproxy-ротация снята (#2616 шаг 2) — аккаунт закрыт
|
||||
(407/connection refused, проверено вживую #2613), changeip-ссылка удалена
|
||||
из ScraperConfig. Метод оставлен (returns False) — вызывающие (proактивная
|
||||
ротация 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
|
||||
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
|
||||
return False
|
||||
|
||||
async def _fetch_serp_html(self, url: str, page: int) -> str | None:
|
||||
"""GET SERP HTML — dispatcher между browser-default и curl_cffi (#1805).
|
||||
|
|
@ -508,7 +485,9 @@ class AvitoScraper(BaseScraper):
|
|||
исчерпаны → AvitoRateLimitedError (graceful mark_banned, partial preserved).
|
||||
"""
|
||||
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
|
||||
# Независимый бюджет коротких retry на transient sidecar-ошибки (timeout/
|
||||
# crash) — НЕ тратит бюджет ротации. Каждая ветка ниже (ротация / transient)
|
||||
|
|
@ -641,7 +620,9 @@ class AvitoScraper(BaseScraper):
|
|||
AvitoBlockedError / AvitoRateLimitedError — если бан не снят.
|
||||
"""
|
||||
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
|
||||
# mproxy). Пересоздание сессии (_reset_cffi) даёт свежий exit-IP вне зависимости
|
||||
# от наличия changeip-URL (тот относится к ДРУГОМУ, browser/auv-прокси). Поэтому
|
||||
|
|
|
|||
|
|
@ -24,8 +24,9 @@ Studio roomsTotal:
|
|||
Response entities: roomsTotal=None -> mapped to rooms=0 (existing convention).
|
||||
|
||||
Strangler-копия `app.services.scrapers.yandex_realty` (#2133). Развязка от `app.*`:
|
||||
- `app.core.config.settings` → инжектируемый `ScraperConfig` (yandex_proxy_rotate_url,
|
||||
avito_proxy_rotate_url)
|
||||
- `app.core.config.settings` → инжектируемый `ScraperConfig` (scraper_proxy_url).
|
||||
`_rotate_ip()` ниже — changeip-stub (#2616 шаг 2, ссылка снята вместе с мёртвой
|
||||
mobileproxy-подпиской)
|
||||
- `app.services.scraper_settings.get_scraper_delay` → инжектируемый `delay_provider`
|
||||
- `app.services.scrapers.{base,browser_fetcher,price_brackets}` → `scraper_kit.*`
|
||||
- `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 →
|
||||
# ЕКБ-дефолт (_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._cookies: dict[str, str] = {}
|
||||
# #2625: счётчики gate-API "структура извлечена" за время жизни этого
|
||||
|
|
@ -586,20 +590,14 @@ class YandexRealtyScraper(BaseScraper):
|
|||
return _CurlResponse(status_code=200, text=json_text)
|
||||
|
||||
async def _rotate_ip(self) -> bool:
|
||||
"""Rotate mobile proxy IP via changeip URL. Returns True on success."""
|
||||
rotate_url = self._config.yandex_proxy_rotate_url or self._config.avito_proxy_rotate_url
|
||||
if not rotate_url:
|
||||
return False
|
||||
sep = "&" if "?" in rotate_url else "?"
|
||||
try:
|
||||
async with _CurlCffiSession(timeout=30) as rot:
|
||||
await rot.get(f"{rotate_url}{sep}format=json")
|
||||
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
|
||||
"""changeip mobileproxy-ротация снята (#2616 шаг 2) — YANDEX_PROXY_ROTATE_URL /
|
||||
AVITO_PROXY_ROTATE_URL указывали на закрытые аккаунты (407/connection refused,
|
||||
#2613), удалены из ScraperConfig. Метод оставлен (returns False) — вызывающие
|
||||
(soft-ban retry в SERP-цикле) продолжают работать через тот же no-op путь, что
|
||||
и раньше при незаданном rotate_url. Живая ротация — ASOCKS proxy_rotation
|
||||
(#2611), отдельный от SERP-цикла путь.
|
||||
"""
|
||||
return False
|
||||
|
||||
def _build_url(
|
||||
self,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue