Авито: добор карточек больше не сжигает 1600 попыток на упавшем сайдкаре и везде ходит браузером #3560
9 changed files with 289 additions and 55 deletions
|
|
@ -1194,14 +1194,6 @@ class Settings(BaseSettings):
|
|||
# False = старое поведение (без фильтра). ENV: AVITO_SERP_EKB_ONLY.
|
||||
avito_serp_ekb_only: bool = Field(default=True, validation_alias="AVITO_SERP_EKB_ONLY")
|
||||
|
||||
# ── Yandex SERP cookies (#801/T4) ───────────────────────────────────────
|
||||
# Путь к JSON-файлу с cookies браузера (формат: [{name, value, ...}, ...]).
|
||||
# Если задан и файл существует — cookies передаются в curl_cffi-сессию при
|
||||
# Yandex SERP-запросах; снижает вероятность captcha на datacenter IP.
|
||||
# Пусто / файл не найден = запросы без cookies (не падаем, только warning).
|
||||
# ENV: YANDEX_COOKIES_FILE.
|
||||
yandex_cookies_file: str | None = None
|
||||
|
||||
# ── #639: Cian browser auto-login (Variant B) ────────────────────────────
|
||||
# Провалидировано вживую 2026-05-31: email+пароль, без SMS/капчи. Флоу 2-шаговый
|
||||
# (после 1-го сабмита экран «Введите пароль» → повтор). Селекторы env-overridable.
|
||||
|
|
@ -1221,25 +1213,19 @@ class Settings(BaseSettings):
|
|||
cian_login_success_cookie: str = "DMIR_AUTH"
|
||||
cian_login_wait_ms: int = 4000
|
||||
|
||||
# detail_backfill через curl_cffi+backconnect (mproxy) вместо браузера/auv.
|
||||
# SERP (full_load/city_sweep) и detail_backfill делят один прокси-аккаунт auv
|
||||
# (~5 параллельных коннектов); browser-фетч в backfill открывает десятки коннектов
|
||||
# → cap превышается → HTTP 500 / краши. Backconnect (mproxy, авто-ротация,
|
||||
# 1 коннект/запрос) развязывает прокси-аккаунты.
|
||||
# True (дефолт) = curl_cffi через settings.scraper_proxy_url (backconnect mproxy).
|
||||
# False = старое browser-поведение (BrowserFetcher/auv, как scraper_fetch_mode).
|
||||
# ENV: AVITO_DETAIL_BACKFILL_USE_CURL.
|
||||
# detail_backfill через curl_cffi вместо браузера. ENV: AVITO_DETAIL_BACKFILL_USE_CURL.
|
||||
# False (дефолт) = BrowserFetcher (camoufox дожидается QRATOR PoW, #3045/#3046).
|
||||
# True = curl_cffi через settings.scraper_proxy_url.
|
||||
#
|
||||
# ⚠️ Обоснование выше УСТАРЕЛО для Авито (замер 2026-08-21). Авито за QRATOR
|
||||
# отдаёт JS proof-of-work челлендж, который curl_cffi не решает: прогоны
|
||||
# 4348/4394/4508 — 3-4 обогащённых из 46-53 попыток (~6%) против ~74% на
|
||||
# браузерном пути. Опасение «browser превышает cap прокси-аккаунта auv» снято:
|
||||
# браузер сериализован BROWSER_CONCURRENCY=1 и ходит через тот же backconnect.
|
||||
# Прод переведён на браузер через docker-compose.prod.yml (environment
|
||||
# перекрывает env_file). Дефолт оставлен True, чтобы не менять поведение
|
||||
# других окружений вслепую.
|
||||
# Дефолт False (#3045): Авито за QRATOR отдаёт JS proof-of-work челлендж, который
|
||||
# curl_cffi не решает в принципе — замер 2026-08-21, прогоны 4348/4394/4508: 3-4
|
||||
# обогащённых из 46-53 попыток (~6%) против ~74% на браузере. Старое обоснование
|
||||
# curl (браузер превышал cap прокси-аккаунта auv) снято: браузер сериализован
|
||||
# BROWSER_CONCURRENCY=1. Пока дефолт был True, браузер включался только
|
||||
# environment'ом сервиса scraper, а tradein-backend (ручка админки
|
||||
# scrape_avito_detail_backfill исполняет добор в своём процессе) шёл curl'ом.
|
||||
avito_detail_backfill_use_curl: bool = Field(
|
||||
default=True, validation_alias="AVITO_DETAIL_BACKFILL_USE_CURL"
|
||||
default=False, validation_alias="AVITO_DETAIL_BACKFILL_USE_CURL"
|
||||
)
|
||||
|
||||
# #1950: hard-timeout на один detail-fetch внутри avito_detail_backfill. Зависший
|
||||
|
|
|
|||
|
|
@ -67,8 +67,12 @@ class BlockRatioBreaker:
|
|||
ratio_threshold: float
|
||||
safety_min: int
|
||||
snapshot_size: int
|
||||
# #3439: сколько отказов НАШЕЙ стороны (kind != platform) подряд, без единого ответа
|
||||
# площадки, рвут прогон. 0 — критерий выключен.
|
||||
infra_streak_limit: int = 0
|
||||
_window: deque[bool] = field(init=False, repr=False)
|
||||
_consecutive_blocks: int = field(default=0, init=False)
|
||||
_consecutive_infra: int = field(default=0, init=False)
|
||||
_pure_block_run: bool = field(default=True, init=False)
|
||||
streak_histogram: Counter[int] = field(default_factory=Counter, init=False)
|
||||
|
||||
|
|
@ -106,10 +110,16 @@ class BlockRatioBreaker:
|
|||
короче окна рвал бы прогон по тем же infra-отказам, только другим
|
||||
критерием. Дефолт 'platform' сохраняет поведение вызывающих, которые вид
|
||||
не считают.
|
||||
|
||||
Свой предохранитель у такого отказа — серия infra_streak_limit (#3439): без
|
||||
него серия любой длины не рвала прогон ничем (7302: 1598 отказов сайдкара из
|
||||
1600, status='done'). Серию гасит любой ответ площадки — успех, её блок, 404.
|
||||
"""
|
||||
if kind != BAN_KIND_PLATFORM:
|
||||
self._consecutive_infra += 1
|
||||
self.record_failure()
|
||||
return
|
||||
self._consecutive_infra = 0
|
||||
self._consecutive_blocks += 1
|
||||
self._window.append(True)
|
||||
|
||||
|
|
@ -117,6 +127,7 @@ class BlockRatioBreaker:
|
|||
"""Единственный исход, снимающий safety-net (#3184: пачка блоков ПОСЛЕ хотя
|
||||
бы одного успеха -- уже не "чистый с рождения прогона" burst)."""
|
||||
self._flush_streak()
|
||||
self._consecutive_infra = 0
|
||||
self._pure_block_run = False
|
||||
self._window.append(False)
|
||||
|
||||
|
|
@ -131,8 +142,11 @@ class BlockRatioBreaker:
|
|||
AvitoListingGoneError). Класс написан источник-агностично на будущее (не
|
||||
только avito), но сейчас единственный вызывающий -- avito_detail_backfill
|
||||
(#3184 review MAJOR 1: применение к domclick_detail_backfill снято из этой
|
||||
задачи -- своя калибровка, свои ограничения прокси-пула)."""
|
||||
return
|
||||
задачи -- своя калибровка, свои ограничения прокси-пула).
|
||||
|
||||
Единственное, что 404 двигает, -- серия infra (#3439): площадка ответила,
|
||||
значит наш тракт жив."""
|
||||
self._consecutive_infra = 0
|
||||
|
||||
def _flush_streak(self) -> None:
|
||||
if self._consecutive_blocks:
|
||||
|
|
@ -142,9 +156,11 @@ class BlockRatioBreaker:
|
|||
def abort_reason(self) -> str | None:
|
||||
"""Какой критерий требует обрыва прямо сейчас, или None.
|
||||
|
||||
Возвращает "safety_net" / "ratio" / None. Состояние не меняет, поэтому
|
||||
вызывать можно сколько угодно раз -- в том числе повторно, ради текста лога.
|
||||
Возвращает "infra_streak" / "safety_net" / "ratio" / None. Состояние не меняет,
|
||||
поэтому вызывать можно сколько угодно раз -- в том числе повторно, ради текста лога.
|
||||
"""
|
||||
if self.infra_streak_limit > 0 and self._consecutive_infra >= self.infra_streak_limit:
|
||||
return "infra_streak"
|
||||
# Safety-net -- ТОЛЬКО когда ratio-критерий физически недостижим (снапшот
|
||||
# короче окна), иначе пачка safety_min блоков в начале длинного прогона
|
||||
# абортила бы его так же, как до правки (#3184 review MAJOR 2).
|
||||
|
|
@ -176,6 +192,11 @@ class BlockRatioBreaker:
|
|||
should_abort(), так что в логи не попадает.
|
||||
"""
|
||||
reason = self.abort_reason()
|
||||
if reason == "infra_streak":
|
||||
return (
|
||||
f"{self._consecutive_infra} отказов нашего тракта (сайдкар/пул) подряд "
|
||||
"без единого ответа площадки"
|
||||
)
|
||||
if reason == "ratio":
|
||||
return (
|
||||
f"доля блоков {self.window_blocks}/{self.window_len} в окне "
|
||||
|
|
|
|||
|
|
@ -42,6 +42,12 @@ counters["block_streak_histogram"], иначе эффект правки неч
|
|||
своей величиной: доля печатает "14/20", safety-net -- длину серии. Раньше лог
|
||||
печатал серию всегда, и прогон 5210 (обрыв по доле 14/20) отчитался как
|
||||
"ABORT -- 1 consecutive blocks".
|
||||
|
||||
Третий критерий, "infra_streak" (#3439): max_consecutive_failures отказов НАШЕГО
|
||||
тракта (сайдкар/пул, ban_kind='infra') подряд без единого ответа площадки. Такой
|
||||
отказ в долю не входит (#3288), и до этого серия любой длины не рвала прогон ничем:
|
||||
7193/7264/7302 — по 1600 попыток, 1-2 карточки, status='done'. Прогон завершается
|
||||
'failed' с причиной «тракт недоступен», а не 'banned': площадка молчала.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -98,9 +104,9 @@ from app.services.scraper_adapters import RealProxyProvider, RealScraperConfig
|
|||
# принимает config: ScraperConfig | None (Strangler-инъекция #2330) и пробрасывает
|
||||
# его в _build_detail_session() для settings.scraper_proxy_url (sticky МГТС-прокси),
|
||||
# зеркаля fetch_detail. Оба call site'а ниже передают config=RealScraperConfig() —
|
||||
# без него warm-batch curl-путь (use_curl=True, ДЕФОЛТ прод-режима,
|
||||
# avito_detail_backfill_use_curl=True) молча терял бы backconnect (прямое
|
||||
# datacenter-подключение вместо sticky-прокси), тот же класс бага что #2322/#2310.
|
||||
# без него warm-batch curl-путь (use_curl=True, avito_detail_backfill_use_curl) молча
|
||||
# терял бы backconnect (прямое datacenter-подключение вместо sticky-прокси), тот же
|
||||
# класс бага что #2322/#2310.
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -232,12 +238,10 @@ async def _rotate_current_proxy(
|
|||
settings.scraper_proxy_url БЕЗ lease — там ротировать нечего, вызывающий цикл не
|
||||
зовёт эту функцию в том режиме вовсе.
|
||||
|
||||
Прод при этом в browser-режиме, а не в curl: у контейнера tradein-scraper (там же
|
||||
живёт планировщик) проверено AVITO_DETAIL_BACKFILL_USE_CURL=false при
|
||||
SCRAPER_FETCH_MODE=browser и USE_PROXY_POOL_BROWSER=true, так что ротация
|
||||
активна. Значение true стоит только у tradein-backend, который добор не запускает.
|
||||
Комментарий ниже по файлу (~строка 653) называет use_curl=True «прод-дефолтом» —
|
||||
это предсуществующее заблуждение, а не описание текущего прода.
|
||||
Прод в browser-режиме, а не в curl: дефолт avito_detail_backfill_use_curl=False
|
||||
(#3045). Добор исполняют ДВА процесса — планировщик в tradein-scraper и ручка
|
||||
админки scrape_avito_detail_backfill в tradein-backend, — поэтому режим задаётся
|
||||
дефолтом кода, а не environment'ом одного сервиса.
|
||||
|
||||
Отказ провайдера (лимит исчерпан, нет rotate_url, сетевой сбой, неизвестный хост)
|
||||
НЕ должен ронять прогон — логируем и продолжаем на текущем адресе. Эта попытка НЕ
|
||||
|
|
@ -329,7 +333,8 @@ async def run_avito_detail_backfill(
|
|||
скользящем окне, см. settings.detail_backfill_block_ratio_window/
|
||||
_threshold (module docstring).
|
||||
max_consecutive_failures: int -- порог обрыва по отказам-не-блокам,
|
||||
default 25 (см. комментарий у чтения параметра ниже).
|
||||
default 25 (см. комментарий у чтения параметра ниже). Тот же порог рвёт
|
||||
серию отказов НАШЕГО тракта без ответа площадки (infra_streak, #3439).
|
||||
|
||||
Lifecycle: update_heartbeat -> snapshot -> loop with budget guard ->
|
||||
mark_backfill_finished (done / banned при блоках / failed при нуле, #2674);
|
||||
|
|
@ -549,6 +554,10 @@ async def run_avito_detail_backfill(
|
|||
window_size=int(settings.detail_backfill_block_ratio_window),
|
||||
ratio_threshold=float(settings.detail_backfill_block_ratio_threshold),
|
||||
safety_min=max_consecutive_blocks,
|
||||
# #3439: отказ сайдкара/пула — отказ нашей стороны, тот же разряд, что
|
||||
# отказы-не-блоки, и тот же порог. Без него серия infra не рвала прогон
|
||||
# ничем: 7302 — 1598 отказов сайдкара из 1600 попыток, 72 минуты, 'done'.
|
||||
infra_streak_limit=max_consecutive_failures,
|
||||
# snapshot_size гейтит safety-net (#3184 review MAJOR 2): пачка блоков в
|
||||
# начале ДЛИННОГО прогона не должна абортить его так же, как раньше --
|
||||
# safety-net включён только когда снапшот короче окна и ratio-критерий
|
||||
|
|
@ -705,7 +714,7 @@ async def run_avito_detail_backfill(
|
|||
# config.scraper_proxy_url=None для backconnect-gate'а
|
||||
# (elif not use_curl own-session path), теряя reconnect-
|
||||
# on-403 поведение legacy (settings.scraper_proxy_url
|
||||
# напрямую). Не влияет на use_curl=True (прод-дефолт) —
|
||||
# напрямую). Не влияет на use_curl=True —
|
||||
# там reconnect_on_block=False уже гасит backconnect.
|
||||
config=RealScraperConfig(),
|
||||
origin=serp_origin,
|
||||
|
|
@ -1025,6 +1034,12 @@ async def run_avito_detail_backfill(
|
|||
|
||||
counters.duration_sec = time.monotonic() - start
|
||||
current_counters = counters.to_dict()
|
||||
# #3288 п.5: перепись диагнозов блоков — в counters ЛЮБОГО финала, а не только
|
||||
# 'banned' (mark_backfill_finished кладёт её лишь на ветке бана). Прогоны
|
||||
# 7193/7264/7302 (1600 попыток, ~1599 блоков, status='done') ушли без диагноза
|
||||
# в БД: чей был отказ — площадки или нашего тракта — SQL'ем не узнать.
|
||||
if block_ban_kinds:
|
||||
current_counters["ban_kinds"] = dict(block_ban_kinds) # type: ignore[assignment]
|
||||
# #3184: гистограмма длин пачек блоков (streak -> сколько раз встретилась) --
|
||||
# иначе эффект правки на #2674-статистике нечем измерить постфактум. finalize()
|
||||
# досчитывает хвостовую пачку, если прогон оборвался посреди серии.
|
||||
|
|
@ -1036,21 +1051,28 @@ async def run_avito_detail_backfill(
|
|||
# и без этого ключа "banned" опять не отличить по причине (#3178).
|
||||
if abort_reason is not None:
|
||||
current_counters["abort_reason"] = abort_reason # type: ignore[assignment]
|
||||
# Остановки по вине НАШЕЙ стороны — НЕ блок, поэтому и не aborted_by_blocks:
|
||||
# иначе прогон уйдёт в 'banned' (или в 'done' по #3367) и запись будет
|
||||
# утверждать про площадку то, чего не было.
|
||||
stop_reason: str | None = None
|
||||
if no_proxy_stop:
|
||||
# #3288 (как #3283 у домклика): остановка из-за пустого пула — НЕ блок,
|
||||
# поэтому и не aborted_by_blocks: иначе прогон уйдёт в 'banned' и запись
|
||||
# будет утверждать про площадку то, чего не было. Это отказ нашей стороны.
|
||||
# #3288 (как #3283 у домклика): пустой пул — к площадке не ходили вовсе.
|
||||
current_counters["no_proxy_stop"] = 1
|
||||
runs_mod.mark_failed(
|
||||
db,
|
||||
run_id,
|
||||
"пул прокси пуст — к площадке не ходили (#3288)",
|
||||
current_counters,
|
||||
stop_reason = "пул прокси пуст — к площадке не ходили (#3288)"
|
||||
elif abort_reason == "infra_streak":
|
||||
# #3439: сайдкар/пул отказывали подряд — площадка всё это время молчала.
|
||||
stop_reason = (
|
||||
f"тракт недоступен: {breaker.abort_explanation()}, обогащено "
|
||||
f"{counters.enriched} из {counters.attempted}; причина: "
|
||||
f"{_top_failure(failure_census)} (#3439)"
|
||||
)
|
||||
if stop_reason is not None:
|
||||
runs_mod.mark_failed(db, run_id, stop_reason, current_counters)
|
||||
logger.info(
|
||||
"avito_detail_backfill: run_id=%d FINISHED (пул пуст) -- attempted=%d "
|
||||
"avito_detail_backfill: run_id=%d FINISHED (%s) -- attempted=%d "
|
||||
"enriched=%d blocked=%d gone=%d failed=%d duration=%.1fs",
|
||||
run_id,
|
||||
stop_reason,
|
||||
counters.attempted,
|
||||
counters.enriched,
|
||||
counters.blocked,
|
||||
|
|
|
|||
|
|
@ -194,7 +194,7 @@ async def test_backfill_build_warmed_session_receives_config() -> None:
|
|||
"""#2397 Part D1 / #2330 regression guard: kit's build_warmed_session() now accepts
|
||||
config=ScraperConfig and silently drops settings.scraper_proxy_url (sticky МГТС-прокси)
|
||||
-- NO crash, just a proxy-less session -- unless config=RealScraperConfig() is passed
|
||||
at the call site. use_curl=True is the prod-default path (avito_detail_backfill_use_curl),
|
||||
at the call site. use_curl=True is the curl path (avito_detail_backfill_use_curl),
|
||||
so this is the setup-time build_warmed_session() call (~line 177). assert_awaited_once()
|
||||
alone wouldn't catch someone dropping the kwarg later (mirrors the fetch_detail config=
|
||||
guard above, lines 141-148)."""
|
||||
|
|
|
|||
28
tradein-mvp/backend/tests/test_3045_use_curl_default.py
Normal file
28
tradein-mvp/backend/tests/test_3045_use_curl_default.py
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
"""#3045: дефолт avito_detail_backfill_use_curl — браузер, а не curl.
|
||||
|
||||
curl_cffi не решает QRATOR PoW Авито (замер 21.08: ~6% обогащений против ~74% на
|
||||
браузере). Пока дефолт был True, браузер включал только environment сервиса scraper,
|
||||
а tradein-backend (ручка админки scrape_avito_detail_backfill исполняет добор в своём
|
||||
процессе) шёл curl'ом. Проверка по значению поля Settings без env и без .env-файла.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.config import Settings
|
||||
|
||||
|
||||
def test_detail_backfill_defaults_to_browser(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("AVITO_DETAIL_BACKFILL_USE_CURL", raising=False)
|
||||
|
||||
assert Settings(_env_file=None).avito_detail_backfill_use_curl is False
|
||||
|
||||
|
||||
def test_env_still_switches_to_curl(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Контроль: переменная окружения по-прежнему переключает режим — поле живое."""
|
||||
monkeypatch.setenv("AVITO_DETAIL_BACKFILL_USE_CURL", "true")
|
||||
|
||||
assert Settings(_env_file=None).avito_detail_backfill_use_curl is True
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
"""#3182: режим detail-добора Авито не зависит от того, какой контейнер взял задачу.
|
||||
|
||||
Добор исполняют два процесса образа backend: планировщик (tradein-scraper) и ручка
|
||||
админки scrape_avito_detail_backfill (tradein-backend). Хостовый backend/.env.runtime
|
||||
задаёт AVITO_DETAIL_BACKFILL_USE_CURL=true, а environment сервиса перекрывает env_file —
|
||||
до правки "false" стояло только у scraper, и printenv на проде давал scraper=false,
|
||||
backend=true. Проверка по значению: разобранный compose, каждый сервис образа backend.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from app.core.config import Settings
|
||||
|
||||
_COMPOSE = Path(__file__).resolve().parents[2] / "docker-compose.prod.yml"
|
||||
|
||||
|
||||
def test_every_backend_image_service_pins_browser_transport() -> None:
|
||||
services = yaml.safe_load(_COMPOSE.read_text())["services"]
|
||||
backend_image = {
|
||||
name: svc
|
||||
for name, svc in services.items()
|
||||
if "gendesign-tradein-backend" in svc.get("image", "")
|
||||
}
|
||||
# Контроль разбора: без него пустой словарь дал бы зелёный тест ни о чём.
|
||||
assert {"backend", "scraper"} <= set(backend_image), sorted(backend_image)
|
||||
|
||||
values = {
|
||||
name: (svc.get("environment") or {}).get("AVITO_DETAIL_BACKFILL_USE_CURL")
|
||||
for name, svc in backend_image.items()
|
||||
}
|
||||
assert values == dict.fromkeys(backend_image, "false"), values
|
||||
|
||||
|
||||
def test_leftover_yandex_cookies_env_is_ignored(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""YANDEX_COOKIES_FILE удалён из Settings (никто не читал); хостовый .env.runtime
|
||||
его ещё несёт — старт не должен падать, а поле не должно всплыть обратно."""
|
||||
monkeypatch.setenv("YANDEX_COOKIES_FILE", "/app/yandex_cookies.json")
|
||||
|
||||
assert not hasattr(Settings(_env_file=None), "yandex_cookies_file")
|
||||
|
|
@ -229,6 +229,25 @@ async def test_sidecar_run_walks_the_whole_snapshot_and_is_not_banned() -> None:
|
|||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_done_run_keeps_ban_kinds_in_counters() -> None:
|
||||
"""#3288 п.5: диагноз доезжает в counters и у прогона, который не 'banned'.
|
||||
|
||||
Прогоны 7193/7264/7302 (1600 попыток, ~1599 отказов сайдкара, status='done') легли
|
||||
в БД без ban_kinds: перепись писалась в counters только на ветке бана.
|
||||
"""
|
||||
fetches: list[Any] = [AvitoSidecarUnavailableError("sidecar 503") for _ in range(20)]
|
||||
fetches += [MagicMock() for _ in range(10)]
|
||||
|
||||
_, runs = await _run_avito(fetches, snapshot_size=30)
|
||||
|
||||
written = runs.mark_backfill_finished.call_args.args[2]
|
||||
assert written.get("ban_kinds") == {BAN_KIND_INFRA: 20}, (
|
||||
f"counters={written}: прогон без обрыва уходит в mark_done с этими counters — "
|
||||
"без переписи диагноз отказов теряется"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_real_platform_bans_still_abort_by_ratio() -> None:
|
||||
"""Контроль: 14 настоящих AvitoBlockedError из 20 рвут прогон как раньше."""
|
||||
|
|
@ -371,3 +390,113 @@ def test_platform_dominant_run_is_still_banned() -> None:
|
|||
assert banned.call_args.kwargs["ban_kind"] == BAN_KIND_PLATFORM, (
|
||||
f"ban_kind={banned.call_args.kwargs['ban_kind']!r}, ожидали 'platform'"
|
||||
)
|
||||
|
||||
|
||||
# ── #3439: серия отказов НАШЕГО тракта рвёт прогон своим критерием ─────────────
|
||||
#
|
||||
# Прогоны 7193/7264/7302: по 1600 попыток, 1-2 карточки, ~1599 отказов сайдкара
|
||||
# (InvalidIP-шторм на узле), status='done'. Infra ушёл из доли (#3288), а своего
|
||||
# предохранителя не получил. Порог — max_consecutive_failures (дефолт 25): тот же
|
||||
# разряд «отказ нашей стороны», что и отказы-не-блоки.
|
||||
|
||||
_INFRA_LIMIT = 25
|
||||
|
||||
|
||||
def _infra_breaker() -> BlockRatioBreaker:
|
||||
return BlockRatioBreaker(
|
||||
window_size=_WINDOW,
|
||||
ratio_threshold=_THRESHOLD,
|
||||
safety_min=5,
|
||||
snapshot_size=1600,
|
||||
infra_streak_limit=_INFRA_LIMIT,
|
||||
)
|
||||
|
||||
|
||||
def test_infra_streak_aborts_exactly_at_limit() -> None:
|
||||
breaker = _infra_breaker()
|
||||
breaker.record_success()
|
||||
for _ in range(_INFRA_LIMIT - 1):
|
||||
breaker.record_block(BAN_KIND_INFRA)
|
||||
assert breaker.abort_reason() is None, "24 отказа подряд — ещё не порог 25"
|
||||
|
||||
breaker.record_block(BAN_KIND_INFRA)
|
||||
assert breaker.abort_reason() == "infra_streak", (
|
||||
f"abort_reason={breaker.abort_reason()!r} на 25-м отказе сайдкара подряд"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("answer", ["success", "platform", "gone"])
|
||||
def test_any_platform_answer_resets_infra_streak(answer: str) -> None:
|
||||
"""Площадка ответила (успех / её блок / 404) — тракт жив, серия с нуля."""
|
||||
breaker = _infra_breaker()
|
||||
for _ in range(_INFRA_LIMIT - 1):
|
||||
breaker.record_block(BAN_KIND_INFRA)
|
||||
{
|
||||
"success": breaker.record_success,
|
||||
"platform": lambda: breaker.record_block(BAN_KIND_PLATFORM),
|
||||
"gone": breaker.record_neutral,
|
||||
}[answer]()
|
||||
for _ in range(_INFRA_LIMIT - 1):
|
||||
breaker.record_block(BAN_KIND_INFRA)
|
||||
|
||||
assert breaker.abort_reason() is None, (
|
||||
f"abort_reason={breaker.abort_reason()!r}: 24 + ответ площадки ({answer}) + 24 — "
|
||||
"серии в 25 подряд не было"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sidecar_storm_stops_run_as_failed_not_done() -> None:
|
||||
"""Шторм сайдкара: 1 успех, дальше только отказы — обрыв на 25-м, 'failed'."""
|
||||
fetches: list[Any] = [MagicMock()]
|
||||
fetches += [AvitoSidecarUnavailableError("browser unavailable 503") for _ in range(1599)]
|
||||
|
||||
counters, runs = await _run_avito(fetches, snapshot_size=1600)
|
||||
|
||||
_assert_identity(counters, expected_attempted=1 + _INFRA_LIMIT)
|
||||
assert runs.mark_backfill_finished.call_args is None, (
|
||||
"прогон ушёл в backfill-honest-status: там 1 карточка превращает шторм в 'done'"
|
||||
)
|
||||
failed = runs.mark_failed.call_args
|
||||
assert failed is not None, "прогон не финализирован mark_failed"
|
||||
written = failed.args[3]
|
||||
assert (written["abort_reason"], written["ban_kinds"]) == (
|
||||
"infra_streak",
|
||||
{BAN_KIND_INFRA: _INFRA_LIMIT},
|
||||
), f"counters={written}"
|
||||
assert "тракт недоступен" in failed.args[2], f"причина={failed.args[2]!r}"
|
||||
assert runs.mark_banned.call_args is None, "площадка не отказывала — не 'banned'"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_interleaved_platform_answers_do_not_trip_infra_stop() -> None:
|
||||
"""Контроль: 24 отказа сайдкара + ответ площадки, трижды — снапшот пройден целиком."""
|
||||
fetches: list[Any] = []
|
||||
for answer in (AvitoBlockedError("firewall"), MagicMock(), MagicMock()):
|
||||
fetches += [AvitoSidecarUnavailableError("503") for _ in range(_INFRA_LIMIT - 1)]
|
||||
fetches.append(answer)
|
||||
|
||||
counters, runs = await _run_avito(fetches, snapshot_size=len(fetches))
|
||||
|
||||
_assert_identity(counters, expected_attempted=len(fetches))
|
||||
finished = runs.mark_backfill_finished.call_args
|
||||
assert finished is not None and finished.args[2].get("abort_reason") is None, (
|
||||
f"прогон оборван: counters={finished and finished.args[2]}, "
|
||||
f"mark_failed={runs.mark_failed.call_args}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_quarter_platform_blocks_run_is_not_aborted() -> None:
|
||||
"""Контроль: здоровый прогон с 25% настоящих блоков не рвётся ни одним критерием."""
|
||||
fetches: list[Any] = []
|
||||
for _ in range(10):
|
||||
fetches += [MagicMock(), MagicMock(), MagicMock(), AvitoBlockedError("firewall")]
|
||||
|
||||
counters, runs = await _run_avito(fetches, snapshot_size=40)
|
||||
|
||||
_assert_identity(counters, expected_attempted=40)
|
||||
finished = runs.mark_backfill_finished.call_args
|
||||
assert finished is not None and finished.args[2].get("abort_reason") is None, (
|
||||
f"counters={finished and finished.args[2]}: 10 блоков из 40 — рвать не по чему"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -40,10 +40,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||
import pytest
|
||||
|
||||
|
||||
def _mock_settings(
|
||||
scraper_proxy_url: str | None,
|
||||
yandex_cookies_file: str | None = None,
|
||||
) -> SimpleNamespace:
|
||||
def _mock_settings(scraper_proxy_url: str | None) -> SimpleNamespace:
|
||||
"""Minimal settings stand-in with fields read by proxy helpers and scraper __aenter__."""
|
||||
return SimpleNamespace(
|
||||
scraper_proxy_url=scraper_proxy_url,
|
||||
|
|
@ -52,7 +49,6 @@ def _mock_settings(
|
|||
# removed — dead mobileproxy accounts). Mirrored here for test compat.
|
||||
cian_proxy_url=scraper_proxy_url,
|
||||
yandex_proxy_url=scraper_proxy_url,
|
||||
yandex_cookies_file=yandex_cookies_file,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -340,6 +340,11 @@ services:
|
|||
# деградация. У scraper те же значения с #2126/#2160.
|
||||
USE_PROXY_POOL_CURL: "true"
|
||||
USE_PROXY_POOL_BROWSER: "true"
|
||||
# #3182: ручка админки scrape_avito_detail_backfill исполняет добор В ЭТОМ
|
||||
# процессе. Хостовый backend/.env.runtime задаёт true (curl не проходит QRATOR
|
||||
# PoW, см. scraper ниже) — environment перекрывает env_file. Значение одно на
|
||||
# все сервисы образа backend, чтобы режим не зависел от того, кто взял задачу.
|
||||
AVITO_DETAIL_BACKFILL_USE_CURL: "false"
|
||||
depends_on:
|
||||
browser:
|
||||
condition: service_started
|
||||
|
|
@ -453,6 +458,8 @@ services:
|
|||
# Settings()-объект образа где-то читает флаг при импорте (defense-in-depth,
|
||||
# аналогично backend). tgbot_main.py не должен зависеть от этого значения.
|
||||
SCHEDULER_ENABLE: "false"
|
||||
# #3182: то же значение, что у backend/scraper (см. комментарий у backend).
|
||||
AVITO_DETAIL_BACKFILL_USE_CURL: "false"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue