Merge pull request 'fix(tradein/domclick): подтверждённый отказ площадки уехал в ветку «сбой транспорта» и перестал банить узел' (#3241) from fix/3239-sidecar-ban-page-reaches-ban-path into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 14s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Successful in 3m0s
Deploy Trade-In / test (push) Successful in 4m5s
Deploy Trade-In / build-backend (push) Successful in 1m36s
Deploy Trade-In / deploy (push) Successful in 8m32s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s
All checks were successful
Deploy Trade-In / changes (push) Successful in 14s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Successful in 3m0s
Deploy Trade-In / test (push) Successful in 4m5s
Deploy Trade-In / build-backend (push) Successful in 1m36s
Deploy Trade-In / deploy (push) Successful in 8m32s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s
This commit is contained in:
commit
3d51c08e44
6 changed files with 275 additions and 2 deletions
|
|
@ -36,7 +36,9 @@ import json
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
from scraper_kit.browser_fetcher import SidecarBanPageError
|
||||||
from scraper_kit.domclick_exceptions import DomClickBlockedError, DomClickParseError
|
from scraper_kit.domclick_exceptions import DomClickBlockedError, DomClickParseError
|
||||||
from scraper_kit.offer_price_history import clamp_diff_percent
|
from scraper_kit.offer_price_history import clamp_diff_percent
|
||||||
from scraper_kit.providers.domclick.detail import (
|
from scraper_kit.providers.domclick.detail import (
|
||||||
|
|
@ -438,6 +440,61 @@ async def test_fetch_detail_transport_failure_does_not_report_ban() -> None:
|
||||||
bf.report_ban.assert_not_called()
|
bf.report_ban.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
# ── #3239: бан-страница ОТ САЙДКАРА — тоже генуинный маркер-детект ────────────
|
||||||
|
#
|
||||||
|
# #3237 перенёс распознавание статического отказа Домклика из parse_detail_html в
|
||||||
|
# сайдкар (иначе незавершённое QRATOR-рукопожатие считалось блоком). Отказ стал
|
||||||
|
# приезжать обычной 500-кой и попадать в транспортную ветку, где report_ban по
|
||||||
|
# построению НЕ зовётся → ban_kind 'unknown' вместо 'platform' → ротация IP не
|
||||||
|
# запускалась вовсе. Тесты ниже держат обе ветки РАЗДЕЛЬНО: подтверждённый отказ
|
||||||
|
# банит, голый транспортный сбой — нет (тест выше).
|
||||||
|
|
||||||
|
|
||||||
|
def _sidecar_ban_page_error(upstream_status: int | None = 401) -> SidecarBanPageError:
|
||||||
|
return SidecarBanPageError(
|
||||||
|
"Server error '500' | tradein-browser: BanPageDetectedError: статический отказ",
|
||||||
|
request=httpx.Request("POST", "http://tradein-browser:3000/fetch"),
|
||||||
|
response=httpx.Response(500, request=httpx.Request("POST", "http://x/fetch")),
|
||||||
|
upstream_status=upstream_status,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fetch_detail_reports_ban_on_sidecar_ban_page() -> None:
|
||||||
|
bf = MagicMock()
|
||||||
|
bf.fetch = AsyncMock(side_effect=_sidecar_ban_page_error())
|
||||||
|
with pytest.raises(DomClickBlockedError):
|
||||||
|
await fetch_detail(_CARD_URL, browser_fetcher=bf)
|
||||||
|
bf.report_ban.assert_called_once()
|
||||||
|
assert _CARD_URL in bf.report_ban.call_args.args[0]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fetch_detail_sidecar_ban_page_carries_upstream_status() -> None:
|
||||||
|
"""Статус берётся ИЗ ИСКЛЮЧЕНИЯ, а не из last_response_status.
|
||||||
|
|
||||||
|
Несущая деталь #3239: на error-пути fetch() обнуляет last_response_status,
|
||||||
|
поэтому у Домклика (отказ приходит с 401) классификатор без этого поля
|
||||||
|
поставил бы 'unknown'. MagicMock отдаёт last_response_status как Mock —
|
||||||
|
если бы код читал его, ассерт ниже упал бы.
|
||||||
|
"""
|
||||||
|
bf = MagicMock()
|
||||||
|
bf.fetch = AsyncMock(side_effect=_sidecar_ban_page_error(401))
|
||||||
|
with pytest.raises(DomClickBlockedError) as excinfo:
|
||||||
|
await fetch_detail(_CARD_URL, browser_fetcher=bf)
|
||||||
|
assert excinfo.value.status == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fetch_detail_sidecar_ban_page_without_status_stays_none() -> None:
|
||||||
|
"""Сайдкар не отдал статус → None, а не выдуманный код (#2764)."""
|
||||||
|
bf = MagicMock()
|
||||||
|
bf.fetch = AsyncMock(side_effect=_sidecar_ban_page_error(None))
|
||||||
|
with pytest.raises(DomClickBlockedError) as excinfo:
|
||||||
|
await fetch_detail(_CARD_URL, browser_fetcher=bf)
|
||||||
|
assert excinfo.value.status is None
|
||||||
|
|
||||||
|
|
||||||
# ── save_detail_enrichment (MagicMock — зеркало test_cian_detail) ─────────────
|
# ── save_detail_enrichment (MagicMock — зеркало test_cian_detail) ─────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,8 +27,14 @@ from __future__ import annotations
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
from scraper_kit.browser_fetcher import BrowserFetcher, ban_kind_from_status
|
from scraper_kit.browser_fetcher import (
|
||||||
|
BrowserFetcher,
|
||||||
|
SidecarBanPageError,
|
||||||
|
_raise_for_sidecar_status,
|
||||||
|
ban_kind_from_status,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _mock_client(json_payload: dict[str, Any], *, raise_exc: Exception | None = None) -> MagicMock:
|
def _mock_client(json_payload: dict[str, Any], *, raise_exc: Exception | None = None) -> MagicMock:
|
||||||
|
|
@ -158,3 +164,73 @@ def test_ban_kind_values_fit_scrape_runs_check() -> None:
|
||||||
allowed = {"platform", "infra", "unknown", None}
|
allowed = {"platform", "infra", "unknown", None}
|
||||||
for status in (None, 200, 301, 403, 404, 429, 499, 500, 503, 599, 600):
|
for status in (None, 200, 301, 403, 404, 429, 499, 500, 503, 599, 600):
|
||||||
assert ban_kind_from_status(status) in allowed
|
assert ban_kind_from_status(status) in allowed
|
||||||
|
|
||||||
|
|
||||||
|
# ── #3239: подтверждённая бан-страница отличима от прочих 500-ок ──────────────
|
||||||
|
#
|
||||||
|
# Тип, а не подстрока: _raise_for_sidecar_status обрезает тело до 300 символов, и
|
||||||
|
# формулировка отказа у сайдкара менялась дважды за месяц. upstream_status нужен
|
||||||
|
# отдельным полем — на error-пути fetch() обнуляет last_response_status, и без
|
||||||
|
# него у DomClick (отказ приходит с 401) диагноз стал бы 'unknown' вместо
|
||||||
|
# 'platform', то есть ротация IP не запустилась бы вовсе.
|
||||||
|
|
||||||
|
|
||||||
|
def _sidecar_response(body: Any) -> httpx.Response:
|
||||||
|
return httpx.Response(
|
||||||
|
500, json=body, request=httpx.Request("POST", "http://tradein-browser:3000/fetch")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ban_page_body_raises_typed_error_with_upstream_status() -> None:
|
||||||
|
resp = _sidecar_response(
|
||||||
|
{"error": "BanPageDetectedError: статический отказ", "ban_page": True, "status": 401}
|
||||||
|
)
|
||||||
|
with pytest.raises(SidecarBanPageError) as excinfo:
|
||||||
|
_raise_for_sidecar_status(resp)
|
||||||
|
assert excinfo.value.upstream_status == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_ban_page_error_is_httpx_status_error() -> None:
|
||||||
|
"""Подкласс — иначе retry-политика fetch() и ловля у прочих поставщиков сломались бы."""
|
||||||
|
resp = _sidecar_response({"error": "BanPageDetectedError: x", "ban_page": True, "status": 403})
|
||||||
|
with pytest.raises(httpx.HTTPStatusError):
|
||||||
|
_raise_for_sidecar_status(resp)
|
||||||
|
|
||||||
|
|
||||||
|
def test_plain_500_stays_plain_status_error() -> None:
|
||||||
|
resp = _sidecar_response({"error": "Error: Page.goto: NS_ERROR_PROXY_BAD_GATEWAY"})
|
||||||
|
with pytest.raises(httpx.HTTPStatusError) as excinfo:
|
||||||
|
_raise_for_sidecar_status(resp)
|
||||||
|
assert not isinstance(excinfo.value, SidecarBanPageError)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ban_page_without_status_gives_none() -> None:
|
||||||
|
resp = _sidecar_response({"error": "BanPageDetectedError: x", "ban_page": True, "status": None})
|
||||||
|
with pytest.raises(SidecarBanPageError) as excinfo:
|
||||||
|
_raise_for_sidecar_status(resp)
|
||||||
|
assert excinfo.value.upstream_status is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_ban_page_true_as_bool_status_is_rejected() -> None:
|
||||||
|
"""JSON true не должен уехать статусом: bool — подтип int (та же ловушка, что в #3196)."""
|
||||||
|
resp = _sidecar_response({"error": "x", "ban_page": True, "status": True})
|
||||||
|
with pytest.raises(SidecarBanPageError) as excinfo:
|
||||||
|
_raise_for_sidecar_status(resp)
|
||||||
|
assert excinfo.value.upstream_status is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_json_error_body_does_not_break() -> None:
|
||||||
|
resp = httpx.Response(
|
||||||
|
500, text="not json", request=httpx.Request("POST", "http://tradein-browser:3000/fetch")
|
||||||
|
)
|
||||||
|
with pytest.raises(httpx.HTTPStatusError) as excinfo:
|
||||||
|
_raise_for_sidecar_status(resp)
|
||||||
|
assert not isinstance(excinfo.value, SidecarBanPageError)
|
||||||
|
|
||||||
|
|
||||||
|
def test_success_response_raises_nothing() -> None:
|
||||||
|
resp = httpx.Response(
|
||||||
|
200, json={"html": "<html/>", "status": 200},
|
||||||
|
request=httpx.Request("POST", "http://tradein-browser:3000/fetch"),
|
||||||
|
)
|
||||||
|
_raise_for_sidecar_status(resp)
|
||||||
|
|
|
||||||
|
|
@ -961,7 +961,17 @@ async def fetch_handler(request: web.Request) -> web.Response:
|
||||||
type(exc).__name__,
|
type(exc).__name__,
|
||||||
exc,
|
exc,
|
||||||
)
|
)
|
||||||
return web.json_response({"error": f"{type(exc).__name__}: {exc}"}, status=500)
|
error_body: dict = {"error": f"{type(exc).__name__}: {exc}"}
|
||||||
|
if isinstance(exc, BanPageDetectedError):
|
||||||
|
# #3239: бан-страница — ПОДТВЕРЖДЁННЫЙ маркер-детект, а не сбой
|
||||||
|
# транспорта, и только за первым стоит report_ban на клиенте.
|
||||||
|
# До этой правки оба случая приезжали одинаковой 500-кой, клиент
|
||||||
|
# различить их не мог и настоящий отказ площадки переставал
|
||||||
|
# ротировать узел (регрессия #3237). Код ответа НЕ меняем: на 500
|
||||||
|
# завязана classify_browser_probe, признак несёт тело.
|
||||||
|
error_body["ban_page"] = True
|
||||||
|
error_body["status"] = _last_response_status.get(provider)
|
||||||
|
return web.json_response(error_body, status=500)
|
||||||
|
|
||||||
# Аддитивно (#3196): ключ "html" на месте и не изменился — клиент, читающий
|
# Аддитивно (#3196): ключ "html" на месте и не изменился — клиент, читающий
|
||||||
# только его, ничего не заметит. "status" может быть null (goto вернул None).
|
# только его, ничего не заметит. "status" может быть null (goto вернул None).
|
||||||
|
|
|
||||||
|
|
@ -293,3 +293,62 @@ def test_fetch_handler_status_null_without_response(monkeypatch: pytest.MonkeyPa
|
||||||
body = _json_body(response)
|
body = _json_body(response)
|
||||||
assert body["html"] == _REAL_HTML
|
assert body["html"] == _REAL_HTML
|
||||||
assert body["status"] is None
|
assert body["status"] is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── #3239: 500-ка бан-страницы отличима от 500-ки транспорта ──────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_handler_error_body(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, exc: Exception, upstream_status: int | None
|
||||||
|
) -> tuple[int, dict]:
|
||||||
|
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] = upstream_status
|
||||||
|
raise exc
|
||||||
|
|
||||||
|
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"})))
|
||||||
|
return response.status, _json_body(response)
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_handler_marks_ban_page_in_error_body(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Бан-страница несёт ban_page + апстрим-статус.
|
||||||
|
|
||||||
|
Без этого признака клиент видит обычную 500-ку, уводит отказ в транспортную
|
||||||
|
ветку (report_ban там не зовётся) и перестаёт ротировать отказавший узел —
|
||||||
|
регрессия, которую #3237 внёс, а #3239 чинит.
|
||||||
|
"""
|
||||||
|
status, body = _fetch_handler_error_body(
|
||||||
|
monkeypatch, server.BanPageDetectedError("статический отказ площадки"), 401
|
||||||
|
)
|
||||||
|
assert status == 500 # код НЕ меняем: на него завязана classify_browser_probe
|
||||||
|
assert body["ban_page"] is True
|
||||||
|
assert body["status"] == 401
|
||||||
|
assert "BanPageDetectedError" in body["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_handler_transport_error_has_no_ban_page_flag(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Обычный сбой — признака нет вовсе, клиент трактует его как раньше."""
|
||||||
|
status, body = _fetch_handler_error_body(
|
||||||
|
monkeypatch, RuntimeError("NS_ERROR_PROXY_BAD_GATEWAY"), None
|
||||||
|
)
|
||||||
|
assert status == 500
|
||||||
|
assert "ban_page" not in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_handler_ban_page_without_status_stays_null(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Апстрим-статуса нет — отдаём null, а не выдуманный код (#2764)."""
|
||||||
|
_status, body = _fetch_handler_error_body(
|
||||||
|
monkeypatch, server.BanPageDetectedError("бан-страница"), None
|
||||||
|
)
|
||||||
|
assert body["ban_page"] is True
|
||||||
|
assert body["status"] is None
|
||||||
|
|
|
||||||
|
|
@ -130,6 +130,51 @@ _PROXY_FAIL_MARKERS: tuple[str, ...] = (
|
||||||
_LEASE_ROTATE_AFTER_FAILS: int = 3
|
_LEASE_ROTATE_AFTER_FAILS: int = 3
|
||||||
|
|
||||||
|
|
||||||
|
class SidecarBanPageError(httpx.HTTPStatusError):
|
||||||
|
"""Сайдкар подтвердил бан-страницу по маркерам её тела (#3239).
|
||||||
|
|
||||||
|
Подкласс ``HTTPStatusError``, а не самостоятельный тип: ловля по
|
||||||
|
``httpx.HTTPError`` у всех прочих поставщиков и retry-политика
|
||||||
|
``fetch()`` продолжают работать не зная о нём. Отличать его нужно ровно
|
||||||
|
там, где решается судьба узла: за подтверждённым маркером стоит
|
||||||
|
``report_ban`` (площадка отказала этому IP), за обычной 500-кой — нет
|
||||||
|
(сбой транспорта, #2600 п.4).
|
||||||
|
|
||||||
|
``upstream_status`` — HTTP-код САМОЙ целевой навигации, а не ответа
|
||||||
|
сайдкара. На error-пути ``fetch()`` обнуляет ``last_response_status``,
|
||||||
|
поэтому иначе диагноз узнать неоткуда: у DomClick отказ приходит с 401
|
||||||
|
и без него классификатор ставит 'unknown' вместо 'platform'.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
message: str,
|
||||||
|
*,
|
||||||
|
request: httpx.Request,
|
||||||
|
response: httpx.Response,
|
||||||
|
upstream_status: int | None,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(message, request=request, response=response)
|
||||||
|
self.upstream_status = upstream_status
|
||||||
|
|
||||||
|
|
||||||
|
def _sidecar_ban_page_status(resp: httpx.Response) -> tuple[bool, int | None]:
|
||||||
|
"""(это бан-страница?, апстрим-статус) из тела ошибки сайдкара (#3239).
|
||||||
|
|
||||||
|
Тело — ``{"error": ..., "ban_page": true, "status": <int|null>}``. Сайдкар
|
||||||
|
старой сборки ключей не отдаёт → (False, None), поведение как до правки.
|
||||||
|
``bool`` отсекаем явно: он подтип ``int`` и JSON ``true`` уехал бы статусом.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
body = resp.json()
|
||||||
|
except Exception:
|
||||||
|
return False, None
|
||||||
|
if not isinstance(body, dict) or body.get("ban_page") is not True:
|
||||||
|
return False, None
|
||||||
|
raw = body.get("status")
|
||||||
|
return True, raw if isinstance(raw, int) and not isinstance(raw, bool) else None
|
||||||
|
|
||||||
|
|
||||||
def _raise_for_sidecar_status(resp: httpx.Response) -> None:
|
def _raise_for_sidecar_status(resp: httpx.Response) -> None:
|
||||||
"""`raise_for_status()`, но с ПРИЧИНОЙ отказа из тела ответа сайдкара в тексте ошибки.
|
"""`raise_for_status()`, но с ПРИЧИНОЙ отказа из тела ответа сайдкара в тексте ошибки.
|
||||||
|
|
||||||
|
|
@ -153,6 +198,17 @@ def _raise_for_sidecar_status(resp: httpx.Response) -> None:
|
||||||
# Тело не прочиталось/не декодируется — причина не обязана быть; отдаём
|
# Тело не прочиталось/не декодируется — причина не обязана быть; отдаём
|
||||||
# исходную ошибку, а не роняем вызывающего на разборе тела.
|
# исходную ошибку, а не роняем вызывающего на разборе тела.
|
||||||
raise exc from None
|
raise exc from None
|
||||||
|
is_ban_page, upstream_status = _sidecar_ban_page_status(resp)
|
||||||
|
if is_ban_page:
|
||||||
|
# #3239: тип несёт диагноз наверх — подстрокой в тексте его искать
|
||||||
|
# нельзя, detail обрезан до 300 символов и формулировка отказа
|
||||||
|
# менялась дважды за месяц.
|
||||||
|
raise SidecarBanPageError(
|
||||||
|
f"{exc} | tradein-browser: {detail or 'ban page'}",
|
||||||
|
request=exc.request,
|
||||||
|
response=exc.response,
|
||||||
|
upstream_status=upstream_status,
|
||||||
|
) from exc
|
||||||
if not detail:
|
if not detail:
|
||||||
raise
|
raise
|
||||||
raise httpx.HTTPStatusError(
|
raise httpx.HTTPStatusError(
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,7 @@ from scraper_kit.domclick_exceptions import (
|
||||||
DomClickBlockedError,
|
DomClickBlockedError,
|
||||||
DomClickParseError,
|
DomClickParseError,
|
||||||
)
|
)
|
||||||
|
from scraper_kit.browser_fetcher import SidecarBanPageError
|
||||||
from scraper_kit.offer_price_history import clamp_diff_percent
|
from scraper_kit.offer_price_history import clamp_diff_percent
|
||||||
from scraper_kit.repair_state_normalizer import (
|
from scraper_kit.repair_state_normalizer import (
|
||||||
infer_repair_state_from_text,
|
infer_repair_state_from_text,
|
||||||
|
|
@ -544,6 +545,20 @@ async def fetch_detail(
|
||||||
# но НЕ сообщаем пулу здесь: неизвестно, был ли это реальный маркер-бан или
|
# но НЕ сообщаем пулу здесь: неизвестно, был ли это реальный маркер-бан или
|
||||||
# обёртка сетевой ошибки — не смешиваем "сеть" с "бан" (issue #2600 п.4).
|
# обёртка сетевой ошибки — не смешиваем "сеть" с "бан" (issue #2600 п.4).
|
||||||
raise
|
raise
|
||||||
|
except SidecarBanPageError as exc:
|
||||||
|
# #3239: сайдкар опознал статический отказ площадки по маркерам тела
|
||||||
|
# (#3237 перенёс это распознавание из parse_detail_html в сайдкар — иначе
|
||||||
|
# незавершённое QRATOR-рукопожатие считалось блоком). Это ГЕНУИННЫЙ
|
||||||
|
# ban-сигнал, ровно того же рода, что и ветка parse_detail_html ниже,
|
||||||
|
# поэтому здесь — report_ban, в отличие от транспортной ветки следом.
|
||||||
|
# Статус берём из исключения: на error-пути fetch() обнулил
|
||||||
|
# last_response_status, а без 401 классификатор поставил бы 'unknown'
|
||||||
|
# вместо 'platform' и ротация IP не запустилась бы вовсе.
|
||||||
|
browser_fetcher.report_ban(f"domclick detail: sidecar ban page for {card_url}")
|
||||||
|
raise DomClickBlockedError(
|
||||||
|
f"DomClick detail: sidecar detected platform refusal for {card_url}: {exc}",
|
||||||
|
status=exc.upstream_status,
|
||||||
|
) from exc
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
# Сетевая/инфраструктурная ошибка самого fetch() (timeout/5xx/transport) —
|
# Сетевая/инфраструктурная ошибка самого fetch() (timeout/5xx/transport) —
|
||||||
# НЕ репортим mark_banned: это не подтверждённый маркер-бан площадки, а
|
# НЕ репортим mark_banned: это не подтверждённый маркер-бан площадки, а
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue