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
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:
parent
0ac443d0ba
commit
bf3214b9e4
14 changed files with 1386 additions and 32 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 "
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
224
tradein-mvp/backend/tests/test_3196_cian_ban_kind.py
Normal file
224
tradein-mvp/backend/tests/test_3196_cian_ban_kind.py
Normal 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
|
||||
216
tradein-mvp/backend/tests/test_3196_domclick_ban_kind.py
Normal file
216
tradein-mvp/backend/tests/test_3196_domclick_ban_kind.py
Normal 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()
|
||||
186
tradein-mvp/backend/tests/test_3196_yandex_ban_kind.py
Normal file
186
tradein-mvp/backend/tests/test_3196_yandex_ban_kind.py
Normal 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
|
||||
160
tradein-mvp/backend/tests/test_kit_browser_fetcher_status.py
Normal file
160
tradein-mvp/backend/tests/test_kit_browser_fetcher_status.py
Normal 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
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
292
tradein-mvp/browser/test_server_http_status.py
Normal file
292
tradein-mvp/browser/test_server_http_status.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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-стейт
|
||||
|
|
|
|||
|
|
@ -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:`
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue