fix(tradein/avito-detail): sticky-IP реальность — on-block cooldown+research вместо бесполезного rebuild, честные комменты (МГТС не ротирует)

This commit is contained in:
bot-backend 2026-06-29 01:16:12 +03:00
parent d311efb431
commit 5bb998a625
2 changed files with 94 additions and 27 deletions

View file

@ -98,6 +98,7 @@ async def run_avito_detail_backfill(
max_consecutive_blocks = int(params.get("max_consecutive_blocks", 5))
warm_batch = int(params.get("warm_batch", 500))
research_every = int(params.get("research_every", 50))
block_cooldown_sec = float(params.get("block_cooldown_sec", 30.0))
# #1950: hard-timeout'ы на блокирующие await'ы внутри loop'а. Без них один
# зависший fetch_detail/rotate ронял весь run в zombie (run 423 завис 7.7ч).
@ -139,10 +140,11 @@ async def run_avito_detail_backfill(
own_browser = True
scraper._browser = browser_fetcher
elif use_curl:
# use_curl=True (#1551 warm-batch): прогретая shared-сессия — yandex-referer ->
# avito-search сеет антибот-куки, сессия держит батч detail без 403 (доказано
# пробами). Ротация каждые warm_batch карточек И на блоке = свежий exit-IP +
# свежий органический прогрев.
# use_curl=True (#1551 warm-batch): прогретая shared-сессия на sticky МГТС-IP —
# yandex-referer -> avito-search сеет антибот-куки, сессия держит батч detail без
# 403 (доказано пробами: ≥66 карточек подряд, 0 блоков). NB: МГТС sticky — один
# фикс. exit-IP, per-connection ротации нет (rebuild != новый IP); on-block —
# cooldown + in-session re-search, не дискард сессии.
own_session = True
session = await build_warmed_session()
elif not use_curl:
@ -255,12 +257,14 @@ async def run_avito_detail_backfill(
item_url = urlparse(source_url).path if source_url.startswith("http") else source_url
# use_curl warm-batch (#1551): интервалы по размеру SERP-страницы (~50).
# Полная ротация (новый exit-IP + полный прогрев) каждые warm_batch (~500);
# между ротациями — лёгкий in-session перепоиск каждые research_every (~50)
# на ТОМ ЖЕ exit-IP (освежить куки, без смены IP). On-block rebuild = backstop.
# МГТС sticky-IP: exit-IP константа (rebuild НЕ даёт новый IP). Каждые
# warm_batch (~500) — полный re-warm (close+build) на ТОМ ЖЕ sticky exit-IP:
# session-hygiene (свежий TLS-хендшейк + свежие куки yandex->search), НЕ смена
# IP. Между ними — лёгкий in-session перепоиск каждые research_every (~50)
# (re-GET search той же сессией, освежить куки). On-block — cooldown ниже.
if use_curl and session is not None:
if warm_batch and items_since_warm >= warm_batch:
# полная ротация: новый exit-IP + полный прогрев (yandex->search)
# периодический полный re-warm на том же sticky exit-IP (свежий TLS+куки)
try:
await session.close()
except Exception:
@ -340,18 +344,16 @@ async def run_avito_detail_backfill(
consecutive_blocks,
e,
)
# use_curl warm-batch (#1551): блок = текущий exit-IP залочен → закрываем
# прогретую сессию и строим новую (свежий exit-IP + новый органический
# прогрев) вместо changeip-ротации. Иначе legacy auv/browser-путь —
# bound rotate backconnect-IP.
# МГТС sticky-IP: один фикс. exit-IP, per-connection ротации нет (проверено:
# 6/6 свежих сессий = тот же IP 109.252.125.80; ротация только вручную
# кнопкой). Уйти на свежий IP софтом нельзя → блок = rate-limit текущего IP:
# даём окну остыть (cooldown) и освежаем куки in-session, НЕ дискардим
# рабочую прогретую сессию (rebuild на том же IP бесполезен для escape +
# грузит rate-limited IP полным прогревом). items_since_warm НЕ сбрасываем.
if use_curl:
await asyncio.sleep(block_cooldown_sec)
if session is not None:
try:
await session.close()
except Exception:
pass
session = await build_warmed_session()
items_since_warm = 0
await research_in_session(session)
else:
# #1950: bound rotate — _rotate_ip ждёт settle + до 3 changeip-попыток;
# на блокирующем changeip (зависшее соединение) без timeout loop виснет.

View file

@ -29,15 +29,21 @@ def _reset_shutdown() -> None:
@pytest.fixture(autouse=True)
def _patch_build_warmed() -> object:
"""use_curl-ветка (#1551) строит прогретую сессию через build_warmed_session
(реальный yandex/avito warm-up + sleep). fetch_detail в этих тестах всё равно
замокан мокаем build_warmed_session не-сетевой AsyncMock-сессией, чтобы setup
и rebuild-on-block не ходили в сеть."""
with patch(
"app.tasks.avito_detail_backfill.build_warmed_session",
AsyncMock(return_value=AsyncMock()),
) as p:
yield p
"""use_curl-ветка (#1551) строит прогретую сессию через build_warmed_session и
освежает куки через research_in_session (реальный yandex/avito GET + sleep).
fetch_detail в этих тестах всё равно замокан мокаем обе не-сетевыми AsyncMock,
чтобы setup / periodic re-warm / on-block cooldown не ходили в сеть."""
with (
patch(
"app.tasks.avito_detail_backfill.build_warmed_session",
AsyncMock(return_value=AsyncMock()),
),
patch(
"app.tasks.avito_detail_backfill.research_in_session",
AsyncMock(return_value=True),
) as research,
):
yield research
# ---------------------------------------------------------------------------
@ -66,6 +72,8 @@ _SESSION = "app.tasks.avito_detail_backfill.AsyncSession"
_SCRAPER = "app.tasks.avito_detail_backfill.AvitoScraper"
_SETTINGS = "app.tasks.avito_detail_backfill.settings"
_SHUTDOWN = "app.tasks.avito_detail_backfill.shutdown_requested"
_BUILD_WARM = "app.tasks.avito_detail_backfill.build_warmed_session"
_RESEARCH = "app.tasks.avito_detail_backfill.research_in_session"
# ---------------------------------------------------------------------------
# Tests
@ -536,3 +544,60 @@ async def test_backfill_use_curl_false_creates_browser_fetcher() -> None:
assert kwargs.get("browser_fetcher") is not None
assert result.enriched == 1
runs.mark_done.assert_called_once()
@pytest.mark.asyncio
async def test_backfill_use_curl_block_cooldown_research_no_rebuild() -> None:
"""#1551 sticky-IP: on-block (use_curl) даёт cooldown + in-session research, НЕ
пересоздаёт прогретую сессию и НЕ дёргает changeip-ротацию.
МГТС sticky один фикс. exit-IP (rebuild != новый IP), уйти на свежий IP софтом
нельзя. Блок = rate-limit текущего IP cooldown + re-search той же сессией.
fetch: 1-й вызов BLOCKED, 2-й успех.
"""
from app.services.scrapers.avito_exceptions import AvitoBlockedError
snapshot = _make_snapshot(2)
db = _mock_db(snapshot)
runs = MagicMock()
mock_enrichment = MagicMock()
blocked_exc = AvitoBlockedError("rate-limited")
mock_fetch = AsyncMock(side_effect=[blocked_exc, mock_enrichment])
warmed_session = AsyncMock()
fake_settings = MagicMock(
scraper_fetch_mode="cffi",
avito_detail_backfill_use_curl=True,
)
with (
patch(_SETTINGS, fake_settings),
patch(_SCRAPER) as mock_scraper,
patch(_RUNS, runs),
patch(_FETCH, mock_fetch),
patch(_SAVE, return_value=True),
patch(_SLEEP, new_callable=AsyncMock),
patch(_BUILD_WARM, AsyncMock(return_value=warmed_session)) as mock_build,
patch(_RESEARCH, new_callable=AsyncMock) as mock_research,
):
result = await run_avito_detail_backfill(
db,
run_id=14,
params={
"batch_size": 10,
"budget_sec": 3600,
"max_consecutive_blocks": 5,
"block_cooldown_sec": 0.0,
},
)
assert result.blocked == 1
assert result.enriched == 1
assert result.attempted == 2
# on-block: research_in_session вызван (освежить куки in-session)...
mock_research.assert_awaited_once()
# ...а build_warmed_session НЕ дёргался повторно (вызван только 1× в setup —
# прогретая сессия НЕ пересоздаётся на блоке, sticky-IP rebuild бесполезен).
assert mock_build.await_count == 1
# changeip-ротация (legacy путь) под use_curl НЕ дёргается.
mock_scraper.return_value._rotate_ip.assert_not_called()
runs.mark_done.assert_called_once()
runs.mark_failed.assert_not_called()