From 26cd8dbe34ef311b0aad244284a8bdd667ab9a69 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 27 Aug 2026 15:33:29 +0300 Subject: [PATCH] =?UTF-8?q?fix(tradein/avito):=20=D0=BF=D0=BE=D0=B2=D1=82?= =?UTF-8?q?=D0=BE=D1=80=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20=D0=B1=D0=BB?= =?UTF-8?q?=D0=BE=D0=BA=D0=B0=20=D1=82=D0=B5=D1=80=D1=8F=D0=BB=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D0=BA=D1=81=D0=B8=20=D0=B8=20=D1=83=D1=85=D0=BE?= =?UTF-8?q?=D0=B4=D0=B8=D0=BB=20=D1=81=20=D0=B0=D0=B4=D1=80=D0=B5=D1=81?= =?UTF-8?q?=D0=B0=20=D0=BC=D0=B0=D1=88=D0=B8=D0=BD=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Обе ветки повтора в `fetch_detail` — 403/firewall и 429 — пересоздавали эфемерную сессию вызовом `_build_detail_session()` БЕЗ `config`. Прокси kit-версия читает только из `config.scraper_proxy_url`, поэтому такая сессия уходила напрямую. Замысел ветки прямо обратный, он записан в её же комментарии: «эфемерная свежая сессия (новый CONNECT-туннель = свежий exit-IP)». Ветка вообще исполняется только при backconnect=True, а он вычисляется как «задан config.scraper_proxy_url» — то есть в момент вызова достоверно известно, что прокси есть, и он терялся. ПОЧЕМУ НЕ БЫЛО ВИДНО. Пока адрес самой машины не был заблокирован, прямой повтор часто срабатывал, и подмена канала выглядела как успех. Замер 27.08 из прод-контейнера, один и тот же URL Авито: через прокси — 200, 3.3 МБ страницы напрямую — 429, «доступ ограничен», firewall С этого момента каждый повтор после блока обречён. Обогащение avito_detail_backfill по суткам: 21-25.08 — 178/129/147/138/111, 26.08 — 23, 27.08 — 0 при 25 блоках. Обвал начинается ровно с окна, в котором сменился адрес машины. Тот же класс ошибки чинили в #2330 для build_warmed_session; в пути повтора он оставался. Три теста: обе ветки на месте (страховка от проверки пустоты), ни одна не строит сессию без config, и отдельно доказано, что без config прокси в сессии действительно нет. Проверил красноту на старом коде — падает с точным текстом. Прогон: 107 тестов scrapers зелёные, ruff чист. Refs #3034, #3045 --- .../test_3034_detail_retry_keeps_proxy.py | 88 +++++++++++++++++++ .../src/scraper_kit/providers/avito/detail.py | 17 +++- 2 files changed, 103 insertions(+), 2 deletions(-) create mode 100644 tradein-mvp/backend/tests/scrapers/test_3034_detail_retry_keeps_proxy.py diff --git a/tradein-mvp/backend/tests/scrapers/test_3034_detail_retry_keeps_proxy.py b/tradein-mvp/backend/tests/scrapers/test_3034_detail_retry_keeps_proxy.py new file mode 100644 index 00000000..18ec7edd --- /dev/null +++ b/tradein-mvp/backend/tests/scrapers/test_3034_detail_retry_keeps_proxy.py @@ -0,0 +1,88 @@ +"""Повтор после блока обязан сохранять прокси, ради которого он и затеян. + +БАГ. В `fetch_detail` обе ветки повтора (403/firewall и 429) пересоздавали +эфемерную сессию вызовом `_build_detail_session()` — БЕЗ `config`. А прокси +kit-версия читает только из `config.scraper_proxy_url`, поэтому такая сессия +уходила напрямую с адреса самой машины. + +Замысел ветки прямо обратный, он записан в её же комментарии: «эфемерная +свежая сессия (новый CONNECT-туннель = свежий exit-IP)». Ветка вообще +исполняется только при `backconnect=True`, а он вычисляется как «задан +`config.scraper_proxy_url`» — то есть в момент вызова достоверно известно, что +прокси есть, и он терялся. + +ПОЧЕМУ ЭТО БЫЛО НЕ ВИДНО. Пока адрес самой машины не был заблокирован, прямой +повтор часто срабатывал, и подмена канала выглядела как успех. Замер 27.08 из +прод-контейнера, один и тот же URL Авито: + + через прокси — 200, 3.3 МБ страницы + напрямую — 429, «доступ ограничен», firewall + +С этого момента каждый повтор после блока стал обречён. Обогащение +`avito_detail_backfill` по суткам: 21-25.08 — 178/129/147/138/111, 26.08 — 23, +27.08 — 0 при 25 блоках. + +Тот же класс ошибки уже чинили в #2330 для `build_warmed_session`; здесь он +оставался в пути повтора. +""" + +from __future__ import annotations + +import inspect +import re +from pathlib import Path +from types import SimpleNamespace + +from scraper_kit.providers.avito import detail as kit_detail + +SOURCE = Path(inspect.getfile(kit_detail)).read_text(encoding="utf-8") + + +def _retry_rebuild_calls() -> list[str]: + """Все места, где пересоздаётся эфемерная `retry_session`.""" + return re.findall(r"retry_session = _build_detail_session\(([^)]*)\)", SOURCE) + + +def test_both_retry_branches_exist() -> None: + """Страховка от того, что тест начнёт проверять пустоту. + + Веток две — 403/firewall и 429; если рефакторинг их переименует, тест ниже + молча перестанет что-либо проверять, поэтому фиксируем количество. + """ + assert len(_retry_rebuild_calls()) == 2, ( + f"ожидались две ветки повтора, найдено: {_retry_rebuild_calls()}" + ) + + +def test_retry_session_is_rebuilt_with_config() -> None: + """Ни одна ветка повтора не смеет строить сессию без `config`. + + Пустой аргумент = `proxy_url=None` = выход напрямую с адреса машины, тогда + как вся ветка существует ради свежего exit-IP ЧЕРЕЗ прокси. + """ + for arg in _retry_rebuild_calls(): + assert arg.strip() == "config", ( + "повтор строит сессию как _build_detail_session(" + f"{arg!r}) — прокси теряется, запрос уйдёт напрямую" + ) + + +def test_session_without_config_really_has_no_proxy() -> None: + """Доказываем цену пропуска, а не верим на слово. + + Ровно то же построение, что делала сломанная ветка: без config прокси в + сессии нет, с config — есть. + """ + proxy = "http://user:pass@proxy.example:8080" + with_cfg = kit_detail._build_detail_session(SimpleNamespace(scraper_proxy_url=proxy)) + without_cfg = kit_detail._build_detail_session() + try: + assert proxy in str(getattr(with_cfg, "proxies", "")), "прокси не доехал из config" + assert not getattr(without_cfg, "proxies", None), ( + "сессия без config неожиданно с прокси — тест ниже потерял бы смысл" + ) + finally: + for s in (with_cfg, without_cfg): + close = getattr(s, "close", None) + if close is not None and not inspect.iscoroutinefunction(close): + close() diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/detail.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/detail.py index 73e7db6a..02144cfd 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/detail.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/detail.py @@ -597,7 +597,9 @@ async def fetch_detail( await retry_session.close() except Exception: pass - retry_session = _build_detail_session() + # config ОБЯЗАТЕЛЕН: без него прокси не подставляется + # вовсе — разбор того, чем это кончается, у 429-ветки ниже. + retry_session = _build_detail_session(config) attempt_session = retry_session logger.info( "avito detail 403/firewall/soft-block %s — backconnect reconnect " @@ -624,7 +626,18 @@ async def fetch_detail( await retry_session.close() except Exception: pass - retry_session = _build_detail_session() + # config ОБЯЗАТЕЛЕН. Без него `_build_detail_session` берёт + # proxy_url=None, и «эфемерная сессия ради свежего exit-IP» + # уходит НАПРЯМУЮ с адреса самой машины — то есть делает + # ровно обратное замыслу. Ветка исполняется только когда + # backconnect=True, а он и означает «прокси задан»: мы + # достоверно знаем, что прокси есть, и теряли его здесь. + # + # Замер 27.08 из прод-контейнера, один и тот же URL: + # через прокси — 200, 3.3 МБ страницы + # напрямую — 429, «доступ ограничен», firewall + # То есть каждый повтор после блока был обречён. + retry_session = _build_detail_session(config) attempt_session = retry_session logger.info( "avito detail HTTP 429 short-retry exhausted %s — "