fix(tradein/scrapers): диагноз блока брался из текстовых маркеров чужой площадки, а не из HTTP-статуса (#3196)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Successful in 1m7s
CI Trade-In / backend-tests (pull_request) Successful in 4m57s

Сайдкар вообще не читал код ответа page.goto: страница классифицировалась
только по маркерам, снятым с Авито. Домклик отдаёт статическую `403 | Домклик`
на 26 624 байта, где нет ни одного такого маркера (замер прода 28.08.2026) —
она уезжала наверх как валидный HTML, парсер не находил состояние, и прогон
получал блок неизвестной природы. За 14 дней все 14 прогонов домклика легли с
ban_kind='unknown'; у Яндекса счётчика blocked не было вовсе, поэтому ветка
перевода прогона в 'banned' была недостижима по построению — ноль банов.

- browser/server.py: статус целевой навигации сохраняется per-provider и
  доезжает в тело /fetch аддитивным ключом "status" (ключ "html" не тронут);
  403/429 с маркерами челленджа больше не ждут PoW — ждать нечего, статическая
  страница сама себя не перезагрузит. Наверх идёт BanPageDetectedError, а не
  заглушка: вернув её контентом, воскресили бы #3045.
- scraper_kit/browser_fetcher.py: BrowserFetcher.last_response_status +
  ban_kind_from_status (403/429 → platform, 5xx → infra, прочее → None).
  Поток управления не менялся: fetch() по-прежнему отдаёт str.
- domclick: DomClickBlockedError несёт .status — один тип исключения на
  маркер-детект и на сбой фетча разводится без размножения типов; прогон
  передаёт перепись диагнозов в mark_backfill_finished.
- yandex: появился счётчик blocked, оживляющий ветку бана. Серии блоков и
  промахов парсера считаются РАЗДЕЛЬНО: иначе четыре промаха плюс один 403
  пятым давали 'banned' с переписью {platform: 1}.
- cian: ban_kinds наполняется только диагностируемым статусом. HTTP 200 с
  пустым разбором — дрейф разметки на нашей стороне, а не отказ площадки;
  записав его блоком, мы бы штамповали фиктивные баны у здорового источника
  (13 done против 1 banned за 14 дней).

Инвариант: непустой ban_kinds ⟺ виден ответ 403/429/5xx. Значения остаются в
пределах CHECK scrape_runs.ban_kind.

Известный пробел: шов providers/domclick/detail.py `blocked.status = status`
тестами не покрыт — существующие домкликовые тесты подают исключение готовым
моком и боевой fetch_detail не исполняют.
This commit is contained in:
bot-backend 2026-08-28 23:21:54 +03:00
parent 0ac443d0ba
commit bf3214b9e4
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)}
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(
"scheduler: cian_history_backfill run_id=%d done — listings=%d/%d houses=%d/%d %.1fs",
run_id,

View file

@ -33,10 +33,11 @@ from __future__ import annotations
import asyncio
import logging
import time
from collections import Counter
from collections.abc import Callable
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.valuation import estimate_via_cian_valuation
from sqlalchemy import text
@ -71,6 +72,42 @@ class CianBackfillResult:
valuations_succeeded: int = 0
valuations_failed: int = 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(
@ -145,21 +182,29 @@ async def backfill_cian_history(
try:
enrichment = await fetch_detail(source_url, browser_fetcher=bf)
except Exception as exc:
kind = _note_refusal(result, bf.last_response_status)
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,
source_url,
exc,
bf.last_response_status,
kind,
)
result.listings_failed_fetch += 1
await asyncio.sleep(delay)
continue
if enrichment is None:
kind = _note_refusal(result, bf.last_response_status)
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,
source_url,
bf.last_response_status,
kind,
)
result.listings_failed_fetch += 1
await asyncio.sleep(delay)

View file

@ -50,12 +50,15 @@ Exception triad differs from Avito:
непринятое решение. Разбор цены и рисков: #2854 (блок бьёт внутри первой
комнатной корзины, buckets_completed=0 во ВСЕХ прогонах; свежий узел, судя по
длительности до блока 111-332 с, получает свой бюджет).
ОГРАНИЧЕНИЕ (#2764): диагноз scrape_runs.ban_kind этот прогон НЕ передаёт и
получает 'unknown'. Один и тот же DomClickBlockedError поднимается и на
распознанном QRATOR-маркере (площадка), и на любом сбое браузерного fetch
(наш тракт) -- см. providers/domclick/detail.py::fetch_detail. Пока эти два
случая не разведены отдельным подтипом (как AvitoSidecarUnavailableError у
avito), любой диагноз отсюда был бы назначенным, а не установленным.
ДИАГНОЗ (#3196, снимает ограничение #2764): scrape_runs.ban_kind теперь
передаётся. Один и тот же DomClickBlockedError по-прежнему поднимается и на
распознанном маркере, и на сбое браузерного fetch, но несёт HTTP-статус ответа
(`.status`, см. providers/domclick/detail.py::fetch_detail) разводить типы
исключений ради этого не понадобилось. 403/429 'platform' (ровно тот случай,
что намеряли вживую 28.08: статическая страница `403 | Домклик` на 26 624 байта,
без PoW и капчи это отказ, а не челлендж), 5xx 'infra', статуса нет
'unknown', честно «не установлено». Раньше все 14 прогонов за 14 дней
получали 'unknown' просто потому, что диагноз не доезжал до финализатора.
- DomClickParseError (__SSR_STATE__ missing/malformed -- schema drift, NOT a
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
@ -84,10 +87,11 @@ import asyncio
import logging
import random
import time
from collections import Counter
from dataclasses import dataclass, field
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.providers.domclick.detail import fetch_detail, save_detail_enrichment
from sqlalchemy import text
@ -97,6 +101,7 @@ from app.core.config import settings
from app.core.shutdown import shutdown_requested
from app.services import domclick_session as domclick_session_svc
from app.services import scrape_runs as runs_mod
from app.services.scrape_runs import BAN_KIND_UNKNOWN
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
class DomClickDetailBackfillResult:
"""Counters for one backfill run."""
@ -267,6 +285,9 @@ async def run_domclick_detail_backfill(
consecutive_blocks = 0
aborted_by_blocks = 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
# module docstring). source="domclick" -- infra identifier, dedicated
@ -339,28 +360,32 @@ async def run_domclick_detail_backfill(
except DomClickBlockedError as e:
consecutive_blocks += 1
counters.blocked += 1
ban_kind = _ban_kind_of_block(e)
block_ban_kinds[ban_kind] += 1
# #3118: сожжённый переиспользуемый context (протухший qrator_jsid2
# / стухшая сессия) не должен тянуться дальше по прогону — просим
# ОДИН сброс на следующем fetch(), не на каждый последующий запрос.
bf.request_context_reset()
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,
idx + 1,
len(snapshot),
consecutive_blocks,
getattr(e, "status", None),
ban_kind,
e,
)
if consecutive_blocks >= max_consecutive_blocks:
# #2764/#3178: DomClickBlockedError не разводит площадку (QRATOR)
# и наш браузерный тракт (см. докстринг класса выше) — причину
# НЕ выдумываем, пишем как есть.
# #3196: причину больше не выдумываем и не молчим — печатаем
# перепись диагнозов по HTTP-статусам этого прогона.
logger.error(
"domclick_detail_backfill: run_id=%d ABORT -- %d consecutive "
"blocks, причина не определена (площадка либо наш тракт). "
"enriched=%d attempted=%d",
"blocks, диагнозы: %s. enriched=%d attempted=%d",
run_id,
consecutive_blocks,
dict(block_ban_kinds) or "нет",
counters.enriched,
counters.attempted,
)
@ -392,6 +417,7 @@ async def run_domclick_detail_backfill(
current_counters,
source="domclick_detail_backfill",
aborted_by_blocks=aborted_by_blocks,
ban_kinds=block_ban_kinds,
)
logger.info(
"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
curl_cffi path (kit-профиль impersonation + mobile proxy) is already proven
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
@ -44,9 +51,11 @@ from __future__ import annotations
import asyncio
import logging
import time
from collections import Counter
from dataclasses import dataclass, field
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.yandex.detail import YandexDetailScraper, save_detail_enrichment
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.proxy_egress import resolve_proxy_url
from app.services.scrape_runs import BAN_KIND_UNKNOWN
logger = logging.getLogger(__name__)
@ -121,6 +131,11 @@ class YandexDetailBackfillResult:
attempted: int = 0
enriched: int = 0
# Отказы площадки среди попыток (non-200 ответ) с разобранной природой.
# Подмножество failed, а не отдельная корзина: смысл failed («попытка не дала
# обогащения») не переписываем. Без этого счётчика ветка перевода прогона в
# 'banned' у Яндекса недостижима по построению (#3196).
blocked: int = 0
failed: int = 0
# Ждут обогащения, сохранённый source_url непарсим, но адрес восстановим из
# source_id — идут в очередь по вычисленному URL. Должен убывать от прогона к
@ -134,6 +149,7 @@ class YandexDetailBackfillResult:
return {
"attempted": self.attempted,
"enriched": self.enriched,
"blocked": self.blocked,
"failed": self.failed,
"url_from_offer_id": self.url_from_offer_id,
"unenrichable_pending": self.unenrichable_pending,
@ -298,7 +314,17 @@ async def run_yandex_detail_backfill(
_proxies = {"http": _proxy, "https": _proxy} if _proxy else None
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
aborted_by_blocks = False
# Перепись диагнозов блоков (kind -> сколько раз). Кратности нужны целыми:
# доминирующий вид выбирает _dominant_ban_kind в scrape_runs.py (#3178).
block_ban_kinds: Counter[str] = Counter()
scraper = YandexDetailScraper()
async with AsyncSession(
@ -360,22 +386,33 @@ async def run_yandex_detail_backfill(
continue
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_blocks += 1
counters.failed += 1
logger.warning(
"yandex_detail_backfill: run_id=%d listing_id=%d "
"HTTP %d (consecutive=%d)",
"HTTP %d ban_kind=%s (consecutive=%d)",
run_id,
listing_id,
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(
"yandex_detail_backfill: run_id=%d ABORT -- %d consecutive "
"non-200 responses. enriched=%d attempted=%d",
run_id,
consecutive_none,
consecutive_blocks,
counters.enriched,
counters.attempted,
)
@ -387,7 +424,15 @@ async def run_yandex_detail_backfill(
if enrichment is None:
# parse→None: captcha wall / shell-HTML / no JSON-LD.
# Do not mark listing as done — retry next night.
# НАРОЧНО не считаем блоком (#3196): при HTTP 200 отличить
# капчу от промаха нашего парсера нечем, а записав это в
# blocked, мы объявляли бы 'banned' (внешняя причина) любой
# прогон, сломанный на нашей стороне, и потеряли бы сигнал
# 'failed', ради которого он заведён (#2674).
consecutive_none += 1
# Площадка ОТВЕТИЛА (HTTP 200) — серии подтверждённых блоков
# нет, что бы ни случилось дальше с парсингом (#3196).
consecutive_blocks = 0
counters.failed += 1
logger.warning(
"yandex_detail_backfill: run_id=%d listing_id=%d source_url=%s "
@ -410,6 +455,7 @@ async def run_yandex_detail_backfill(
continue
consecutive_none = 0
consecutive_blocks = 0
if save_detail_enrichment(db, listing_id, enrichment):
counters.enriched += 1
@ -432,14 +478,20 @@ async def run_yandex_detail_backfill(
counters.duration_sec = time.monotonic() - start
current_counters = counters.to_dict()
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(
"yandex_detail_backfill: run_id=%d FINISHED -- attempted=%d enriched=%d "
"failed=%d duration=%.1fs",
"blocked=%d failed=%d duration=%.1fs",
run_id,
counters.attempted,
counters.enriched,
counters.blocked,
counters.failed,
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
reputation likely burned for the session/proxy' -- утверждение, для которого нет
основания в этом прогоне.
#3196: причину теперь устанавливает HTTP-статус ответа. Здесь его нет (блок без
`.status`), поэтому лог печатает перепись диагнозов с честным 'unknown' -- и
по-прежнему НЕ называет QRATOR.
"""
snapshot = _make_snapshot(10)
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]
assert abort_records, "ожидался ABORT-лог"
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

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:
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 /login {"url": "...", "email": "...", "password": "...", ...} {"cookies": [...]}
@ -377,6 +378,10 @@ _page_counters: dict[str, int] = {} # provider → страниц с мом
_locks: dict[str, asyncio.Lock] = {} # provider → Lock (весь доступ к инстансу)
_retry_tasks: dict[str, asyncio.Task[None]] = {} # provider → фоновая retry-задача
_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 или динамический
# из пула, переданный в теле /fetch). Нужен для политики «relaunch ТОЛЬКО при реальной
# смене прокси» — camoufox берёт proxy на launch, релонч дорогой, поэтому не релончим,
@ -762,7 +767,7 @@ async def health_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 страницы.
Поставщик определяется по host URL (или явному body["provider"]/["source"]). Берётся
@ -844,6 +849,9 @@ async def fetch_handler(request: web.Request) -> web.Response:
reuse_context=reuse_context,
reset_context=reset_context,
)
# Читаем статус под тем же локом, что и сам фетч, — иначе следующий
# запрос этого провайдера успел бы его перезаписать.
status = _last_response_status.get(provider)
except Exception as exc:
logger.error(
"tradein-browser[%s]: fetch error url=%r: %s: %s",
@ -854,7 +862,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({"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:
@ -1067,6 +1077,28 @@ _CHALLENGE_MARKERS: tuple[str, ...] = (
_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):
"""PoW-челлендж не снялся за BROWSER_CHALLENGE_WAIT_MS.
@ -1255,6 +1287,10 @@ async def _fetch_once(
browser = _browsers.get(provider)
assert browser is not None, "browser not launched"
# Гасим статус прошлой навигации ДО работы: если goto упадёт, наверх не должен
# уехать статус предыдущей страницы этого же провайдера (#3196).
_last_response_status[provider] = None
if reset_context:
await _close_reusable_context(provider)
@ -1279,7 +1315,12 @@ async def _fetch_once(
origin, timeout=BROWSER_NAV_TIMEOUT_MS, wait_until="domcontentloaded"
)
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:
await page.wait_for_timeout(BROWSER_WAIT_MS) # type: ignore[attr-defined]
html: str = await page.content() # type: ignore[attr-defined]
@ -1293,6 +1334,19 @@ async def _fetch_once(
# PoW-челлендж (QRATOR) — в отличие от бана снимается сам по себе; ждём его
# прохождения вместо того, чтобы вернуть 7891-байтную заглушку как контент.
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)
finally:
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)}"
# ── классификация отказа по 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:
"""Async context manager: HTTP-клиент к tradein-browser HTTP-сервису.
@ -332,6 +364,13 @@ class BrowserFetcher:
self._environment = environment
self._lease: ProxyLease | None = None
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 ──────────────────────────────────────────────────────────────
@ -396,6 +435,11 @@ class BrowserFetcher:
При HTTPError или ConnectError делает одну повторную попытку после
короткой паузы. Остальные исключения всплывают к вызывающему коду.
Побочный эффект (#3196): после возврата ``self.last_response_status`` содержит
HTTP-код целевой навигации (или None, если сайдкар его не отдал). Читать
СРАЗУ после await следующий fetch() его перезапишет. Классифицировать
помогает ``ban_kind_from_status`` (в этом же модуле).
Returns:
Полный HTML-контент страницы.
"""
@ -707,6 +751,10 @@ class BrowserFetcher:
(#2164 P4 sticky-session fix, живая регрессия 2026-08). Исход репортится в lease
через _report_fetch_result (touch-heartbeat + mark_health + осознанная ротация
при N подряд провалах) best-effort, саму ошибку не глотает (re-raise).
Побочный эффект (#3196): выставляет ``self.last_response_status`` — HTTP-код
целевой навигации из тела ответа (``{"html": ..., "status": ...}``). На любой
неудаче сбрасывает в None, чтобы не отдать статус прошлого запроса.
"""
assert self._client is not None
assert self._endpoint is not None
@ -728,11 +776,20 @@ class BrowserFetcher:
try:
resp = await self._client.post(f"{self._endpoint}/fetch", json=payload)
_raise_for_sidecar_status(resp) # #2698: причина отказа из тела, не только код
data: dict[str, str] = resp.json()
data: dict = resp.json()
html = data["html"]
except Exception:
self.last_response_status = None
self._report_fetch_result(False)
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)
logger.debug("BrowserFetcher: fetch OK url=%r html_len=%d", url, len(html))
return html

View file

@ -36,8 +36,19 @@ class DomClickBlockedError(ProxyBanError):
curl-слоя (см. avito_exceptions.AvitoBlockedError для того же паттерна). Прямая
двойная база `(Exception, ProxyBanError)` даёт MRO-конфликт (ProxyBanError уже
сам наследует 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):
"""DomClick detail-карточка получена (HTTP 200, не block), но SSR-стейт

View file

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