fix(tradein/avito-detail): живой прогрев сессии (yandex-referer→search) + батч-на-сессию вместо холодного per-card fetch — снимает 403 (#1551)
This commit is contained in:
parent
a96af4c19c
commit
cb1f9e9514
4 changed files with 256 additions and 24 deletions
|
|
@ -22,11 +22,12 @@ from __future__ import annotations
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import random
|
||||||
import re
|
import re
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
from urllib.parse import urljoin, urlparse
|
from urllib.parse import quote, urljoin, urlparse
|
||||||
|
|
||||||
from curl_cffi.requests import AsyncSession
|
from curl_cffi.requests import AsyncSession
|
||||||
from selectolax.parser import HTMLParser, Node
|
from selectolax.parser import HTMLParser, Node
|
||||||
|
|
@ -49,6 +50,18 @@ logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
AVITO_BASE = "https://www.avito.ru"
|
AVITO_BASE = "https://www.avito.ru"
|
||||||
|
|
||||||
|
# Живой прогрев сессии (#1551): GET Yandex SERP (органический referer-источник) -> GET
|
||||||
|
# Avito EKB search-страница (Referer=yandex) сеет антибот-куки, после чего прогретая
|
||||||
|
# curl_cffi-сессия держит батч detail-GET'ов без 403 (доказано пробами на проде: 22/22
|
||||||
|
# карточек подряд, 0 блоков). referer на detail-GET = URL avito-search страницы.
|
||||||
|
_AVITO_WARM_SEARCH_URL = "https://www.avito.ru/ekaterinburg/kvartiry/prodam?q=" + quote(
|
||||||
|
"купить квартиру"
|
||||||
|
)
|
||||||
|
_AVITO_WARM_YANDEX_URL = "https://yandex.ru/search/?text=" + quote(
|
||||||
|
"купить квартиру екатеринбург авито"
|
||||||
|
)
|
||||||
|
_AVITO_WARM_YANDEX_REFERER = "https://yandex.ru/"
|
||||||
|
|
||||||
# Reconnect-retry на detail-странице под backconnect-прокси — зеркало SERP-фикса
|
# Reconnect-retry на detail-странице под backconnect-прокси — зеркало SERP-фикса
|
||||||
# (#1765/#1769, avito.py): 403/firewall = залочен лишь текущий exit-IP backconnect-прокси;
|
# (#1765/#1769, avito.py): 403/firewall = залочен лишь текущий exit-IP backconnect-прокси;
|
||||||
# пересоздание curl_cffi-сессии = новый CONNECT-туннель = свежий exit-IP → escape.
|
# пересоздание curl_cffi-сессии = новый CONNECT-туннель = свежий exit-IP → escape.
|
||||||
|
|
@ -283,6 +296,38 @@ def _build_detail_session() -> AsyncSession:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def warm_up_session(session: AsyncSession) -> bool:
|
||||||
|
"""Органический прогрев сессии: Yandex SERP (best-effort) -> Avito EKB search
|
||||||
|
(Referer=yandex). Сеет антибот-куки, после чего сессия держит батч detail-GET'ов
|
||||||
|
без 403. Возвращает True если search-страница загрузилась (не firewall).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await session.get(_AVITO_WARM_YANDEX_URL) # органический referer-источник, best-effort
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
await asyncio.sleep(random.uniform(2.0, 3.5))
|
||||||
|
try:
|
||||||
|
r = await session.get(
|
||||||
|
_AVITO_WARM_SEARCH_URL, headers={"Referer": _AVITO_WARM_YANDEX_REFERER}
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("avito warm-up: search fetch error: %s", exc)
|
||||||
|
return False
|
||||||
|
ok = r.status_code == 200 and not _is_firewall_page(r.text)
|
||||||
|
if not ok:
|
||||||
|
logger.warning("avito warm-up: search page blocked sc=%s", r.status_code)
|
||||||
|
return ok
|
||||||
|
|
||||||
|
|
||||||
|
async def build_warmed_session() -> AsyncSession:
|
||||||
|
"""Построить detail-сессию (_build_detail_session) и прогреть её. Возвращает
|
||||||
|
прогретую сессию (даже если warm-up soft-failed — caller может всё равно пробовать).
|
||||||
|
"""
|
||||||
|
session = _build_detail_session()
|
||||||
|
await warm_up_session(session)
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
# ── Soft-block detection (#1967 / #1950) ──────────────────────────────────────
|
# ── Soft-block detection (#1967 / #1950) ──────────────────────────────────────
|
||||||
# Под нагрузкой Avito иногда вместо detail-страницы отдаёт HTTP 200 с ОБЩЕЙ
|
# Под нагрузкой Avito иногда вместо detail-страницы отдаёт HTTP 200 с ОБЩЕЙ
|
||||||
# витриной (<title>Авито — Объявления на сайте Авито</title>) вместо запрошенного
|
# витриной (<title>Авито — Объявления на сайте Авито</title>) вместо запрошенного
|
||||||
|
|
@ -327,11 +372,19 @@ async def fetch_detail(
|
||||||
*,
|
*,
|
||||||
cffi_session: AsyncSession | None = None,
|
cffi_session: AsyncSession | None = None,
|
||||||
browser_fetcher: BrowserFetcher | None = None,
|
browser_fetcher: BrowserFetcher | None = None,
|
||||||
|
referer: str | None = None,
|
||||||
|
reconnect_on_block: bool = True,
|
||||||
) -> DetailEnrichment:
|
) -> DetailEnrichment:
|
||||||
"""GET <avito>/{item_url} → parse HTML via selectolax → DetailEnrichment.
|
"""GET <avito>/{item_url} → parse HTML via selectolax → DetailEnrichment.
|
||||||
|
|
||||||
Если browser_fetcher передан — использует браузерный fetch (browser mode).
|
Если browser_fetcher передан — использует браузерный fetch (browser mode).
|
||||||
Если cffi_session не передана — создаёт новую (impersonate='chrome120').
|
Если cffi_session не передана — создаёт новую (impersonate='chrome120').
|
||||||
|
referer — если задан, шлётся в Referer-заголовке detail-GET'а (warm-batch #1551:
|
||||||
|
referer = URL avito-search страницы, на которой прогрелась сессия).
|
||||||
|
reconnect_on_block — если False, 403/firewall поднимает AvitoBlockedError СРАЗУ
|
||||||
|
(без холодных reconnect'ов — ими займётся outer-loop через rebuild+rewarm прогретой
|
||||||
|
сессии); 429 после short-retry поднимает AvitoRateLimitedError. Дефолт True
|
||||||
|
сохраняет старое поведение own-session / scrape_pipeline путей.
|
||||||
Raises:
|
Raises:
|
||||||
httpx.HTTPError — если status != 200 (через raise_for_status-like).
|
httpx.HTTPError — если status != 200 (через raise_for_status-like).
|
||||||
ValueError — если item_id не извлечён из HTML.
|
ValueError — если item_id не извлечён из HTML.
|
||||||
|
|
@ -387,7 +440,12 @@ async def fetch_detail(
|
||||||
# ТОЛЬКО на наличие proxy (зеркало SERP-фикса #1765). Без proxy — старое поведение
|
# ТОЛЬКО на наличие proxy (зеркало SERP-фикса #1765). Без proxy — старое поведение
|
||||||
# (raise на первом 403). Caller-provided сессию НЕ трогаем: reconnect использует
|
# (raise на первом 403). Caller-provided сессию НЕ трогаем: reconnect использует
|
||||||
# эфемерную retry_session, которую сами закрываем в finally.
|
# эфемерную retry_session, которую сами закрываем в finally.
|
||||||
backconnect = bool(settings.scraper_proxy_url)
|
# reconnect_on_block=False (warm-batch #1551): не делаем холодных per-card
|
||||||
|
# reconnect'ов — блок поднимает AvitoBlockedError сразу, outer-loop пересоздаёт+
|
||||||
|
# прогревает сессию (свежий exit-IP). reconnect_on_block=True (own-session /
|
||||||
|
# scrape_pipeline) — старое поведение: backconnect-reconnect эфемерной сессией.
|
||||||
|
backconnect = bool(settings.scraper_proxy_url) and reconnect_on_block
|
||||||
|
req_headers = {"Referer": referer} if referer else None
|
||||||
r403 = 0
|
r403 = 0
|
||||||
r429 = 0
|
r429 = 0
|
||||||
r429_reconnect = 0
|
r429_reconnect = 0
|
||||||
|
|
@ -395,7 +453,7 @@ async def fetch_detail(
|
||||||
retry_session: AsyncSession | None = None
|
retry_session: AsyncSession | None = None
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
response = await attempt_session.get(full_url)
|
response = await attempt_session.get(full_url, headers=req_headers)
|
||||||
sc = response.status_code
|
sc = response.status_code
|
||||||
is_firewall = sc == 200 and (
|
is_firewall = sc == 200 and (
|
||||||
_is_firewall_page(response.text) or _is_detail_soft_block(response.text)
|
_is_firewall_page(response.text) or _is_detail_soft_block(response.text)
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import random
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
@ -30,7 +31,12 @@ from app.core.shutdown import shutdown_requested
|
||||||
from app.services import scrape_runs as runs_mod
|
from app.services import scrape_runs as runs_mod
|
||||||
from app.services.scrape_pipeline import _CHROME_HEADERS, _avito_proxies
|
from app.services.scrape_pipeline import _CHROME_HEADERS, _avito_proxies
|
||||||
from app.services.scrapers.avito import AvitoScraper
|
from app.services.scrapers.avito import AvitoScraper
|
||||||
from app.services.scrapers.avito_detail import fetch_detail, save_detail_enrichment
|
from app.services.scrapers.avito_detail import (
|
||||||
|
_AVITO_WARM_SEARCH_URL,
|
||||||
|
build_warmed_session,
|
||||||
|
fetch_detail,
|
||||||
|
save_detail_enrichment,
|
||||||
|
)
|
||||||
from app.services.scrapers.avito_exceptions import (
|
from app.services.scrapers.avito_exceptions import (
|
||||||
AvitoBlockedError,
|
AvitoBlockedError,
|
||||||
AvitoListingGoneError,
|
AvitoListingGoneError,
|
||||||
|
|
@ -89,6 +95,7 @@ async def run_avito_detail_backfill(
|
||||||
budget_sec = float(params.get("budget_sec", 3600))
|
budget_sec = float(params.get("budget_sec", 3600))
|
||||||
request_delay_sec = float(params.get("request_delay_sec", 6.0))
|
request_delay_sec = float(params.get("request_delay_sec", 6.0))
|
||||||
max_consecutive_blocks = int(params.get("max_consecutive_blocks", 5))
|
max_consecutive_blocks = int(params.get("max_consecutive_blocks", 5))
|
||||||
|
warm_batch = int(params.get("warm_batch", 20))
|
||||||
|
|
||||||
# #1950: hard-timeout'ы на блокирующие await'ы внутри loop'а. Без них один
|
# #1950: hard-timeout'ы на блокирующие await'ы внутри loop'а. Без них один
|
||||||
# зависший fetch_detail/rotate ронял весь run в zombie (run 423 завис 7.7ч).
|
# зависший fetch_detail/rotate ронял весь run в zombie (run 423 завис 7.7ч).
|
||||||
|
|
@ -129,6 +136,13 @@ async def run_avito_detail_backfill(
|
||||||
await browser_fetcher.__aenter__()
|
await browser_fetcher.__aenter__()
|
||||||
own_browser = True
|
own_browser = True
|
||||||
scraper._browser = browser_fetcher
|
scraper._browser = browser_fetcher
|
||||||
|
elif use_curl:
|
||||||
|
# use_curl=True (#1551 warm-batch): прогретая shared-сессия — yandex-referer ->
|
||||||
|
# avito-search сеет антибот-куки, сессия держит батч detail без 403 (доказано
|
||||||
|
# пробами). Ротация каждые warm_batch карточек И на блоке = свежий exit-IP +
|
||||||
|
# свежий органический прогрев.
|
||||||
|
own_session = True
|
||||||
|
session = await build_warmed_session()
|
||||||
elif not use_curl:
|
elif not use_curl:
|
||||||
# curl_cffi legacy path (scraper_fetch_mode="curl_cffi", use_curl=False):
|
# curl_cffi legacy path (scraper_fetch_mode="curl_cffi", use_curl=False):
|
||||||
# строим shared сессию через auv, как делал scrape_pipeline.
|
# строим shared сессию через auv, как делал scrape_pipeline.
|
||||||
|
|
@ -140,8 +154,6 @@ async def run_avito_detail_backfill(
|
||||||
proxies=_avito_proxies(),
|
proxies=_avito_proxies(),
|
||||||
)
|
)
|
||||||
scraper._cffi = session
|
scraper._cffi = session
|
||||||
# use_curl=True: ничего не строим — fetch_detail вызывает _build_detail_session()
|
|
||||||
# (scraper_proxy_url = backconnect mproxy) на каждый запрос.
|
|
||||||
|
|
||||||
runs_mod.update_heartbeat(db, run_id, current_counters)
|
runs_mod.update_heartbeat(db, run_id, current_counters)
|
||||||
|
|
||||||
|
|
@ -197,6 +209,7 @@ async def run_avito_detail_backfill(
|
||||||
|
|
||||||
consecutive_blocks = 0
|
consecutive_blocks = 0
|
||||||
do_sleep = False
|
do_sleep = False
|
||||||
|
items_since_warm = 0
|
||||||
|
|
||||||
for idx, row in enumerate(snapshot):
|
for idx, row in enumerate(snapshot):
|
||||||
# Budget guard
|
# Budget guard
|
||||||
|
|
@ -227,9 +240,10 @@ async def run_avito_detail_backfill(
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
|
|
||||||
# Delay before each request except the first
|
# Delay before each request except the first (джиттер ±30% — менее
|
||||||
|
# роботизированный паттерн на органически прогретой сессии).
|
||||||
if do_sleep:
|
if do_sleep:
|
||||||
await asyncio.sleep(request_delay_sec)
|
await asyncio.sleep(request_delay_sec * random.uniform(0.7, 1.4))
|
||||||
do_sleep = True
|
do_sleep = True
|
||||||
|
|
||||||
source_url: str = row["source_url"]
|
source_url: str = row["source_url"]
|
||||||
|
|
@ -238,6 +252,17 @@ async def run_avito_detail_backfill(
|
||||||
# Normalise URL -> path (mirrors scrape_pipeline.py line 311)
|
# Normalise URL -> path (mirrors scrape_pipeline.py line 311)
|
||||||
item_url = urlparse(source_url).path if source_url.startswith("http") else source_url
|
item_url = urlparse(source_url).path if source_url.startswith("http") else source_url
|
||||||
|
|
||||||
|
# use_curl warm-batch (#1551): ротация прогретой сессии каждые warm_batch
|
||||||
|
# карточек — свежий exit-IP + свежий органический прогрев, щадит IP-пул
|
||||||
|
# (1 exit-IP на батч вместо 1 на карточку).
|
||||||
|
if use_curl and session is not None and items_since_warm >= warm_batch:
|
||||||
|
try:
|
||||||
|
await session.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
session = await build_warmed_session()
|
||||||
|
items_since_warm = 0
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# #1950: hard-timeout — зависший fetch (browser hang / curl-stall) не
|
# #1950: hard-timeout — зависший fetch (browser hang / curl-stall) не
|
||||||
# должен блокировать loop навсегда (иначе budget-guard/heartbeat молчат
|
# должен блокировать loop навсегда (иначе budget-guard/heartbeat молчат
|
||||||
|
|
@ -247,11 +272,15 @@ async def run_avito_detail_backfill(
|
||||||
item_url,
|
item_url,
|
||||||
cffi_session=session,
|
cffi_session=session,
|
||||||
browser_fetcher=browser_fetcher,
|
browser_fetcher=browser_fetcher,
|
||||||
|
referer=_AVITO_WARM_SEARCH_URL if use_curl else None,
|
||||||
|
reconnect_on_block=not use_curl,
|
||||||
),
|
),
|
||||||
timeout=fetch_timeout_s,
|
timeout=fetch_timeout_s,
|
||||||
)
|
)
|
||||||
if save_detail_enrichment(db, enrichment):
|
if save_detail_enrichment(db, enrichment):
|
||||||
counters.enriched += 1
|
counters.enriched += 1
|
||||||
|
if use_curl:
|
||||||
|
items_since_warm += 1
|
||||||
consecutive_blocks = 0
|
consecutive_blocks = 0
|
||||||
|
|
||||||
except AvitoListingGoneError:
|
except AvitoListingGoneError:
|
||||||
|
|
@ -299,18 +328,31 @@ async def run_avito_detail_backfill(
|
||||||
consecutive_blocks,
|
consecutive_blocks,
|
||||||
e,
|
e,
|
||||||
)
|
)
|
||||||
# #1950: bound rotate — _rotate_ip ждёт settle + до 3 changeip-попыток;
|
# use_curl warm-batch (#1551): блок = текущий exit-IP залочен → закрываем
|
||||||
# на блокирующем changeip (зависшее соединение) без timeout loop виснет.
|
# прогретую сессию и строим новую (свежий exit-IP + новый органический
|
||||||
try:
|
# прогрев) вместо changeip-ротации. Иначе legacy auv/browser-путь —
|
||||||
await asyncio.wait_for(scraper._rotate_ip(), timeout=rotate_timeout_s)
|
# bound rotate backconnect-IP.
|
||||||
except Exception:
|
if use_curl:
|
||||||
logger.warning(
|
if session is not None:
|
||||||
"avito_detail_backfill: run_id=%d rotate_ip timed out/failed "
|
try:
|
||||||
"(>%.0fs) -- continuing",
|
await session.close()
|
||||||
run_id,
|
except Exception:
|
||||||
rotate_timeout_s,
|
pass
|
||||||
exc_info=True,
|
session = await build_warmed_session()
|
||||||
)
|
items_since_warm = 0
|
||||||
|
else:
|
||||||
|
# #1950: bound rotate — _rotate_ip ждёт settle + до 3 changeip-попыток;
|
||||||
|
# на блокирующем changeip (зависшее соединение) без timeout loop виснет.
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(scraper._rotate_ip(), timeout=rotate_timeout_s)
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
"avito_detail_backfill: run_id=%d rotate_ip timed out/failed "
|
||||||
|
"(>%.0fs) -- continuing",
|
||||||
|
run_id,
|
||||||
|
rotate_timeout_s,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
if consecutive_blocks >= max_consecutive_blocks:
|
if consecutive_blocks >= max_consecutive_blocks:
|
||||||
logger.error(
|
logger.error(
|
||||||
"avito_detail_backfill: run_id=%d ABORT -- %d consecutive blocks, "
|
"avito_detail_backfill: run_id=%d ABORT -- %d consecutive blocks, "
|
||||||
|
|
|
||||||
106
tradein-mvp/backend/tests/scrapers/test_avito_detail_warmup.py
Normal file
106
tradein-mvp/backend/tests/scrapers/test_avito_detail_warmup.py
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
"""Живой прогрев сессии + батч-на-сессию (#1551).
|
||||||
|
|
||||||
|
warm_up_session: GET Yandex SERP (best-effort) -> GET Avito EKB search-страница с
|
||||||
|
Referer=yandex сеет антибот-куки. Прогретая сессия потом держит батч detail-GET'ов
|
||||||
|
без 403 (доказано пробами на проде: 22/22 карточек подряд, 0 блоков).
|
||||||
|
|
||||||
|
fetch_detail(referer=..., reconnect_on_block=False): шлёт Referer detail-GET'а и при
|
||||||
|
403/firewall поднимает AvitoBlockedError СРАЗУ — без холодных per-card reconnect'ов
|
||||||
|
(их делает outer-loop через rebuild+rewarm прогретой сессии в avito_detail_backfill).
|
||||||
|
|
||||||
|
Стиль фикстур/моков зеркалит test_avito_detail_403_reconnect.py.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.services.scrapers import avito_detail as detail_mod
|
||||||
|
from app.services.scrapers.avito_detail import fetch_detail, warm_up_session
|
||||||
|
from app.services.scrapers.avito_exceptions import AvitoBlockedError
|
||||||
|
|
||||||
|
|
||||||
|
def _resp(status_code: int, text: str = "") -> MagicMock:
|
||||||
|
r = MagicMock()
|
||||||
|
r.status_code = status_code
|
||||||
|
r.text = text
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
def _enable_backconnect(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(settings, "scraper_proxy_url_env", "http://u:p@mproxy.site:14619")
|
||||||
|
|
||||||
|
|
||||||
|
# ── warm_up_session ───────────────────────────────────────────────────────────
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_warm_up_session_search_with_yandex_referer_true_on_200() -> None:
|
||||||
|
"""warm_up_session шлёт GET на _AVITO_WARM_SEARCH_URL с Referer=yandex и
|
||||||
|
возвращает True на 200/non-firewall."""
|
||||||
|
session = AsyncMock()
|
||||||
|
session.get = AsyncMock(return_value=_resp(200, "<html>ok</html>"))
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("app.services.scrapers.avito_detail.asyncio.sleep", AsyncMock()),
|
||||||
|
patch("app.services.scrapers.avito_detail._is_firewall_page", return_value=False),
|
||||||
|
):
|
||||||
|
ok = await warm_up_session(session)
|
||||||
|
|
||||||
|
assert ok is True
|
||||||
|
# search-GET (на avito-search URL) ушёл ровно один раз и с Referer=yandex.
|
||||||
|
search_calls = [
|
||||||
|
c
|
||||||
|
for c in session.get.await_args_list
|
||||||
|
if c.args and c.args[0] == detail_mod._AVITO_WARM_SEARCH_URL
|
||||||
|
]
|
||||||
|
assert len(search_calls) == 1
|
||||||
|
assert search_calls[0].kwargs["headers"]["Referer"] == detail_mod._AVITO_WARM_YANDEX_REFERER
|
||||||
|
# Yandex SERP — органический referer-источник — тоже фетчился (best-effort, до search).
|
||||||
|
assert any(
|
||||||
|
c.args and c.args[0] == detail_mod._AVITO_WARM_YANDEX_URL
|
||||||
|
for c in session.get.await_args_list
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_warm_up_session_false_on_403() -> None:
|
||||||
|
"""search-страница вернула 403 → warm_up_session возвращает False."""
|
||||||
|
session = AsyncMock()
|
||||||
|
session.get = AsyncMock(return_value=_resp(403))
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("app.services.scrapers.avito_detail.asyncio.sleep", AsyncMock()),
|
||||||
|
patch("app.services.scrapers.avito_detail._is_firewall_page", return_value=False),
|
||||||
|
):
|
||||||
|
ok = await warm_up_session(session)
|
||||||
|
|
||||||
|
assert ok is False
|
||||||
|
|
||||||
|
|
||||||
|
# ── fetch_detail referer + reconnect_on_block=False ───────────────────────────
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fetch_detail_referer_forwarded_and_block_raises_immediately(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""fetch_detail(referer="X", reconnect_on_block=False): Referer="X" уходит в
|
||||||
|
detail-GET, а 403 поднимает AvitoBlockedError СРАЗУ — без холодного reconnect
|
||||||
|
(даже когда backconnect-proxy включён → гейт = reconnect_on_block, не наличие proxy).
|
||||||
|
"""
|
||||||
|
_enable_backconnect(monkeypatch)
|
||||||
|
|
||||||
|
caller = AsyncMock()
|
||||||
|
caller.get = AsyncMock(return_value=_resp(403))
|
||||||
|
build = MagicMock() # _build_detail_session НЕ должна дёргаться (нет reconnect'а)
|
||||||
|
|
||||||
|
with patch.object(detail_mod, "_build_detail_session", build):
|
||||||
|
with pytest.raises(AvitoBlockedError, match="HTTP 403"):
|
||||||
|
await fetch_detail(
|
||||||
|
"/ekaterinburg/kvartiry/test-123",
|
||||||
|
cffi_session=caller,
|
||||||
|
referer="X",
|
||||||
|
reconnect_on_block=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
build.assert_not_called() # никаких холодных reconnect'ов
|
||||||
|
assert caller.get.await_count == 1 # один detail-GET → сразу raise
|
||||||
|
assert caller.get.await_args.kwargs["headers"] == {"Referer": "X"}
|
||||||
|
|
@ -27,6 +27,19 @@ def _reset_shutdown() -> None:
|
||||||
_sd.reset_shutdown()
|
_sd.reset_shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Helpers
|
# Helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -129,7 +142,9 @@ async def test_backfill_blocked_abort_after_max_consecutive() -> None:
|
||||||
mock_fetch = AsyncMock(side_effect=blocked_exc)
|
mock_fetch = AsyncMock(side_effect=blocked_exc)
|
||||||
mock_scraper = MagicMock()
|
mock_scraper = MagicMock()
|
||||||
mock_scraper.return_value._rotate_ip = AsyncMock(return_value=True)
|
mock_scraper.return_value._rotate_ip = AsyncMock(return_value=True)
|
||||||
fake_settings = MagicMock(scraper_fetch_mode="cffi")
|
# use_curl=False: legacy block→_rotate_ip путь (#1551 warm-batch rebuild только при
|
||||||
|
# use_curl=True). MagicMock без явного флага сделал бы use_curl truthy.
|
||||||
|
fake_settings = MagicMock(scraper_fetch_mode="cffi", avito_detail_backfill_use_curl=False)
|
||||||
with (
|
with (
|
||||||
patch(_SETTINGS, fake_settings),
|
patch(_SETTINGS, fake_settings),
|
||||||
patch(_SESSION, return_value=AsyncMock()),
|
patch(_SESSION, return_value=AsyncMock()),
|
||||||
|
|
@ -249,7 +264,9 @@ async def test_backfill_rotate_ip_called_on_each_block() -> None:
|
||||||
mock_fetch = AsyncMock(side_effect=[blocked_exc, mock_enrichment])
|
mock_fetch = AsyncMock(side_effect=[blocked_exc, mock_enrichment])
|
||||||
mock_scraper = MagicMock()
|
mock_scraper = MagicMock()
|
||||||
mock_scraper.return_value._rotate_ip = AsyncMock(return_value=True)
|
mock_scraper.return_value._rotate_ip = AsyncMock(return_value=True)
|
||||||
fake_settings = MagicMock(scraper_fetch_mode="cffi")
|
# use_curl=False: legacy block→_rotate_ip путь (#1551 warm-batch rebuild только при
|
||||||
|
# use_curl=True). MagicMock без явного флага сделал бы use_curl truthy.
|
||||||
|
fake_settings = MagicMock(scraper_fetch_mode="cffi", avito_detail_backfill_use_curl=False)
|
||||||
with (
|
with (
|
||||||
patch(_SETTINGS, fake_settings),
|
patch(_SETTINGS, fake_settings),
|
||||||
patch(_SESSION, return_value=AsyncMock()),
|
patch(_SESSION, return_value=AsyncMock()),
|
||||||
|
|
@ -342,7 +359,14 @@ async def test_backfill_fetch_timeout_skips_and_continues() -> None:
|
||||||
|
|
||||||
call_urls: list[str] = []
|
call_urls: list[str] = []
|
||||||
|
|
||||||
async def _fetch(url: str, *, cffi_session: object = None, browser_fetcher: object = None):
|
async def _fetch(
|
||||||
|
url: str,
|
||||||
|
*,
|
||||||
|
cffi_session: object = None,
|
||||||
|
browser_fetcher: object = None,
|
||||||
|
referer: object = None,
|
||||||
|
reconnect_on_block: bool = True,
|
||||||
|
):
|
||||||
call_urls.append(url)
|
call_urls.append(url)
|
||||||
if len(call_urls) == 1:
|
if len(call_urls) == 1:
|
||||||
await asyncio.Event().wait() # висит вечно → wait_for отменит по timeout
|
await asyncio.Event().wait() # висит вечно → wait_for отменит по timeout
|
||||||
|
|
@ -394,7 +418,9 @@ async def test_backfill_listing_gone_marks_inactive_no_breaker() -> None:
|
||||||
mock_fetch = AsyncMock(side_effect=AvitoListingGoneError("404 gone"))
|
mock_fetch = AsyncMock(side_effect=AvitoListingGoneError("404 gone"))
|
||||||
mock_scraper = MagicMock()
|
mock_scraper = MagicMock()
|
||||||
mock_scraper.return_value._rotate_ip = AsyncMock(return_value=True)
|
mock_scraper.return_value._rotate_ip = AsyncMock(return_value=True)
|
||||||
fake_settings = MagicMock(scraper_fetch_mode="cffi")
|
# use_curl=False: legacy block→_rotate_ip путь (#1551 warm-batch rebuild только при
|
||||||
|
# use_curl=True). MagicMock без явного флага сделал бы use_curl truthy.
|
||||||
|
fake_settings = MagicMock(scraper_fetch_mode="cffi", avito_detail_backfill_use_curl=False)
|
||||||
with (
|
with (
|
||||||
patch(_SETTINGS, fake_settings),
|
patch(_SETTINGS, fake_settings),
|
||||||
patch(_SESSION, return_value=AsyncMock()),
|
patch(_SESSION, return_value=AsyncMock()),
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue