diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index d291f723..46e1df2a 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -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. Зависший diff --git a/tradein-mvp/backend/app/services/backfill_block_breaker.py b/tradein-mvp/backend/app/services/backfill_block_breaker.py index 75352846..9c55100d 100644 --- a/tradein-mvp/backend/app/services/backfill_block_breaker.py +++ b/tradein-mvp/backend/app/services/backfill_block_breaker.py @@ -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} в окне " diff --git a/tradein-mvp/backend/app/tasks/avito_detail_backfill.py b/tradein-mvp/backend/app/tasks/avito_detail_backfill.py index dcbe755a..94053726 100644 --- a/tradein-mvp/backend/app/tasks/avito_detail_backfill.py +++ b/tradein-mvp/backend/app/tasks/avito_detail_backfill.py @@ -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, diff --git a/tradein-mvp/backend/tests/tasks/test_avito_detail_backfill.py b/tradein-mvp/backend/tests/tasks/test_avito_detail_backfill.py index 9e963679..e2c89336 100644 --- a/tradein-mvp/backend/tests/tasks/test_avito_detail_backfill.py +++ b/tradein-mvp/backend/tests/tasks/test_avito_detail_backfill.py @@ -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).""" diff --git a/tradein-mvp/backend/tests/test_3045_use_curl_default.py b/tradein-mvp/backend/tests/test_3045_use_curl_default.py new file mode 100644 index 00000000..893d740b --- /dev/null +++ b/tradein-mvp/backend/tests/test_3045_use_curl_default.py @@ -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 diff --git a/tradein-mvp/backend/tests/test_3182_detail_transport_one_value.py b/tradein-mvp/backend/tests/test_3182_detail_transport_one_value.py new file mode 100644 index 00000000..446a74dd --- /dev/null +++ b/tradein-mvp/backend/tests/test_3182_detail_transport_one_value.py @@ -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") diff --git a/tradein-mvp/backend/tests/test_3288b_block_kind_ratio.py b/tradein-mvp/backend/tests/test_3288b_block_kind_ratio.py index 61fd0460..a43dcefd 100644 --- a/tradein-mvp/backend/tests/test_3288b_block_kind_ratio.py +++ b/tradein-mvp/backend/tests/test_3288b_block_kind_ratio.py @@ -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 — рвать не по чему" + ) diff --git a/tradein-mvp/backend/tests/test_scraper_proxy.py b/tradein-mvp/backend/tests/test_scraper_proxy.py index 165539d8..b202ffdc 100644 --- a/tradein-mvp/backend/tests/test_scraper_proxy.py +++ b/tradein-mvp/backend/tests/test_scraper_proxy.py @@ -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, ) diff --git a/tradein-mvp/docker-compose.prod.yml b/tradein-mvp/docker-compose.prod.yml index 191dfd8a..5aa3ef03 100644 --- a/tradein-mvp/docker-compose.prod.yml +++ b/tradein-mvp/docker-compose.prod.yml @@ -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