Merge pull request 'fix(tradein/scrapers): диагноз блока из HTTP-статуса, а не из маркеров чужой площадки (#3196)' (#3202) from fix/3196-ban-kind-http-status into main
Some checks failed
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / test (push) Has been cancelled
Deploy Trade-In / build-browser (push) Has been cancelled

Reviewed-on: #3202
This commit is contained in:
lekss361 2026-08-28 20:25:40 +00:00
commit 79e469de1c
14 changed files with 1386 additions and 32 deletions

View file

@ -149,7 +149,23 @@ async def _execute_cian_backfill(
) )
counters = {**_counters(result), "duration_sec": int(result.duration_sec)} counters = {**_counters(result), "duration_sec": int(result.duration_sec)}
runs_mod.mark_done(db, run_id, counters) # #3196: отказ detail-фетча теперь несёт диагноз (HTTP-статус последнего ответа
# сайдкара). В 'banned' переводим ТОЛЬКО прогон, который отказы видел и не
# обогатил НИЧЕГО, — частичный успех остаётся 'done', как и был.
if result.ban_kinds and (result.listings_succeeded + result.houses_succeeded) == 0:
counters["blocked"] = result.listings_blocked
# Полная перепись диагнозов, а не только доминирующий вид (#3196) — иначе
# запись прогона теряет, например, единичный infra среди platform.
counters["ban_kinds"] = dict(result.ban_kinds)
runs_mod.mark_banned(
db,
run_id,
f"cian detail: {result.listings_blocked} отказов, ни одного обогащения",
counters,
ban_kind=result.ban_kind,
)
else:
runs_mod.mark_done(db, run_id, counters)
logger.info( logger.info(
"scheduler: cian_history_backfill run_id=%d done — listings=%d/%d houses=%d/%d %.1fs", "scheduler: cian_history_backfill run_id=%d done — listings=%d/%d houses=%d/%d %.1fs",
run_id, run_id,

View file

@ -33,10 +33,11 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
import time import time
from collections import Counter
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass, field from dataclasses import dataclass, field
from scraper_kit.browser_fetcher import BrowserFetcher from scraper_kit.browser_fetcher import BrowserFetcher, ban_kind_from_status
from scraper_kit.providers.cian.detail import fetch_detail, save_detail_enrichment from scraper_kit.providers.cian.detail import fetch_detail, save_detail_enrichment
from scraper_kit.providers.cian.valuation import estimate_via_cian_valuation from scraper_kit.providers.cian.valuation import estimate_via_cian_valuation
from sqlalchemy import text from sqlalchemy import text
@ -71,6 +72,42 @@ class CianBackfillResult:
valuations_succeeded: int = 0 valuations_succeeded: int = 0
valuations_failed: int = 0 valuations_failed: int = 0
duration_sec: float = field(default=0.0) duration_sec: float = field(default=0.0)
# #3196: отказы detail-фетча и их перепись (kind -> сколько раз). До этой правки
# циановский прогон отдавал наверх только "не смогли обогатить", и scrape_runs.ban_kind
# у него не проставлялся вовсе.
listings_blocked: int = 0
ban_kinds: Counter[str] = field(default_factory=Counter)
@property
def ban_kind(self) -> str:
"""Доминирующий диагноз отказов прогона (#3196), пригоден для scrape_runs.ban_kind.
Считает тот же `_dominant_ban_kind`, что и остальные backfill-и: один вид
он; строгое большинство оно; иначе (и при пустой переписи) 'unknown'.
Импорт локальный таск не должен тянуть services на уровне модуля.
"""
from app.services.scrape_runs import _dominant_ban_kind
return _dominant_ban_kind(self.ban_kinds)
def _note_refusal(result: CianBackfillResult, status: int | None) -> str | None:
"""Записать отказ detail-фетча, если его природа диагностируема по HTTP-статусу (#3196).
Инвариант: непустой `ban_kinds` мы видели ответ с 403/429/5xx. `ban_kind_from_status`
отвечает диагнозом только на такие статусы; на всё прочее (в т.ч. статуса нет сайдкар
не дошёл до навигации, или это HTTP 200 с промахом нашего парсера дрейф разметки, а
не отказ площадки) возвращает None, и тогда эта функция НЕ инкрементит ни
`listings_blocked`, ни `ban_kinds` только возвращает None вызывающему. Раньше
недиагностируемые случаи писались как 'unknown' и это в scrape_runs.mark_banned
(scheduler.py) превращало наши собственные сбои в фиктивный бан площадки.
"""
kind = ban_kind_from_status(status)
if kind is None:
return None
result.listings_blocked += 1
result.ban_kinds[kind] += 1
return kind
async def backfill_cian_history( async def backfill_cian_history(
@ -145,21 +182,29 @@ async def backfill_cian_history(
try: try:
enrichment = await fetch_detail(source_url, browser_fetcher=bf) enrichment = await fetch_detail(source_url, browser_fetcher=bf)
except Exception as exc: except Exception as exc:
kind = _note_refusal(result, bf.last_response_status)
logger.warning( logger.warning(
"cian_detail fetch failed for listing_id=%s url=%s: %s", "cian_detail fetch failed for listing_id=%s url=%s: %s "
"(http=%s ban_kind=%s)",
listing_id, listing_id,
source_url, source_url,
exc, exc,
bf.last_response_status,
kind,
) )
result.listings_failed_fetch += 1 result.listings_failed_fetch += 1
await asyncio.sleep(delay) await asyncio.sleep(delay)
continue continue
if enrichment is None: if enrichment is None:
kind = _note_refusal(result, bf.last_response_status)
logger.warning( logger.warning(
"cian_detail fetch returned None for listing_id=%s url=%s", "cian_detail fetch returned None for listing_id=%s url=%s "
"(http=%s ban_kind=%s)",
listing_id, listing_id,
source_url, source_url,
bf.last_response_status,
kind,
) )
result.listings_failed_fetch += 1 result.listings_failed_fetch += 1
await asyncio.sleep(delay) await asyncio.sleep(delay)

View file

@ -50,12 +50,15 @@ Exception triad differs from Avito:
непринятое решение. Разбор цены и рисков: #2854 (блок бьёт внутри первой непринятое решение. Разбор цены и рисков: #2854 (блок бьёт внутри первой
комнатной корзины, buckets_completed=0 во ВСЕХ прогонах; свежий узел, судя по комнатной корзины, buckets_completed=0 во ВСЕХ прогонах; свежий узел, судя по
длительности до блока 111-332 с, получает свой бюджет). длительности до блока 111-332 с, получает свой бюджет).
ОГРАНИЧЕНИЕ (#2764): диагноз scrape_runs.ban_kind этот прогон НЕ передаёт и ДИАГНОЗ (#3196, снимает ограничение #2764): scrape_runs.ban_kind теперь
получает 'unknown'. Один и тот же DomClickBlockedError поднимается и на передаётся. Один и тот же DomClickBlockedError по-прежнему поднимается и на
распознанном QRATOR-маркере (площадка), и на любом сбое браузерного fetch распознанном маркере, и на сбое браузерного fetch, но несёт HTTP-статус ответа
(наш тракт) -- см. providers/domclick/detail.py::fetch_detail. Пока эти два (`.status`, см. providers/domclick/detail.py::fetch_detail) разводить типы
случая не разведены отдельным подтипом (как AvitoSidecarUnavailableError у исключений ради этого не понадобилось. 403/429 'platform' (ровно тот случай,
avito), любой диагноз отсюда был бы назначенным, а не установленным. что намеряли вживую 28.08: статическая страница `403 | Домклик` на 26 624 байта,
без PoW и капчи это отказ, а не челлендж), 5xx 'infra', статуса нет
'unknown', честно «не установлено». Раньше все 14 прогонов за 14 дней
получали 'unknown' просто потому, что диагноз не доезжал до финализатора.
- DomClickParseError (__SSR_STATE__ missing/malformed -- schema drift, NOT a - DomClickParseError (__SSR_STATE__ missing/malformed -- schema drift, NOT a
block) -- counted as failed++, logged, does NOT touch consecutive_blocks and block) -- counted as failed++, logged, does NOT touch consecutive_blocks and
does NOT abort the run (neutral to the block-breaker, mirrors how Avito's does NOT abort the run (neutral to the block-breaker, mirrors how Avito's
@ -84,10 +87,11 @@ import asyncio
import logging import logging
import random import random
import time import time
from collections import Counter
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from scraper_kit.browser_fetcher import BrowserFetcher from scraper_kit.browser_fetcher import BrowserFetcher, ban_kind_from_status
from scraper_kit.domclick_exceptions import DomClickBlockedError, DomClickParseError from scraper_kit.domclick_exceptions import DomClickBlockedError, DomClickParseError
from scraper_kit.providers.domclick.detail import fetch_detail, save_detail_enrichment from scraper_kit.providers.domclick.detail import fetch_detail, save_detail_enrichment
from sqlalchemy import text from sqlalchemy import text
@ -97,6 +101,7 @@ from app.core.config import settings
from app.core.shutdown import shutdown_requested from app.core.shutdown import shutdown_requested
from app.services import domclick_session as domclick_session_svc from app.services import domclick_session as domclick_session_svc
from app.services import scrape_runs as runs_mod from app.services import scrape_runs as runs_mod
from app.services.scrape_runs import BAN_KIND_UNKNOWN
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -159,6 +164,19 @@ def _warn_before_domclick_cookies_expire(db: Session, run_id: int) -> None:
) )
def _ban_kind_of_block(exc: DomClickBlockedError) -> str:
"""Диагноз одного блока по HTTP-статусу ответа площадки (#3196).
Форма повторяет avito_detail_backfill (ban_kind_of_exception Counter
mark_backfill_finished(ban_kinds=...)), но опора другая: у Авито причину несёт
ТИП исключения (AvitoSidecarUnavailableError vs AvitoBlockedError), у Домклика
тип один на оба случая, поэтому смотрим статус. 403/429 'platform', 5xx
'infra' (ban_kind_from_status), статуса нет 'unknown' не назначаем причину,
которую не установили (#2764).
"""
return ban_kind_from_status(getattr(exc, "status", None)) or BAN_KIND_UNKNOWN
@dataclass @dataclass
class DomClickDetailBackfillResult: class DomClickDetailBackfillResult:
"""Counters for one backfill run.""" """Counters for one backfill run."""
@ -267,6 +285,9 @@ async def run_domclick_detail_backfill(
consecutive_blocks = 0 consecutive_blocks = 0
aborted_by_blocks = False aborted_by_blocks = False
do_sleep = False do_sleep = False
# #3196: перепись диагнозов ВСЕХ блоков прогона (kind -> сколько раз);
# кратности важны — их разбирает _dominant_ban_kind в scrape_runs.py.
block_ban_kinds: Counter[str] = Counter()
# Exactly ONE BrowserFetcher per run (no curl fallback for DomClick, see # Exactly ONE BrowserFetcher per run (no curl fallback for DomClick, see
# module docstring). source="domclick" -- infra identifier, dedicated # module docstring). source="domclick" -- infra identifier, dedicated
@ -339,28 +360,32 @@ async def run_domclick_detail_backfill(
except DomClickBlockedError as e: except DomClickBlockedError as e:
consecutive_blocks += 1 consecutive_blocks += 1
counters.blocked += 1 counters.blocked += 1
ban_kind = _ban_kind_of_block(e)
block_ban_kinds[ban_kind] += 1
# #3118: сожжённый переиспользуемый context (протухший qrator_jsid2 # #3118: сожжённый переиспользуемый context (протухший qrator_jsid2
# / стухшая сессия) не должен тянуться дальше по прогону — просим # / стухшая сессия) не должен тянуться дальше по прогону — просим
# ОДИН сброс на следующем fetch(), не на каждый последующий запрос. # ОДИН сброс на следующем fetch(), не на каждый последующий запрос.
bf.request_context_reset() bf.request_context_reset()
logger.warning( logger.warning(
"domclick_detail_backfill: run_id=%d BLOCKED #%d/%d (consecutive=%d): %s", "domclick_detail_backfill: run_id=%d BLOCKED #%d/%d "
"(consecutive=%d, http=%s, kind=%s): %s",
run_id, run_id,
idx + 1, idx + 1,
len(snapshot), len(snapshot),
consecutive_blocks, consecutive_blocks,
getattr(e, "status", None),
ban_kind,
e, e,
) )
if consecutive_blocks >= max_consecutive_blocks: if consecutive_blocks >= max_consecutive_blocks:
# #2764/#3178: DomClickBlockedError не разводит площадку (QRATOR) # #3196: причину больше не выдумываем и не молчим — печатаем
# и наш браузерный тракт (см. докстринг класса выше) — причину # перепись диагнозов по HTTP-статусам этого прогона.
# НЕ выдумываем, пишем как есть.
logger.error( logger.error(
"domclick_detail_backfill: run_id=%d ABORT -- %d consecutive " "domclick_detail_backfill: run_id=%d ABORT -- %d consecutive "
"blocks, причина не определена (площадка либо наш тракт). " "blocks, диагнозы: %s. enriched=%d attempted=%d",
"enriched=%d attempted=%d",
run_id, run_id,
consecutive_blocks, consecutive_blocks,
dict(block_ban_kinds) or "нет",
counters.enriched, counters.enriched,
counters.attempted, counters.attempted,
) )
@ -392,6 +417,7 @@ async def run_domclick_detail_backfill(
current_counters, current_counters,
source="domclick_detail_backfill", source="domclick_detail_backfill",
aborted_by_blocks=aborted_by_blocks, aborted_by_blocks=aborted_by_blocks,
ban_kinds=block_ban_kinds,
) )
logger.info( logger.info(
"domclick_detail_backfill: run_id=%d FINISHED -- attempted=%d enriched=%d " "domclick_detail_backfill: run_id=%d FINISHED -- attempted=%d enriched=%d "

View file

@ -37,6 +37,13 @@ Why curl_cffi and not YandexDetailScraper.fetch_detail:
parse always returns None backfill would be 0% effective. The parse always returns None backfill would be 0% effective. The
curl_cffi path (kit-профиль impersonation + mobile proxy) is already proven curl_cffi path (kit-профиль impersonation + mobile proxy) is already proven
by yandex_address_backfill, which fetches identical offer detail pages. by yandex_address_backfill, which fetches identical offer detail pages.
Природа блока (#3196): счётчика blocked у Яндекса не было вовсе, поэтому ветка
перевода прогона в 'banned' (scrape_runs.mark_backfill_finished) была недостижима
по построению за 14 дней 12 done, 1 failed, 1 zombie и НОЛЬ банов. Теперь
non-200 ответ считается блоком, а его диагноз берётся из HTTP-статуса
(ban_kind_from_status: 403/429 platform, 5xx infra, прочее unknown), а не
из текстовых маркеров страницы, снятых с чужой площадки.
""" """
from __future__ import annotations from __future__ import annotations
@ -44,9 +51,11 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
import time import time
from collections import Counter
from dataclasses import dataclass, field from dataclasses import dataclass, field
from curl_cffi.requests import AsyncSession from curl_cffi.requests import AsyncSession
from scraper_kit.browser_fetcher import ban_kind_from_status
from scraper_kit.providers._base import DEFAULT_IMPERSONATE from scraper_kit.providers._base import DEFAULT_IMPERSONATE
from scraper_kit.providers.yandex.detail import YandexDetailScraper, save_detail_enrichment from scraper_kit.providers.yandex.detail import YandexDetailScraper, save_detail_enrichment
from sqlalchemy import text from sqlalchemy import text
@ -54,6 +63,7 @@ from sqlalchemy.orm import Session
from app.services import scrape_runs as runs_mod from app.services import scrape_runs as runs_mod
from app.services.proxy_egress import resolve_proxy_url from app.services.proxy_egress import resolve_proxy_url
from app.services.scrape_runs import BAN_KIND_UNKNOWN
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -121,6 +131,11 @@ class YandexDetailBackfillResult:
attempted: int = 0 attempted: int = 0
enriched: int = 0 enriched: int = 0
# Отказы площадки среди попыток (non-200 ответ) с разобранной природой.
# Подмножество failed, а не отдельная корзина: смысл failed («попытка не дала
# обогащения») не переписываем. Без этого счётчика ветка перевода прогона в
# 'banned' у Яндекса недостижима по построению (#3196).
blocked: int = 0
failed: int = 0 failed: int = 0
# Ждут обогащения, сохранённый source_url непарсим, но адрес восстановим из # Ждут обогащения, сохранённый source_url непарсим, но адрес восстановим из
# source_id — идут в очередь по вычисленному URL. Должен убывать от прогона к # source_id — идут в очередь по вычисленному URL. Должен убывать от прогона к
@ -134,6 +149,7 @@ class YandexDetailBackfillResult:
return { return {
"attempted": self.attempted, "attempted": self.attempted,
"enriched": self.enriched, "enriched": self.enriched,
"blocked": self.blocked,
"failed": self.failed, "failed": self.failed,
"url_from_offer_id": self.url_from_offer_id, "url_from_offer_id": self.url_from_offer_id,
"unenrichable_pending": self.unenrichable_pending, "unenrichable_pending": self.unenrichable_pending,
@ -298,7 +314,17 @@ async def run_yandex_detail_backfill(
_proxies = {"http": _proxy, "https": _proxy} if _proxy else None _proxies = {"http": _proxy, "https": _proxy} if _proxy else None
consecutive_none = 0 consecutive_none = 0
# #3196: два счётчика серий — разной природы. consecutive_none — весь подряд
# идущий неуспех (фетч-ошибка, non-200, парс-None) и двигает только ABORT
# парс-None (наш дефект/дрейф разметки). consecutive_blocks — подряд идущие
# ТОЛЬКО non-200 ответы и двигает aborted_by_blocks/'banned': серию промахов
# парсера при HTTP 200 нельзя засчитывать как серию блоков площадки.
consecutive_blocks = 0
do_sleep = False do_sleep = False
aborted_by_blocks = False
# Перепись диагнозов блоков (kind -> сколько раз). Кратности нужны целыми:
# доминирующий вид выбирает _dominant_ban_kind в scrape_runs.py (#3178).
block_ban_kinds: Counter[str] = Counter()
scraper = YandexDetailScraper() scraper = YandexDetailScraper()
async with AsyncSession( async with AsyncSession(
@ -360,22 +386,33 @@ async def run_yandex_detail_backfill(
continue continue
if resp.status_code != 200: if resp.status_code != 200:
# Природу отказа берём из HTTP-статуса, а не из текста
# страницы (#3196): 403/429 -> platform, 5xx -> infra.
# Статус, который о блоке ничего не сообщает, честно
# остаётся 'unknown' — это «не знаем, чей отказ», а не
# «отказа не было».
ban_kind = ban_kind_from_status(resp.status_code) or BAN_KIND_UNKNOWN
counters.blocked += 1
block_ban_kinds[ban_kind] += 1
consecutive_none += 1 consecutive_none += 1
consecutive_blocks += 1
counters.failed += 1 counters.failed += 1
logger.warning( logger.warning(
"yandex_detail_backfill: run_id=%d listing_id=%d " "yandex_detail_backfill: run_id=%d listing_id=%d "
"HTTP %d (consecutive=%d)", "HTTP %d ban_kind=%s (consecutive=%d)",
run_id, run_id,
listing_id, listing_id,
resp.status_code, resp.status_code,
consecutive_none, ban_kind,
consecutive_blocks,
) )
if consecutive_none >= max_consecutive_blocks: if consecutive_blocks >= max_consecutive_blocks:
aborted_by_blocks = True
logger.error( logger.error(
"yandex_detail_backfill: run_id=%d ABORT -- %d consecutive " "yandex_detail_backfill: run_id=%d ABORT -- %d consecutive "
"non-200 responses. enriched=%d attempted=%d", "non-200 responses. enriched=%d attempted=%d",
run_id, run_id,
consecutive_none, consecutive_blocks,
counters.enriched, counters.enriched,
counters.attempted, counters.attempted,
) )
@ -387,7 +424,15 @@ async def run_yandex_detail_backfill(
if enrichment is None: if enrichment is None:
# parse→None: captcha wall / shell-HTML / no JSON-LD. # parse→None: captcha wall / shell-HTML / no JSON-LD.
# Do not mark listing as done — retry next night. # Do not mark listing as done — retry next night.
# НАРОЧНО не считаем блоком (#3196): при HTTP 200 отличить
# капчу от промаха нашего парсера нечем, а записав это в
# blocked, мы объявляли бы 'banned' (внешняя причина) любой
# прогон, сломанный на нашей стороне, и потеряли бы сигнал
# 'failed', ради которого он заведён (#2674).
consecutive_none += 1 consecutive_none += 1
# Площадка ОТВЕТИЛА (HTTP 200) — серии подтверждённых блоков
# нет, что бы ни случилось дальше с парсингом (#3196).
consecutive_blocks = 0
counters.failed += 1 counters.failed += 1
logger.warning( logger.warning(
"yandex_detail_backfill: run_id=%d listing_id=%d source_url=%s " "yandex_detail_backfill: run_id=%d listing_id=%d source_url=%s "
@ -410,6 +455,7 @@ async def run_yandex_detail_backfill(
continue continue
consecutive_none = 0 consecutive_none = 0
consecutive_blocks = 0
if save_detail_enrichment(db, listing_id, enrichment): if save_detail_enrichment(db, listing_id, enrichment):
counters.enriched += 1 counters.enriched += 1
@ -432,14 +478,20 @@ async def run_yandex_detail_backfill(
counters.duration_sec = time.monotonic() - start counters.duration_sec = time.monotonic() - start
current_counters = counters.to_dict() current_counters = counters.to_dict()
runs_mod.mark_backfill_finished( runs_mod.mark_backfill_finished(
db, run_id, current_counters, source="yandex_detail_backfill" db,
run_id,
current_counters,
source="yandex_detail_backfill",
aborted_by_blocks=aborted_by_blocks,
ban_kinds=block_ban_kinds,
) )
logger.info( logger.info(
"yandex_detail_backfill: run_id=%d FINISHED -- attempted=%d enriched=%d " "yandex_detail_backfill: run_id=%d FINISHED -- attempted=%d enriched=%d "
"failed=%d duration=%.1fs", "blocked=%d failed=%d duration=%.1fs",
run_id, run_id,
counters.attempted, counters.attempted,
counters.enriched, counters.enriched,
counters.blocked,
counters.failed, counters.failed,
counters.duration_sec, counters.duration_sec,
) )

View file

@ -262,6 +262,10 @@ async def test_backfill_abort_log_has_no_qrator_literal(caplog: pytest.LogCaptur
(#2764: диагноз здесь НЕ установлен). До правки ABORT всегда писал 'QRATOR (#2764: диагноз здесь НЕ установлен). До правки ABORT всегда писал 'QRATOR
reputation likely burned for the session/proxy' -- утверждение, для которого нет reputation likely burned for the session/proxy' -- утверждение, для которого нет
основания в этом прогоне. основания в этом прогоне.
#3196: причину теперь устанавливает HTTP-статус ответа. Здесь его нет (блок без
`.status`), поэтому лог печатает перепись диагнозов с честным 'unknown' -- и
по-прежнему НЕ называет QRATOR.
""" """
snapshot = _make_snapshot(10) snapshot = _make_snapshot(10)
db = _mock_db(snapshot) db = _mock_db(snapshot)
@ -289,7 +293,7 @@ async def test_backfill_abort_log_has_no_qrator_literal(caplog: pytest.LogCaptur
abort_records = [r.message for r in caplog.records if "ABORT" in r.message] abort_records = [r.message for r in caplog.records if "ABORT" in r.message]
assert abort_records, "ожидался ABORT-лог" assert abort_records, "ожидался ABORT-лог"
assert "QRATOR reputation likely burned" not in abort_records[0] assert "QRATOR reputation likely burned" not in abort_records[0]
assert "причина не определена" in abort_records[0] assert "диагнозы: {'unknown': 3}" in abort_records[0]
@pytest.mark.asyncio @pytest.mark.asyncio

View file

@ -0,0 +1,224 @@
"""#3196: циановский прогон обязан ставить диагноз отказа по HTTP-статусу.
До правки браузерный путь `fetch_detail` глотал ЛЮБОЙ отказ: `BrowserFetcher.fetch`
возвращает str и на 403 не бросает, парсер не находил состояние и провайдер отдавал
None. Батч считал это `listings_failed_fetch`, планировщик звал `mark_done` прогон
уходил в 'done' с пустым `scrape_runs.ban_kind`, и отличить «Циан нас отбил» от
«упал наш сайдкар» постфактум было нечем.
Теперь отказ читается из `bf.last_response_status` (контракт фундамента #3196):
403/429 'platform', 5xx 'infra', всё прочее 'unknown'. В 'banned' переводится
только прогон, который отказы видел и не обогатил НИЧЕГО, рабочий путь Циана
(13 done против 1 banned за 14 суток) не трогаем.
"""
from __future__ import annotations
import os
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
from app.services import scheduler as sched_mod
from app.tasks import cian_history_backfill
class _FakeBrowserFetcher:
"""Сайдкар, который отдаёт заданный HTTP-статус последнего ответа."""
def __init__(self, status: int | None = None, **kwargs: Any) -> None:
self.last_response_status = status
async def __aenter__(self) -> _FakeBrowserFetcher:
return self
async def __aexit__(self, *_: object) -> None:
return None
def _fetcher_factory(status: int | None):
def _make(**kwargs: Any) -> _FakeBrowserFetcher:
return _FakeBrowserFetcher(status)
return _make
def _db_with_rows(n: int = 1) -> MagicMock:
db = MagicMock()
db.execute.return_value.mappings.return_value.all.return_value = [
{"id": i, "source_url": f"https://ekb.cian.ru/sale/flat/{i}/"} for i in range(1, n + 1)
]
return db
async def _run(status: int | None, *, detail: Any, rows: int = 1):
"""Один прогон listings-блока с заданным статусом и поведением fetch_detail."""
with (
patch.object(cian_history_backfill, "BrowserFetcher", _fetcher_factory(status)),
patch.object(cian_history_backfill, "fetch_detail", detail),
patch("asyncio.sleep", new_callable=AsyncMock),
):
return await cian_history_backfill.backfill_cian_history(
_db_with_rows(rows), do_listings=True, do_houses=False, do_valuations=False
)
# ── Диагноз по статусу ───────────────────────────────────────────────────────
@pytest.mark.parametrize(
("status", "expected"),
[
(403, "platform"), # WAF Циана отбил узел
(429, "platform"), # тот же класс: площадка отказала
(500, "infra"), # наш сайдкар/прокси не отдал страницу
(503, "infra"),
# Недиагностируемый ответ отказом НЕ считается (#3196): статуса нет —
# сайдкар не дошёл до навигации; HTTP 200 — площадка ответила, а пусто
# вернул наш парсер (дрейф разметки). Записав это в ban_kinds, мы бы
# объявляли фиктивный бан площадки на собственной поломке.
(None, None),
(200, None),
],
)
async def test_ban_kind_from_http_status(status: int | None, expected: str | None) -> None:
result = await _run(status, detail=AsyncMock(return_value=None))
# Попытка провалилась в любом случае — этот счётчик от диагноза не зависит.
assert result.listings_failed_fetch == 1
if expected is None:
assert result.listings_blocked == 0
assert result.ban_kinds == {}
else:
assert result.listings_blocked == 1
assert result.ban_kinds == {expected: 1}
assert result.ban_kind == expected
async def test_fetch_exception_also_carries_diagnosis() -> None:
"""Отказ, поднятый исключением (CianBlockedError на curl-пути), диагноз тоже несёт."""
result = await _run(403, detail=AsyncMock(side_effect=Exception("HTTP 403 (WAF-блок узла)")))
assert result.listings_blocked == 1
assert result.ban_kind == "platform"
async def test_mixed_census_without_majority_is_unknown() -> None:
"""Ровно поровну platform/infra → честное 'unknown' (правило #3178)."""
result = cian_history_backfill.CianBackfillResult()
cian_history_backfill._note_refusal(result, 403)
cian_history_backfill._note_refusal(result, 503)
assert result.ban_kinds == {"platform": 1, "infra": 1}
assert result.ban_kind == "unknown"
async def test_healthy_run_records_no_refusals() -> None:
"""Рабочий путь: обогатили — переписи отказов нет, диагноз не навязывается."""
with (
patch.object(cian_history_backfill, "BrowserFetcher", _fetcher_factory(200)),
patch.object(
cian_history_backfill,
"fetch_detail",
AsyncMock(return_value=SimpleNamespace(price_changes=[])),
),
patch.object(cian_history_backfill, "save_detail_enrichment", MagicMock()),
patch("asyncio.sleep", new_callable=AsyncMock),
):
result = await cian_history_backfill.backfill_cian_history(
_db_with_rows(1), do_listings=True, do_houses=False, do_valuations=False
)
assert result.listings_succeeded == 1
assert result.listings_blocked == 0
assert result.ban_kinds == {}
# ── Диагноз доезжает до строки прогона ───────────────────────────────────────
async def _execute(result: Any) -> SimpleNamespace:
fake_runs = SimpleNamespace(
update_heartbeat=MagicMock(),
mark_done=MagicMock(),
mark_banned=MagicMock(),
mark_failed=MagicMock(),
)
async def _batch(db: Any, **kwargs: Any) -> Any:
return result
with (
patch.object(sched_mod, "runs_mod", fake_runs),
patch.object(cian_history_backfill, "backfill_cian_history", _batch),
):
await sched_mod._execute_cian_backfill(MagicMock(), run_id=7, params={})
return fake_runs
@pytest.mark.parametrize(
("status", "expected"),
[(403, "platform"), (503, "infra")],
)
async def test_run_row_gets_ban_kind(status: int | None, expected: str) -> None:
"""Отказы + ноль обогащений → status='banned' с диагнозом из HTTP-статуса."""
result = cian_history_backfill.CianBackfillResult()
result.listings_processed = 3
for _ in range(3):
cian_history_backfill._note_refusal(result, status)
runs = await _execute(result)
assert not runs.mark_done.called, "прогон без единого обогащения не 'done'"
assert runs.mark_banned.called
assert runs.mark_banned.call_args.kwargs["ban_kind"] == expected
assert runs.mark_banned.call_args.args[3]["blocked"] == 3
assert runs.mark_banned.call_args.args[3]["ban_kinds"] == {expected: 3}
async def test_parser_drift_is_not_a_ban() -> None:
"""Ключевой инвариант #3196: HTTP 200 с пустым разбором — НЕ бан площадки.
Три карточки подряд отдали 200, а enrichment не собрался это дрейф разметки
Циана либо наш парсер. Прогон обязан остаться нашей поломкой (сюда сходится
ветка 'failed' у backfill-ей), а не превратиться в 'banned' с выдуманным
диагнозом: именно такие фиктивные 'unknown' и убирает #3196.
"""
result = cian_history_backfill.CianBackfillResult()
result.listings_processed = 3
result.listings_failed_fetch = 3
for _ in range(3):
assert cian_history_backfill._note_refusal(result, 200) is None
runs = await _execute(result)
assert result.ban_kinds == {}
assert result.listings_blocked == 0
assert not runs.mark_banned.called, "промах парсера не объявляем отказом площадки"
assert runs.mark_done.called
async def test_partial_success_stays_done() -> None:
"""Рабочий путь не трогаем: обогатили хоть что-то — 'done', даже при отказах."""
result = cian_history_backfill.CianBackfillResult()
result.listings_processed = 2
result.listings_succeeded = 1
cian_history_backfill._note_refusal(result, 403)
runs = await _execute(result)
assert runs.mark_done.called
assert not runs.mark_banned.called
async def test_clean_empty_run_stays_done() -> None:
"""Кандидатов не было — ни отказов, ни диагноза: 'done' как и раньше."""
runs = await _execute(cian_history_backfill.CianBackfillResult())
assert runs.mark_done.called
assert not runs.mark_banned.called

View file

@ -0,0 +1,216 @@
"""Честный ban_kind у domclick_detail_backfill по HTTP-статусу ответа (#3196).
Проблема (прод, 14 дней): domclick_detail_backfill дал 14 банов и ВСЕ с
ban_kind='unknown'. Причин две и обе механические:
1. задача вообще не передавала `ban_kinds` в mark_backfill_finished диагноз не
доезжал до финализатора, каким бы он ни был;
2. DomClickBlockedError поднимается и на распознанном маркере, и на любом сбое
браузерного фетча, поэтому по типу исключения их не развести.
Фикс: исключение несёт `.status` (HTTP-код целевой навигации, см.
BrowserFetcher.last_response_status), задача раскладывает его через
ban_kind_from_status и копит перепись kind -> count ровно как это делает
avito_detail_backfill (ban_kind_of_exception Counter mark_backfill_finished).
403/429 'platform' (замер 28.08: статическая страница `403 | Домклик` на
26 624 байта, без PoW и капчи отказ, а не челлендж), 5xx 'infra', статуса нет
'unknown' (не назначаем причину, которую не установили, #2764).
Зеркалит конвенции tests/test_3118_domclick_warm_context.py (module-level
patch-target строки, _mock_db/_mock_session_svc helpers).
"""
from __future__ import annotations
import os
import sys
from collections import Counter
from datetime import UTC, datetime, timedelta
from unittest.mock import AsyncMock, MagicMock, patch
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
_wp_mock = MagicMock()
sys.modules.setdefault("weasyprint", _wp_mock)
import pytest # noqa: E402
from scraper_kit.domclick_exceptions import DomClickBlockedError # noqa: E402
from app.tasks.domclick_detail_backfill import ( # noqa: E402
_ban_kind_of_block,
run_domclick_detail_backfill,
)
_FETCH = "app.tasks.domclick_detail_backfill.fetch_detail"
_SAVE = "app.tasks.domclick_detail_backfill.save_detail_enrichment"
_RUNS = "app.tasks.domclick_detail_backfill.runs_mod"
_SLEEP = "app.tasks.domclick_detail_backfill.asyncio.sleep"
_SETTINGS = "app.tasks.domclick_detail_backfill.settings"
_BROWSER_FETCHER = "app.tasks.domclick_detail_backfill.BrowserFetcher"
_SESSION_SVC = "app.tasks.domclick_detail_backfill.domclick_session_svc"
def _make_snapshot(n: int) -> list[dict]:
return [
{
"id": i + 1,
"source_url": f"https://ekaterinburg.domclick.ru/card/sale__flat__{i + 1}",
}
for i in range(n)
]
def _mock_db(snapshot: list[dict]) -> MagicMock:
db = MagicMock()
sel = MagicMock()
sel.mappings.return_value.all.return_value = snapshot
db.execute.return_value = sel
return db
def _mock_session_svc() -> MagicMock:
svc = MagicMock()
svc.load_session.return_value = {"CAS_ID": "123"}
svc.COOKIE_EXPIRY_WARN_DAYS = 5
svc.session_expires_at.return_value = datetime.now(tz=UTC) + timedelta(days=30)
return svc
def _mock_browser_fetcher_cls() -> MagicMock:
instance = AsyncMock()
instance.__aenter__ = AsyncMock(return_value=instance)
instance.__aexit__ = AsyncMock(return_value=False)
# синхронный метод — иначе остаётся неawaited coroutine
instance.request_context_reset = MagicMock()
return MagicMock(return_value=instance)
async def _run_all_blocked(status: int | None, *, blocks: int = 3) -> MagicMock:
"""Прогон, где КАЖДЫЙ fetch — блок с данным HTTP-статусом. Возвращает runs-мок."""
snapshot = _make_snapshot(blocks)
db = _mock_db(snapshot)
runs = MagicMock()
mock_fetch = AsyncMock(side_effect=DomClickBlockedError("domclick blocked", status=status))
with (
patch(_SETTINGS, MagicMock(browser_http_endpoint="http://browser:9000")),
patch(_SESSION_SVC, _mock_session_svc()),
patch(_RUNS, runs),
patch(_BROWSER_FETCHER, _mock_browser_fetcher_cls()),
patch(_FETCH, mock_fetch),
patch(_SAVE, return_value=True),
patch(_SLEEP, new_callable=AsyncMock),
):
await run_domclick_detail_backfill(
db,
run_id=3196,
params={"batch_size": blocks, "budget_sec": 3600, "max_consecutive_blocks": blocks},
)
return runs
def _ban_kinds_of(runs: MagicMock) -> Counter:
runs.mark_backfill_finished.assert_called_once()
return runs.mark_backfill_finished.call_args.kwargs["ban_kinds"]
# ── диагноз одного блока ─────────────────────────────────────────────────────
@pytest.mark.parametrize(
("status", "expected"),
[
(403, "platform"), # ровно тот случай, что намеряли вживую: `403 | Домклик`
(429, "platform"),
(500, "infra"),
(502, "infra"),
(503, "infra"),
(200, "unknown"), # маркер поймали, но статус про блок ничего не говорит
(404, "unknown"),
(None, "unknown"), # сайдкар статус не отдал / фетч не состоялся
],
)
def test_ban_kind_of_block(status: int | None, expected: str) -> None:
assert _ban_kind_of_block(DomClickBlockedError("boom", status=status)) == expected
def test_ban_kind_of_block_without_status_attr() -> None:
"""Исключение без явного статуса → 'unknown', не падение."""
assert _ban_kind_of_block(DomClickBlockedError("legacy")) == "unknown"
# ── прогон целиком: диагноз доезжает до финализатора ─────────────────────────
@pytest.mark.asyncio
async def test_run_403_gets_platform() -> None:
"""403 (отказ площадки) → ban_kinds={'platform': 3} в mark_backfill_finished."""
runs = await _run_all_blocked(403)
assert _ban_kinds_of(runs) == Counter({"platform": 3})
assert runs.mark_backfill_finished.call_args.kwargs["aborted_by_blocks"] is True
@pytest.mark.asyncio
async def test_run_5xx_gets_infra() -> None:
"""5xx (сбой площадки/прокси, не адресный бан) → 'infra'."""
runs = await _run_all_blocked(503)
assert _ban_kinds_of(runs) == Counter({"infra": 3})
@pytest.mark.asyncio
async def test_run_unrecognized_refusal_stays_unknown() -> None:
"""Статуса нет → по-прежнему 'unknown': причину не выдумываем (#2764)."""
runs = await _run_all_blocked(None)
assert _ban_kinds_of(runs) == Counter({"unknown": 3})
@pytest.mark.asyncio
async def test_run_mixed_statuses_keep_multiplicity() -> None:
"""Перепись несёт КРАТНОСТИ — их разбирает _dominant_ban_kind, а не set (#3178)."""
snapshot = _make_snapshot(3)
db = _mock_db(snapshot)
runs = MagicMock()
mock_fetch = AsyncMock(
side_effect=[
DomClickBlockedError("refused", status=403),
DomClickBlockedError("refused", status=429),
DomClickBlockedError("upstream", status=502),
]
)
with (
patch(_SETTINGS, MagicMock(browser_http_endpoint="http://browser:9000")),
patch(_SESSION_SVC, _mock_session_svc()),
patch(_RUNS, runs),
patch(_BROWSER_FETCHER, _mock_browser_fetcher_cls()),
patch(_FETCH, mock_fetch),
patch(_SAVE, return_value=True),
patch(_SLEEP, new_callable=AsyncMock),
):
await run_domclick_detail_backfill(
db,
run_id=3196,
params={"batch_size": 3, "budget_sec": 3600, "max_consecutive_blocks": 3},
)
assert _ban_kinds_of(runs) == Counter({"platform": 2, "infra": 1})
@pytest.mark.asyncio
async def test_clean_run_passes_empty_ban_kinds() -> None:
"""Без блоков перепись пуста — финализатор не должен видеть выдуманный диагноз."""
snapshot = _make_snapshot(2)
db = _mock_db(snapshot)
runs = MagicMock()
with (
patch(_SETTINGS, MagicMock(browser_http_endpoint="http://browser:9000")),
patch(_SESSION_SVC, _mock_session_svc()),
patch(_RUNS, runs),
patch(_BROWSER_FETCHER, _mock_browser_fetcher_cls()),
patch(_FETCH, AsyncMock(return_value=MagicMock())),
patch(_SAVE, return_value=True),
patch(_SLEEP, new_callable=AsyncMock),
):
await run_domclick_detail_backfill(
db, run_id=3196, params={"batch_size": 2, "budget_sec": 3600}
)
assert _ban_kinds_of(runs) == Counter()

View file

@ -0,0 +1,186 @@
"""#3196: у yandex_detail_backfill не было счётчика blocked — бан был невозможен.
`mark_backfill_finished` переводит прогон в 'banned' только при
`counters["blocked"] > 0`. Яндексовый detail-backfill этот счётчик не вёл вовсе,
поэтому ветка (`scrape_runs.py`, `if blocked and (aborted_by_blocks or produced == 0)`)
была недостижима ПО ПОСТРОЕНИЮ: прод за 14 дней 12 done, 1 failed, 1 zombie и
НОЛЬ банов, при том что 403/429 от площадки прогоны видели.
Диагноз теперь берётся из HTTP-статуса ответа, а не из текстовых маркеров
страницы (маркеры сняты с чужой площадки и на домкликовском отказе не сработали
исходный симптом эпика): 403/429 platform, 5xx infra, статус, который о блоке
ничего не сообщает 'unknown' (честное «не знаем», а не «блока не было»).
Тесты гоняют НАСТОЯЩИЙ `mark_backfill_finished` иначе проверялась бы только
передача аргумента, а не итоговый `scrape_runs.ban_kind`.
"""
from __future__ import annotations
import os
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from app.services import scrape_runs as real_runs
from app.tasks.yandex_detail_backfill import run_yandex_detail_backfill
_ASYNC_SESSION = "app.tasks.yandex_detail_backfill.AsyncSession"
_PARSE = "app.tasks.yandex_detail_backfill.YandexDetailScraper.parse"
_RUNS = "app.tasks.yandex_detail_backfill.runs_mod"
_SLEEP = "app.tasks.yandex_detail_backfill.asyncio.sleep"
_RESOLVE_PROXY_URL = "app.tasks.yandex_detail_backfill.resolve_proxy_url"
class _Recorder:
"""Подменяет runs_mod, но финализацию отдаёт настоящей mark_backfill_finished.
Так тест видит именно тот исход, который получит строка scrape_runs: статус и
ban_kind считает продовый код, а не сам тест.
"""
def __init__(self) -> None:
self.status: str | None = None
self.ban_kind: str | None = None
self.counters: dict[str, Any] = {}
def update_heartbeat(self, db: Any, run_id: int, counters: dict[str, Any]) -> None:
return None
def mark_done(self, db: Any, run_id: int, counters: dict[str, Any]) -> None:
self.status = "done"
def mark_failed(self, db: Any, run_id: int, error: str, counters: dict[str, Any]) -> None:
self.status = "failed"
def mark_banned(
self, db: Any, run_id: int, error: str, counters: dict[str, Any], **kw: Any
) -> None:
self.status = "banned"
self.ban_kind = kw.get("ban_kind")
def mark_backfill_finished(
self, db: Any, run_id: int, counters: dict[str, Any], **kw: Any
) -> None:
self.counters = counters
with (
patch.object(real_runs, "mark_done", self.mark_done),
patch.object(real_runs, "mark_failed", self.mark_failed),
patch.object(real_runs, "mark_banned", self.mark_banned),
):
real_runs.mark_backfill_finished(db, run_id, counters, **kw)
def _mock_db(n: int) -> MagicMock:
snapshot = [
{"id": i + 1, "source_url": f"https://realty.yandex.ru/offer/{i + 1}/"} for i in range(n)
]
db = MagicMock()
sel = MagicMock()
sel.mappings.return_value.all.return_value = snapshot
sel.one.return_value = SimpleNamespace(url_from_offer_id=0, unenrichable_pending=0)
db.execute.return_value = sel
return db
def _resp(status: int) -> MagicMock:
resp = MagicMock()
resp.status_code = status
resp.text = "<html>отказ</html>"
return resp
def _session_cls(responses: list[MagicMock]) -> MagicMock:
session = AsyncMock()
session.get = AsyncMock(side_effect=responses)
ctx = MagicMock()
ctx.__aenter__ = AsyncMock(return_value=session)
ctx.__aexit__ = AsyncMock(return_value=None)
return MagicMock(return_value=ctx)
async def _drive(status: int, *, count: int = 5, parse_result: Any = None) -> _Recorder:
"""Прогон из `count` одинаковых ответов со статусом `status`."""
recorder = _Recorder()
db = _mock_db(count)
with (
patch(_ASYNC_SESSION, _session_cls([_resp(status)] * count)),
patch(_PARSE, return_value=parse_result),
patch(_RUNS, recorder),
patch(_SLEEP, new_callable=AsyncMock),
patch(_RESOLVE_PROXY_URL, MagicMock(return_value="http://proxy:3128")),
):
await run_yandex_detail_backfill(
db,
run_id=3196,
params={"batch_size": count, "budget_sec": 3600, "max_consecutive_blocks": count},
)
return recorder
@pytest.mark.asyncio
async def test_http_403_marks_platform_ban() -> None:
"""403 — осознанный отказ площадки. До правки: 0 банов, blocked не считался."""
rec = await _drive(403)
assert rec.status == "banned", rec.status
assert rec.ban_kind == "platform"
assert rec.counters["blocked"] == 5
assert rec.counters["ban_kinds"] == {"platform": 5}
@pytest.mark.asyncio
async def test_http_429_marks_platform_ban() -> None:
"""429 (rate-limit) — та же природа, что 403: отказала площадка."""
rec = await _drive(429)
assert rec.status == "banned"
assert rec.ban_kind == "platform"
@pytest.mark.asyncio
async def test_http_5xx_marks_infra_ban() -> None:
"""5xx — сбой тракта/площадки, не адресный бан: 'infra', не 'platform'."""
rec = await _drive(503)
assert rec.status == "banned"
assert rec.ban_kind == "infra"
assert rec.counters["ban_kinds"] == {"infra": 5}
@pytest.mark.asyncio
async def test_unrecognized_refusal_marks_unknown_ban() -> None:
"""Отказ есть, а статус о его природе молчит → 'unknown', но прогон банится.
Ровно случай эпика: страница-отказ, которую наши маркеры не опознают. Молчание
статуса не должно превращаться в «блока не было».
"""
rec = await _drive(418)
assert rec.status == "banned"
assert rec.ban_kind == "unknown"
assert rec.counters["blocked"] == 5
@pytest.mark.asyncio
async def test_parse_none_at_200_is_not_a_ban() -> None:
"""Контроль: HTTP 200 + parse→None — по-прежнему 'failed', а не 'banned'.
При 200 отличить капчу от промаха НАШЕГО парсера нечем. Считать это блоком
значило бы называть внешней причиной каждый прогон, сломанный у нас, и потерять
сигнал 'failed' из #2674.
"""
rec = await _drive(200, parse_result=None)
assert rec.status == "failed", rec.status
assert rec.ban_kind is None
assert rec.counters["blocked"] == 0
@pytest.mark.asyncio
async def test_successful_run_stays_done() -> None:
"""Контроль: обогащение идёт — прогон 'done', банов нет."""
with patch("app.tasks.yandex_detail_backfill.save_detail_enrichment", return_value=True):
rec = await _drive(200, parse_result=MagicMock())
assert rec.status == "done", rec.status
assert rec.ban_kind is None
assert rec.counters["blocked"] == 0

View file

@ -0,0 +1,160 @@
"""HTTP-статус сайдкара наверх: `BrowserFetcher.last_response_status` (#3196).
Сайдкар (tradein-mvp/browser/server.py) теперь кладёт в тело /fetch HTTP-код целевой
навигации рядом с html: ``{"html": ..., "status": <int|null>}``. Kit выносит его на
инстанс фетчера АТРИБУТОМ, а не возвратом ``fetch()``: поток управления менять
нельзя, ``fetch()`` по-прежнему отдаёт ``str`` и по-прежнему не бросает там, где не
бросал раньше.
Зачем: ДомКлик отдаёт статическую страницу «403 | Домклик» на 26 624 байта, где нет
ни startpow, ни qrator, ни капчи ни один текстовый маркер сайдкара (все сняты с
Авито) на неё не срабатывает, и отказ уезжал наверх как валидный контент. 14 прогонов
domclick_detail_backfill подряд получили ban_kind=unknown ровно поэтому.
Инварианты:
- status из тела last_response_status (int) на КАЖДЫЙ успешный fetch;
- ключа "status" нет (сайдкар старой версии) ИЛИ он null None, БЕЗ исключения;
- status нечислового типа None (мусор в теле не должен ронять фетч);
- fetch упал last_response_status сброшен в None (не отдаём статус прошлого);
- ban_kind_from_status раскладывает код в значение, допустимое CHECK-ограничением
scrape_runs.ban_kind ("platform" | "infra" | "unknown" | NULL).
httpx полностью замокан (зеркалит test_kit_browser_fetcher_proxy_pool.py).
"""
from __future__ import annotations
from typing import Any
from unittest.mock import AsyncMock, MagicMock
import pytest
from scraper_kit.browser_fetcher import BrowserFetcher, ban_kind_from_status
def _mock_client(json_payload: dict[str, Any], *, raise_exc: Exception | None = None) -> MagicMock:
"""httpx.AsyncClient-заглушка: .post → resp c raise_for_status/json."""
resp = MagicMock()
if raise_exc is not None:
resp.raise_for_status.side_effect = raise_exc
else:
resp.raise_for_status.return_value = None
resp.json.return_value = json_payload
client = MagicMock()
client.post = AsyncMock(return_value=resp)
client.aclose = AsyncMock(return_value=None)
return client
async def _fetcher(client: MagicMock, **kwargs: Any) -> BrowserFetcher:
"""Реально входит в `__aenter__`, потом подменяет httpx-клиент."""
bf = BrowserFetcher(endpoint="http://browser:3000", **kwargs)
await bf.__aenter__()
bf._client = client
return bf
# ── last_response_status ──────────────────────────────────────────────────────
async def test_status_starts_as_none() -> None:
"""До первого fetch статуса нет — атрибут существует и равен None."""
client = _mock_client({"html": "<ok>", "status": 200})
bf = await _fetcher(client, source="domclick")
assert bf.last_response_status is None
async def test_status_from_body_is_exposed() -> None:
client = _mock_client({"html": "<403 page>", "status": 403})
bf = await _fetcher(client, source="domclick")
html = await bf.fetch("https://domclick.ru/card/1")
assert html == "<403 page>" # поток управления не изменился — fetch отдаёт str
assert bf.last_response_status == 403
async def test_status_updated_on_every_fetch() -> None:
"""Атрибут обновляется КАЖДЫМ _post_fetch, а не только первым."""
client = _mock_client({"html": "<ok>", "status": 200})
bf = await _fetcher(client, source="domclick")
await bf.fetch("https://domclick.ru/1")
assert bf.last_response_status == 200
client.post.return_value.json.return_value = {"html": "<403>", "status": 403}
await bf.fetch("https://domclick.ru/2")
assert bf.last_response_status == 403
async def test_missing_status_key_is_none_and_does_not_raise() -> None:
"""Сайдкар старой версии (тело без "status") — фетч проходит, статуса просто нет."""
client = _mock_client({"html": "<ok>"})
bf = await _fetcher(client, source="avito")
html = await bf.fetch("https://avito.ru/x")
assert html == "<ok>"
assert bf.last_response_status is None
async def test_null_status_is_none() -> None:
"""goto вернул None (редирект/навигационная гонка) → сайдкар шлёт status=null."""
client = _mock_client({"html": "<ok>", "status": None})
bf = await _fetcher(client, source="avito")
await bf.fetch("https://avito.ru/x")
assert bf.last_response_status is None
async def test_non_int_status_is_ignored() -> None:
"""Мусор в поле status не должен ронять фетч — читается как «статуса нет»."""
client = _mock_client({"html": "<ok>", "status": "403"})
bf = await _fetcher(client, source="avito")
await bf.fetch("https://avito.ru/x")
assert bf.last_response_status is None
async def test_status_reset_on_failed_fetch() -> None:
"""Фетч упал — не отдаём статус ПРОШЛОГО запроса."""
client = _mock_client({"html": "<ok>", "status": 200})
bf = await _fetcher(client, source="avito")
await bf.fetch("https://avito.ru/1")
assert bf.last_response_status == 200
client.post.side_effect = RuntimeError("transport down")
with pytest.raises(RuntimeError):
await bf.fetch("https://avito.ru/2")
assert bf.last_response_status is None
# ── ban_kind_from_status ──────────────────────────────────────────────────────
@pytest.mark.parametrize(
("status", "expected"),
[
(403, "platform"),
(429, "platform"),
(500, "infra"),
(502, "infra"),
(599, "infra"),
(200, None),
(301, None),
(404, None),
(None, None),
],
)
def test_ban_kind_from_status(status: int | None, expected: str | None) -> None:
assert ban_kind_from_status(status) == expected
def test_ban_kind_values_fit_scrape_runs_check() -> None:
"""Возврат обязан быть пригоден для scrape_runs.ban_kind как есть."""
allowed = {"platform", "infra", "unknown", None}
for status in (None, 200, 301, 403, 404, 429, 499, 500, 503, 599, 600):
assert ban_kind_from_status(status) in allowed

View file

@ -5,7 +5,8 @@
экспонирует простой HTTP API на базе aiohttp: экспонирует простой HTTP API на базе aiohttp:
GET /health {"status": "ok", "browsers": {"avito": bool, ...}} GET /health {"status": "ok", "browsers": {"avito": bool, ...}}
POST /fetch {"url","origin"(опц.),"cookies"(опц.)} {"html": "..."} POST /fetch {"url","origin"(опц.),"cookies"(опц.)} {"html": "...",
"status": <int|null>} # status — HTTP-код целевой навигации
POST /fetch-json {"url","method","headers","body","origin"} {"status","body"} POST /fetch-json {"url","method","headers","body","origin"} {"status","body"}
POST /login {"url": "...", "email": "...", "password": "...", ...} {"cookies": [...]} POST /login {"url": "...", "email": "...", "password": "...", ...} {"cookies": [...]}
@ -384,6 +385,10 @@ _page_counters: dict[str, int] = {} # provider → страниц с мом
_locks: dict[str, asyncio.Lock] = {} # provider → Lock (весь доступ к инстансу) _locks: dict[str, asyncio.Lock] = {} # provider → Lock (весь доступ к инстансу)
_retry_tasks: dict[str, asyncio.Task[None]] = {} # provider → фоновая retry-задача _retry_tasks: dict[str, asyncio.Task[None]] = {} # provider → фоновая retry-задача
_last_goto_at: dict[str, float] = {} # provider → loop-time последнего page.goto (пейсинг) _last_goto_at: dict[str, float] = {} # provider → loop-time последнего page.goto (пейсинг)
# provider → HTTP-статус ответа ПОСЛЕДНЕЙ целевой навигации (#3196). Пишется в
# _fetch_once (сбрасывается в None перед навигацией, чтобы не отдать чужой
# протухший статус), читается fetch_handler'ом под тем же _locks[provider] — гонки нет.
_last_response_status: dict[str, int | None] = {}
# #2164 P4: proxy-url, с которым СЕЙЧАС запущен инстанс провайдера (env или динамический # #2164 P4: proxy-url, с которым СЕЙЧАС запущен инстанс провайдера (env или динамический
# из пула, переданный в теле /fetch). Нужен для политики «relaunch ТОЛЬКО при реальной # из пула, переданный в теле /fetch). Нужен для политики «relaunch ТОЛЬКО при реальной
# смене прокси» — camoufox берёт proxy на launch, релонч дорогой, поэтому не релончим, # смене прокси» — camoufox берёт proxy на launch, релонч дорогой, поэтому не релончим,
@ -765,7 +770,7 @@ async def health_handler(request: web.Request) -> web.Response:
async def fetch_handler(request: web.Request) -> web.Response: async def fetch_handler(request: web.Request) -> web.Response:
"""POST /fetch {"url": "..."} → {"html": "..."} """POST /fetch {"url": "..."} → {"html": "...", "status": <int|null>}
Выполняет навигацию в браузере поставщика и возвращает полный HTML страницы. Выполняет навигацию в браузере поставщика и возвращает полный HTML страницы.
Поставщик определяется по host URL (или явному body["provider"]/["source"]). Берётся Поставщик определяется по host URL (или явному body["provider"]/["source"]). Берётся
@ -847,6 +852,9 @@ async def fetch_handler(request: web.Request) -> web.Response:
reuse_context=reuse_context, reuse_context=reuse_context,
reset_context=reset_context, reset_context=reset_context,
) )
# Читаем статус под тем же локом, что и сам фетч, — иначе следующий
# запрос этого провайдера успел бы его перезаписать.
status = _last_response_status.get(provider)
except Exception as exc: except Exception as exc:
logger.error( logger.error(
"tradein-browser[%s]: fetch error url=%r: %s: %s", "tradein-browser[%s]: fetch error url=%r: %s: %s",
@ -857,7 +865,9 @@ async def fetch_handler(request: web.Request) -> web.Response:
) )
return web.json_response({"error": f"{type(exc).__name__}: {exc}"}, status=500) return web.json_response({"error": f"{type(exc).__name__}: {exc}"}, status=500)
return web.json_response({"html": html}) # Аддитивно (#3196): ключ "html" на месте и не изменился — клиент, читающий
# только его, ничего не заметит. "status" может быть null (goto вернул None).
return web.json_response({"html": html, "status": status})
async def fetch_json_handler(request: web.Request) -> web.Response: async def fetch_json_handler(request: web.Request) -> web.Response:
@ -1070,6 +1080,28 @@ _CHALLENGE_MARKERS: tuple[str, ...] = (
_BAN_MARKERS: tuple[str, ...] = ("доступ ограничен: проблема с ip",) _BAN_MARKERS: tuple[str, ...] = ("доступ ограничен: проблема с ip",)
# HTTP-статусы, которые означают отказ площадки, а не временную проверку. Домклик
# отдаёт ровно 403 со статической страницей на 26 624 байта, где нет НИ одного
# маркера из _CHALLENGE_MARKERS/_BAN_MARKERS (оба сняты с Авито) — замер прода
# 28.08.2026, #3196. Такой ответ уезжал наверх как валидный HTML, парсер не находил
# состояние страницы, и прогон получал блок неизвестной природы (ban_kind=unknown).
# Статус — ДОПОЛНИТЕЛЬНЫЙ сигнал: ветки _is_ban_page/_is_pow_challenge остаются, они
# ловят отказ, отданный с кодом 200 (так делает Авито).
_REFUSAL_STATUSES: frozenset[int] = frozenset({403, 429})
def _status_of(response: object) -> int | None:
"""HTTP-статус playwright Response; None, если ответа нет или статус нечитаем.
``page.goto`` штатно возвращает None (редирект на тот же документ, навигационная
гонка) это не ошибка, просто статус неизвестен.
"""
if response is None:
return None
status = getattr(response, "status", None)
return status if isinstance(status, int) else None
class ChallengeTimeoutError(Exception): class ChallengeTimeoutError(Exception):
"""PoW-челлендж не снялся за BROWSER_CHALLENGE_WAIT_MS. """PoW-челлендж не снялся за BROWSER_CHALLENGE_WAIT_MS.
@ -1258,6 +1290,10 @@ async def _fetch_once(
browser = _browsers.get(provider) browser = _browsers.get(provider)
assert browser is not None, "browser not launched" assert browser is not None, "browser not launched"
# Гасим статус прошлой навигации ДО работы: если goto упадёт, наверх не должен
# уехать статус предыдущей страницы этого же провайдера (#3196).
_last_response_status[provider] = None
if reset_context: if reset_context:
await _close_reusable_context(provider) await _close_reusable_context(provider)
@ -1282,7 +1318,12 @@ async def _fetch_once(
origin, timeout=BROWSER_NAV_TIMEOUT_MS, wait_until="domcontentloaded" origin, timeout=BROWSER_NAV_TIMEOUT_MS, wait_until="domcontentloaded"
) )
await page.wait_for_timeout(FETCH_JSON_SETTLE_MS) # type: ignore[attr-defined] await page.wait_for_timeout(FETCH_JSON_SETTLE_MS) # type: ignore[attr-defined]
await page.goto(url, timeout=BROWSER_NAV_TIMEOUT_MS, wait_until="domcontentloaded") # type: ignore[attr-defined] # Статус берём у ЦЕЛЕВОЙ навигации, а не у прогрева origin выше: интересует
# ответ площадки на запрошенный url (#3196).
response = await page.goto( # type: ignore[attr-defined]
url, timeout=BROWSER_NAV_TIMEOUT_MS, wait_until="domcontentloaded"
)
_last_response_status[provider] = _status_of(response)
if BROWSER_WAIT_MS > 0: if BROWSER_WAIT_MS > 0:
await page.wait_for_timeout(BROWSER_WAIT_MS) # type: ignore[attr-defined] await page.wait_for_timeout(BROWSER_WAIT_MS) # type: ignore[attr-defined]
html: str = await page.content() # type: ignore[attr-defined] html: str = await page.content() # type: ignore[attr-defined]
@ -1296,6 +1337,19 @@ async def _fetch_once(
# PoW-челлендж (QRATOR) — в отличие от бана снимается сам по себе; ждём его # PoW-челлендж (QRATOR) — в отличие от бана снимается сам по себе; ждём его
# прохождения вместо того, чтобы вернуть 7891-байтную заглушку как контент. # прохождения вместо того, чтобы вернуть 7891-байтную заглушку как контент.
if _is_pow_challenge(html): if _is_pow_challenge(html):
status = _last_response_status.get(provider)
if status in _REFUSAL_STATUSES:
# Маркеры челленджа при 403/429 — это отказ, а не проверка:
# статическая страница сама себя не перезагрузит, ждать нечего.
# Наверх идёт ИСКЛЮЧЕНИЕ, а не заглушка: вернув её как валидный
# контент, мы бы воскресили регрессию #3045 — авитовская бан-страница
# приходит ровно с 403/429 (см. _BAN_MARKERS выше), и парсер получил
# бы 7891-байтный челлендж вместо карточки. Клиент уже трактует
# BanPageDetectedError как блок.
raise BanPageDetectedError(
f"tradein-browser[{provider}]: HTTP {status} + маркеры челленджа "
f"— отказ площадки, ждать нечего url={url!r}"
)
html = await _wait_out_pow_challenge(page, provider, url) html = await _wait_out_pow_challenge(page, provider, url)
finally: finally:
await page.close() # type: ignore[attr-defined] await page.close() # type: ignore[attr-defined]

View file

@ -0,0 +1,292 @@
"""test_server_http_status.py — HTTP-статус целевой навигации в /fetch (#3196).
Проблема, установленная замером на проде 28.08.2026: ДомКлик отдаёт статическую
страницу «403 | Домклик» ровно на 26 624 байта, в которой НЕТ ни startpow, ни
qrator, ни капчи, ни PoW-скрипта. Ни один маркер из _CHALLENGE_MARKERS/_BAN_MARKERS
(оба сняты с авитовских страниц) в неё не попадает, поэтому отказ уезжал наверх как
валидный контент, парсер не находил состояние страницы, и прогон получал блок
неизвестной природы 14 прогонов domclick_detail_backfill подряд с ban_kind=unknown.
_fetch_once игнорировал Response, который возвращает page.goto. Теперь статус
целевой навигации сохраняется в _last_response_status[provider] и отдаётся в теле
/fetch рядом с html АДДИТИВНО, ключ "html" не меняется.
camoufox НЕ запускается: _browsers[provider] поддельный browser/page (зеркалит
test_server_pow_challenge.py). wait_for_timeout на фейковой page no-op.
Запуск (из tradein-mvp/browser/)::
python -m pytest test_server_http_status.py -q
"""
from __future__ import annotations
import asyncio
import importlib.util
import json
from pathlib import Path
from typing import Any
import pytest
from aiohttp.test_utils import make_mocked_request
# server.py — не пакет (отдельный сервис без __init__/pyproject). Грузим по пути.
_SERVER_PATH = Path(__file__).resolve().parent / "server.py"
_spec = importlib.util.spec_from_file_location("tradein_browser_server", _SERVER_PATH)
assert _spec is not None and _spec.loader is not None
server = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(server)
@pytest.fixture(autouse=True)
def _reset_state(monkeypatch: pytest.MonkeyPatch) -> None:
"""Чистое per-provider состояние на каждый тест (зеркалит соседние тесты)."""
monkeypatch.setattr(server, "_browsers", {})
monkeypatch.setattr(server, "_browser_cms", {})
monkeypatch.setattr(server, "_page_counters", {})
monkeypatch.setattr(server, "_locks", {})
monkeypatch.setattr(server, "_retry_tasks", {})
monkeypatch.setattr(server, "_last_goto_at", {})
monkeypatch.setattr(server, "_last_response_status", {})
monkeypatch.setattr(server, "_launched_proxy", {})
monkeypatch.setattr(server, "_locks_guard", asyncio.Lock())
monkeypatch.delenv("SCRAPER_PROXY_URL", raising=False)
# Заглушка ДомКлика: 403 и НИ ОДНОГО маркера из _CHALLENGE_MARKERS/_BAN_MARKERS —
# ровно то, на чём ломался старый детектор.
_DOMCLICK_403_HTML = "<html><head><title>403 | Домклик</title></head><body></body></html>"
_REAL_HTML = "<html><body>listing card content</body></html>"
# Челлендж Авито: маркер startPow при HTTP 200 — путь, который трогать нельзя.
_CHALLENGE_HTML = "<html><body><script>startPow(1);</script></body></html>"
class _Response:
"""Поддельный playwright Response — интересует только .status."""
def __init__(self, status: int) -> None:
self.status = status
class _Page:
"""Поддельная page: goto отдаёт заданный Response, content() — заданный HTML.
goto_results по одному элементу на вызов goto (None = «playwright вернул None»,
навигационная гонка/редирект). После исчерпания повторяет последний.
"""
def __init__(
self,
html_sequence: list[str],
goto_results: list[_Response | None],
) -> None:
self._html_sequence = html_sequence
self._goto_results = goto_results
self._content_calls = 0
self.goto_urls: list[str] = []
self.wait_for_timeout_calls: list[int] = []
self.closed = 0
async def route(self, pattern: str, handler: Any) -> None:
return None
async def goto(self, url: str, **kwargs: Any) -> _Response | None:
idx = min(len(self.goto_urls), len(self._goto_results) - 1)
self.goto_urls.append(url)
return self._goto_results[idx]
async def wait_for_timeout(self, ms: int) -> None:
self.wait_for_timeout_calls.append(ms)
async def content(self) -> str:
idx = min(self._content_calls, len(self._html_sequence) - 1)
self._content_calls += 1
return self._html_sequence[idx]
async def close(self) -> None:
self.closed += 1
class _Browser:
def __init__(self, page: _Page) -> None:
self._page = page
async def new_page(self) -> _Page:
return self._page
def _install(monkeypatch: pytest.MonkeyPatch, page: _Page, provider: str = "avito") -> None:
server._browsers[provider] = _Browser(page)
monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000)
monkeypatch.setattr(server, "BROWSER_WAIT_MS", 0)
monkeypatch.setattr(server, "_MIN_PAGE_INTERVAL_BY_PROVIDER", {})
monkeypatch.setattr(server, "BROWSER_MIN_PAGE_INTERVAL_S", 0.0)
def _json_body(response: Any) -> dict[str, Any]:
return json.loads(response.body.decode())
async def _coro(value: Any) -> Any:
return value
def _make_request(body: dict[str, Any]) -> Any:
request = make_mocked_request("POST", "/fetch")
request.json = lambda: _coro(body) # type: ignore[method-assign]
return request
# ── _status_of: чтение статуса ────────────────────────────────────────────────
def test_status_of_reads_response_status() -> None:
assert server._status_of(_Response(403)) == 403
def test_status_of_none_response_is_none() -> None:
"""goto вернул None (редирект/навигационная гонка) — не падаем, статуса нет."""
assert server._status_of(None) is None
# ── _fetch_once: статус пишется в per-provider состояние ──────────────────────
def test_fetch_once_records_target_status(monkeypatch: pytest.MonkeyPatch) -> None:
page = _Page([_DOMCLICK_403_HTML], [_Response(403)])
_install(monkeypatch, page)
html = asyncio.run(server._fetch_once("avito", "https://domclick.ru/card/1"))
assert html == _DOMCLICK_403_HTML # контент отдан как раньше — правка аддитивна
assert server._last_response_status["avito"] == 403
def test_fetch_once_status_none_when_goto_returns_none(monkeypatch: pytest.MonkeyPatch) -> None:
page = _Page([_REAL_HTML], [None])
_install(monkeypatch, page)
html = asyncio.run(server._fetch_once("avito", "https://www.avito.ru/x"))
assert html == _REAL_HTML
assert server._last_response_status["avito"] is None
def test_fetch_once_status_taken_from_target_not_origin(monkeypatch: pytest.MonkeyPatch) -> None:
"""Прогрев origin отдал 200, целевая навигация — 403. Наверх идёт 403."""
page = _Page([_DOMCLICK_403_HTML], [_Response(200), _Response(403)])
_install(monkeypatch, page)
asyncio.run(
server._fetch_once("avito", "https://domclick.ru/card/1", origin="https://domclick.ru/")
)
assert page.goto_urls == ["https://domclick.ru/", "https://domclick.ru/card/1"]
assert server._last_response_status["avito"] == 403
def test_fetch_once_resets_stale_status_before_navigation(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Статус прошлого запроса не должен пережить следующую навигацию."""
server._last_response_status["avito"] = 403
page = _Page([_REAL_HTML], [None])
_install(monkeypatch, page)
asyncio.run(server._fetch_once("avito", "https://www.avito.ru/x"))
assert server._last_response_status["avito"] is None
# ── статус в диагностике: 403/429 не ждут PoW ─────────────────────────────────
def test_fetch_once_skips_pow_wait_on_403(monkeypatch: pytest.MonkeyPatch) -> None:
"""403 + маркеры челленджа — отказ, а не проверка: бюджет ожидания не тратим.
Заглушку наверх НЕ отдаём: вернув её как валидный HTML, воскресили бы #3045 —
парсер Авито получил бы страницу челленджа вместо карточки.
"""
page = _Page([_CHALLENGE_HTML], [_Response(403)])
_install(monkeypatch, page)
monkeypatch.setattr(server, "BROWSER_CHALLENGE_WAIT_MS", 30_000)
with pytest.raises(server.BanPageDetectedError):
asyncio.run(server._fetch_once("avito", "https://www.avito.ru/x"))
assert page.wait_for_timeout_calls == [] # ни одного тика ожидания челленджа
def test_fetch_once_skips_pow_wait_on_429(monkeypatch: pytest.MonkeyPatch) -> None:
page = _Page([_CHALLENGE_HTML], [_Response(429)])
_install(monkeypatch, page)
with pytest.raises(server.BanPageDetectedError):
asyncio.run(server._fetch_once("avito", "https://www.avito.ru/x"))
assert page.wait_for_timeout_calls == []
def test_fetch_once_still_waits_out_challenge_on_200(monkeypatch: pytest.MonkeyPatch) -> None:
"""HTTP 200 с PoW-заглушкой (путь Авито) не задет: ждём и получаем контент."""
page = _Page([_CHALLENGE_HTML, _CHALLENGE_HTML, _REAL_HTML], [_Response(200)])
_install(monkeypatch, page)
html = asyncio.run(server._fetch_once("avito", "https://www.avito.ru/x"))
assert html == _REAL_HTML
assert page.wait_for_timeout_calls # ожидание челленджа реально шло
def test_fetch_once_ban_marker_still_raises_on_200(monkeypatch: pytest.MonkeyPatch) -> None:
"""Ветка _is_ban_page осталась: «проблема с IP» с кодом 200 — по-прежнему бан."""
page = _Page(["<html>Доступ ограничен: проблема с IP</html>"], [_Response(200)])
_install(monkeypatch, page)
with pytest.raises(server.BanPageDetectedError):
asyncio.run(server._fetch_once("avito", "https://www.avito.ru/x"))
# ── /fetch: статус доезжает в тело ответа ─────────────────────────────────────
def test_fetch_handler_returns_status_next_to_html(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(server, "IS_PROD", False)
async def _ensure(provider: str, proxy_override: str | None = None) -> bool:
return True
async def _fake_do_fetch(provider: str, url: str, **_kw: Any) -> str:
server._last_response_status[provider] = 403
return _DOMCLICK_403_HTML
monkeypatch.setattr(server, "_ensure_browser", _ensure)
monkeypatch.setattr(server, "_do_fetch", _fake_do_fetch)
response = asyncio.run(server.fetch_handler(_make_request({"url": "https://domclick.ru/x"})))
assert response.status == 200
body = _json_body(response)
assert body["html"] == _DOMCLICK_403_HTML # старый ключ не тронут
assert body["status"] == 403
def test_fetch_handler_status_null_without_response(monkeypatch: pytest.MonkeyPatch) -> None:
"""Статуса нет (goto вернул None) — ответ не ломается, status=null."""
monkeypatch.setattr(server, "IS_PROD", False)
async def _ensure(provider: str, proxy_override: str | None = None) -> bool:
return True
async def _fake_do_fetch(provider: str, url: str, **_kw: Any) -> str:
server._last_response_status[provider] = None
return _REAL_HTML
monkeypatch.setattr(server, "_ensure_browser", _ensure)
monkeypatch.setattr(server, "_do_fetch", _fake_do_fetch)
response = asyncio.run(server.fetch_handler(_make_request({"url": "https://www.avito.ru/x"})))
body = _json_body(response)
assert body["html"] == _REAL_HTML
assert body["status"] is None

View file

@ -254,6 +254,38 @@ async def probe_proxy_via_browser(
return True, None, f"html_len={len(html)}" return True, None, f"html_len={len(html)}"
# ── классификация отказа по HTTP-статусу (#3196) ─────────────────────────────
# Значения совпадают с CHECK-ограничением scrape_runs.ban_kind: допустимы только
# "platform", "infra", "unknown" либо NULL. Здесь "unknown" НЕ возвращается
# намеренно: статус, который ни о чём не говорит (200/404/None), не повод объявлять
# блок — решение остаётся за вызывающим кодом, который знает остальной контекст.
_BAN_KIND_REFUSAL_STATUSES: frozenset[int] = frozenset({403, 429})
def ban_kind_from_status(status: int | None) -> str | None:
"""Раскладывает HTTP-статус ответа площадки в ``scrape_runs.ban_kind``.
403/429 "platform" (площадка отказала осознанно: WAF/rate-limit);
5xx "infra" (сбой на стороне площадки/прокси, не адресный бан);
всё остальное и None None (статус ничего не сообщает о блоке).
Args:
status: HTTP-код целевой навигации (``BrowserFetcher.last_response_status``)
либо None, если сайдкар статус не отдал (старая версия / goto вернул None).
Returns:
"platform" | "infra" | None значение, пригодное для записи в
``scrape_runs.ban_kind`` как есть.
"""
if status is None:
return None
if status in _BAN_KIND_REFUSAL_STATUSES:
return "platform"
if 500 <= status <= 599:
return "infra"
return None
class BrowserFetcher: class BrowserFetcher:
"""Async context manager: HTTP-клиент к tradein-browser HTTP-сервису. """Async context manager: HTTP-клиент к tradein-browser HTTP-сервису.
@ -332,6 +364,13 @@ class BrowserFetcher:
self._environment = environment self._environment = environment
self._lease: ProxyLease | None = None self._lease: ProxyLease | None = None
self._lease_fail_streak: int = 0 self._lease_fail_streak: int = 0
# HTTP-статус ПОСЛЕДНЕЙ целевой навигации сайдкара (#3196). Публичный
# атрибут, а не возврат fetch(): поток управления менять нельзя — fetch()
# по-прежнему отдаёт str и по-прежнему не бросает там, где не бросал.
# Обновляется КАЖДЫМ _post_fetch (в т.ч. на неудаче — сбрасывается в None),
# читается сразу после await fetch(...). None = статуса нет: сайдкар старой
# версии без ключа "status", goto вернул None, либо фетч упал транспортом.
self.last_response_status: int | None = None
# ── lifecycle ────────────────────────────────────────────────────────────── # ── lifecycle ──────────────────────────────────────────────────────────────
@ -396,6 +435,11 @@ class BrowserFetcher:
При HTTPError или ConnectError делает одну повторную попытку после При HTTPError или ConnectError делает одну повторную попытку после
короткой паузы. Остальные исключения всплывают к вызывающему коду. короткой паузы. Остальные исключения всплывают к вызывающему коду.
Побочный эффект (#3196): после возврата ``self.last_response_status`` содержит
HTTP-код целевой навигации (или None, если сайдкар его не отдал). Читать
СРАЗУ после await следующий fetch() его перезапишет. Классифицировать
помогает ``ban_kind_from_status`` (в этом же модуле).
Returns: Returns:
Полный HTML-контент страницы. Полный HTML-контент страницы.
""" """
@ -707,6 +751,10 @@ class BrowserFetcher:
(#2164 P4 sticky-session fix, живая регрессия 2026-08). Исход репортится в lease (#2164 P4 sticky-session fix, живая регрессия 2026-08). Исход репортится в lease
через _report_fetch_result (touch-heartbeat + mark_health + осознанная ротация через _report_fetch_result (touch-heartbeat + mark_health + осознанная ротация
при N подряд провалах) best-effort, саму ошибку не глотает (re-raise). при N подряд провалах) best-effort, саму ошибку не глотает (re-raise).
Побочный эффект (#3196): выставляет ``self.last_response_status`` — HTTP-код
целевой навигации из тела ответа (``{"html": ..., "status": ...}``). На любой
неудаче сбрасывает в None, чтобы не отдать статус прошлого запроса.
""" """
assert self._client is not None assert self._client is not None
assert self._endpoint is not None assert self._endpoint is not None
@ -728,11 +776,20 @@ class BrowserFetcher:
try: try:
resp = await self._client.post(f"{self._endpoint}/fetch", json=payload) resp = await self._client.post(f"{self._endpoint}/fetch", json=payload)
_raise_for_sidecar_status(resp) # #2698: причина отказа из тела, не только код _raise_for_sidecar_status(resp) # #2698: причина отказа из тела, не только код
data: dict[str, str] = resp.json() data: dict = resp.json()
html = data["html"] html = data["html"]
except Exception: except Exception:
self.last_response_status = None
self._report_fetch_result(False) self._report_fetch_result(False)
raise raise
# Аддитивно: ключа "status" может не быть (сайдкар старой версии) и он
# может быть null — оба случая дают None, исключений не бросаем.
# bool отсекаем явно: он подтип int, поэтому JSON true прошёл бы проверку
# и уехал бы наверх статусом True, который дальше сравнивается с 403/5xx
# как 1 — молчаливо неверный диагноз вместо честного «статуса нет».
raw_status = data.get("status")
has_status = isinstance(raw_status, int) and not isinstance(raw_status, bool)
self.last_response_status = raw_status if has_status else None
self._report_fetch_result(True) self._report_fetch_result(True)
logger.debug("BrowserFetcher: fetch OK url=%r html_len=%d", url, len(html)) logger.debug("BrowserFetcher: fetch OK url=%r html_len=%d", url, len(html))
return html return html

View file

@ -36,8 +36,19 @@ class DomClickBlockedError(ProxyBanError):
curl-слоя (см. avito_exceptions.AvitoBlockedError для того же паттерна). Прямая curl-слоя (см. avito_exceptions.AvitoBlockedError для того же паттерна). Прямая
двойная база `(Exception, ProxyBanError)` даёт MRO-конфликт (ProxyBanError уже двойная база `(Exception, ProxyBanError)` даёт MRO-конфликт (ProxyBanError уже
сам наследует Exception) единственная база решает это чище. сам наследует Exception) единственная база решает это чище.
`status` (#3196) — HTTP-код ответа площадки, если он известен
(`BrowserFetcher.last_response_status`). Одно и то же исключение поднимается и
на распознанном маркере, и на сбое браузерного фетча по счётчикам они были
неразличимы, и прогон получал ban_kind='unknown'. Статус разводит их без
размножения типов: 403/429 отказ площадки, 5xx инфраструктура, None
правда «не знаем» (сайдкар статус не отдал либо фетч не состоялся).
""" """
def __init__(self, *args: object, status: int | None = None) -> None:
super().__init__(*args)
self.status = status
class DomClickParseError(Exception): class DomClickParseError(Exception):
"""DomClick detail-карточка получена (HTTP 200, не block), но SSR-стейт """DomClick detail-карточка получена (HTTP 200, не block), но SSR-стейт

View file

@ -523,7 +523,10 @@ async def fetch_detail(
зонды с одного IP жгут его репутацию, свежий/чистый IP проходит с зонды с одного IP жгут его репутацию, свежий/чистый IP проходит с
первого раза, инъекция cookies валидной аутентифицированной сессии первого раза, инъекция cookies валидной аутентифицированной сессии
обходит блок даже на уже подозрительном IP issue #2000). Оркестратор обходит блок даже на уже подозрительном IP issue #2000). Оркестратор
считает это блоком и помечает listing failed. считает это блоком и помечает listing failed. Что именно
произошло, видно по атрибуту `.status` (#3196): 403/429 — отказ
площадки, 5xx инфраструктура площадки/прокси, None статус
неизвестен (сайдкар его не отдал либо фетч не состоялся).
DomClickParseError: HTTP 200, но SSR-стейт не парсится (дрейф схемы). DomClickParseError: HTTP 200, но SSR-стейт не парсится (дрейф схемы).
""" """
parsed = urlsplit(card_url) parsed = urlsplit(card_url)
@ -532,6 +535,9 @@ async def fetch_detail(
# Заход на same-site vtorichka-SERP ПЕРЕД карточкой снижает подозрительность # Заход на same-site vtorichka-SERP ПЕРЕД карточкой снижает подозрительность
# холодной навигации для QRATOR (эмпирически подтверждено вживую 2026-07-04). # холодной навигации для QRATOR (эмпирически подтверждено вживую 2026-07-04).
html = await browser_fetcher.fetch(card_url, origin=origin, cookies=cookies) html = await browser_fetcher.fetch(card_url, origin=origin, cookies=cookies)
# #3196: статус читаем СРАЗУ после await — следующий fetch() его перезапишет.
# getattr — сайдкар/фетчер старой сборки атрибута не имеет, это не повод падать.
status = getattr(browser_fetcher, "last_response_status", None)
except DomClickBlockedError: except DomClickBlockedError:
# Defensive passthrough — browser_fetcher.fetch() сегодня НЕ поднимает # Defensive passthrough — browser_fetcher.fetch() сегодня НЕ поднимает
# DomClickBlockedError сама (она domclick-specific, fetch() про неё не знает), # DomClickBlockedError сама (она domclick-specific, fetch() про неё не знает),
@ -544,11 +550,16 @@ async def fetch_detail(
# обычный сбой транспорта. mark_health(ok=False) её уже учла внутри # обычный сбой транспорта. mark_health(ok=False) её уже учла внутри
# browser_fetcher._post_fetch (см. #2600 п.4 — различимость трёх причин). # browser_fetcher._post_fetch (см. #2600 п.4 — различимость трёх причин).
raise DomClickBlockedError( raise DomClickBlockedError(
f"DomClick detail browser fetch failed for {card_url}: {exc}" f"DomClick detail browser fetch failed for {card_url}: {exc}",
status=getattr(browser_fetcher, "last_response_status", None),
) from exc ) from exc
try: try:
return parse_detail_html(html, card_url) return parse_detail_html(html, card_url)
except DomClickBlockedError: except DomClickBlockedError as blocked:
# #3196: маркер поймал parse_detail_html, статус ответа знает только этот
# кадр — дописываем его в исключение, иначе оркестратор снова получит блок
# без причины (страница `403 | Домклик` неотличима от QRATOR-челленджа).
blocked.status = status
# #2600 п.1: ЗДЕСЬ — настоящий маркер-детект (QRATOR/капча HTML, HTTP 200), # #2600 п.1: ЗДЕСЬ — настоящий маркер-детект (QRATOR/капча HTML, HTTP 200),
# ГЕНУИННЫЙ ban-сигнал в отличие от except-веток выше. browser_fetcher — # ГЕНУИННЫЙ ban-сигнал в отличие от except-веток выше. browser_fetcher —
# ещё живой lease (caller держит `async with BrowserFetcher(...) as bf:` # ещё живой lease (caller держит `async with BrowserFetcher(...) as bf:`